From 4dd69dd621e029e51e99aa428443fa3a4ec1f6cf Mon Sep 17 00:00:00 2001 From: huashen <2494946808@qq.com> Date: Fri, 14 Aug 2026 19:14:17 +0800 Subject: [PATCH] feat: add plugin api v3 contract --- README.md | 6 +- akashic_plugin_contracts/cli.py | 8 +- akashic_plugin_contracts/contract.py | 150 ++++++++++++++++++++++----- pyproject.toml | 4 +- tests/test_contract.py | 77 ++++++++++++++ 5 files changed, 216 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 65cb14a..e718686 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Akashic Plugin Contracts -这个仓库拥有 Akashic Plugin API v2 的跨仓库静态门控。它不加载插件,也不读取正式 +这个仓库拥有 Akashic Plugin API v2/v3 的跨仓库静态门控。它不加载插件,也不读取正式 workspace;只解析候选仓库的 `plugin.py`。 ```bash @@ -9,6 +9,8 @@ python -m akashic_plugin_contracts check /path/to/plugin.py 当前硬规则: +- 模块声明 `api_version = 3` 时,必须提供非空 `name`、`version` 与精确的 `apply(ctx, config)`; +- API v3 不要求继承 `Plugin`,直接后台任务必须用 `ctx.spawn()` 绑定 Fiber scope; - `Plugin` 子类必须显式声明 `api_version = 2`; - 禁止旧 `initialize()` 生命周期; - `prepare()` 与 `terminate()` 必须是 async,`activate()` 与 `retire()` 必须同步; @@ -16,4 +18,4 @@ python -m akashic_plugin_contracts check /path/to/plugin.py - `prepare()` 不能启动后台任务; - 生命周期不得绕过 `context.create_task()` 直接调用 `asyncio.create_task()`。 -命令成功时返回 0,并输出绑定文件 SHA-256 的 JSON;违反契约时返回 1。 +迁移期允许同一文件同时提供 v3 模块入口与 v2 `Plugin` 类;两套声明都会校验。命令成功时返回 0,并输出 API version、entrypoint 与文件 SHA-256 的 JSON;违反契约时返回 1。 diff --git a/akashic_plugin_contracts/cli.py b/akashic_plugin_contracts/cli.py index 3cc9b6c..23b9663 100644 --- a/akashic_plugin_contracts/cli.py +++ b/akashic_plugin_contracts/cli.py @@ -15,10 +15,16 @@ def main(argv: list[str] | None = None) -> int: args = parser.parse_args(argv) reports = [check_plugin(path) for path in args.paths] + versions = {report.api_version for report in reports} + contract = ( + f"akashic-plugin-api-v{next(iter(versions))}" + if len(versions) == 1 + else "akashic-plugin-api" + ) print( json.dumps( { - "contract": "akashic-plugin-api-v2", + "contract": contract, "passed": all(report.passed for report in reports), "reports": [report.to_dict() for report in reports], }, diff --git a/akashic_plugin_contracts/contract.py b/akashic_plugin_contracts/contract.py index f6861d2..9c39786 100644 --- a/akashic_plugin_contracts/contract.py +++ b/akashic_plugin_contracts/contract.py @@ -17,6 +17,8 @@ class ContractViolation: class ContractReport: path: str sha256: str + api_version: int + entrypoint: str plugin_classes: tuple[str, ...] violations: tuple[ContractViolation, ...] @@ -28,6 +30,8 @@ def to_dict(self) -> dict[str, object]: return { "path": self.path, "sha256": self.sha256, + "api_version": self.api_version, + "entrypoint": self.entrypoint, "plugin_classes": list(self.plugin_classes), "passed": self.passed, "violations": [asdict(item) for item in self.violations], @@ -35,35 +39,123 @@ def to_dict(self) -> dict[str, object]: def check_plugin(path: Path) -> ContractReport: - """Parse one plugin entrypoint and report every API v2 contract violation.""" + """Parse one plugin entrypoint and report every supported API violation.""" # 1. 固定被验收源码身份 source = path.read_bytes() tree = ast.parse(source, filename=str(path)) - # 2. 逐个检查 Plugin 子类的版本与生命周期 + # 2. Select the module v3 contract or the legacy class v2 contract. classes = [ node for node in tree.body if isinstance(node, ast.ClassDef) and _inherits_plugin(node) ] violations: list[ContractViolation] = [] - if not classes: - violations.append( - ContractViolation("PLG200", 1, "plugin.py 缺少 Plugin 子类") - ) - for plugin_class in classes: - violations.extend(_check_class(plugin_class)) + module_api_version = _module_constant(tree, "api_version") + if module_api_version == 3: + api_version = 3 + entrypoint = "module" + violations.extend(_check_v3_module(tree)) + for plugin_class in classes: + violations.extend(_check_class(plugin_class)) + else: + api_version = 2 + entrypoint = "class" + if module_api_version is not None: + violations.append( + ContractViolation( + "PLG300", + 1, + "模块 api_version 只支持 3;API v2 版本声明属于 Plugin 子类", + ) + ) + if not classes: + violations.append( + ContractViolation("PLG200", 1, "plugin.py 缺少 Plugin 子类") + ) + for plugin_class in classes: + violations.extend(_check_class(plugin_class)) # 3. 输出可供跨仓库 CI 绑定的稳定报告 return ContractReport( path=str(path.resolve()), sha256=hashlib.sha256(source).hexdigest(), + api_version=api_version, + entrypoint=entrypoint, plugin_classes=tuple(item.name for item in classes), violations=tuple(violations), ) +def _check_v3_module(tree: ast.Module) -> list[ContractViolation]: + """Validate the named exports that Core invokes for API v3.""" + + # 1. Identity exports are literal and reviewable without importing plugin code. + violations: list[ContractViolation] = [] + for name in ("name", "version"): + value = _module_constant(tree, name) + if not isinstance(value, str) or not value or value != value.strip(): + violations.append( + ContractViolation( + "PLG301", + 1, + f"API v3 模块必须声明非空字符串 {name}", + ) + ) + + # 2. Core calls exactly apply(ctx, config); sync and async bodies are both valid. + apply = next( + ( + node + for node in tree.body + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) + and node.name == "apply" + ), + None, + ) + if apply is None: + violations.append( + ContractViolation("PLG302", 1, "API v3 模块缺少 apply(ctx, config)") + ) + return violations + positional = (*apply.args.posonlyargs, *apply.args.args) + if ( + tuple(arg.arg for arg in positional) != ("ctx", "config") + or apply.args.vararg is not None + or apply.args.kwarg is not None + or apply.args.kwonlyargs + ): + violations.append( + ContractViolation( + "PLG303", + apply.lineno, + "API v3 apply 必须精确声明 apply(ctx, config)", + ) + ) + violations.extend(_check_v3_task_ownership(apply)) + return violations + + +def _check_v3_task_ownership( + apply: ast.FunctionDef | ast.AsyncFunctionDef, +) -> list[ContractViolation]: + violations: list[ContractViolation] = [] + for node in ast.walk(apply): + if isinstance(node, ast.Call) and _attribute_path(node.func) == ( + "asyncio", + "create_task", + ): + violations.append( + ContractViolation( + "PLG304", + node.lineno, + "API v3 后台任务必须用 ctx.spawn() 绑定 Fiber scope", + ) + ) + return violations + + def _check_class(plugin_class: ast.ClassDef) -> list[ContractViolation]: violations: list[ContractViolation] = [] api_version = _class_constant(plugin_class, "api_version") @@ -89,18 +181,10 @@ def _check_class(plugin_class: ast.ClassDef) -> list[ContractViolation]: "API v2 禁止 initialize(),按副作用归入 prepare() 或 activate()", ) ) - violations.extend( - _require_method_kind(methods, "prepare", asynchronous=True) - ) - violations.extend( - _require_method_kind(methods, "activate", asynchronous=False) - ) - violations.extend( - _require_method_kind(methods, "retire", asynchronous=False) - ) - violations.extend( - _require_method_kind(methods, "terminate", asynchronous=True) - ) + violations.extend(_require_method_kind(methods, "prepare", asynchronous=True)) + violations.extend(_require_method_kind(methods, "activate", asynchronous=False)) + violations.extend(_require_method_kind(methods, "retire", asynchronous=False)) + violations.extend(_require_method_kind(methods, "terminate", asynchronous=True)) prepare = methods.get("prepare") if prepare is not None: violations.extend(_check_prepare_boundary(prepare)) @@ -138,7 +222,11 @@ def _check_prepare_boundary( ) -> list[ContractViolation]: violations: list[ContractViolation] = [] for node in ast.walk(method): - if _attribute_path(node) == ("self", "context", "data_dir"): + if isinstance(node, ast.Attribute) and _attribute_path(node) == ( + "self", + "context", + "data_dir", + ): violations.append( ContractViolation( "PLG204", @@ -146,9 +234,10 @@ def _check_prepare_boundary( "prepare() 不得取得正式 plugin-data 路径", ) ) - if ( - isinstance(node, ast.Call) - and _attribute_path(node.func) == ("self", "context", "create_task") + if isinstance(node, ast.Call) and _attribute_path(node.func) == ( + "self", + "context", + "create_task", ): violations.append( ContractViolation( @@ -192,6 +281,19 @@ def _class_constant(node: ast.ClassDef, name: str) -> object: return None +def _module_constant(node: ast.Module, name: str) -> object: + for item in node.body: + if not isinstance(item, ast.Assign) or len(item.targets) != 1: + continue + target = item.targets[0] + if isinstance(target, ast.Name) and target.id == name: + try: + return ast.literal_eval(item.value) + except (ValueError, TypeError): + return None + return None + + def _attribute_path(node: ast.AST) -> tuple[str, ...]: parts: list[str] = [] current = node diff --git a/pyproject.toml b/pyproject.toml index 3819ffe..d02acf7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,8 +4,8 @@ build-backend = "setuptools.build_meta" [project] name = "akashic-plugin-contracts" -version = "2.0.0" -description = "Akashic Plugin API v2 static contract gate" +version = "3.0.0" +description = "Akashic Plugin API v2 and v3 static contract gate" requires-python = ">=3.11" [project.scripts] diff --git a/tests/test_contract.py b/tests/test_contract.py index 8f00ca9..291fd4e 100644 --- a/tests/test_contract.py +++ b/tests/test_contract.py @@ -21,6 +21,8 @@ def test_accepts_explicit_v2_lifecycle(tmp_path: Path) -> None: report = check_plugin(path) assert report.passed + assert report.api_version == 2 + assert report.entrypoint == "class" assert report.plugin_classes == ("WeatherPlugin",) @@ -67,3 +69,78 @@ def test_rejects_prepare_data_dir_and_unscoped_task(tmp_path: Path) -> None: "PLG205", "PLG206", } + + +def test_accepts_v3_named_exports_without_plugin_class(tmp_path: Path) -> None: + path = tmp_path / "plugin.py" + path.write_text( + "from agent.plugin_composition import ServiceKey\n" + "api_version = 3\n" + "name = 'weather'\n" + "version = '1.0.0'\n" + "inject = (ServiceKey('clock'),)\n" + "async def apply(ctx, config):\n" + " await ctx.spawn(worker(), name='weather')\n", + encoding="utf-8", + ) + + report = check_plugin(path) + + assert report.passed + assert report.api_version == 3 + assert report.entrypoint == "module" + assert report.plugin_classes == () + + +def test_accepts_v3_with_transition_v2_class(tmp_path: Path) -> None: + path = tmp_path / "plugin.py" + path.write_text( + "from agent.plugins import Plugin\n" + "api_version = 3\n" + "name = 'weather'\n" + "version = '1.0.0'\n" + "def apply(ctx, config):\n" + " return None\n" + "class WeatherPlugin(Plugin):\n" + " api_version = 2\n", + encoding="utf-8", + ) + + report = check_plugin(path) + + assert report.passed + assert report.api_version == 3 + assert report.plugin_classes == ("WeatherPlugin",) + + +def test_rejects_invalid_v3_identity_signature_and_task(tmp_path: Path) -> None: + path = tmp_path / "plugin.py" + path.write_text( + "import asyncio\n" + "api_version = 3\n" + "name = ' '\n" + "version = ''\n" + "async def apply(context, config, extra):\n" + " asyncio.create_task(worker())\n", + encoding="utf-8", + ) + + report = check_plugin(path) + + assert {item.code for item in report.violations} == { + "PLG301", + "PLG303", + "PLG304", + } + + +def test_rejects_v3_without_apply(tmp_path: Path) -> None: + path = tmp_path / "plugin.py" + path.write_text( + "api_version = 3\n" "name = 'weather'\n" "version = '1.0.0'\n", + encoding="utf-8", + ) + + report = check_plugin(path) + + assert {item.code for item in report.violations} == {"PLG302"}