Skip to content

Commit 748f1a2

Browse files
committed
refactor: remove global mutable state from plugins
Rename commands/ to plugins/. Stateful plugins (nem, nem_relay, nemp) now define a Plugin class with instance attributes instead of module-level globals. Add @command and @subcommand decorators to command_router for declarative command registration with auto-generated subcommand dispatch. - command_router: add decorators, add_plugin/remove_plugin, PluginEntry.instance - nem: move session/versions/cache state into Plugin class - nem_relay: move session/webhook config into Plugin class, access nem via router.plugins["nem"].instance - nemp: move poller/troubled_mods/auto_disabled_mods/poll_cycle_count/ announcement_queue from router attrs into Plugin class - plugin_management: call teardown before reload, support new-style plugins - irc_bot: remove _main_task global, pass as signal handler arg
1 parent 3b5cdcd commit 748f1a2

29 files changed

Lines changed: 1924 additions & 1834 deletions

command_router.py

Lines changed: 154 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,37 @@
1515
from user_auth import AuthTracker
1616

1717

18+
def command(name, permission, allow_private=False):
19+
"""Decorator that marks a Plugin method as a bot command."""
20+
21+
def decorator(func):
22+
func._command_info = {
23+
"name": name,
24+
"permission": permission,
25+
"allow_private": allow_private,
26+
}
27+
return func
28+
29+
return decorator
30+
31+
32+
def subcommand(group, name, permission=None):
33+
"""Decorator that marks a Plugin method as a subcommand of a command group.
34+
35+
If permission is None, the group's permission is used.
36+
"""
37+
38+
def decorator(func):
39+
func._subcommand_info = {
40+
"group": group,
41+
"name": name,
42+
"permission": permission,
43+
}
44+
return func
45+
46+
return decorator
47+
48+
1849
class Permission(IntEnum):
1950
"""Minimum rank required to use a command.
2051
@@ -47,13 +78,15 @@ class PluginEntry(NamedTuple):
4778
command_names: Tuple of command names registered by this plugin.
4879
setup: ``async setup(router, startup)`` coroutine, or *None*.
4980
teardown: ``async teardown(router)`` coroutine, or *None*.
81+
instance: For new-style plugins, the ``Plugin`` class instance.
5082
"""
5183

5284
module: object
5385
path: str
5486
command_names: tuple
5587
setup: Callable | None
5688
teardown: Callable | None
89+
instance: object | None = None
5790

5891

5992
class CommandEntry(NamedTuple):
@@ -106,7 +139,7 @@ def __init__(self, channels, cmdprefix, name, ident, adminlist, loglevel):
106139
self.name = name
107140
self.ident = ident
108141
self.protocol_handlers = self._load_protocol_handlers("irc_handlers")
109-
self.plugins, self.commands = self._load_plugins("commands")
142+
self.plugins, self.commands = self._load_plugins("plugins")
110143

111144
self.operators = adminlist
112145
self.auth_tracker = AuthTracker(adminlist)
@@ -385,15 +418,93 @@ def _load_source(name, path):
385418
spec.loader.exec_module(module)
386419
return module
387420

