mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-15 17:30:13 +08:00
Merge pull request #4 from letr007/chore/compat-structure-cleanup
This commit is contained in:
@@ -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` 都可能被污染。
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -13,8 +13,9 @@
|
||||
维护约束:
|
||||
|
||||
- ``astrbot_sdk.api.*`` 保持为受控兼容面,后续会随迁移推进逐步移除
|
||||
- 包内模块优先作为 facade / 重导出层存在
|
||||
- 包内模块优先作为 facade / 重导出层存在,但允许少量必须保留行为的 compat 模块
|
||||
- compat 真实行为应尽量收口到顶层 private compat 模块
|
||||
- 新增运行时逻辑应优先放在 v4 主路径,由 compat 层按需转发或包装
|
||||
"""
|
||||
|
||||
from loguru import logger
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
"""过渡期旧版顶层导入路径 compat facade。"""
|
||||
"""过渡期旧版顶层导入路径 compat facade。
|
||||
|
||||
这个模块只承接历史上的顶层 legacy 导入习惯,例如 ``Context`` /
|
||||
``CommandComponent``。更细的旧路径兼容仍保留在 ``astrbot_sdk.api`` 下。
|
||||
|
||||
新代码不应从这里导入;这里的职责是给旧插件一个明确、可隔离的旁路入口。
|
||||
"""
|
||||
|
||||
from ._legacy_api import (
|
||||
CommandComponent,
|
||||
|
||||
@@ -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=[
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user