Ruff 类型检查器(ty)对 attrs 库的类型推断支持:mdtest 外部依赖测试全解析
2026/9/10 18:44:26 网站建设 项目流程

Ruff 类型检查器(ty)对 attrs 库的类型推断支持:mdtest 外部依赖测试全解析

【免费下载链接】ruffAn extremely fast Python linter and code formatter, written in Rust.项目地址: https://gitcode.com/GitHub_Trending/ru/ruff

Ruff 仓库中的ty类型检查器通过 Markdown 驱动的测试框架(mdtest)来验证对第三方库的类型推断能力,其中 attrs.md 正是针对attrs库的专项测试套件。本文将以此文档为骨架,逐一拆解其中覆盖的attr.s/attrs.define两种声明式 API、field参数(initkw_onlyconverteralias)的类型行为,以及当前尚未支持的default装饰器限制,并深入对应源码验证实现原理,最后给出完整的本地运行方法。读完本文,你将理解 ty 类型检查器如何处理 attrs 声明式类定义,以及如何基于 mdtest 复现与扩展这些类型检查测试。

一、先读懂文件本质:attrs.md 是一个可执行的测试套件

位于 crates/ty_python_semantic/resources/mdtest/external/attrs.md 的这份文档并非普通的技术笔记,而是遵循 mdtest 格式编写的类型推断与类型检查测试套件。根据 crates/ty_test/README.md 的说明,任何 Markdown 文件都可以成为测试套件:其中的py代码块会被写入内存文件系统,交由类型检查器检查,然后通过与断言注释匹配来验证诊断结果。

mdtest 支持两种核心断言,本文档中两者都用到了:

  • # revealed: <类型>:必须与reveal_type(...)揭示出的推断类型精确一致,用于验证某个表达式被推断出的类型;
  • # error: [规则码]:断言在该行会产生指定规则码的诊断,例如invalid-assignment(类型不兼容赋值)、call-non-callable(对不可调用对象发起调用)、missing-argument(缺少必填参数)。

该文件处于external/子目录,意味着它依赖外部第三方包。external/README.md 明确说明该目录下的测试需要使用外部包,并在运行时通过uv sync --locked安装依赖后把site-packages复制进测试的内存文件系统(详见 crates/ty_test/README.md 的"Testing with external dependencies"一节)。同目录下还存放着pydantic.locknumpy.locksqlalchemy.lock等锁文件,attrs.lock即对应本测试。

二、测试环境:固定 Python 3.13 与 attrs 25.4.0

文档开头的 TOML 代码块声明了本次测试的运行环境,这是外部依赖测试的标准配置格式:

[environment] python-version = "3.13" python-platform = "linux" [project] dependencies = ["attrs==25.4.0"]
  • [environment]指定python-version(3.13)与python-platform(linux)。对于带外部依赖的测试,这两项是必填的,因为它们参与包的解析过程(详见 crates/ty_test/README.md);
  • [project]通过dependencies声明外部依赖,官方建议锁定精确版本以保证可复现。这里固定为attrs==25.4.0

对应地,仓库中存放了 attrs.lock,其中记录了 attrs 25.4.0 的 sdist 与 wheel 哈希、requires-python = "==3.13.*"以及mdtest-deps虚拟包信息。锁文件确保测试在任何环境、任何 CI 运行下结果一致。

三、旧式 API:attr.s+attr.ib的基础类

第一个测试用例覆盖 attrs 的传统(legacy)API。import attr导入的是模块名,attr.s是类装饰器,attr.ib()用于声明字段:

import attr @attr.s class User: id: int = attr.ib() name: str = attr.ib() user = User(id=1, name="John Doe") reveal_type(user.id) # revealed: int reveal_type(user.name) # revealed: str

这个用例断言了三点:

  1. @attr.s修饰的类可以正常实例化:User(id=1, name="John Doe")不会产生参数相关诊断,说明 ty 能识别 attrs 自动生成的构造器签名;
  2. 字段属性按声明注解推断类型:user.id被揭示为intuser.name被揭示为str
  3. attr.ib()作为字段说明符,其推断类型不影响字段本身的声明类型——字段类型始终以注解为准。

四、新式 API:attrs.define+field,含别名(alias)机制

第二个用例改用 attrs 现代 API,并引入field(alias=...)参数:

