mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-15 17:30:13 +08:00
feat: 添加插件热重载功能,支持文件变更时自动重新加载插件
This commit is contained in:
@@ -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()` 复用旧代码;热重载前必须先按插件目录清理模块缓存。
|
||||
|
||||
@@ -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()` 继续复用旧代码;热重载前必须先按插件目录清掉已加载模块缓存。
|
||||
|
||||
# 开发命令
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}."):
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user