Merge pull request #4 from letr007/chore/compat-structure-cleanup

This commit is contained in:
whatevertogo
2026-03-14 00:41:21 +08:00
14 changed files with 126 additions and 103 deletions

View File

@@ -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` 都可能被污染。

View File

@@ -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.

View File

@@ -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 = [

View File

@@ -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

View File

@@ -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()

View File

@@ -13,8 +13,9 @@
维护约束:
- ``astrbot_sdk.api.*`` 保持为受控兼容面,后续会随迁移推进逐步移除
- 包内模块优先作为 facade / 重导出层存在
- 包内模块优先作为 facade / 重导出层存在,但允许少量必须保留行为的 compat 模块
- compat 真实行为应尽量收口到顶层 private compat 模块
- 新增运行时逻辑应优先放在 v4 主路径,由 compat 层按需转发或包装
"""
from loguru import logger

View File

@@ -1,4 +1,10 @@
"""过渡期旧版顶层导入路径 compat facade。"""
"""过渡期旧版顶层导入路径 compat facade。
这个模块只承接历史上的顶层 legacy 导入习惯,例如 ``Context`` /
``CommandComponent``。更细的旧路径兼容仍保留在 ``astrbot_sdk.api`` 下。
新代码不应从这里导入;这里的职责是给旧插件一个明确、可隔离的旁路入口。
"""
from ._legacy_api import (
CommandComponent,

View File

@@ -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=[

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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,

View File

@@ -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()