6fafd95d6c
两个自行调研后决定的问题,各自的证据写进了 design doc: 一、handler 返回 str 而不是带截断计数的小结构(0008 决策三,文末新增一节)。三条实据: 两个真实消费者的执行函数今天就返回纯字符串(GovDoc 的 handler 是 Coroutine[..., str], dissect 的环境 execute 是 -> str);dissect 的 observation_truncated_chars 唯一的生产写入点 硬编码 0 且全仓零读取点,存在的是名字不是需求;reference/pi 是唯一把截断做完整的,它记的是 totalBytes/outputBytes/maxBytes 这组绝对量而不是一个差值——现在补 truncated_chars 补的 大概率是错形状,正是 scope.md 说的「猜出来的接缝比没有接缝更难拆」。 二、新增 design 0009:src/polyloop/ 下每个数据类都加 kw_only=True,另加一条扫描测试守它。 实验室七个仓库 223 个 dataclass 里 kw_only 出现零次,但那是默认行为不是选择。真正的证据是 PolyGateway:它的 LLMResponse 前 11 个字段顺序被三个下游的测试替身按位置构造锁死,模块 docstring 写着「字段顺序即公共承诺」,还得专门写一条 test_eleven_legacy_fields_positional 守着,从此再也插不进字段。那个约束不是它选的是它继承的,而本库还没有下游装上。 扫描测试查的是构造签名不是那个装饰器参数——要守的承诺是「按位置构造不了」。 executor() 在派生那一刻全查一遍实现,缺一个就报错,不拖到分发时才炸。RegistryExecutor 是 具体类而不是闭包,因为 RunRequest 要用 isinstance 认它。CancelledError 不被那个 except Exception 接住(它继承 BaseException),有测试守着。
80 lines
3.5 KiB
Python
80 lines
3.5 KiB
Python
"""`research-wiki/design/0009-keyword-only-public-types.md`:数据类一律只收关键字参数。
|
|
|
|
守的是一条对下游的承诺——**字段顺序不受任何保护**,所以往中间插字段永远是安全的。没有这条
|
|
限制,只要有人能按位置构造,字段顺序就自动成了承诺的一部分,而插字段会静默改掉后面每一个
|
|
参数的含义:不报错,只是每个值都进错了字段。
|
|
|
|
实验室里已经有一个现成的教训:另一个库的一个 18 字段类型,前 11 个的顺序被三个下游的测试
|
|
替身按位置构造锁死,它从此再也不能往中间插东西,还得专门写一条测试守着。那个约束不是它选
|
|
的,是它继承的。本库还没有任何下游装上,所以它可以不长出来。
|
|
|
|
**这条测试断言的是承诺不是实现**(`CLAUDE.md` §1.8):构造方式是对外承诺过的东西。
|
|
"""
|
|
|
|
import dataclasses
|
|
import importlib
|
|
import inspect
|
|
import pkgutil
|
|
|
|
import pytest
|
|
|
|
import polyloop
|
|
|
|
pytestmark = pytest.mark.unit
|
|
|
|
|
|
def _all_dataclasses() -> list[type]:
|
|
"""遍历包内每一个模块,收集其中定义的数据类。
|
|
|
|
按 `__module__` 过滤,免得把一个模块 import 进来的别处的数据类重复算一遍——重复本身无害,
|
|
但它会让下面那条 fail-closed 守卫的计数虚高,于是守卫失去意义。
|
|
"""
|
|
found: dict[str, type] = {}
|
|
modules = [polyloop]
|
|
for info in pkgutil.walk_packages(polyloop.__path__, prefix="polyloop."):
|
|
modules.append(importlib.import_module(info.name))
|
|
for module in modules:
|
|
for name in dir(module):
|
|
candidate = getattr(module, name)
|
|
if not isinstance(candidate, type) or not dataclasses.is_dataclass(candidate):
|
|
continue
|
|
if candidate.__module__ != module.__name__:
|
|
continue
|
|
found[f"{candidate.__module__}.{candidate.__qualname__}"] = candidate
|
|
return list(found.values())
|
|
|
|
|
|
def test_the_scan_finds_something() -> None:
|
|
"""守卫自身的 fail-closed 检查。
|
|
|
|
没有这一条,包被改名或搬走之后下面那条断言会遍历一个空列表然后安静地绿,而绿的含义从
|
|
「全都合规」变成了「什么都没检查」,两者在输出上分不出来。
|
|
"""
|
|
found = _all_dataclasses()
|
|
|
|
assert len(found) >= 20, f"只扫到 {len(found)} 个数据类,扫描范围多半错了"
|
|
assert {cls.__module__ for cls in found} >= {"polyloop.types", "polyloop.ports"}
|
|
|
|
|
|
def test_every_dataclass_is_keyword_only() -> None:
|
|
"""包内每一个数据类都只收关键字参数,内部模块也一样。
|
|
|
|
统一规则不留判断余地,也才写得成这条检查。按类型大小分档的话,每加一个类型都要判一次,
|
|
而判错那次不会当场报错。
|
|
|
|
**查的是构造签名,不是那个装饰器参数。** 要守的承诺是「按位置构造不了」,而
|
|
`kw_only=True` 只是达成它的一种写法——逐字段标 `field(kw_only=True)` 是另一种,将来
|
|
Python 再多一种也一样。查签名的话这条检查不会因为写法换了就漏掉。
|
|
"""
|
|
positional: list[str] = []
|
|
for cls in _all_dataclasses():
|
|
offenders = [
|
|
name
|
|
for name, parameter in inspect.signature(cls).parameters.items()
|
|
if parameter.kind is not inspect.Parameter.KEYWORD_ONLY
|
|
]
|
|
if offenders:
|
|
positional.append(f"{cls.__module__}.{cls.__qualname__}: {offenders}")
|
|
|
|
assert positional == [], f"这些数据类还能按位置构造:{positional}"
|