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:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user