diff --git a/CHANGELOG.md b/CHANGELOG.md index 15e0b438..2a09808c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/lang/zh-CN/ ## [Unreleased] +### Fixed + +- 修复插件元数据异常时导致插件测试验证报错的问题 + ## [4.0.7] - 2024-11-20 ### Fixed diff --git a/src/plugins/github/plugins/publish/render.py b/src/plugins/github/plugins/publish/render.py index 0cfde69f..b2197c31 100644 --- a/src/plugins/github/plugins/publish/render.py +++ b/src/plugins/github/plugins/publish/render.py @@ -102,7 +102,7 @@ async def render_summary(test_result: DockerTestResult, output: str, project_lin version=test_result.version, load=test_result.load, run=test_result.run, - metadata=dumps_json(test_result.metadata.model_dump(), False) + metadata=dumps_json(test_result.metadata, False) if test_result.metadata else {}, output=output, diff --git a/src/plugins/github/plugins/publish/validation.py b/src/plugins/github/plugins/publish/validation.py index 4fddf5f0..6bdec5ae 100644 --- a/src/plugins/github/plugins/publish/validation.py +++ b/src/plugins/github/plugins/publish/validation.py @@ -108,7 +108,7 @@ async def validate_plugin_info_from_issue( metadata = test_result.metadata if metadata: # 从插件测试结果中获得元数据 - raw_data.update(metadata.model_dump()) + raw_data.update(metadata) raw_data["load"] = test_result.load raw_data["test_output"] = test_output @@ -130,7 +130,7 @@ async def validate_plugin_info_from_issue( if not result.valid_data.get("metadata") and not skip_test: # 如果没有跳过测试且缺少插件元数据,则跳过元数据相关的错误 # 因为这个时候这些项都会报错,错误在此时没有意义 - metadata_keys = Metadata.model_fields.keys() + metadata_keys = Metadata.__annotations__.keys() # 如果是重复报错,error["loc"] 是 () result.errors = [ error diff --git a/src/providers/docker_test/__init__.py b/src/providers/docker_test/__init__.py index 97d4c8ef..fd4a64ff 100644 --- a/src/providers/docker_test/__init__.py +++ b/src/providers/docker_test/__init__.py @@ -1,41 +1,20 @@ import json -from typing import Any +from typing import TypedDict import docker -from pydantic import BaseModel, Field, field_validator, model_validator -from pydantic_core import PydanticCustomError -from pyjson5 import Json5DecoderException +from pydantic import BaseModel, Field, SkipValidation, field_validator from src.providers.constants import DOCKER_IMAGES, REGISTRY_PLUGINS_URL -from src.providers.utils import load_json -class Metadata(BaseModel): +class Metadata(TypedDict): """插件元数据""" name: str desc: str - homepage: str | None = None - type: str | None = None - supported_adapters: list[str] | None = None - - @model_validator(mode="before") - @classmethod - def model_validator(cls, data: dict[str, Any]): - if data.get("desc") is None: - data["desc"] = data.get("description") - return data - - @field_validator("supported_adapters", mode="before") - @classmethod - def supported_adapters_validator(cls, v: list[str] | str | None): - if isinstance(v, str): - try: - v = load_json(v) - except Json5DecoderException: - raise PydanticCustomError("json_type", "JSON 格式不合法") - - return v + homepage: str | None + type: str | None + supported_adapters: list[str] | None class DockerTestResult(BaseModel): @@ -47,16 +26,9 @@ class DockerTestResult(BaseModel): config: str = "" # 测试环境 python==3.10 pytest==6.2.5 nonebot2==2.0.0a1 ... test_env: str = Field(default="unknown") - metadata: Metadata | None + metadata: SkipValidation[Metadata] | None outputs: list[str] - @field_validator("metadata", mode="before") - @classmethod - def metadata_validator(cls, v: Any): - if v: - return v - return None - @field_validator("config", mode="before") @classmethod def config_validator(cls, v: str | None): diff --git a/src/providers/docker_test/plugin_test.py b/src/providers/docker_test/plugin_test.py index 1aba5a13..8bfd0511 100644 --- a/src/providers/docker_test/plugin_test.py +++ b/src/providers/docker_test/plugin_test.py @@ -100,17 +100,16 @@ def get_session( """ RUNNER_SCRIPT = """import json -import os from nonebot import init, load_plugin, logger, require from pydantic import BaseModel class SetEncoder(json.JSONEncoder): - def default(self, obj): - if isinstance(obj, set): - return list(obj) - return json.JSONEncoder.default(self, obj) + def default(self, o): + if isinstance(o, set): + return list(o) + return json.JSONEncoder.default(self, o) init() @@ -122,7 +121,7 @@ def default(self, obj): if plugin.metadata: metadata = {{ "name": plugin.metadata.name, - "description": plugin.metadata.description, + "desc": plugin.metadata.description, "usage": plugin.metadata.usage, "type": plugin.metadata.type, "homepage": plugin.metadata.homepage, @@ -264,7 +263,7 @@ async def run(self): ) await self.run_poetry_project() - metadata = {} + metadata = None metadata_path = self.path / "metadata.json" if metadata_path.exists(): with open(self.path / "metadata.json", encoding="utf-8") as f: @@ -373,7 +372,7 @@ async def run_poetry_project(self) -> None: f.write( RUNNER_SCRIPT.format( self.module_name, - "\n".join([f"require('{i}')" for i in self._deps]), + "\n".join([f'require("{i}")' for i in self._deps]), ) ) diff --git a/src/providers/models.py b/src/providers/models.py index a262eaf9..29ec4e53 100644 --- a/src/providers/models.py +++ b/src/providers/models.py @@ -353,7 +353,7 @@ def from_info(cls, info: PluginPublishInfo) -> Self: homepage=info.homepage, type=info.type, supported_adapters=info.supported_adapters, - ).model_dump(), + ), }, ) diff --git a/src/providers/store_test/validation.py b/src/providers/store_test/validation.py index e089bdb5..a3fd647e 100644 --- a/src/providers/store_test/validation.py +++ b/src/providers/store_test/validation.py @@ -82,7 +82,7 @@ async def validate_plugin( # 使用最新的插件元数据更新插件信息 raw_data["metadata"] = bool(plugin_metadata) if plugin_metadata: - raw_data.update(plugin_metadata.model_dump()) + raw_data.update(plugin_metadata) # 通过 Github API 获取插件作者名称 try: diff --git a/tests/utils/docker_test/test_docker_plugin_test.py b/tests/utils/docker_test/test_docker_plugin_test.py index 5d66afd9..7cd656aa 100644 --- a/tests/utils/docker_test/test_docker_plugin_test.py +++ b/tests/utils/docker_test/test_docker_plugin_test.py @@ -11,7 +11,7 @@ async def test_docker_plugin_test(mocked_api: MockRouter, mocker: MockerFixture) mocked_run = mocker.Mock() mocked_run.return_value = json.dumps( { - "metadata": {}, + "metadata": None, "outputs": ["test"], "load": True, "run": True, @@ -58,7 +58,7 @@ async def test_docker_plugin_test_metadata_some_fields_empty( mocked_api: MockRouter, mocker: MockerFixture ): """测试 metadata 的部分字段为空""" - from src.providers.docker_test import DockerPluginTest, DockerTestResult, Metadata + from src.providers.docker_test import DockerPluginTest, DockerTestResult mocked_run = mocker.Mock() mocked_run.return_value = json.dumps( @@ -85,16 +85,80 @@ async def test_docker_plugin_test_metadata_some_fields_empty( test = DockerPluginTest("project_link", "module_name") result = await test.run("3.12") + assert result == snapshot( + DockerTestResult( + config="", + load=True, + metadata={ + "name": "name", + "desc": "desc", + "homepage": None, + "type": None, + "supported_adapters": None, + }, + outputs=["test"], + run=True, + test_env="python==3.12", + version="0.0.1", + ) + ) + + assert not mocked_api["store_plugins"].called + mocked_run.assert_called_once_with( + "ghcr.io/nonebot/nonetest:3.12-latest", + environment=snapshot( + { + "PLUGIN_INFO": "project_link:module_name", + "PLUGIN_CONFIG": "", + "PLUGINS_URL": "https://raw.githubusercontent.com/nonebot/registry/results/plugins.json", + } + ), + detach=False, + remove=True, + ) + + +async def test_docker_plugin_test_metadata_some_fields_invalid( + mocked_api: MockRouter, mocker: MockerFixture +): + """测试 metadata 的部分字段不符合规范""" + from src.providers.docker_test import DockerPluginTest, DockerTestResult, Metadata + + mocked_run = mocker.Mock() + mocked_run.return_value = json.dumps( + { + "metadata": { + "name": "name", + "desc": "desc", + "homepage": 12, + "type": True, + "supported_adapters": {}, + }, + "outputs": ["test"], + "load": True, + "run": True, + "version": "0.0.1", + "config": "", + } + ).encode() + mocked_client = mocker.Mock() + mocked_client.containers.run = mocked_run + mocked_docker = mocker.patch("docker.DockerClient") + mocked_docker.return_value = mocked_client + + test = DockerPluginTest("project_link", "module_name") + result = await test.run("3.12") + assert result == snapshot( DockerTestResult( config="", load=True, metadata=Metadata( - desc="desc", - homepage=None, name="name", - supported_adapters=None, - type=None, + desc="desc", + homepage=12, # type: ignore + type=True, # type: ignore + supported_adapters={}, # type: ignore ), outputs=["test"], run=True, diff --git a/tests/utils/store_test/output.json b/tests/utils/store_test/output.json index fe8dc224..b7e312d8 100644 --- a/tests/utils/store_test/output.json +++ b/tests/utils/store_test/output.json @@ -1,7 +1,7 @@ { "metadata": { "name": "TREEHELP", - "description": "\u8ba2\u9605\u725b\u5ba2/CF/AT\u5e73\u53f0\u7684\u6bd4\u8d5b\u4fe1\u606f", + "desc": "\u8ba2\u9605\u725b\u5ba2/CF/AT\u5e73\u53f0\u7684\u6bd4\u8d5b\u4fe1\u606f", "usage": "/contest.list \u83b7\u53d6\u6240\u6709/CF/\u725b\u5ba2/AT\u5e73\u53f0\u7684\u6bd4\u8d5b\u4fe1\u606f\n/contest.subscribe \u8ba2\u9605CF/\u725b\u5ba2/AT\u5e73\u53f0\u7684\u6bd4\u8d5b\u4fe1\u606f\n/contest.update \u624b\u52a8\u66f4\u65b0\u6bd4\u8d5b\u4fe1\u606f\n", "type": "application", "homepage": "https://nonebot.dev/", diff --git a/tests/utils/store_test/output_failed.json b/tests/utils/store_test/output_failed.json index a40c5187..58074199 100644 --- a/tests/utils/store_test/output_failed.json +++ b/tests/utils/store_test/output_failed.json @@ -1,5 +1,5 @@ { - "metadata": {}, + "metadata": null, "outputs": [ "\u521b\u5efa\u6d4b\u8bd5\u76ee\u5f55 plugin_test", " For further information visit https://errors.pydantic.dev/2.9/v/model_type\u001b[0m" diff --git a/tests/utils/store_test/test_validate_plugin.py b/tests/utils/store_test/test_validate_plugin.py index 75e479f1..6d033bbc 100644 --- a/tests/utils/store_test/test_validate_plugin.py +++ b/tests/utils/store_test/test_validate_plugin.py @@ -26,12 +26,7 @@ def mock_docker_result(path: Path, mocker: MockerFixture): async def test_validate_plugin(mocked_api: MockRouter, mocker: MockerFixture) -> None: """验证插件信息""" - from src.providers.models import ( - Metadata, - RegistryPlugin, - StorePlugin, - StoreTestResult, - ) + from src.providers.models import RegistryPlugin, StorePlugin, StoreTestResult from src.providers.store_test.validation import validate_plugin mock_datetime = mocker.patch("src.providers.models.datetime") @@ -61,13 +56,18 @@ async def test_validate_plugin(mocked_api: MockRouter, mocker: MockerFixture) -> 创建测试目录 plugin_test require("nonebot_plugin_alconna")\ """, - "metadata": Metadata( - desc="订阅牛客/CF/AT平台的比赛信息", - homepage="https://nonebot.dev/", - name="TREEHELP", - supported_adapters=None, - type="application", - ), + "metadata": { + "name": "TREEHELP", + "desc": "订阅牛客/CF/AT平台的比赛信息", + "usage": """\ +/contest.list 获取所有/CF/牛客/AT平台的比赛信息 +/contest.subscribe 订阅CF/牛客/AT平台的比赛信息 +/contest.update 手动更新比赛信息 +""", + "type": "application", + "homepage": "https://nonebot.dev/", + "supported_adapters": None, + }, }, results={"validation": True, "load": True, "metadata": True}, test_env={"unknown": True}, @@ -103,7 +103,6 @@ async def test_validate_plugin_with_previous( 需要能够正常更新 author_id, tags 和 is_official 等信息 """ - from src.providers.docker_test import Metadata from src.providers.models import Color, RegistryPlugin, StoreTestResult, Tag from src.providers.store_test.validation import StorePlugin, validate_plugin @@ -153,13 +152,18 @@ async def test_validate_plugin_with_previous( 创建测试目录 plugin_test require("nonebot_plugin_alconna")\ """, - "metadata": Metadata( - desc="订阅牛客/CF/AT平台的比赛信息", - homepage="https://nonebot.dev/", - name="TREEHELP", - supported_adapters=None, - type="application", - ), + "metadata": { + "name": "TREEHELP", + "desc": "订阅牛客/CF/AT平台的比赛信息", + "usage": """\ +/contest.list 获取所有/CF/牛客/AT平台的比赛信息 +/contest.subscribe 订阅CF/牛客/AT平台的比赛信息 +/contest.update 手动更新比赛信息 +""", + "type": "application", + "homepage": "https://nonebot.dev/", + "supported_adapters": None, + }, }, results={"validation": True, "load": True, "metadata": True}, test_env={"unknown": True}, @@ -196,7 +200,7 @@ async def test_validate_plugin_skip_test( 如果插件之前是跳过测试的,如果插件测试成功,应将 skip_test 设置为 False。 """ - from src.providers.models import Metadata, RegistryPlugin, StoreTestResult + from src.providers.models import RegistryPlugin, StoreTestResult from src.providers.store_test.validation import StorePlugin, validate_plugin mock_datetime = mocker.patch("src.providers.models.datetime") @@ -226,13 +230,18 @@ async def test_validate_plugin_skip_test( 创建测试目录 plugin_test require("nonebot_plugin_alconna")\ """, - "metadata": Metadata( - desc="订阅牛客/CF/AT平台的比赛信息", - homepage="https://nonebot.dev/", - name="TREEHELP", - supported_adapters=None, - type="application", - ), + "metadata": { + "name": "TREEHELP", + "desc": "订阅牛客/CF/AT平台的比赛信息", + "usage": """\ +/contest.list 获取所有/CF/牛客/AT平台的比赛信息 +/contest.subscribe 订阅CF/牛客/AT平台的比赛信息 +/contest.update 手动更新比赛信息 +""", + "type": "application", + "homepage": "https://nonebot.dev/", + "supported_adapters": None, + }, }, results={"validation": True, "load": True, "metadata": True}, test_env={"unknown": True}, diff --git a/tests/utils/validation/test_plugin.py b/tests/utils/validation/test_plugin.py index e2a73386..f3ff283b 100644 --- a/tests/utils/validation/test_plugin.py +++ b/tests/utils/validation/test_plugin.py @@ -139,7 +139,7 @@ async def test_plugin_info_validation_failed(mocked_api: MockRouter) -> None: async def test_plugin_info_validation_plugin_load_failed( mocked_api: MockRouter, ) -> None: - """测试验证失败的情况""" + """插件无法正常加载的情况""" from src.providers.validation import PublishType, ValidationDict, validate_info data = generate_plugin_data(load=False, metadata=False) @@ -198,3 +198,84 @@ async def test_plugin_info_validation_plugin_load_failed( ) assert mocked_api["homepage"].called + + +async def test_plugin_info_validation_plugin_invalid_metadata( + mocked_api: MockRouter, +) -> None: + """获取到的插件元数据无效的情况""" + from src.providers.validation import PublishType, ValidationDict, validate_info + + data = generate_plugin_data( + homepage=12, # type: ignore + type=True, # type: ignore + supported_adapters={}, + ) + + result = validate_info(PublishType.PLUGIN, data, []) + + assert result == snapshot( + ValidationDict( + errors=[ + { + "type": "homepage", + "loc": ("homepage",), + "msg": "项目主页无法访问", + "input": 12, + "ctx": { + "status_code": -1, + "msg": "Invalid type for url. Expected str or httpx.URL, got : 12", + }, + }, + { + "type": "plugin.type", + "loc": ("type",), + "msg": "插件类型只能是 application 或 library", + "input": True, + }, + { + "type": "set_type", + "loc": ("supported_adapters",), + "msg": "值不是合法的集合", + "input": {}, + }, + ], + info=None, + raw_data={ + "author": "author", + "module_name": "module_name", + "project_link": "project_link", + "tags": '[{"label": "test", "color": "#ffffff"}]', + "name": "name", + "desc": "desc", + "homepage": 12, + "type": True, + "supported_adapters": {}, + "skip_test": False, + "metadata": True, + "author_id": 1, + "load": True, + "version": "0.0.1", + "test_output": "test_output", + "time": "2023-09-01T00:00:00+00:00Z", + }, + type=PublishType.PLUGIN, + valid_data={ + "module_name": "module_name", + "project_link": "project_link", + "time": "2023-09-01T00:00:00+00:00Z", + "name": "name", + "desc": "desc", + "author": "author", + "author_id": 1, + "tags": [{"label": "test", "color": "#ffffff"}], + "load": True, + "metadata": True, + "skip_test": False, + "version": "0.0.1", + "test_output": "test_output", + }, + ) + ) + + assert not mocked_api["homepage"].called