Skip to content
This repository has been archived by the owner on Mar 13, 2023. It is now read-only.

Commit

Permalink
feat: Add jurigged hot reloading to debug extension (#652)
Browse files Browse the repository at this point in the history
* feat: Add jurigged hot reloading to debug extension

* fix: Add jurigged to requirements.txt

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* refactor: Move jurigged to be separate from debug

* docs: Add documentation about jurigged

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* refactor: Move get_all_commands to better location

* fix: Move get_all_commands to naff.ext.jurigged

* fix:  Add jurigged extra, and better error when loading extension without jurigged installed.

* Rename Jurigged guide to Live Patching

* fix: jurigged as optional requirement

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Katelyn Gigante <[email protected]>
  • Loading branch information
3 people authored Oct 28, 2022
1 parent 3cfb47b commit 9a9512c
Show file tree
Hide file tree
Showing 11 changed files with 436 additions and 18 deletions.
3 changes: 3 additions & 0 deletions docs/src/API Reference/ext/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ These files contain useful features that help you develop a bot
- [Debug Extension](debug_ext)
- An extension preloaded with a load of debugging utilities to help you find and fix bugs

- [Jurigged](jurigged)
- An extension to enable live code patching for faster development

- [Paginators](paginators)
- An automatic message paginator to help you get a lot of information across

Expand Down
1 change: 1 addition & 0 deletions docs/src/API Reference/ext/jurigged.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
::: naff.ext.jurigged
19 changes: 19 additions & 0 deletions docs/src/Guides/22 Live Patching.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Live Patching

NAFF has a few built-in extensions that add some features, primarily for debugging. One of these extensions that you can enable separately is to add [`jurigged`](https://github.com/breuleux/jurigged) for live patching of code.

## How to enable

```py
bot.load_extension("naff.ext.jurigged")
```

That's it! The extension will handle all of the leg work, and all you'll notice is that you have more messages in your logs (depending on the log level).

## What is jurigged?

`jurigged` is a library written to allow code hot reloading in Python. It allows you to edit code and have it automagically be updated in your program the next time it is run. The code under the hood is extremely complicated, but the interface to use it is relatively simple.

## How is this useful?

NAFF takes advantage of jurigged to reload any and all commands that were edited whenever a change is made, allowing you to have more uptime with while still adding/improving features of your bot.
File renamed without changes.
File renamed without changes.
File renamed without changes.
209 changes: 209 additions & 0 deletions naff/ext/jurigged.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
import inspect
from pathlib import Path
from types import ModuleType
from typing import Callable, Dict

from naff import Extension, SlashCommand, listen
from naff.client.errors import ExtensionLoadException, ExtensionNotFound
from naff.client.utils.misc_utils import find
from naff.client.const import get_logger

try:
from jurigged import watch, CodeFile
from jurigged.live import WatchOperation
from jurigged.codetools import (
AddOperation,
DeleteOperation,
UpdateOperation,
LineDefinition,
)
except ModuleNotFoundError:
get_logger().error(
"jurigged not installed, cannot enable jurigged integration. Install with `pip install naff[jurigged]`"
)
raise


__all__ = ("Jurigged", "setup")


def get_all_commands(module: ModuleType) -> Dict[str, Callable]:
"""
Get all SlashCommands from a specified module.
Args:
module: Module to extract commands from
"""
commands = {}

def is_extension(e) -> bool:
"""Check that an object is an extension."""
return inspect.isclass(e) and issubclass(e, Extension) and e is not Extension

def is_slashcommand(e) -> bool:
"""Check that an object is a slash command."""
return isinstance(e, SlashCommand)

for _name, item in inspect.getmembers(module, is_extension):
inspect_result = inspect.getmembers(item, is_slashcommand)
exts = []
for _, val in inspect_result:
exts.append(val)
commands[f"{module.__name__}"] = exts

return {k: v for k, v in commands.items() if v is not None}


class Jurigged(Extension):
@listen(event_name="on_startup")
async def jurigged_startup(self) -> None:
"""Jurigged starting utility."""
self.command_cache = {}
self.bot.logger.warning("Setting sync_ext to True by default for syncing changes")
self.bot.sync_ext = True

self.bot.logger.info("Loading jurigged")
path = Path().resolve()
self.watcher = watch(f"{path}/[!.]*.py", logger=self.jurigged_log)
self.watcher.prerun.register(self.jurigged_prerun)
self.watcher.postrun.register(self.jurigged_postrun)

def jurigged_log(self, event: WatchOperation | AddOperation | DeleteOperation | UpdateOperation) -> None:
"""
Log a jurigged event
Args:
event: jurigged event
"""
if isinstance(event, WatchOperation):
self.bot.logger.debug(f"Watch {event.filename}")
elif isinstance(event, (Exception, SyntaxError)):
self.bot.logger.exception("Jurigged encountered an error", exc_info=True)
else:
event_str = "{action} {dotpath}:{lineno}{extra}"
action = None
lineno = event.defn.stashed.lineno
dotpath = event.defn.dotpath()
extra = ""

if isinstance(event.defn, LineDefinition):
dotpath = event.defn.parent.dotpath()
extra = f" | {event.defn.text}"

if isinstance(event, AddOperation):
action = "Add"
if isinstance(event.defn, LineDefinition):
action = "Run"
elif isinstance(event, UpdateOperation):
action = "Update"
elif isinstance(event, DeleteOperation):
action = "Delete"
if not action:
self.bot.logger.debug(event)
else:
self.bot.logger.debug(event_str.format(action=action, dotpath=dotpath, lineno=lineno, extra=extra))

def jurigged_prerun(self, _path: str, cf: CodeFile) -> None:
"""
Jurigged prerun event.
Args:
path: Path to file
cf: File information
"""
if self.bot.get_ext(cf.module_name):
self.bot.logger.debug(f"Caching {cf.module_name}")
self.command_cache = get_all_commands(cf.module)

def jurigged_postrun(self, _path: str, cf: CodeFile) -> None:
"""
Jurigged postrun event.
Args:
path: Path to file
cf: File information
"""
if self.bot.get_ext(cf.module_name):
self.bot.logger.debug(f"Checking {cf.module_name}")
commands = get_all_commands(cf.module)

self.bot.logger.debug("Checking for changes")
for module, cmds in commands.items():
# Check if a module was removed
if module not in commands:
self.bot.logger.debug(f"Module {module} removed")
self.bot.unload_extension(module)

# Check if a module is new
elif module not in self.command_cache:
self.bot.logger.debug(f"Module {module} added")
try:
self.bot.load_extension(module)
except ExtensionLoadException:
self.bot.logger.warning(f"Failed to load new module {module}")

# Check if a module has more/less commands
elif len(self.command_cache[module]) != len(cmds):
self.bot.logger.debug("Number of commands changed, reloading")
try:
self.bot.reload_extension(module)
except ExtensionNotFound:
try:
self.bot.load_extension(module)
except ExtensionLoadException:
self.bot.logger.warning(f"Failed to update module {module}")
except ExtensionLoadException:
self.bot.logger.warning(f"Failed to update module {module}")

# Check each command for differences
else:
for cmd in cmds:
old_cmd = find(
lambda x, cmd=cmd: x.resolved_name == cmd.resolved_name,
self.command_cache[module],
)

# Extract useful info
old_args = old_cmd.options
old_arg_names = []
new_arg_names = []
if old_args:
old_arg_names = [x.name.default for x in old_args]
new_args = cmd.options
if new_args:
new_arg_names = [x.name.default for x in new_args]

# No changes
if not old_args and not new_args:
continue

# Check if number of args has changed
if len(old_arg_names) != len(new_arg_names):
self.bot.logger.debug("Number of arguments changed, reloading")
try:
self.bot.reload_extension(module)
except Exception:
self.bot.logger.exception(f"Failed to update module {module}", exc_info=True)

# Check if arg names have changed
elif len(set(old_arg_names) - set(new_arg_names)) > 0:
self.bot.logger.debug("Argument names changed, reloading")
try:
self.bot.reload_extension(module)
except Exception:
self.bot.logger.exception(f"Failed to update module {module}", exc_info=True)

# Check if arg types have changed
elif any(new_args[idx].type != x.type for idx, x in enumerate(old_args)):
self.bot.logger.debug("Argument types changed, reloading")
try:
self.bot.reload_extension(module)
except Exception:
self.bot.logger.exception(f"Failed to update module {module}", exc_info=True)
else:
self.bot.logger.debug("No changes detected")
self.command_cache.clear()


def setup(bot) -> None:
Jurigged(bot)
Loading

0 comments on commit 9a9512c

Please sign in to comment.