from attrs import define, field @define class User: id: int = field() internal_name: str = field(alias="name") user = User(id=1, name="John Doe") reveal_type(user.id) # revealed: int reveal_type(user.internal_name) # revealed: str

关键点在于alias:字段在类内部的属性名是internal_name,但构造器接受的参数名是name。因此:

  • 实例化时传入name="John Doe"(构造参数名走 alias);
  • 访问属性时使用user.internal_name(内部名),类型揭示为str

这验证了 ty 能正确区分"构造参数名"与"属性名"两条通道,并且对 alias 后的构造调用不报missing-argument/unknown-argument类诊断。

五、field参数的精细控制:init、kw_only、converter

第三个用例是整份文档中信息量最大的一节,它用同一个Product类验证了三个field参数在类型层面的完整语义:

from attrs import define, field def serialize_data(data: dict[str, int]) -> bytes: raise NotImplementedError @define class Product: id: int = field(init=False) name: str = field() price_cent: int = field(kw_only=True) data: bytes = field(converter=serialize_data, kw_only=True) reveal_type(Product.__init__) # revealed: (self: Product, name: str, *, price_cent: int, data: dict[str, int]) -> None p = Product(name="Gadget", price_cent=1999, data={"a": 1}) p.data = {"b": 2} reveal_type(p.data) # revealed: bytes p.data = "not a dict" # error: [invalid-assignment]

逐一解读:

  • field(init=False)id不参与构造器参数。揭示出的构造器签名中只有nameprice_centdataid被排除,因此Product(name=..., price_cent=..., data=...)的调用是合法的;
  • field(kw_only=True)price_centdata变为仅限关键字参数。注意revealed的签名里,nameself之间用, *分隔,*之后即为关键字专用参数——这是对kw_only最直观的类型层面呈现;
  • field(converter=serialize_data)data字段的属性类型bytes(由注解决定),但构造器接受的入参类型dict[str, int](即 converter 函数的入参类型)。签名中data: dict[str, int]而非bytes,证明 ty 在构造器签名推导中把 converter 函数的参数类型作为实参类型;
  • 赋值时的类型检查:由于属性类型是bytesp.data = {"b": 2}这类与属性类型不符的赋值会触发invalid-assignment诊断;而reveal_type(p.data)依然揭示为bytes,说明赋值行为不会污染属性的推断类型。

该测试同时覆盖了"构造参数类型"与"属性类型"分离推断的完整链路,这也是类型检查器对声明式数据类支持中最复杂的部分之一。

六、已知限制:default装饰器暂不支持

文档最后一节以"我们目前不支持这个特性"(We currently do not support this)明确标注了当前实现的边界:

from attrs import define, field @define class Person: id: int = field() name: str = field() # error: [call-non-callable] "Object of type `_MISSING_TYPE` is not callable" @id.default def _default_id(self) -> int: raise NotImplementedError # error: [missing-argument] "No argument provided for required parameter `id`" person = Person(name="Alice") reveal_type(person.id) # revealed: int reveal_type(person.name) # revealed: str

这里呈现了两个预期内的诊断:

  1. @id.defaultcall-non-callable:attrs 的default装饰器允许为字段注册默认值工厂。由于 ty 目前没有对该模式做特殊建模,id.default被当作普通属性访问,其推断类型是缺省值哨兵_MISSING_TYPE,而_MISSING_TYPE不是可调用对象,于是触发"Object of type_MISSING_TYPEis not callable";
  2. Person(name="Alice")missing-argument:因为没有识别出default装饰器注册的默认值,构造器签名中id仍是必填参数,未提供时触发 "No argument provided for required parameterid"。

值得注意的是,尽管存在上述两个诊断,person.idperson.name的属性类型仍被正确揭示为intstr,说明该限制仅影响"默认值注册与构造器签名推导",不影响字段本身的类型推断。这是一个典型的"已知缺口 + 期望行为"测试用例——它把当前实现的不足固化为可回归的断言,一旦未来实现了default装饰器支持,这些# error:断言就会驱动开发者更新测试。

七、源码佐证:字段说明符(field specifier)的实现机制

上述测试行为在源码中有清晰的对应实现,主要集中在类型推断与调用绑定两个环节。

7.1 字段说明符的类型保留设计