421+
def add_plugin(self, plugin_id, instance, module, path):
422+
"""Register a new-style Plugin instance. Discovers decorated methods."""
423+
command_names = []
424+
group_commands = {}
425+
subcommands = {}
426+
427+
for attr_name in dir(instance):
428+
method = getattr(instance, attr_name, None)
429+
if method is None:
430+
continue
431+
432+
cmd_info = getattr(method, "_command_info", None)
433+
if cmd_info is not None:
434+
group_commands[cmd_info["name"]] = (method, cmd_info)
435+
436+
sub_info = getattr(method, "_subcommand_info", None)
437+
if sub_info is not None:
438+
group = sub_info["group"]
439+
subcommands.setdefault(group, {})[sub_info["name"]] = (
440+
method,
441+
sub_info["permission"],
442+
)
443+
444+
for cmd_name, (method, cmd_info) in group_commands.items():
445+
subs = subcommands.get(cmd_name)
446+
execute = self._make_group_dispatch(method, subs, cmd_info["permission"]) if subs else method
447+
448+
self.commands[cmd_name] = CommandEntry(
449+
execute=execute,
450+
permission=cmd_info["permission"],
451+
allow_private=cmd_info.get("allow_private", False),
452+
plugin_id=plugin_id,
453+
)
454+
command_names.append(cmd_name)
455+
456+
setup_fn = getattr(instance, "setup", None)
457+
teardown_fn = getattr(instance, "teardown", None)
458+
459+
self.plugins[plugin_id] = PluginEntry(
460+
module=module,
461+
path=path,
462+
command_names=tuple(command_names),
463+
setup=setup_fn,
464+
teardown=teardown_fn,
465+
instance=instance,
466+
)
467+
468+
def _make_group_dispatch(self, fallback, subcommands, group_permission):
469+
"""Create an auto-dispatch function for a command group."""
470+
471+
async def dispatch(router, name, params, channel, userdata, rank, is_channel):
472+
if params:
473+
sub_name = params[0].lower()
474+
if sub_name in subcommands:
475+
method, sub_perm = subcommands[sub_name]
476+
required = sub_perm if sub_perm is not None else group_permission
477+
if rank >= required:
478+
await method(router, name, params, channel, userdata, rank)
479+
else:
480+
await router.send_message(channel, "You're not authorized to use this command.")
481+
return
482+
await fallback(router, name, params, channel, userdata, rank, is_channel)
483+
484+
return dispatch
485+
486+
def remove_plugin(self, plugin_id):
487+
"""Unregister a plugin and all its commands."""
488+
plugin = self.plugins.get(plugin_id)
489+
if not plugin:
490+
return
491+
for name in plugin.command_names:
492+
self.commands.pop(name, None)
493+
del self.plugins[plugin_id]
494+
388495
def _load_plugins(self, path):
389496
"""Load all plugin modules from *path*.
390497
391-
Each plugin module must define ``PLUGIN_ID`` (str) and ``COMMANDS``
392-
(dict mapping command names to dicts with keys ``execute``,
393-
``permission``, and optionally ``allow_private``).
498+
Each plugin module must define ``PLUGIN_ID`` (str).
499+
500+
**New-style** plugins define a ``Plugin`` class with ``@command``
501+
and ``@subcommand`` decorated methods. **Old-style** plugins
502+
define a ``COMMANDS`` dict mapping command names to dicts with
503+
keys ``execute``, ``permission``, and optionally ``allow_private``.
394504
395505
A module may optionally define ``async setup(router, startup)``
396-
and/or ``async teardown(router)`` for lifecycle hooks.
506+
and/or ``async teardown(router)`` for lifecycle hooks (old-style),
507+
or the ``Plugin`` class may define those methods (new-style).
397508
398509
Returns:
399510
``(plugins_dict, commands_dict)`` — *plugins_dict* maps plugin
@@ -403,46 +514,54 @@ def _load_plugins(self, path):
403514
file_list = self._list_dir(path)
404515
self._logger.info("Loading plugins in path '%s'...", path)
405516

406-
plugins = {}
407-
commands = {}
517+
# add_plugin() writes directly to self.plugins / self.commands,
518+
# so we initialize them here and return them at the end.
519+
self.plugins = {}
520+
self.commands = {}
408521

409522
for filename in file_list:
410523
filepath = path + "/" + filename
411524
self._logger.debug("Loading file %s in path '%s'", filename, path)
412525
module = self._load_source("NEMP_" + filename[:-3], filepath)
413526

414527
plugin_id = module.PLUGIN_ID
415-
commands_dict = module.COMMANDS
416-
417-
setup_fn = getattr(module, "setup", None)
418-
if setup_fn is not None and not callable(setup_fn):
419-
setup_fn = None
420-
421-
teardown_fn = getattr(module, "teardown", None)
422-
if teardown_fn is not None and not callable(teardown_fn):
423-
teardown_fn = None
424-
425-
command_names = []
426-
for cmd_name, cmd_info in commands_dict.items():
427-
entry = CommandEntry(
428-
execute=cmd_info["execute"],
429-
permission=cmd_info["permission"],
430-
allow_private=cmd_info.get("allow_private", False),
431-
plugin_id=plugin_id,
528+
plugin_cls = getattr(module, "Plugin", None)
529+
530+
if plugin_cls is not None:
531+
instance = plugin_cls()
532+
self.add_plugin(plugin_id, instance, module, filepath)
533+
else:
534+
commands_dict = module.COMMANDS
535+
536+
setup_fn = getattr(module, "setup", None)
537+
if setup_fn is not None and not callable(setup_fn):
538+
setup_fn = None
539+
540+
teardown_fn = getattr(module, "teardown", None)
541+
if teardown_fn is not None and not callable(teardown_fn):
542+
teardown_fn = None
543+
544+
command_names = []
545+
for cmd_name, cmd_info in commands_dict.items():
546+
entry = CommandEntry(
547+
execute=cmd_info["execute"],
548+
permission=cmd_info["permission"],
549+
allow_private=cmd_info.get("allow_private", False),
550+
plugin_id=plugin_id,
551+
)
552+
self.commands[cmd_name] = entry
553+
command_names.append(cmd_name)
554+
555+
self.plugins[plugin_id] = PluginEntry(
556+
module=module,
557+
path=filepath,
558+
command_names=tuple(command_names),
559+
setup=setup_fn,
560+
teardown=teardown_fn,
432561
)
433-
commands[cmd_name] = entry
434-
command_names.append(cmd_name)
435-
436-
plugins[plugin_id] = PluginEntry(
437-
module=module,
438-
path=filepath,
439-
command_names=tuple(command_names),
440-
setup=setup_fn,
441-
teardown=teardown_fn,
442-
)
443562

444563
self._logger.info("Plugins in path '%s' loaded.", path)
445-
return plugins, commands
564+
return self.plugins, self.commands
446565

447566
def _load_protocol_handlers(self, path):
448567
"""Load IRC protocol handler modules from *path*.

0 commit comments

Comments
 (0)