feat: 添加插件热重载功能,支持文件变更时自动重新加载插件

This commit is contained in:
whatevertogo
2026-03-14 22:25:51 +08:00
parent 5aa760f4a6
commit 731fa6d5bd
5 changed files with 403 additions and 7 deletions

View File

@@ -72,3 +72,4 @@ old文件夹是兼容旧插件的测试旧插件全部放进old文件夹
- 2026-03-14: grouped worker / grouped env 路径不要再复制单 worker 的 compat 生命周期和 legacy runtime 绑定逻辑。优先复用 `_legacy_runtime.py` 里的 `bind_legacy_runtime_contexts()``run_legacy_worker_startup_hooks()``run_legacy_worker_shutdown_hooks()` 以及 `resolve_plugin_lifecycle_hook()`,否则很容易出现“普通 worker 测试通过,但真正的 grouped subprocess 路径在运行时 NameError/行为漂移”的回归。
- 2026-03-14: `inspect.getmembers(module, inspect.isclass)` 会按属性名排序,所以 legacy `main.py` 组件发现若要保留声明顺序,必须遍历 `module.__dict__`;只删除后面的 `.sort()` 仍然不够。
- 2026-03-14: 如果仓库正在收敛为纯 v4 SDK删除 compat 文件前必须先移除或延迟隔离所有公开入口里的 `_legacy_*` import。`testing.py``cli.py` 里残留对 `_legacy_runtime` 的 eager import会让 `import astrbot_sdk.testing``python -m astrbot_sdk --help` 在运行前直接失败,而仅检查 site-packages 安装态的 CLI smoke test 很容易漏掉这类回归。
- 2026-03-14: 本地 `dev --watch` 或任何同一路径插件重复加载场景,不能只依赖 `import_string()` 的“跨插件模块根冲突”清理。即使模块仍属于当前插件目录,`sys.modules` 也会让 `load_plugin()` 复用旧代码;热重载前必须先按插件目录清理模块缓存。

View File

@@ -63,6 +63,7 @@
- 2026-03-14: If the repo is being simplified to a pure v4 SDK, remove or lazily isolate every `_legacy_*` import from public entrypoints before deleting the compat files. Leaving `testing.py` or `cli.py` with eager `_legacy_runtime` imports makes `import astrbot_sdk.testing` and `python -m astrbot_sdk --help` fail immediately, and install-only entrypoint tests can miss that regression.
- 2026-03-14: `MemoryClient` 新增 `save_with_ttl()` / `get_many()` / `delete_many()` / `stats()` 方法,对应的 protocol schema 和 CapabilityRouter 处理器已同步添加。测试实现中 TTL 仅记录不实际过期,实际过期由后端实现。
- 2026-03-14: `SupervisorRuntime._register_plugin_capability()` 改进冲突处理:保留命名空间冲突直接跳过,非保留命名空间冲突自动添加插件名前缀(如 `plugin_name.capability_name`)解决。
- 2026-03-14: 本地 `dev --watch`/重复加载同一路径插件时,不能只依赖 `import_string()` 的跨插件根包冲突清理。即使缓存模块仍然属于当前插件目录,`sys.modules` 也会让 `load_plugin()` 继续复用旧代码;热重载前必须先按插件目录清掉已加载模块缓存。
# 开发命令

View File

