feat: add hot-reload, shorthand commands, and plugin help

- Plugin registry: add unload_plugin(), reload_plugin(), path tracking
- Bot: add load_plugin(), reload_plugin(), unload_plugin() public API
- Core plugin: add !load, !reload, !unload, !plugins commands
- Command dispatch: support unambiguous prefix matching (!h -> !help)
- Help: support !help <plugin> to show plugin description and commands
- Tests: 17 new tests covering hot-reload, prefix matching

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
user
2026-02-15 01:15:59 +01:00
parent ad18a902dd
commit 77f9a364e6
6 changed files with 508 additions and 22 deletions

View File

@@ -3,6 +3,7 @@
import textwrap
from pathlib import Path
from derp.bot import _AMBIGUOUS, Bot
from derp.plugin import PluginRegistry, command, event
@@ -126,3 +127,242 @@ class TestRegistry:
registry = PluginRegistry()
registry.load_directory(tmp_path / "nonexistent")
assert len(registry.commands) == 0
def test_load_plugin_returns_count(self, tmp_path: Path):
plugin_file = tmp_path / "counting.py"
plugin_file.write_text(textwrap.dedent("""\
from derp.plugin import command, event
@command("one", help="first")
async def cmd_one(bot, msg):
pass
@command("two", help="second")
async def cmd_two(bot, msg):
pass
@event("JOIN")
async def on_join(bot, msg):
pass
"""))
registry = PluginRegistry()
count = registry.load_plugin(plugin_file)
assert count == 3
def test_load_plugin_stores_path(self, tmp_path: Path):
plugin_file = tmp_path / "pathed.py"
plugin_file.write_text(textwrap.dedent("""\
from derp.plugin import command
@command("pathed")
async def cmd(bot, msg):
pass
"""))
registry = PluginRegistry()
registry.load_plugin(plugin_file)
assert "pathed" in registry._paths
assert registry._paths["pathed"] == plugin_file.resolve()
class TestHotReload:
"""Test hot-load, unload, and reload of plugins."""
def _write_plugin(self, tmp_path: Path, name: str, code: str) -> Path:
"""Write a plugin file and return its path."""
path = tmp_path / f"{name}.py"
path.write_text(textwrap.dedent(code))
return path
def test_unload_removes_commands(self, tmp_path: Path):
registry = PluginRegistry()
self._write_plugin(tmp_path, "greet", """\
from derp.plugin import command
@command("hello")
async def cmd(bot, msg):
pass
""")
registry.load_plugin(tmp_path / "greet.py")
assert "hello" in registry.commands
result = registry.unload_plugin("greet")
assert result is True
assert "hello" not in registry.commands
assert "greet" not in registry._modules
assert "greet" not in registry._paths
def test_unload_removes_events(self, tmp_path: Path):
registry = PluginRegistry()
self._write_plugin(tmp_path, "evplug", """\
from derp.plugin import event
@event("JOIN")
async def on_join(bot, msg):
pass
""")
registry.load_plugin(tmp_path / "evplug.py")
assert "JOIN" in registry.events
assert len(registry.events["JOIN"]) == 1
registry.unload_plugin("evplug")
assert "JOIN" not in registry.events
def test_unload_unknown_returns_false(self):
registry = PluginRegistry()
assert registry.unload_plugin("nonexistent") is False
def test_unload_core_refused(self, tmp_path: Path):
registry = PluginRegistry()
self._write_plugin(tmp_path, "core", """\
from derp.plugin import command
@command("ping")
async def cmd(bot, msg):
pass
""")
registry.load_plugin(tmp_path / "core.py")
assert "ping" in registry.commands
result = registry.unload_plugin("core")
assert result is False
assert "ping" in registry.commands
def test_reload_plugin(self, tmp_path: Path):
registry = PluginRegistry()
path = self._write_plugin(tmp_path, "mutable", """\
from derp.plugin import command
@command("old")
async def cmd(bot, msg):
pass
""")
registry.load_plugin(path)
assert "old" in registry.commands
# Rewrite the file with a different command
path.write_text(textwrap.dedent("""\
from derp.plugin import command
@command("new")
async def cmd(bot, msg):
pass
"""))
ok, reason = registry.reload_plugin("mutable")
assert ok is True
assert "old" not in registry.commands
assert "new" in registry.commands
def test_reload_unknown_returns_failure(self):
registry = PluginRegistry()
ok, reason = registry.reload_plugin("ghost")
assert ok is False
assert "not loaded" in reason
def test_reload_core_allowed(self, tmp_path: Path):
registry = PluginRegistry()
self._write_plugin(tmp_path, "core", """\
from derp.plugin import command
@command("ping")
async def cmd(bot, msg):
pass
""")
registry.load_plugin(tmp_path / "core.py")
ok, _ = registry.reload_plugin("core")
assert ok is True
assert "ping" in registry.commands
def test_unload_preserves_other_plugins(self, tmp_path: Path):
registry = PluginRegistry()
self._write_plugin(tmp_path, "keep", """\
from derp.plugin import command, event
@command("keeper")
async def cmd(bot, msg):
pass
@event("JOIN")
async def on_join(bot, msg):
pass
""")
self._write_plugin(tmp_path, "drop", """\
from derp.plugin import command, event
@command("dropper")
async def cmd(bot, msg):
pass
@event("JOIN")
async def on_join(bot, msg):
pass
""")
registry.load_directory(tmp_path)
assert "keeper" in registry.commands
assert "dropper" in registry.commands
assert len(registry.events["JOIN"]) == 2
registry.unload_plugin("drop")
assert "keeper" in registry.commands
assert "dropper" not in registry.commands
assert len(registry.events["JOIN"]) == 1
class TestPrefixMatch:
"""Test shorthand / prefix matching for commands."""
@staticmethod
def _make_bot(commands: list[str]) -> Bot:
"""Create a minimal Bot with the given command names registered."""
config = {
"server": {"host": "localhost", "port": 6667, "tls": False,
"nick": "test", "user": "test", "realname": "test"},
"bot": {"prefix": "!", "channels": [], "plugins_dir": "plugins"},
}
registry = PluginRegistry()
for name in commands:
async def _noop(bot, msg):
pass
registry.register_command(name, _noop, plugin="test")
return Bot(config, registry)
def test_exact_match(self):
bot = self._make_bot(["ping", "pong", "plugins"])
handler = bot._resolve_command("ping")
assert handler is not None
assert handler.name == "ping"
def test_unique_prefix(self):
bot = self._make_bot(["ping", "help", "version"])
handler = bot._resolve_command("h")
assert handler is not None
assert handler.name == "help"
def test_unique_prefix_partial(self):
bot = self._make_bot(["ping", "plugins", "help"])
handler = bot._resolve_command("pi")
assert handler is not None
assert handler.name == "ping"
def test_ambiguous_prefix(self):
bot = self._make_bot(["ping", "plugins", "help"])
result = bot._resolve_command("p")
assert result is _AMBIGUOUS
def test_no_match(self):
bot = self._make_bot(["ping", "help"])
assert bot._resolve_command("z") is None
def test_exact_match_beats_prefix(self):
bot = self._make_bot(["help", "helper"])
handler = bot._resolve_command("help")
assert handler is not None
assert handler.name == "help"
def test_single_char_unique(self):
bot = self._make_bot(["version", "help", "ping"])
handler = bot._resolve_command("v")
assert handler is not None
assert handler.name == "version"