在 crates/ty_python_semantic/src/types/infer/builder.rs 中:

fn should_preserve_inferred_binding_type(ty: Type<'_>) -> bool { // Dataclass field specifiers carry metadata in the inferred RHS type; replacing it with the // declared field type would lose settings like `init=False`. matches!(ty, Type::KnownInstance(KnownInstanceType::Field(_))) }

attr.ib()attrs.field()这类字段说明符的推断类型需要被保留在绑定中,否则init=Falsekw_only等设置在后续步骤中会丢失。同一文件的注释还披露了内部存储策略:

attrs uses 2 specifiers, pydantic and strawberry use 3 specifiers. SQLAlchemy uses 7 field specifiers. We could probably store more inline if this turns out to be a performance problem. For now, we optimize for memory usage.

即当前只为标准库 dataclass 预留 1 个内联字段说明符槽位,attrs 需要 2 个,pydantic/strawberry 需要 3 个,SQLAlchemy 需要 7 个——这是出于内存占用考量而有意为之的优化取舍(常量NUM_FIELD_SPECIFIERS_INLINE = 1)。

7.2 字段说明符的返回类型约定

在 crates/ty_python_semantic/src/types/call/bind.rs 中,注释解释了处理dataclasses.fieldpydanticattrsSQLAlchemy等库字段说明符函数的统一策略:

dataclasses.fieldand field-specifier functions of commonly used libraries likepydantic,attrs, andSQLAlchemyall return the default type for the field (orAny) instead of an actualFieldinstance, even if this is not what happens at runtime... We still make use of this fact and pretend that all field specifiers return the type of the default value.

也就是说,尽管运行时字段说明符返回的是Field实例,类型检查器仍按约定把其返回类型视为字段默认值的类型(无默认值时为Any)。这解释了为什么field(converter=...)场景下需要额外读取 converter 参数来推导构造器入参类型——字段说明符的返回类型约定并不直接给出 converter 的入参信息。

八、本地运行:如何复现这些断言

方式一:cargo test(过滤 mdtest 套件)

所有 Markdown 测试由 crates/ty_python_semantic/tests/mdtest.rs 中的datatest_stable::harness!驱动,自动发现resources/mdtest下所有.md文件。运行全部 mdtest:

cargo test -p ty_python_semantic -- mdtest

方式二:运行带外部依赖的测试

由于 attrs.md 属于外部依赖测试,默认情况下这些测试可能被跳过(通过MDTEST_EXTERNAL环境变量控制,见 mdtest.py 中的_run_mdtest)。显式启用外部依赖运行:

MDTEST_EXTERNAL=1 cargo test -p ty_python_semantic --test mdtest -- mdtest__external

该过程要求本机安装uv并位于PATH中:测试框架会创建临时pyproject.toml、复制attrs.lock、执行uv sync --locked安装依赖,再把虚拟环境的site-packages挂载进测试的内存文件系统(crates/ty_test/README.md 相关章节)。

方式三:Python 运行器 + watch 模式

仓库提供了带监视模式的 Python 运行器:

uv run crates/ty_python_semantic/mdtest.py -e external/

其中-e/--enable-external启用外部依赖测试,external/是过滤参数。运行器会监视 Markdown 与 Rust 源码变化:Markdown 修改后自动重跑对应测试,Rust 代码变化时自动重新编译测试再运行(mdtest.py 的 watch 实现),适合在开发类型检查器时做快速回归。

结语

通过这份 attrs.md,我们可以看到 ty 类型检查器对 attrs 库支持的全貌:旧式attr.s/attr.ib与新式attrs.define/field均能正确推断字段类型并推导构造器签名;aliasinit=Falsekw_onlyconverter等参数在类型层面各有精确呈现;而default装饰器则被明确标记为尚未支持的已知限制,并以可回归的# error:断言形式固化在测试中。配合 infer/builder.rs 与 call/bind.rs 的实现细节,这类 Markdown 测试不仅是最直观的行为规范文档,也是驱动类型检查器持续演进的回归基线——对理解声明式库(attrs、pydantic、dataclasses)的类型支持机制具有直接的参考价值。

【免费下载链接】ruffAn extremely fast Python linter and code formatter, written in Rust.项目地址: https://gitcode.com/GitHub_Trending/ru/ruff

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询