From cbdde6443d136943041f2cb4eb08d7bf56410dcc Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Thu, 20 Aug 2026 19:01:02 +0800 Subject: [PATCH] test(plugins): align repository imports with runtime namespace (#6368) --- app/testing/bootstrap.py | 27 +++++----- ...test_testing_bootstrap_plugin_namespace.py | 49 +++++++++++++++++++ 2 files changed, 64 insertions(+), 12 deletions(-) create mode 100644 tests/test_testing_bootstrap_plugin_namespace.py diff --git a/app/testing/bootstrap.py b/app/testing/bootstrap.py index 2762652eb..67c453a95 100644 --- a/app/testing/bootstrap.py +++ b/app/testing/bootstrap.py @@ -2,8 +2,8 @@ 主程序 ``tests/conftest.py`` 与各插件仓的极薄 shim(``tests/_bootstrap.py``,仅负责把 后端定位并加入 ``sys.path``)都委托到这里,使「隔离 CONFIG_DIR / 建表 / 注入插件目录 / -按目录打 v1·v2 marker / 退出清理」等引导逻辑只在主程序维护一处,所有消费方行为与修复一致。 -其中 :func:`isolate_config_dir` 为主程序与插件仓共用,``prepare_v1/v2_backend`` 与 +按目录打 v1·v2·v3 marker / 退出清理」等引导逻辑只在主程序维护一处,所有消费方行为与修复一致。 +其中 :func:`isolate_config_dir` 为主程序与插件仓共用,``prepare_v1/v2/v3_backend`` 与 :func:`mark_plugin_generation` 为插件仓专用。 本模块只依赖标准库,``import`` 期不触发 ``app.*``:调用方可安全地「先 import 本模块、 @@ -112,11 +112,14 @@ def isolate_config_dir() -> str: return tmp -def _prepend_sys_path(path: Path) -> None: - """把目录前置到 ``sys.path``(去重),使其内顶层包可被导入。""" +def _expose_plugin_source(path: Path) -> None: + """让插件仓源码通过生产运行时的 ``app.plugins.`` 命名空间导入。""" + from importlib import import_module + + plugins_package = import_module("app.plugins") value = str(path) - if value not in sys.path: - sys.path.insert(0, value) + if value not in plugins_package.__path__: + plugins_package.__path__.insert(0, value) def ensure_sites_stub() -> None: @@ -188,7 +191,7 @@ def prepare_backend() -> None: def prepare_v2_backend(plugins_repo: Path) -> None: - """v2 插件单测引导:``prepare_backend`` + 把 ``/plugins.v2`` 注入 ``sys.path``。 + """v2 插件单测引导:准备后端并暴露 ``/plugins.v2`` 源码。 与 :func:`prepare_v1_backend` 互斥:v1/v2 存在同名插件包,同一进程同时加载会相互覆盖, 须在各自独立的 pytest 会话中运行。 @@ -196,11 +199,11 @@ def prepare_v2_backend(plugins_repo: Path) -> None: :param plugins_repo: 插件仓根目录(由调用方 shim 传入) """ prepare_backend() - _prepend_sys_path(Path(plugins_repo) / "plugins.v2") + _expose_plugin_source(Path(plugins_repo) / "plugins.v2") def prepare_v3_backend(plugins_repo: Path) -> None: - """v3 插件单测引导:``prepare_backend`` + 把 ``/plugins.v3`` 注入 ``sys.path``。 + """v3 插件单测引导:准备后端并暴露 ``/plugins.v3`` 源码。 v3 插件与旧代插件可能存在同名包,必须在独立 pytest 会话中加载,避免 Python 模块 缓存把其它代际实现复用到当前测试进程。 @@ -208,16 +211,16 @@ def prepare_v3_backend(plugins_repo: Path) -> None: :param plugins_repo: 插件仓根目录(由调用方 shim 传入) """ prepare_backend() - _prepend_sys_path(Path(plugins_repo) / "plugins.v3") + _expose_plugin_source(Path(plugins_repo) / "plugins.v3") def prepare_v1_backend(plugins_repo: Path) -> None: - """v1 插件单测引导:``prepare_backend`` + 把 ``/plugins`` 注入 ``sys.path``(与 v2 互斥)。 + """v1 插件单测引导:准备后端并暴露 ``/plugins`` 源码(与 v2 互斥)。 :param plugins_repo: 插件仓根目录(由调用方 shim 传入) """ prepare_backend() - _prepend_sys_path(Path(plugins_repo) / "plugins") + _expose_plugin_source(Path(plugins_repo) / "plugins") def mark_plugin_generation(items, pytest_module) -> None: diff --git a/tests/test_testing_bootstrap_plugin_namespace.py b/tests/test_testing_bootstrap_plugin_namespace.py new file mode 100644 index 000000000..019e68e35 --- /dev/null +++ b/tests/test_testing_bootstrap_plugin_namespace.py @@ -0,0 +1,49 @@ +"""验证插件仓测试引导复现生产运行时的插件导入命名空间。""" + +from importlib import import_module +from pathlib import Path +import sys + +import pytest + +from app.testing import bootstrap + + +@pytest.mark.parametrize( + ("prepare_name", "source_dir"), + ( + ("prepare_v1_backend", "plugins"), + ("prepare_v2_backend", "plugins.v2"), + ("prepare_v3_backend", "plugins.v3"), + ), +) +def test_prepare_plugin_backend_exposes_runtime_namespace( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + prepare_name: str, + source_dir: str, +) -> None: + """市场仓源码只通过 ``app.plugins.`` 规范身份导入。""" + plugin_id = f"namespace_probe_{source_dir.replace('.', '_')}" + plugin_dir = tmp_path / source_dir / plugin_id + plugin_dir.mkdir(parents=True) + (plugin_dir / "__init__.py").write_text( + f"PLUGIN_ID = {plugin_id!r}\n", + encoding="utf-8", + ) + + plugins_package = import_module("app.plugins") + monkeypatch.setattr(plugins_package, "__path__", list(plugins_package.__path__)) + monkeypatch.setattr(bootstrap, "prepare_backend", lambda: None) + + runtime_name = f"app.plugins.{plugin_id}" + try: + getattr(bootstrap, prepare_name)(tmp_path) + + runtime = import_module(runtime_name) + + assert Path(runtime.__file__).resolve() == plugin_dir / "__init__.py" + with pytest.raises(ModuleNotFoundError): + import_module(plugin_id) + finally: + sys.modules.pop(runtime_name, None)