feat(testing): 契约套件搬进 polyloop.testing 随包发布,五套全部接上实现

tests/ 不进 wheel,所以那套被 CLAUDE.md §0 称作「任何新适配器的准入标准」的用例,第一个
下游根本拿不到。**接法同时换掉**:pytest 的 conftest 只沿被收集文件的目录链查找,装在
site-packages 里的测试模块看不见下游的 conftest,原来那个「在自己的 conftest 里覆盖同名
fixture」的接法在发布之后走不通。改成继承契约基类,下游的子类定义在自己的目录链上。

**搬的过程中发现这套准入标准从来没被执行过。** test_model_client.py 有四条用例调用
records.model_call(...),而工厂里根本没有这个方法——它没炸是因为那个 fixture 默认 skip。
五个接缝里只有存储那套被真跑过(15 条跳过里有 15 条是这四套)。

所以这个提交的另一半是让它真的跑起来。存储接两个实现(一份契约同时验多个实现,正是换接法
换来的);动作执行接注册表分发器,外加一个有真实等待点的替身,否则那条取消用例的断言半边
永远走不到;模型调用接网关适配器,落在 integration,它连的是真网关;决策解释与事件出口各
接一个测试替身——替身住在 tests/ 里不进 wheel,下游拿不到,所以不违反「库不带默认实现」,
判据是下游拿不拿得到。

**一并清掉两类坏用例。** 五条函数体只有 docstring、一个断言都没有却报 PASSED 的假绿——一个
准入标准里出现假绿比出现跳过糟得多,下游看到全绿会以为验过了。以及一条端口从没承诺过的
长度断言(len(history_text) <= len(reply.content)):压测的 AppWorld 场景为了迁就它,刻意
不补被复刻的实现真的会补的三个反引号,注释里写着「补一个字符就违约」。七条「这一层验不了」
统一成无条件 skip,理由字符串写全「承诺是什么/为什么验不了/你该在哪儿自己验」。

