1fac387e75
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 才验得出来。
213 lines
11 KiB
TOML
213 lines
11 KiB
TOML
[build-system]
|
||
requires = ["setuptools>=68.0"]
|
||
build-backend = "setuptools.build_meta"
|
||
|
||
[project]
|
||
name = "polyloop"
|
||
# 与 src/polyloop/__init__.py 的 __version__ 必须一致,由 tests/unit/test_package.py 断言。
|
||
version = "1.0.1"
|
||
description = "PolyLoop:实验室共用的 Agent 执行内核——一次运行的预算、停止语义、取消、逐步轨迹与 Skill 注入"
|
||
# registry 的包页面正文只认这一项:缺了它页面就是一片空白,而 twine 只会警告
|
||
# long_description missing,不阻塞上传——三步全绿、产物是坏的(PolyGateway 1.1.2 的教训)。
|
||
# README 在打包时被固化进产物,发布之后再改无效,所以改 README 必须排在构建之前。
|
||
readme = "README.md"
|
||
requires-python = ">=3.11"
|
||
# 3.11 是被下游钉死的:dissect 与 GovDoc-SaaS 都跑在 3.11,一个库不能要求比它的消费者更高的版本。
|
||
dependencies = []
|
||
# 核心没有运行时依赖,这是刻意的。公共类型与接缝签名上不许出现第三方类型(依赖规则 6),
|
||
# 否则那个包的 major 就是我们的 major。
|
||
|
||
[project.optional-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 会在
|
||
# 没有任何人改代码的情况下随上游发版随机变红,而随机的红叉很快就会训练所有人忽略红叉。
|
||
# 升级流程:改这里的版本号 → 本地重装 → 修掉新规则的告警 → 一起提交。
|
||
dev = [
|
||
"pytest==9.1.1",
|
||
"pytest-asyncio==1.4.0",
|
||
"ruff==0.16.2",
|
||
"import-linter==2.13",
|
||
# 发布用的两个。**刻意放进 dev 而不是靠人手装**:PolyGateway 那边它们不在任何 extra 里,
|
||
# 于是发布指南必须多写一条「记得先装这两个」,而那种步骤迟早有人漏。
|
||
"build==1.5.0",
|
||
"twine==7.0.0",
|
||
]
|
||
|
||
# 包页面上那几个链接。PyPI 的元数据里没有「仓库」这个字段,所以 registry 不会自动把包挂到
|
||
# 仓库上——那一步只能在网页上手动做,这里这几条是包页面上唯一能自带的去处。
|
||
[project.urls]
|
||
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"]
|
||
|
||
# py.typed 让下游装上之后拿得到类型信息。一个库不带它,下游的类型检查器会把整个包当成
|
||
# 无注解的黑盒,签名和公共类型上写的东西一条都用不上——而这个库对下游的承诺大半就写在
|
||
# 签名里(CLAUDE.md §6:下游看不到实现,只能靠签名和类型判断该传什么)。
|
||
[tool.setuptools.package-data]
|
||
polyloop = ["py.typed"]
|
||
|
||
[tool.ruff]
|
||
target-version = "py311"
|
||
line-length = 100
|
||
|
||
[tool.ruff.lint]
|
||
# S110(try-except-pass)与 E722(裸 except)断言 CLAUDE.md §1.7「禁止吞掉错误」。
|
||
# 这两条是硬约束的机器形式,不许在本文件或行内 noqa 里放开。
|
||
select = ["E", "F", "W", "I", "N", "UP", "B", "A", "C4", "SIM", "TCH", "S110"]
|
||
ignore = ["E501"]
|
||
|
||
[tool.ruff.lint.isort]
|
||
known-first-party = ["polyloop"]
|
||
|
||
[tool.pytest.ini_options]
|
||
pythonpath = ["src"]
|
||
testpaths = ["tests"]
|
||
asyncio_mode = "auto"
|
||
|
||
# 四层的判据是「依赖什么」,不是「叫什么」(CLAUDE.md §1.9)。按名字分层的话,改个函数名
|
||
# 就要挪测试文件;按依赖分,只要这个测试还是不连外部服务,它就一直待在原地。
|
||
#
|
||
# unit 里会出现少数只读源码文本、根本不执行被测代码的测试(依赖规则 6 那条「types 与 ports
|
||
# 禁止 import 任何第三方包」就是这种,它写不成 import-linter 契约)。它们不连任何外部服务,
|
||
# 按上面那条判据就是 unit,不为它们单开一层。哪天这类测试多到想单独跑,再拆不迟。
|
||
markers = [
|
||
"unit: 用测试替身,不连任何外部服务",
|
||
"integration: 连真 PolyGateway",
|
||
"e2e: 打真实模型网关,会产生真实调用与费用",
|
||
"contract: 公共 Protocol 的行为一致性套件,也是任何新适配器的准入标准",
|
||
]
|
||
|
||
# --strict-markers:拼错的标记直接报错,不给警告。默认配置下 @pytest.mark.uint 只是一条
|
||
# 警告,而那个文件会从此不属于任何一层、被所有筛选漏掉,静默消失。
|
||
# --import-mode=importlib:按文件路径建模块名,不按 basename。默认的 prepend 模式下
|
||
# tests/unit/test_stopping.py 与 tests/integration/test_stopping.py 会被当成同一个模块,
|
||
# 收集时报 import file mismatch——而且挂掉的是**整次收集**,连没被 -m 选中的层也一起挂。
|
||
# 另一种解法是给每个层目录补 __init__.py,选这个是因为它不要求以后新建目录的人记得补。
|
||
# -m 'not e2e':e2e 打真实网关、要花钱,默认不跑。显式 `pytest -m e2e` 覆盖(CLI 的 -m
|
||
# 后到优先)。这一条是刻意的默认排除,不是静默降级——它写在这里,且 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/ 里的测试:
|
||
# 规则 6(types 与 ports 禁止 import 任何第三方包)——「任何第三方」不是一份可枚举的清单。
|
||
# 规则 9(import polyloop 之后 sys.modules 里没有 polygateway)——那是运行时事实,不是静态图。
|
||
# 规则 10 的后半(import polyloop 之后 sys.modules 里没有 pytest)——同上。前半在下面。
|
||
# ---------------------------------------------------------------------------
|
||
[tool.importlinter]
|
||
root_packages = ["polyloop"]
|
||
# 有几条契约要禁止 import 外部包(下游项目、polygateway、asyncio / pathlib),
|
||
# 而外部包默认不进 import 图,所以必须在顶层打开这个开关。
|
||
include_external_packages = true
|
||
|
||
# 规则 1、2、3、8。`|` 表示同层且互不 import,`:` 表示同层可以互相 import。
|
||
# 装配层那三个用 `|`:session 一旦 import 了某个存储实现,那个实现就成了隐式默认。
|
||
# 逻辑层那五个也用 `|`:它们之间的编织只能发生在 session 里。
|
||
[[tool.importlinter.contracts]]
|
||
name = "分层:装配层 > 逻辑层 > ports > types,且同层互不 import"
|
||
type = "layers"
|
||
layers = [
|
||
"polyloop.session | polyloop.stores | polyloop.adapters | polyloop.testing",
|
||
"polyloop.tools | polyloop._assembly | polyloop._stopping | polyloop._recovery | polyloop.serialization",
|
||
"polyloop.ports",
|
||
"polyloop.types",
|
||
]
|
||
|
||
# 规则 8 单列。上一条已经覆盖它,重复一遍是为了让违规信息直接指向
|
||
# 「接缝定义模块被污染了」,而不是一条读起来要绕一圈的分层报错。
|
||
[[tool.importlinter.contracts]]
|
||
name = "ports 只依赖 types,不依赖任何其他 polyloop 模块"
|
||
type = "forbidden"
|
||
source_modules = ["polyloop.ports"]
|
||
forbidden_modules = [
|
||
"polyloop.session",
|
||
"polyloop.stores",
|
||
"polyloop.adapters",
|
||
"polyloop.testing",
|
||
"polyloop.tools",
|
||
"polyloop._assembly",
|
||
"polyloop._stopping",
|
||
"polyloop._recovery",
|
||
"polyloop.serialization",
|
||
]
|
||
|
||
# 规则 4。三个下游的顶层包名:dissect 是 harness,GovDoc-SaaS 是 docagent_core,
|
||
# CHSAnalyzer 还没写到 agent 那一步,等它有了包名再加进来。
|
||
[[tool.importlinter.contracts]]
|
||
name = "不反向 import 任何下游项目"
|
||
type = "forbidden"
|
||
source_modules = ["polyloop"]
|
||
forbidden_modules = ["harness", "docagent_core"]
|
||
|
||
# 规则 5。适配器是唯一允许碰网关的地方。
|
||
[[tool.importlinter.contracts]]
|
||
name = "除 adapters 外一切禁止 import polygateway"
|
||
type = "forbidden"
|
||
source_modules = [
|
||
"polyloop.session",
|
||
"polyloop.stores",
|
||
"polyloop.testing",
|
||
"polyloop.tools",
|
||
"polyloop._assembly",
|
||
"polyloop._stopping",
|
||
"polyloop._recovery",
|
||
"polyloop.serialization",
|
||
"polyloop.ports",
|
||
"polyloop.types",
|
||
]
|
||
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 一下存储、顺手读个文件),拦不住存心的。
|
||
[[tool.importlinter.contracts]]
|
||
name = "三个纯逻辑模块不碰 I/O 与事件循环"
|
||
type = "forbidden"
|
||
source_modules = [
|
||
"polyloop._assembly",
|
||
"polyloop._stopping",
|
||
"polyloop._recovery",
|
||
]
|
||
forbidden_modules = ["asyncio", "pathlib"]
|