mirror of
https://github.com/EstrellaXD/Auto_Bangumi.git
synced 2026-11-04 06:38:05 +08:00
fix(4.0-P7): 评审修复:插件扫描跳过工具链目录、v3 迁移忽略空旧字段、通用与 LLM 插件互不干扰
This commit is contained in:
@@ -22,6 +22,9 @@
|
||||
- **破坏性变更(通知渠道)**:Bark 不再读取旧字段 `token`(改用 `device_key`),WeCom 不再读取 `chat_id`(改用 `webhook_url`)。升级后第一次启动时自动把旧字段迁移到新字段,原文件备份为 `config.json.v3.bak`
|
||||
- **新增(插件文档与示例)**:插件开发文档拆为总览、核心概念、配置表单、事件、前端挂载点、命令行、签名与分发、内置插件、示例和每个扩展点一页(中 / 英 / 日)。`examples/plugins/` 新增 `webhook-on-event`、`custom-rss-site`、`template-rename`、`nfo-writer`、`ntfy-notifier`,CI 逐个校验并运行它们的测试。新增插件作者 skill(`skills/autobangumi-plugin/`)
|
||||
- **新增(发布)**:beta 与稳定版的 GitHub Release 附带 `autobangumi-sdk` 轮子和 `autobangumi-plugin-skill-<版本>.zip`
|
||||
- **修复(插件开发工具)**:插件目录里的 `.venv`、`node_modules` 等工具链目录不再被当作原生扩展(`ab-plugin validate` / `pack` / `dev` 和宿主加载此前会报「含原生扩展」),`dev_mode` 也不再监听它们
|
||||
- **修复(配置迁移)**:配置里有 Bark 或 WeCom 渠道时,每次启动都会新增一份 `config.json.v3.bak.N` 并改写配置;现在只迁移带值的旧字段
|
||||
- **修复(插件安装)**:`DELETE /api/v1/plugins/{id}` 不再能卸载 LLM 提供商插件;安装通用插件后,LLM 插件列举不再反复输出 `Skipping broken plugin` 警告
|
||||
- **破坏性变更(升级路径)**:只支持从 3.3.x 升级。检测到更早版本的数据(`config/version.info` 低于 3.3,或残留 2.x 的 `data/data.json`)时拒绝启动,并提示先升级到最新 3.3.x 启动一次完成迁移
|
||||
- **破坏性变更(API)**:移除 3.2 兼容的 GET 控制端点(`/api/v1/restart`、`/start`、`/stop`、`/shutdown`)和 `GET /api/v1/auth/refresh_token`,请改用 POST
|
||||
- **破坏性变更(配置)**:移除旧版 `experimental_openai` 配置节和通知的单 provider 旧字段(`type` / `token` / `chat_id`),3.3 已把它们迁移到 `llm` 与 `notification.providers`;移除废弃的 `normal` 重命名方式,已有配置自动改为语义相同的 `none`
|
||||
|
||||
@@ -13,12 +13,10 @@ from pathlib import Path
|
||||
from string import Template
|
||||
|
||||
from . import SDK_VERSION, points
|
||||
from .manifest import MANIFEST_NAME, PluginManifest, check
|
||||
from .manifest import MANIFEST_NAME, TOOLING_DIRS, PluginManifest, check
|
||||
|
||||
# 打包时排除的目录名与文件后缀:开发产物、测试与作者侧的工程文件
|
||||
_PACK_EXCLUDE_DIRS = frozenset(
|
||||
{"__pycache__", ".git", ".venv", "dist", "tests", ".pytest_cache", ".ruff_cache"}
|
||||
)
|
||||
_PACK_EXCLUDE_DIRS = TOOLING_DIRS | {"tests"}
|
||||
_PACK_EXCLUDE_FILES = frozenset({".DS_Store", "pyproject.toml", "uv.lock"})
|
||||
_ZIP_EPOCH = (1980, 1, 1, 0, 0, 0) # 固定时间戳,同样的内容打出同样的 sha256
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
宿主加载器与 ``ab-plugin`` 命令行共用,所以放在 SDK 里:插件作者不装宿主也能校验。
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import tomllib
|
||||
from pathlib import Path, PurePosixPath
|
||||
@@ -17,6 +18,19 @@ from . import SDK_VERSION
|
||||
MANIFEST_NAME = "plugin.toml"
|
||||
# 只允许纯 Python 代码(设计文档第 2.4 节):带这些后缀的文件一律拒绝
|
||||
NATIVE_SUFFIXES = (".so", ".pyd", ".dylib", ".dll")
|
||||
# 工具链产生的目录(虚拟环境、缓存、构建产物):不属于插件代码,扫描与打包都跳过
|
||||
TOOLING_DIRS = frozenset(
|
||||
{
|
||||
"__pycache__",
|
||||
".git",
|
||||
".venv",
|
||||
"dist",
|
||||
"node_modules",
|
||||
".pytest_cache",
|
||||
".ruff_cache",
|
||||
".mypy_cache",
|
||||
}
|
||||
)
|
||||
ID_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
|
||||
_ENTRY_RE = re.compile(r"^[A-Za-z_][\w.]*:[A-Za-z_]\w*$")
|
||||
# 合法的 custom element 名(小写、含连字符),统一以 ab-plugin- 开头避免与宿主冲突
|
||||
@@ -179,11 +193,15 @@ def load_manifest(plugin_dir: Path) -> PluginManifest:
|
||||
|
||||
def native_files(plugin_dir: Path) -> list[Path]:
|
||||
"""插件目录中的原生扩展文件(相对路径)。"""
|
||||
return [
|
||||
p.relative_to(plugin_dir)
|
||||
for p in sorted(plugin_dir.rglob("*"))
|
||||
if p.suffix in NATIVE_SUFFIXES
|
||||
]
|
||||
found = []
|
||||
for root, dirs, names in os.walk(plugin_dir):
|
||||
dirs[:] = sorted(d for d in dirs if d not in TOOLING_DIRS)
|
||||
found += [
|
||||
(Path(root) / n).relative_to(plugin_dir)
|
||||
for n in names
|
||||
if n.endswith(NATIVE_SUFFIXES)
|
||||
]
|
||||
return sorted(found)
|
||||
|
||||
|
||||
def check(plugin_dir: Path) -> tuple[PluginManifest | None, list[str]]:
|
||||
|
||||
@@ -50,6 +50,9 @@ class ProviderRegistry:
|
||||
try:
|
||||
version = json.loads(pointer.read_text(encoding="utf-8"))["version"]
|
||||
version_dir = plugin_dir / version
|
||||
# 通用插件(plugin.toml)与 LLM 插件共用此目录布局,不属于这里
|
||||
if (version_dir / "plugin.toml").exists():
|
||||
continue
|
||||
manifest = json.loads(
|
||||
(version_dir / "plugin.json").read_text(encoding="utf-8")
|
||||
)
|
||||
|
||||
@@ -28,8 +28,8 @@ from typing import Any, ClassVar, Optional
|
||||
import httpx
|
||||
import semver
|
||||
|
||||
from ab_sdk.manifest import ID_RE, RESERVED_IDS, check
|
||||
from module.plugin.loader import BUILTIN_ROOT, INSTALLED_FILE
|
||||
from ab_sdk.manifest import ID_RE, MANIFEST_NAME, RESERVED_IDS, check
|
||||
from module.plugin.loader import BUILTIN_ROOT, INSTALLED_FILE, installed_version
|
||||
from module.update.signing import DEFAULT_PUBKEY_PATH, verify_bundle_signature
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -258,8 +258,13 @@ class PluginInstaller(SignedCatalogInstaller):
|
||||
return None
|
||||
|
||||
def _uninstall_reject_reason(self, plugin_id: str) -> Optional[str]:
|
||||
# 只删经安装器装入的目录,绝不碰 config/plugins/ 下的其它内容
|
||||
if not (self.root / plugin_id / INSTALLED_FILE).is_file():
|
||||
# 只删经本安装器装入的目录(installed.json 指向含 plugin.toml 的版本目录),
|
||||
# 绝不碰 config/plugins/ 下的其它内容,包括同样带 installed.json 的 LLM 插件
|
||||
version = installed_version(self.root, plugin_id)
|
||||
if (
|
||||
not version
|
||||
or not (self.root / plugin_id / version / MANIFEST_NAME).is_file()
|
||||
):
|
||||
return f"Plugin not installed: {plugin_id}"
|
||||
return None
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ from pydantic import BaseModel
|
||||
|
||||
from ab_sdk import SDK_VERSION, Plugin, PluginDisabled, PluginLoaded
|
||||
from ab_sdk.hooks import HOOK_ATTR, PROVIDER_ATTR, SUBSCRIBE_ATTR
|
||||
from ab_sdk.manifest import TOOLING_DIRS
|
||||
|
||||
from .bus import EventBus
|
||||
from .context import PLUGIN_DATA_ROOT, HostPluginContext
|
||||
@@ -37,7 +38,7 @@ def _fingerprint(root: Path) -> Fingerprint:
|
||||
"""目录下所有文件的(相对路径、修改时间、大小),用于判断插件源码是否变过。"""
|
||||
stamps = []
|
||||
for dirpath, dirs, names in os.walk(root):
|
||||
dirs[:] = [d for d in dirs if d != "__pycache__"]
|
||||
dirs[:] = [d for d in dirs if d not in TOOLING_DIRS]
|
||||
for name in names:
|
||||
path = Path(dirpath, name)
|
||||
try:
|
||||
|
||||
@@ -71,7 +71,8 @@ def _migrate_provider_aliases(config: dict[str, Any]) -> list[str]:
|
||||
old, new = _PROVIDER_ALIASES.get(
|
||||
str(provider.get("type", "")).lower(), ("", "")
|
||||
)
|
||||
if old in provider:
|
||||
# Settings.save() 总是把旧字段以 null 写回,只有带值的才是 3.3 遗留
|
||||
if provider.get(old):
|
||||
legacy = provider.pop(old)
|
||||
provider[new] = provider.get(new) or legacy
|
||||
moved.append(f"notification.providers[{i}].{old}")
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import pytest
|
||||
|
||||
from module.parser.analyser.providers import registry
|
||||
from module.parser.analyser.providers import ProviderRegistry, registry
|
||||
from module.parser.analyser.providers.base import LLMProviderAdapter
|
||||
|
||||
|
||||
@@ -30,3 +30,16 @@ class TestListInfos:
|
||||
infos = {info.id: info for info in registry.list_infos()}
|
||||
assert infos["openai"].needs_base_url is True
|
||||
assert infos["anthropic"].needs_base_url is False
|
||||
|
||||
|
||||
def test_installed_manifests_skips_generic_plugin_dirs_silently(tmp_path, caplog):
|
||||
# 通用插件(plugin.toml)与 LLM 插件共用 config/plugins/<id>/ 布局
|
||||
version_dir = tmp_path / "generic" / "1.0.0"
|
||||
version_dir.mkdir(parents=True)
|
||||
(version_dir / "plugin.toml").write_text("[plugin]\n")
|
||||
(tmp_path / "generic" / "installed.json").write_text('{"version": "1.0.0"}')
|
||||
|
||||
with caplog.at_level("WARNING"):
|
||||
found = ProviderRegistry(plugins_root=tmp_path)._installed_manifests()
|
||||
|
||||
assert found == {} and caplog.records == []
|
||||
|
||||
@@ -227,6 +227,18 @@ class TestInstall:
|
||||
|
||||
assert not result.success and unmanaged.is_dir()
|
||||
|
||||
async def test_uninstall_leaves_llm_provider_plugins_alone(self, tmp_path, keypair):
|
||||
installer = make_installer(tmp_path, keypair, build_zip(tmp_path))
|
||||
# LLM 插件:有 installed.json 与 plugin.json,没有 plugin.toml
|
||||
llm_dir = tmp_path / "plugins" / "github-copilot"
|
||||
(llm_dir / "1.0.0").mkdir(parents=True)
|
||||
(llm_dir / "1.0.0" / "plugin.json").write_text("{}")
|
||||
(llm_dir / "installed.json").write_text('{"version": "1.0.0"}')
|
||||
|
||||
result = await installer.uninstall("github-copilot")
|
||||
|
||||
assert not result.success and (llm_dir / "installed.json").is_file()
|
||||
|
||||
|
||||
class TestReleaseScript:
|
||||
async def test_catalog_built_by_release_script_installs(self, tmp_path, keypair):
|
||||
|
||||
@@ -69,6 +69,27 @@ def test_validate_broken_plugin_fails(tmp_path, capsys, problem):
|
||||
assert "错误" in capsys.readouterr().err
|
||||
|
||||
|
||||
@pytest.mark.parametrize("tooling_dir", [".venv", "node_modules", ".mypy_cache"])
|
||||
def test_validate_ignores_native_files_in_tooling_dirs(tmp_path, tooling_dir):
|
||||
plugin_dir = _new(tmp_path)
|
||||
native = plugin_dir / tooling_dir / "lib" / "_core.so"
|
||||
native.parent.mkdir(parents=True)
|
||||
native.write_bytes(b"")
|
||||
|
||||
assert main(["validate", str(plugin_dir)]) == 0
|
||||
|
||||
|
||||
def test_fingerprint_ignores_tooling_dirs(tmp_path):
|
||||
from module.plugin.manager import _fingerprint
|
||||
|
||||
plugin_dir = _new(tmp_path)
|
||||
before = _fingerprint(plugin_dir)
|
||||
(plugin_dir / ".venv").mkdir()
|
||||
(plugin_dir / ".venv" / "x.py").write_text("x")
|
||||
|
||||
assert _fingerprint(plugin_dir) == before
|
||||
|
||||
|
||||
def test_pack_puts_contents_at_zip_root_and_is_reproducible(tmp_path):
|
||||
plugin_dir = _new(tmp_path)
|
||||
(plugin_dir / "my_plugin" / "__pycache__").mkdir()
|
||||
|
||||
@@ -197,3 +197,23 @@ def test_migrate_v3_config_rewrites_file_with_legacy_bark_token(tmp_path):
|
||||
saved = json.loads(path.read_text())
|
||||
assert saved["notification"]["providers"] == [{"type": "bark", "device_key": "k"}]
|
||||
assert path.with_name("config.json.v3.bak").exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"provider",
|
||||
[
|
||||
# Settings.save() 总是把渠道的旧字段以 null 写回,不能当作 3.3 配置
|
||||
{"type": "bark", "token": None, "device_key": "k"},
|
||||
{"type": "wecom", "chat_id": None, "webhook_url": "https://hook"},
|
||||
{"type": "bark", "token": "", "device_key": ""},
|
||||
],
|
||||
)
|
||||
def test_migrate_v3_config_empty_legacy_alias_is_noop(tmp_path, provider):
|
||||
path = tmp_path / "config.json"
|
||||
path.write_text(json.dumps({"notification": {"providers": [provider]}}))
|
||||
before = path.read_bytes()
|
||||
|
||||
assert migrate_v3_config(path) is False
|
||||
|
||||
assert path.read_bytes() == before
|
||||
assert list(tmp_path.glob("config.json.v3.bak*")) == []
|
||||
|
||||
@@ -260,6 +260,13 @@ bark / wecom 旧字段别名:
|
||||
- 校验:`uvx check-jsonschema --builtin-schema vendor.github-workflows` 通过;`actionlint` 只报告已有步骤的 SC2086 提示(本次改动之前就存在)。
|
||||
- **未验证**:轮子构建与 release 上传只在真实 tag 推送时才会被 CI 跑到;本地只验证了 `uv build --wheel backend/sdk`(第一部分)、YAML 语法与上面的测试。
|
||||
|
||||
### 实施中的调整(P7 评审修复)
|
||||
|
||||
- **原生扩展扫描与文件监听跳过工具链目录**:`native_files()` 原先遍历整个插件目录,`uv run pytest` 在插件目录里建出的 `.venv`(含 `pydantic_core` 的 `.so`)会让 `validate` / `pack` / `dev` 和宿主加载器都报「含原生扩展」,`dev_mode` 的指纹轮询也每秒遍历 `.venv`。`ab_sdk.manifest.TOOLING_DIRS`(`__pycache__`、`.git`、`.venv`、`dist`、`node_modules`、各类缓存)现在由原生扩展扫描、`pack` 与文件指纹共用;`pack` 仍额外排除 `tests`。
|
||||
- **v3 迁移不再把 `null` 当作旧字段**:`Settings.save()` 总是把 Bark 的 `token`、WeCom 的 `chat_id` 以 `null` 写回,`old in provider` 因此每次启动都成立,每次启动都新增 `config.json.v3.bak.N` 并改写配置。现在只在旧字段有值时迁移。
|
||||
- **通用插件与 LLM 插件共用 `config/plugins/<id>/` 的两处隔离**:LLM 注册表扫描跳过版本目录里是 `plugin.toml` 的目录(原先每次列举都对它打 `Skipping broken plugin` 警告);通用安装器卸载时要求 `installed.json` 指向的版本目录含 `plugin.toml`,不能再通过 `DELETE /plugins/{id}` 删掉 LLM 插件(其凭据清理在 LLM 安装器里,不会被跳过)。
|
||||
- **未修:在线更新 bundle 不含 `ab_sdk`**。`boot_overlay.py` 是镜像自带的稳定脚本,只把 bundle 里的 `backend/src/module` 换进 `/app/module`;只在 `build.yml` 里多拷一份 `ab_sdk` 不会被旧镜像应用,要让 bundle 能更新 SDK 必须改覆盖层的应用方式。选项见 PR 说明,留作后续决定。
|
||||
|
||||
未做(第一部分已列出,本部分也没有做):
|
||||
|
||||
- **插件管理页**:设置 → 插件页没有目录浏览、安装 / 卸载按钮,也没有 `dev_mode` 提示;`GET /plugins` 仍不返回 `dev_mode`。任务清单不含它,推迟到发布阶段前补。
|
||||
|
||||
Reference in New Issue
Block a user