**发一个 pytest11 entry point,只为换回断言重写。** 契约模块不在下游的 python_files 里,
默认不被重写,于是一条契约失败时下游看到的是光秃秃的 AssertionError。不做的话没有任何东西
会报错,纯静默退化。实测过:editable 安装下 entry point 注册了但重写不生效(RECORD 里没有
包文件),要装真 wheel 才验得出来。
This commit is contained in:
2026-08-27 03:59:16 -04:00
parent 3492a2994a
commit 1fac387e75
22 changed files with 1538 additions and 632 deletions
+38 -3
View File
@@ -21,6 +21,11 @@ dependencies = []
# 模型适配器单独成一个 extra:不用它的人不该被迫装上网关,也不该在 import 时把网关连同
# 它的 provider 目录一起拉起来(依赖规则 9)。PolyGateway 不在公共 PyPI 上,装它要配私有源。
gateway = ["polygateway>=1.1,<2"]
# 契约套件(polyloop.testing)跑起来要的两个包。**版本只写下界,不钉死**——这和下面 dev 那组
# 正好相反,理由也正好相反:dev 是本仓库自己的工具链,钉死是为了本地和 CI 跑的是同一套;
# testing 装进的是下游自己的环境,钉死会和下游已经在用的 pytest 打架,而下游没有第二个
# 虚拟环境可以放我们钉的那一版。下次想「顺手统一一下」这两组的写法时,先看这段。
testing = ["pytest>=8", "pytest-asyncio>=0.24"]
# dev 工具链的版本必须钉死,不用下界。教训来自 CHSAnalyzer:本地 conda 是 ruff 0.15.1、
# CI 装到刚发布的 0.16.0,新版本多了几条规则,于是「本地全绿、CI 全红」。不钉的话 CI 会在
# 没有任何人改代码的情况下随上游发版随机变红,而随机的红叉很快就会训练所有人忽略红叉。
@@ -43,6 +48,13 @@ Homepage = "https://gitea.iomgaa.online/iomgaa/PolyLoop"
Changelog = "https://gitea.iomgaa.online/iomgaa/PolyLoop/src/branch/main/CHANGELOG.md"
Issues = "https://gitea.iomgaa.online/iomgaa/PolyLoop/issues"
# pytest 插件入口。它换回来的是**断言重写**:契约模块不在下游的 `python_files` 匹配范围里,
# 默认不被重写,于是一条契约用例失败时下游只看到光秃秃的 AssertionError,没有等号两边的值。
# 声明了 pytest11 入口的发行包,它的每个 .py 文件都会被 pytest 标记为可重写。
# 不做的话没有任何东西会报错——纯静默退化,理由与边界写在 polyloop/testing/_plugin.py 里。
[project.entry-points.pytest11]
polyloop = "polyloop.testing._plugin"
[tool.setuptools.packages.find]
where = ["src"]
@@ -94,13 +106,14 @@ markers = [
addopts = "--strict-markers --import-mode=importlib -m 'not e2e'"
# ---------------------------------------------------------------------------
# 依赖规则的机器形式。条规则本身与它们各自的理由在
# 依赖规则的机器形式。条规则本身与它们各自的理由在
# research-wiki/design/0003-public-api-shape.md 决策八,当前形状在
# research-wiki/explanation/architecture.md 第七节。这里只写契约,不复述理由。
#
# 条里有两条写不成 import-linter 契约,它们是 tests/ 里的测试:
# 条里有两条写不成 import-linter 契约,还有一条只有前半写得成;写不成的那些是 tests/ 里的测试:
# 规则 6types 与 ports 禁止 import 任何第三方包)——「任何第三方」不是一份可枚举的清单。
# 规则 9import polyloop 之后 sys.modules 里没有 polygateway)——那是运行时事实,不是静态图。
# 规则 10 的后半(import polyloop 之后 sys.modules 里没有 pytest)——同上。前半在下面。
# ---------------------------------------------------------------------------
[tool.importlinter]
root_packages = ["polyloop"]
@@ -115,7 +128,7 @@ include_external_packages = true
name = "分层:装配层 > 逻辑层 > ports > types,且同层互不 import"
type = "layers"
layers = [
"polyloop.session | polyloop.stores | polyloop.adapters",
"polyloop.session | polyloop.stores | polyloop.adapters | polyloop.testing",
"polyloop.tools | polyloop._assembly | polyloop._stopping | polyloop._recovery | polyloop.serialization",
"polyloop.ports",
"polyloop.types",
@@ -131,6 +144,7 @@ forbidden_modules = [
"polyloop.session",
"polyloop.stores",
"polyloop.adapters",
"polyloop.testing",
"polyloop.tools",
"polyloop._assembly",
"polyloop._stopping",
@@ -153,6 +167,7 @@ type = "forbidden"
source_modules = [
"polyloop.session",
"polyloop.stores",
"polyloop.testing",
"polyloop.tools",
"polyloop._assembly",
"polyloop._stopping",
@@ -163,6 +178,26 @@ source_modules = [
]
forbidden_modules = ["polygateway"]
# 规则 10 的前半。契约套件是 polyloop 里唯一允许碰 pytest 的地方,形状照着上面那条 polygateway 写。
# 别处 import pytest 的后果是每个装了本库的下游都被迫装上 pytest 才 import 得动 polyloop
# 而 pytest 只在 testing 这个 extra 里,核心的 dependencies 是空的。
[[tool.importlinter.contracts]]
name = "除 testing 外一切禁止 import pytest"
type = "forbidden"
source_modules = [
"polyloop.session",
"polyloop.stores",
"polyloop.adapters",
"polyloop.tools",
"polyloop._assembly",
"polyloop._stopping",
"polyloop._recovery",
"polyloop.serialization",
"polyloop.ports",
"polyloop.types",
]
forbidden_modules = ["pytest"]
# 规则 7。这一条是烟雾报警,不是纯度契约——os、subprocess、sqlite3 都能绕过它,
# 而 open() 是内置函数,import-linter 根本看不见。它拦得住最常见的那种偷懒
# (写着写着顺手 await 一下存储、顺手读个文件),拦不住存心的。