@@ -8,6 +8,7 @@ import sys
import typing
import zipfile
from collections.abc import Coroutine
from dataclasses import dataclass, field
from pathlib import Path
from textwrap import dedent
from typing import Any
@@ -38,6 +39,7 @@ BUILD_EXCLUDED_DIRS = {
BUILD_EXCLUDED_FILES = {
".astrbot-worker-state.json",
}
WATCH_POLL_INTERVAL_SECONDS = 0.5
class _CliPluginValidationError(RuntimeError):
@@ -52,6 +54,25 @@ class _CliPluginExecutionError(RuntimeError):
"""CLI 侧的本地开发插件执行失败。"""
@dataclass(slots=True)
class _PluginTreeWatcher:
plugin_dir: Path
snapshot: dict[str, tuple[int, int]] = field(init=False, default_factory=dict)
def __post_init__(self) -> None:
self.snapshot = _snapshot_watch_files(self.plugin_dir)
def poll_changes(self) -> list[str]:
current = _snapshot_watch_files(self.plugin_dir)
changed = sorted(
path
for path in set(self.snapshot) | set(current)
if self.snapshot.get(path) != current.get(path)
)
self.snapshot = current
return changed
def setup_logger(verbose: bool = False) -> None:
"""初始化 CLI 使用的日志配置。"""
logger.remove()
@@ -168,16 +189,248 @@ def _render_cli_error(
click.echo(f"{key}: {value}", err=True)
def _render_nonfatal_dev_error(
exc: Exception,
*,
context: dict[str, Any] | None = None,
) -> None:
exit_code, error_code, hint = _classify_cli_exception(exc)
_render_cli_error(
error_code=error_code,
message=str(exc),
hint=hint,
context=context,
)
if exit_code == EXIT_UNEXPECTED:
logger.exception("watch 模式收到未分类异常")
def _iter_watch_files(plugin_dir: Path) -> typing.Iterator[Path]:
root = plugin_dir.resolve()
for path in sorted(root.rglob("*")):
if path.is_dir():
continue
relative = path.relative_to(root)
if any(part in BUILD_EXCLUDED_DIRS for part in relative.parts[:-1]):
continue
if relative.name in BUILD_EXCLUDED_FILES:
continue
if path.suffix in {".pyc", ".pyo"}:
continue
yield path
def _snapshot_watch_files(plugin_dir: Path) -> dict[str, tuple[int, int]]:
root = plugin_dir.resolve()
snapshot: dict[str, tuple[int, int]] = {}
for path in _iter_watch_files(root):
try:
stat = path.stat()
except FileNotFoundError:
continue
snapshot[path.relative_to(root).as_posix()] = (
stat.st_mtime_ns,
stat.st_size,
)
return snapshot
def _format_watch_changes(changes: list[str], *, limit: int = 5) -> str:
if not changes:
return "未知文件"
preview = changes[:limit]
text = ", ".join(preview)
if len(changes) > limit:
text += f"{len(changes)} 个文件"
return text
class _ReloadableLocalDevRunner:
def __init__(
self,
*,
plugin_dir: Path,
state: dict[str, Any],
plugin_load_error: type[Exception],
plugin_execution_error: type[Exception],
local_runtime_config,
plugin_harness,
stdout_platform_sink,
) -> None:
self.plugin_dir = plugin_dir
self.state = state
self._plugin_load_error = plugin_load_error
self._plugin_execution_error = plugin_execution_error
self._local_runtime_config = local_runtime_config
self._plugin_harness = plugin_harness
self._stdout_platform_sink = stdout_platform_sink
self._harness = None
self._lock = asyncio.Lock()
async def close(self) -> None:
async with self._lock:
await self._stop_harness()
async def reload(self) -> bool:
async with self._lock:
await self._stop_harness()
harness = self._plugin_harness(
self._local_runtime_config(
plugin_dir=self.plugin_dir,
session_id=str(self.state["session_id"]),
user_id=str(self.state["user_id"]),
platform=str(self.state["platform"]),
group_id=typing.cast(str | None, self.state["group_id"]),
event_type=str(self.state["event_type"]),
),
platform_sink=self._stdout_platform_sink(stream=sys.stdout),
)
try:
await harness.start()
except self._plugin_load_error as exc:
_render_nonfatal_dev_error(
_CliPluginLoadError(str(exc)),
context={"plugin_dir": self.plugin_dir},
)
return False
except self._plugin_execution_error as exc:
_render_nonfatal_dev_error(
_CliPluginExecutionError(str(exc)),
context={"plugin_dir": self.plugin_dir},
)
return False
self._harness = harness
return True
async def dispatch_text(self, text: str) -> bool:
async with self._lock:
if self._harness is None:
click.echo("当前插件未成功加载,等待下一次文件变更后重试。")
return False
try:
await self._harness.dispatch_text(
text,
session_id=str(self.state["session_id"]),
user_id=str(self.state["user_id"]),
platform=str(self.state["platform"]),
group_id=typing.cast(str | None, self.state["group_id"]),
event_type=str(self.state["event_type"]),
)
except (self._plugin_load_error, self._plugin_execution_error) as exc:
_render_nonfatal_dev_error(
_CliPluginExecutionError(str(exc)),
context={"plugin_dir": self.plugin_dir},
)
return False
except Exception as exc:
_render_nonfatal_dev_error(
exc,
context={"plugin_dir": self.plugin_dir},
)
return False
return True
async def _stop_harness(self) -> None:
if self._harness is None:
return
try:
await self._harness.stop()
finally:
self._harness = None
async def _run_local_dev_watch(
*,
runner: _ReloadableLocalDevRunner,
event_text: str | None,
interactive: bool,
watch_poll_interval: float,
max_watch_reloads: int | None = None,
) -> None:
watcher = _PluginTreeWatcher(runner.plugin_dir)
reload_count = 0
async def reload_and_maybe_rerun(*, announce: str | None) -> None:
if announce:
click.echo(announce)
if not await runner.reload():
return
if event_text is not None:
await runner.dispatch_text(event_text)
async def watch_loop(stop_event: asyncio.Event) -> None:
nonlocal reload_count
while not stop_event.is_set():
await asyncio.sleep(watch_poll_interval)
changes = watcher.poll_changes()
if not changes:
continue
await reload_and_maybe_rerun(
announce=(
f"检测到文件变更,重新加载插件:{_format_watch_changes(changes)}"
)
)
reload_count += 1
if max_watch_reloads is not None and reload_count >= max_watch_reloads:
stop_event.set()
return
stop_event = asyncio.Event()
watch_task: asyncio.Task[None] | None = None
try:
await reload_and_maybe_rerun(
announce=(
"watch 模式已启动,监听插件目录变更。"
if event_text is not None
else "watch 模式已启动,监听插件目录变更并按需热重载。"
)
)
if max_watch_reloads == 0:
return
watch_task = asyncio.create_task(watch_loop(stop_event))
if interactive:
click.echo(
"本地交互模式已启动。可用命令:/session <id> /user <id> /platform <name> /group <id> /private /event <type> /exit"
)
while not stop_event.is_set():
line = await asyncio.to_thread(sys.stdin.readline)
if not line:
break
text = line.strip()
if not text:
continue
if _handle_dev_meta_command(text, runner.state):
if text in {"/exit", "/quit"}:
break
continue
await runner.dispatch_text(text)
stop_event.set()
return
await stop_event.wait()
finally:
stop_event.set()
if watch_task is not None:
watch_task.cancel()
try:
await watch_task
except asyncio.CancelledError:
pass
await runner.close()
async def _run_local_dev(
*,
plugin_dir: Path,
event_text: str | None,
interactive: bool,
watch: bool,
session_id: str,
user_id: str,
platform: str,
group_id: str | None,
event_type: str,
watch_poll_interval: float = WATCH_POLL_INTERVAL_SECONDS,
max_watch_reloads: int | None = None,
) -> None:
from .testing import (
LocalRuntimeConfig,
@@ -187,6 +440,32 @@ async def _run_local_dev(
_PluginLoadError,
)
state = {
"session_id": session_id,
"user_id": user_id,
"platform": platform,
"group_id": group_id,
"event_type": event_type,
}
if watch:
runner = _ReloadableLocalDevRunner(
plugin_dir=plugin_dir,
state=state,
plugin_load_error=_PluginLoadError,
plugin_execution_error=_PluginExecutionError,
local_runtime_config=LocalRuntimeConfig,
plugin_harness=PluginHarness,
stdout_platform_sink=StdoutPlatformSink,
)
await _run_local_dev_watch(
runner=runner,
event_text=event_text,
interactive=interactive,
watch_poll_interval=watch_poll_interval,
max_watch_reloads=max_watch_reloads,
)
return
sink = StdoutPlatformSink(stream=sys.stdout)
harness = PluginHarness(
LocalRuntimeConfig(
@@ -199,13 +478,6 @@ async def _run_local_dev(
),
platform_sink=sink,
)
state = {
"session_id": session_id,
"user_id": user_id,
"platform": platform,
"group_id": group_id,
"event_type": event_type,
}
try:
async with harness:
if interactive:
@@ -565,6 +837,11 @@ def build(plugin_dir: Path, output_dir: Path | None) -> None:
)
@click.option("--event-text", type=str, help="Single message text to dispatch")
@click.option("--interactive", is_flag=True, help="Read follow-up messages from stdin")
@click.option(
"--watch",
is_flag=True,
help="Reload the local harness when plugin files change",
)
@click.option("--session-id", default="local-session", show_default=True)
@click.option("--user-id", default="local-user", show_default=True)
@click.option("--platform", "platform_name", default="test", show_default=True)
@@ -576,6 +853,7 @@ def dev(
standalone_mode: bool,
event_text: str | None,
interactive: bool,
watch: bool,
session_id: str,
user_id: str,
platform_name: str,
@@ -594,6 +872,7 @@ def dev(
plugin_dir=plugin_dir,
event_text=event_text,
interactive=interactive,
watch=watch,
session_id=session_id,
user_id=user_id,
platform=platform_name,

View File

@@ -57,6 +57,7 @@ import inspect
import json
import os
import re
import shutil
import sys
from dataclasses import dataclass, field
from importlib import import_module
@@ -540,6 +541,8 @@ def load_plugin(plugin: PluginSpec) -> LoadedPlugin:
plugin_path = str(plugin.plugin_dir)
if plugin_path not in sys.path:
sys.path.insert(0, plugin_path)
_purge_plugin_bytecode(plugin.plugin_dir)
_purge_plugin_modules(plugin.plugin_dir)
instances: list[Any] = []
handlers: list[LoadedHandler] = []
@@ -625,6 +628,28 @@ def _module_belongs_to_plugin(module: Any, plugin_dir: Path) -> bool:
)
def _purge_plugin_modules(plugin_dir: Path) -> None:
plugin_root = plugin_dir.resolve()
for module_name, module in list(sys.modules.items()):
if module is None:
continue
if _module_belongs_to_plugin(module, plugin_root):
sys.modules.pop(module_name, None)
def _purge_plugin_bytecode(plugin_dir: Path) -> None:
plugin_root = plugin_dir.resolve()
for path in plugin_root.rglob("*"):
try:
if path.is_dir() and path.name == "__pycache__":
shutil.rmtree(path, ignore_errors=True)
continue
if path.is_file() and path.suffix in {".pyc", ".pyo"}:
path.unlink(missing_ok=True)
except OSError:
continue
def _purge_module_root(root_name: str) -> None:
for module_name in list(sys.modules):
if module_name == root_name or module_name.startswith(f"{root_name}."):

View File

@@ -1,5 +1,7 @@
from __future__ import annotations
import asyncio
import io
import os
import subprocess
import sys
@@ -40,6 +42,19 @@ def test_cli_help_works_from_source_tree() -> None:
assert "Usage" in process.stdout
def test_dev_help_lists_watch_option() -> None:
process = subprocess.run(
[sys.executable, "-m", "astrbot_sdk", "dev", "--help"],
capture_output=True,
text=True,
check=False,
env=_source_env(),
)
assert process.returncode == 0, process.stderr
assert "--watch" in process.stdout
@pytest.mark.asyncio
async def test_plugin_harness_dispatches_sample_plugin() -> None:
from astrbot_sdk.testing import LocalRuntimeConfig, PluginHarness
@@ -68,3 +83,78 @@ async def test_plugin_harness_supports_metadata_and_http_commands() -> None:
assert any(
"已注册 API当前共 1 个" in (record.text or "") for record in api_records
)
def _write_watch_plugin(plugin_dir: Path, *, reply_text: str) -> None:
plugin_dir.mkdir(parents=True, exist_ok=True)
(plugin_dir / "requirements.txt").write_text("", encoding="utf-8")
(plugin_dir / "plugin.yaml").write_text(
"\n".join(
[
"name: watch_demo",
"display_name: Watch Demo",
"author: test",
"version: 0.1.0",
"runtime:",
' python: "3.13"',
"components:",
" - class: main:WatchDemo",
]
),
encoding="utf-8",
)
(plugin_dir / "main.py").write_text(
"\n".join(
[
"from astrbot_sdk import Context, MessageEvent, Star, on_command",
"",
"class WatchDemo(Star):",
' @on_command("hello")',
" async def hello(self, event: MessageEvent, ctx: Context) -> None:",
f' await event.reply("{reply_text}")',
"",
]
),
encoding="utf-8",
)
@pytest.mark.asyncio
async def test_run_local_dev_watch_reloads_on_file_change(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
from astrbot_sdk.cli import _run_local_dev
plugin_dir = tmp_path / "watch-plugin"
_write_watch_plugin(plugin_dir, reply_text="v1")
stdout = io.StringIO()
monkeypatch.setattr(sys, "stdout", stdout)
task = asyncio.create_task(
_run_local_dev(
plugin_dir=plugin_dir,
event_text="hello",
interactive=False,
watch=True,
session_id="local-session",
user_id="local-user",
platform="test",
group_id=None,
event_type="message",
watch_poll_interval=0.05,
max_watch_reloads=1,
)
)
await asyncio.sleep(0.2)
_write_watch_plugin(plugin_dir, reply_text="v2")
await asyncio.wait_for(task, timeout=3.0)
output = stdout.getvalue()
assert "watch 模式已启动" in output
assert "检测到文件变更" in output
assert "[text][local-session] v1" in output
assert "[text][local-session] v2" in output