diff --git a/AGENTS.md b/AGENTS.md index 40b3a40a7..cf73fa265 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -68,3 +68,4 @@ python run_tests.py --cov # 运行测试并生成覆盖率报告 old文件夹是兼容旧插件的测试,旧插件全部放进old文件夹 - 2026-03-13: 不要再维护第二套 `_legacy/` 并行目录。private compat 以顶层 `_legacy_api.py`、`_legacy_runtime.py`、`_legacy_loader.py`、`_session_waiter.py`、`_shared_preferences.py` 为唯一实现位置,同时保留公开兼容面 `astrbot_sdk.api`、`astrbot_sdk.compat` 和 `src-new/astrbot` facade。 +- 2026-03-14: `test_plugin/old/` 和 `test_plugin/new/` 里可能带着已生成的 `__pycache__` / `*.pyc`。测试夹具复制示例插件时必须显式忽略这些缓存文件,否则临时插件目录、断言结果和 `git status` 都可能被污染。 diff --git a/CLAUDE.md b/CLAUDE.md index 07e129c93..0ac3767a9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,6 +21,7 @@ - 2026-03-13: `Peer.invoke_stream()` intentionally hides `completed` events by default, so any supervisor/bridge layer that wants to preserve a worker stream capability's final `completed.output` must opt in explicitly (for example via `include_completed=True`) or it will silently collapse the final result to an ad-hoc aggregate like `{\"items\": chunks}`. - 2026-03-13: The repository root `test_plugin/` is no longer a single runnable plugin fixture. The maintained compat sample now lives under `test_plugin/old/`; tests or scripts that still copy/load `test_plugin/` directly will mis-detect it as an incomplete legacy plugin and fail. - 2026-03-13: The maintained sample plugins now live under `test_plugin/old/` and `test_plugin/new/`. Runtime/integration tests should copy those real fixture directories instead of inlining synthetic plugin writers, otherwise the sample plugin tree and the exercised test path drift apart. +- 2026-03-14: `test_plugin/old/` and `test_plugin/new/` may contain checked-in `__pycache__` / `*.pyc` artifacts. Any helper that copies sample plugins into temporary test directories must explicitly ignore Python bytecode caches, or fixture contents and `git status` can drift for reasons unrelated to the runtime behavior under test. - 2026-03-13: Real legacy plugins in the wild may still import `astrbot.api.*` instead of `astrbot_sdk.api.*`. Keeping only the `astrbot_sdk.api` compat surface is not enough for no-touch migration tests; preserve the `astrbot.api` alias package while old-plugin support remains a goal. - 2026-03-13: Real legacy `main.py` plugins may rely on package-relative imports like `from .src.tool import ...`. Loading legacy `main.py` as a bare file module breaks those imports; the loader must execute it under a synthetic package module so relative imports resolve. - 2026-03-13: Real legacy plugins may also import `astrbot.core.utils.session_waiter` and use it as a first-class interactive flow primitive. A pure import stub is not enough; compat needs per-session follow-up message routing so awaited waiters can actually receive later messages. diff --git a/pyproject.toml b/pyproject.toml index 0dcbfcc6c..f7eff81c1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta" [project] name = "astrbot-sdk" version = "0.1.0" -description = "Add your description here" +description = "AstrBot SDK with v4 runtime and legacy compatibility surfaces" readme = "README.md" requires-python = ">=3.12" dependencies = [ diff --git a/src-new/astrbot_sdk/__init__.py b/src-new/astrbot_sdk/__init__.py index ca314af9c..f0befb161 100644 --- a/src-new/astrbot_sdk/__init__.py +++ b/src-new/astrbot_sdk/__init__.py @@ -1,8 +1,12 @@ """AstrBot SDK 的顶层公共 API。 这里仅重新导出 v4 推荐直接导入的稳定入口。 -旧版兼容能力由 ``astrbot_sdk.api`` 与 ``astrbot_sdk.compat`` 承接, -避免把迁移层和原生 API 混在同一个包入口里。 + +- ``astrbot_sdk``: v4 原生稳定 API +- ``astrbot_sdk.compat``: 旧版顶层导入路径兼容入口 +- ``astrbot_sdk.api``: 历史 ``api.*`` 导入路径兼容面 + +这样可以把原生 API 与迁移入口明确分开,避免旧路径继续反向污染顶层包。 """ from .context import Context diff --git a/src-new/astrbot_sdk/_legacy_api.py b/src-new/astrbot_sdk/_legacy_api.py index 72e04034a..52c28aaf5 100644 --- a/src-new/astrbot_sdk/_legacy_api.py +++ b/src-new/astrbot_sdk/_legacy_api.py @@ -28,8 +28,6 @@ from .star import Star if TYPE_CHECKING: from .api.provider.entities import LLMResponse - -# TODO-迁移文档要写,我好烦烦烦你烦烦烦你 MIGRATION_DOC_URL = "https://docs.astrbot.app/migration/v3" COMPAT_CONVERSATIONS_KEY = "__compat_conversations__" _warned_methods: set[str] = set() diff --git a/src-new/astrbot_sdk/api/__init__.py b/src-new/astrbot_sdk/api/__init__.py index c053f90bc..9933bb2fe 100644 --- a/src-new/astrbot_sdk/api/__init__.py +++ b/src-new/astrbot_sdk/api/__init__.py @@ -13,8 +13,9 @@ 维护约束: - ``astrbot_sdk.api.*`` 保持为受控兼容面,后续会随迁移推进逐步移除 -- 包内模块优先作为 facade / 重导出层存在 +- 包内模块优先作为 facade / 重导出层存在,但允许少量必须保留行为的 compat 模块 - compat 真实行为应尽量收口到顶层 private compat 模块 +- 新增运行时逻辑应优先放在 v4 主路径,由 compat 层按需转发或包装 """ from loguru import logger diff --git a/src-new/astrbot_sdk/compat.py b/src-new/astrbot_sdk/compat.py index 20523e6c7..7879b21ae 100644 --- a/src-new/astrbot_sdk/compat.py +++ b/src-new/astrbot_sdk/compat.py @@ -1,4 +1,10 @@ -"""过渡期旧版顶层导入路径 compat facade。""" +"""过渡期旧版顶层导入路径 compat facade。 + +这个模块只承接历史上的顶层 legacy 导入习惯,例如 ``Context`` / +``CommandComponent``。更细的旧路径兼容仍保留在 ``astrbot_sdk.api`` 下。 + +新代码不应从这里导入;这里的职责是给旧插件一个明确、可隔离的旁路入口。 +""" from ._legacy_api import ( CommandComponent, diff --git a/src-new/astrbot_sdk/runtime/bootstrap.py b/src-new/astrbot_sdk/runtime/bootstrap.py index 88f9525e2..a1eab0b7d 100644 --- a/src-new/astrbot_sdk/runtime/bootstrap.py +++ b/src-new/astrbot_sdk/runtime/bootstrap.py @@ -191,6 +191,8 @@ class WorkerSession: if existing_pythonpath else repo_src_dir ) + env.setdefault("PYTHONIOENCODING", "utf-8") + env.setdefault("PYTHONUTF8", "1") transport = StdioTransport( command=[ diff --git a/tests_v4/README.md b/tests_v4/README.md index baf86e5f3..685941d6c 100644 --- a/tests_v4/README.md +++ b/tests_v4/README.md @@ -8,8 +8,7 @@ This test suite uses **pytest** with `pytest-asyncio` for testing the AstrBot SD ``` tests_v4/ -├── conftest.py # Shared fixtures and configuration -├── pytest.ini # Pytest configuration +├── conftest.py # Shared fixtures and path bootstrap ├── test_api_contract.py # API contract tests ├── test_api_decorators.py # Decorator and Star class tests ├── test_context.py # Context and CancelToken tests diff --git a/tests_v4/helpers.py b/tests_v4/helpers.py index ceef9e52d..57a40cfef 100644 --- a/tests_v4/helpers.py +++ b/tests_v4/helpers.py @@ -1,6 +1,7 @@ from __future__ import annotations # 启用延迟类型注解求值,避免循环引用 import asyncio +import shutil from types import SimpleNamespace from pathlib import Path @@ -106,3 +107,16 @@ async def drain_loop() -> None: 睡眠时间(0.05秒)足够短不会明显减慢测试,又足够长让事件循环有机会处理任务。 """ await asyncio.sleep(0.05) # 暂停当前协程50毫秒,让出控制权给事件循环 + + +def sample_plugin_dir(name: str) -> Path: + return Path(__file__).resolve().parents[1] / "test_plugin" / name + + +def copy_sample_plugin(name: str, destination: Path) -> Path: + shutil.copytree( + sample_plugin_dir(name), + destination, + ignore=shutil.ignore_patterns("__pycache__", "*.pyc"), + ) + return destination diff --git a/tests_v4/pytest.ini b/tests_v4/pytest.ini deleted file mode 100644 index db005f73a..000000000 --- a/tests_v4/pytest.ini +++ /dev/null @@ -1,14 +0,0 @@ -[pytest] -testpaths = . -python_files = test_*.py -python_classes = Test* -python_functions = test_* -asyncio_mode = auto -asyncio_default_fixture_loop_scope = function -filterwarnings = - ignore::DeprecationWarning - ignore::PendingDeprecationWarning -markers = - slow: marks tests as slow (deselect with '-m "not slow"') - integration: marks tests as integration tests - unit: marks tests as unit tests diff --git a/tests_v4/test_entrypoints.py b/tests_v4/test_entrypoints.py index 76d529f81..7d1b2c86e 100644 --- a/tests_v4/test_entrypoints.py +++ b/tests_v4/test_entrypoints.py @@ -2,6 +2,7 @@ from __future__ import annotations import subprocess import sys +from pathlib import Path import unittest import pytest @@ -25,6 +26,24 @@ def _is_astrbot_sdk_installed_in_site_packages() -> bool: return False +def _astr_console_script() -> str | None: + executable_dir = Path(sys.executable).resolve().parent + search_dirs = [executable_dir] + if executable_dir.name.lower() != "scripts": + search_dirs.append(executable_dir / "Scripts") + + for scripts_dir in search_dirs: + candidates = [ + scripts_dir / "astr", + scripts_dir / "astr.exe", + scripts_dir / "astr.cmd", + ] + for candidate in candidates: + if candidate.exists(): + return str(candidate) + return None + + @pytest.mark.integration @pytest.mark.skipif( not _is_astrbot_sdk_installed_in_site_packages(), @@ -60,3 +79,17 @@ class EntryPointTest(unittest.TestCase): ) self.assertEqual(process.returncode, 0, process.stderr) self.assertIn("--plugins-dir", process.stdout) + + def test_console_script_help(self) -> None: + console_script = _astr_console_script() + if console_script is None: + self.fail("astr console script not found") + + process = subprocess.run( + [console_script, "--help"], + capture_output=True, + text=True, + check=False, + ) + self.assertEqual(process.returncode, 0, process.stderr) + self.assertIn("Usage", process.stdout) diff --git a/tests_v4/test_runtime.py b/tests_v4/test_runtime.py index 62d517e76..de1127fd1 100644 --- a/tests_v4/test_runtime.py +++ b/tests_v4/test_runtime.py @@ -1,7 +1,6 @@ from __future__ import annotations import asyncio -import shutil import tempfile import unittest from pathlib import Path @@ -10,11 +9,11 @@ from astrbot_sdk.protocol.messages import InitializeOutput, PeerInfo from astrbot_sdk.runtime.bootstrap import SupervisorRuntime from astrbot_sdk.runtime.peer import Peer -from tests_v4.helpers import FakeEnvManager, make_transport_pair - - -def sample_plugin_dir(name: str) -> Path: - return Path(__file__).resolve().parents[1] / "test_plugin" / name +from tests_v4.helpers import ( + FakeEnvManager, + copy_sample_plugin, + make_transport_pair, +) class RuntimeIntegrationTest(unittest.IsolatedAsyncioTestCase): @@ -50,7 +49,7 @@ class RuntimeIntegrationTest(unittest.IsolatedAsyncioTestCase): with tempfile.TemporaryDirectory() as temp_dir: plugins_root = Path(temp_dir) / "plugins" plugin_root = plugins_root / "v4_plugin" - shutil.copytree(sample_plugin_dir("new"), plugin_root) + copy_sample_plugin("new", plugin_root) runtime = SupervisorRuntime( transport=self.right, @@ -86,7 +85,7 @@ class RuntimeIntegrationTest(unittest.IsolatedAsyncioTestCase): with tempfile.TemporaryDirectory() as temp_dir: plugins_root = Path(temp_dir) / "plugins" plugin_root = plugins_root / "v4_plugin" - shutil.copytree(sample_plugin_dir("new"), plugin_root) + copy_sample_plugin("new", plugin_root) runtime = SupervisorRuntime( transport=self.right, @@ -157,7 +156,7 @@ class RuntimeIntegrationTest(unittest.IsolatedAsyncioTestCase): with tempfile.TemporaryDirectory() as temp_dir: plugins_root = Path(temp_dir) / "plugins" plugin_root = plugins_root / "v4_plugin" - shutil.copytree(sample_plugin_dir("new"), plugin_root) + copy_sample_plugin("new", plugin_root) runtime = SupervisorRuntime( transport=self.right, @@ -194,7 +193,7 @@ class RuntimeIntegrationTest(unittest.IsolatedAsyncioTestCase): with tempfile.TemporaryDirectory() as temp_dir: plugins_root = Path(temp_dir) / "plugins" plugin_root = plugins_root / "v4_plugin" - shutil.copytree(sample_plugin_dir("new"), plugin_root) + copy_sample_plugin("new", plugin_root) runtime = SupervisorRuntime( transport=self.right, @@ -237,7 +236,7 @@ class RuntimeIntegrationTest(unittest.IsolatedAsyncioTestCase): with tempfile.TemporaryDirectory() as temp_dir: plugins_root = Path(temp_dir) / "plugins" plugin_root = plugins_root / "v4_plugin" - shutil.copytree(sample_plugin_dir("new"), plugin_root) + copy_sample_plugin("new", plugin_root) runtime = SupervisorRuntime( transport=self.right, @@ -262,7 +261,7 @@ class RuntimeIntegrationTest(unittest.IsolatedAsyncioTestCase): with tempfile.TemporaryDirectory() as temp_dir: plugins_root = Path(temp_dir) / "plugins" plugin_root = plugins_root / "compat_plugin" - shutil.copytree(Path.cwd() / "test_plugin" / "old", plugin_root) + copy_sample_plugin("old", plugin_root) runtime = SupervisorRuntime( transport=self.right, @@ -299,7 +298,7 @@ class RuntimeIntegrationTest(unittest.IsolatedAsyncioTestCase): with tempfile.TemporaryDirectory() as temp_dir: plugins_root = Path(temp_dir) / "plugins" plugin_root = plugins_root / "compat_plugin" - shutil.copytree(Path.cwd() / "test_plugin" / "old", plugin_root) + copy_sample_plugin("old", plugin_root) runtime = SupervisorRuntime( transport=self.right, @@ -382,7 +381,7 @@ class RuntimeIntegrationTest(unittest.IsolatedAsyncioTestCase): with tempfile.TemporaryDirectory() as temp_dir: plugins_root = Path(temp_dir) / "plugins" plugin_root = plugins_root / "compat_plugin" - shutil.copytree(Path.cwd() / "test_plugin" / "old", plugin_root) + copy_sample_plugin("old", plugin_root) runtime = SupervisorRuntime( transport=self.right, diff --git a/tests_v4/test_supervisor_migration.py b/tests_v4/test_supervisor_migration.py index c815138b8..aceafb950 100644 --- a/tests_v4/test_supervisor_migration.py +++ b/tests_v4/test_supervisor_migration.py @@ -1,82 +1,52 @@ from __future__ import annotations import asyncio -import sys import tempfile -import textwrap import unittest from pathlib import Path +import yaml + from astrbot_sdk.protocol.messages import InitializeOutput, PeerInfo from astrbot_sdk.runtime.bootstrap import SupervisorRuntime from astrbot_sdk.runtime.loader import discover_plugins from astrbot_sdk.runtime.peer import Peer -from tests_v4.helpers import FakeEnvManager, make_transport_pair +from tests_v4.helpers import FakeEnvManager, copy_sample_plugin, make_transport_pair -def write_plugin( +def prepare_sample_plugin( root: Path, folder_name: str, *, + sample_name: str = "new", plugin_name: str | None = None, python_version: str | None = None, include_requirements: bool = True, - reply_text: str | None = None, ) -> Path: - plugin_dir = root / folder_name - commands_dir = plugin_dir / "commands" - commands_dir.mkdir(parents=True, exist_ok=True) - (commands_dir / "__init__.py").write_text("", encoding="utf-8") - - if python_version is None: - python_version = f"{sys.version_info.major}.{sys.version_info.minor}" - - manifest_lines = [ - "_schema_version: 2", - f"name: {plugin_name or folder_name}", - f"display_name: {folder_name}", - "desc: test plugin", - "author: tester", - "version: 0.1.0", - ] - if python_version != "__missing__": - manifest_lines.extend( - [ - "runtime:", - f' python: "{python_version}"', - ] - ) - manifest_lines.extend( - [ - "components:", - " - class: commands.sample:SamplePlugin", - " type: command", - " name: hello", - " description: hello", - ] - ) - (plugin_dir / "plugin.yaml").write_text( - "\n".join(manifest_lines) + "\n", encoding="utf-8" - ) - if include_requirements: - (plugin_dir / "requirements.txt").write_text("", encoding="utf-8") - - text = reply_text or f"{plugin_name or folder_name} handled" - (commands_dir / "sample.py").write_text( - textwrap.dedent( - f"""\ - from astrbot_sdk import Context, MessageEvent, Star, on_command - - - class SamplePlugin(Star): - @on_command("hello") - async def hello(self, event: MessageEvent, ctx: Context): - await event.reply({text!r}) - """ - ), + plugin_dir = copy_sample_plugin(sample_name, root / folder_name) + manifest_path = plugin_dir / "plugin.yaml" + manifest = yaml.safe_load(manifest_path.read_text(encoding="utf-8")) or {} + manifest["name"] = plugin_name or folder_name + manifest["display_name"] = folder_name + if python_version == "__missing__": + manifest.pop("runtime", None) + elif python_version is not None: + manifest["runtime"] = {"python": python_version} + manifest_path.write_text( + yaml.safe_dump(manifest, allow_unicode=True, sort_keys=False), encoding="utf-8", ) + requirements_path = plugin_dir / "requirements.txt" + if include_requirements: + requirements_path.write_text( + requirements_path.read_text(encoding="utf-8") + if requirements_path.exists() + else "", + encoding="utf-8", + ) + elif requirements_path.exists(): + requirements_path.unlink() return plugin_dir @@ -84,14 +54,14 @@ class PluginDiscoveryMigrationTest(unittest.TestCase): def test_discover_plugins_keeps_old_supervisor_filtering_rules(self) -> None: with tempfile.TemporaryDirectory() as temp_dir: root = Path(temp_dir) - write_plugin(root, "plugin_one", plugin_name="plugin_one") - write_plugin( + prepare_sample_plugin(root, "plugin_one", plugin_name="plugin_one") + prepare_sample_plugin( root, "plugin_two", plugin_name="plugin_two", python_version="__missing__", ) - write_plugin( + prepare_sample_plugin( root, "plugin_three", plugin_name="plugin_three", @@ -132,17 +102,15 @@ class SupervisorMigrationTest(unittest.IsolatedAsyncioTestCase): ) -> None: with tempfile.TemporaryDirectory() as temp_dir: plugins_dir = Path(temp_dir) / "plugins" - write_plugin( + prepare_sample_plugin( plugins_dir, "plugin_one", plugin_name="plugin_one", - reply_text="plugin_one handled", ) - write_plugin( + prepare_sample_plugin( plugins_dir, "plugin_two", plugin_name="plugin_two", - reply_text="plugin_two handled", ) runtime = SupervisorRuntime( @@ -160,12 +128,23 @@ class SupervisorMigrationTest(unittest.IsolatedAsyncioTestCase): self.assertEqual( self.core.remote_metadata["plugins"], ["plugin_one", "plugin_two"] ) - self.assertEqual(len(self.core.remote_handlers), 2) + plugin_one_handlers = [ + item.id + for item in self.core.remote_handlers + if item.id.startswith("plugin_one:") + ] + plugin_two_handlers = [ + item.id + for item in self.core.remote_handlers + if item.id.startswith("plugin_two:") + ] + self.assertTrue(plugin_one_handlers) + self.assertTrue(plugin_two_handlers) handler_id = next( item.id for item in self.core.remote_handlers - if item.id.startswith("plugin_two:") + if item.id.startswith("plugin_two:") and item.id.endswith(".hello") ) await self.core.invoke( "handler.invoke", @@ -184,7 +163,7 @@ class SupervisorMigrationTest(unittest.IsolatedAsyncioTestCase): texts = [ item.get("text") for item in runtime.capability_router.sent_messages ] - self.assertEqual(texts, ["plugin_two handled"]) + self.assertEqual(texts, ["Echo: /hello", "Echo: stream"]) finally: await runtime.stop()