From 7449458710390b32e5d9769eec24d35efb914479 Mon Sep 17 00:00:00 2001 From: Chiguyong Date: Mon, 6 Jul 2026 23:42:09 +0800 Subject: [PATCH] =?UTF-8?q?test:=20=E8=A1=A5=E5=85=85=20PEP=20562=20re-exp?= =?UTF-8?q?ort=20=E5=A5=91=E7=BA=A6=E5=AE=88=E6=8A=A4=E6=B5=8B=E8=AF=95=20?= =?UTF-8?q?(ce-code-review)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ce-code-review 9 个 reviewer 共识 finding:core/exceptions.py 的 PEP 562 __getattr__ re-export 契约无自动化守护。27 个调用方依赖 `from agentkit.core.exceptions import LLMProviderError`,但现有测试仅 顺带触发 happy path,未验证 is identity / isinstance 跨路径 / AttributeError / __dir__ / 缓存行为。误删 __getattr__ 仅靠现有测试可能延迟发现。 新增 tests/unit/test_core_exceptions_reexport.py (6 tests) 锁定 5 个不变量: 1. 三条导入路径返回同一类对象 (is identity) 2. 跨路径 isinstance/except 捕获正常 3. 未知属性抛 AttributeError (PEP 562 契约) 4. __dir__() 包含 re-export 名字 (通配导入可发现) 5. __getattr__ 调用后缓存到模块 __dict__ finding 1 of 2 (P2, gated_auto, requires_verification=true) --- tests/unit/test_core_exceptions_reexport.py | 81 +++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 tests/unit/test_core_exceptions_reexport.py diff --git a/tests/unit/test_core_exceptions_reexport.py b/tests/unit/test_core_exceptions_reexport.py new file mode 100644 index 0000000..12d2c5e --- /dev/null +++ b/tests/unit/test_core_exceptions_reexport.py @@ -0,0 +1,81 @@ +"""PEP 562 re-export 契约守护测试。 + +core/exceptions.py 用模块级 __getattr__ 延迟 re-export LLM 异常类, +打破 core.exceptions ↔ llm.exceptions 循环依赖。27 个调用方依赖 +`from agentkit.core.exceptions import LLMProviderError` 契约。 + +本测试锁定以下不变量,防止 __getattr__ 被误删或 re-export 断裂: +1. 三条导入路径返回同一类对象(is identity) +2. 跨路径 isinstance/except 捕获正常 +3. 未知属性抛 AttributeError +4. __dir__() 使通配导入能发现 re-export +5. __getattr__ 调用后缓存到模块 __dict__(后续访问不再触发 lazy import) +""" + +import agentkit.core.exceptions as core_exc +import agentkit.llm.exceptions as llm_exc +from agentkit.core.exceptions import ( + LLMError, + LLMProviderError, + ModelNotFoundError, +) + +_REEXPORT_NAMES = ("LLMError", "LLMProviderError", "ModelNotFoundError") + + +def test_reexport_class_identity() -> None: + """core.exceptions 的 LLM 异常类与 llm.exceptions 中的是同一对象。""" + assert core_exc.LLMError is llm_exc.LLMError + assert core_exc.LLMProviderError is llm_exc.LLMProviderError + assert core_exc.ModelNotFoundError is llm_exc.ModelNotFoundError + + +def test_reexport_isinstance_cross_path() -> None: + """从 llm.exceptions raise 的异常能被 core.exceptions 的 except 捕获。""" + for exc_cls in (llm_exc.LLMError, llm_exc.LLMProviderError, llm_exc.ModelNotFoundError): + try: + raise exc_cls("test") + except LLMError: + pass # 所有三个都是 LLMError 子类或自身 + else: + raise AssertionError(f"{exc_cls.__name__} 未被 core.exceptions.LLMError 捕获") + + +def test_getattr_raises_attributeerror_for_unknown() -> None: + """访问不存在的属性抛 AttributeError(PEP 562 契约)。""" + try: + core_exc.NonExistentException # noqa: B018 + except AttributeError as e: + assert "NonExistentException" in str(e) + else: + raise AssertionError("预期 AttributeError 未抛出") + + +def test_dir_includes_reexport_names() -> None: + """__dir__() 包含三个 re-export 名字,使 from ... import * 可发现。""" + dir_names = set(dir(core_exc)) + for name in _REEXPORT_NAMES: + assert name in dir_names, f"{name} 不在 dir(core.exceptions) 中" + + +def test_getattr_caches_to_module_dict() -> None: + """__getattr__ 调用后,类被缓存到模块 __dict__,后续访问不再触发 lazy import。 + + 白盒验证:直接调用 __getattr__ 后检查 __dict__。即使其他测试已触发缓存, + 本测试仍有效(幂等:再次调用 __getattr__ 会重新写入相同类对象)。 + """ + result = core_exc.__getattr__("LLMError") # 显式触发 lazy import + assert result is llm_exc.LLMError + # 缓存后,三个名字都应存在于模块 __dict__(__getattr__ 一次性缓存全部) + for name in _REEXPORT_NAMES: + assert name in core_exc.__dict__, f"{name} 未被缓存到 core.exceptions.__dict__" + assert core_exc.__dict__[name] is getattr(llm_exc, name) + + +def test_llmprovider_error_inheritance_preserved() -> None: + """re-export 不影响继承链:LLMProviderError 仍是 LLMError 子类。""" + assert issubclass(LLMProviderError, LLMError) + assert issubclass(ModelNotFoundError, LLMError) + err = LLMProviderError(provider="openai", reason="timeout") + assert isinstance(err, LLMError) + assert err.provider == "openai"