[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.2" 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"]