Ruff 的 ty 类型检查器如何解析.pyiStub 文件:模块解析、PEP 561 与 mdtest 验证全解
【免费下载链接】ruffAn extremely fast Python linter and code formatter, written in Rust.项目地址: https://gitcode.com/GitHub_Trending/ru/ruff
本指南以 Ruff 仓库内置类型检查器 ty 的测试夹具 import/stubs.md 为核心,系统讲解类型检查器在“声明文件(Stub)优先”原则下如何从.pyi与.py文件导入符号、推断类型,并揭示背后的模块解析架构(ty_module_resolver)与测试驱动(mdtest)机制。读完本文,你将掌握 stub 解析的完整链路、reveal_type断言的含义,以及如何读懂并扩展 ty 的类型检查测试用例。
关联文档说明:一份“最小可运行”的解析规范夹具
Ruff 仓库(基于 Rust 实现,包含 lint 工具 ruff 与类型检查器 ty)中,类型检查器的行为由大量 Markdown 测试夹具约束。import/stubs.md 是其中一份高度凝练的文档:它只包含两个测试用例,每个用例由一个「待检查的 Python 文件 + 一个被导入模块的源码」构成,并以内联注释revealed:声明类型检查器必须推导出的类型。这份文档与其说是“说明文档”,不如说是 ty 的可执行行为规范——凡是通过 mdtest 跑出的reveal_type结果与文档注释不一致,测试即失败。
两个用例的骨架如下:
| 用例 | 待检查文件 | 被导入模块 | 期望推导结果 |
|---|---|---|---|
| Import from stub declaration | main(隐式) | b.pyi(stub,仅声明x: int) | y: int |
| Import from non-stub with declaration and definition | main(隐式) | b.py(实现文件,x: int = 1) | y: int |
两个用例的最终推导类型都是int,但被导入模块的存在形式截然不同:前者只有声明(declaration),后者同时具有声明与定义(definition)。这正是类型检查器处理“声明与定义分离”的核心场景。
用例一:从 Stub 声明中导入
文档给出了第一组测试代码:
from b import x y = x reveal_type(y) # revealed: int配套的 stub 文件b.pyi:
x: int解读:什么是 Stub 文件
.pyi后缀文件即 PEP 484 定义的 Stub(类型存根)文件。它只携带类型信息,不包含任何可执行实现,是类型检查器优先使用的“声明层”。本例中b.pyi仅声明x: int,没有给x赋值——这正是 Stub 的典型形态:x: int是纯粹的注解声明(declaration),运行时不产生任何真实对象。
ty 在类型检查(Typing)模式下会把.pyi置于普通.py之前优先解析(详见 resolve.rs 中ModuleResolveMode::Typing的注释:“type checkers are in fact supposed topreferstubs over the actual implementations”)。因此from b import x中的b会解析到b.pyi,x的类型即注解声明的int。
声明与定义的分离
Stub 中的x: int只有声明没有定义,但类型检查不要求“值必须存在”——检查器关心的是类型形状。y = x只是把int绑定到新名字y,所以:
reveal_type(y) # revealed: intreveal_type是 ty 的测试内省原语,其“返回值”出现在注释里,由 mdtest 断言框架与检查器实际推导结果比对。此例验证的核心语义是:Stub 声明(即便无运行时实现)足以支撑完整的类型推导。
用例二:从带声明与定义的普通模块导入
第二组测试代码:
from b import x y = x reveal_type(y) # revealed: int配套的实现文件b.py:
x: int = 1解读:注解声明与初始值同时存在
b.py中x: int = 1同时携带两件事:
- 声明(declaration):
x: int,标注类型为int; - 定义(definition):
x = 1,给出初始值。
对类型检查器而言,x的最终类型以注解为准(int),初始值1也必须与注解兼容。因此y = x之后y的类型同样是int。这一用例与用例一形成对照,覆盖了“声明与定义齐全的运行时模块”这一导入来源,确保类型系统不会只在 Stub 场景下工作。
两个用例背后的共性:ty 的模块解析架构
虽然关联文档只有两个用例,但它们背后是一整套模块解析基础设施,集中实现在 ty_module_resolver crate 中。理解这一层,才能真正读懂“为什么b会解析到b.pyi而不是b.py”。
模块表示:Module 枚举
module.rs 定义了模块的抽象:
pub enum Module<'db> { File(FileModule<'db>), Namespace(NamespacePackage<'db>), }File(FileModule):对应磁盘上真实文件(foo.py或foo.pyi),FileModule内部记录了模块名、ModuleKind、搜索路径、文件句柄与解析环境;Namespace(NamespacePackage):命名空间包,横跨多个搜索路径、没有单一代码文件(module.rs 的注释专门解释了这一点)。
ModuleKind则区分两种形态(module.rs):
pub enum ModuleKind { /// A single-file module (e.g. `foo.py` or `foo.pyi`) Module, /// A python package (`foo/__init__.py` or `foo/__init__.pyi`) Package, }从代码结构可以推断:b.pyi这种单文件 stub 对应ModuleKind::Module;若 stub 以包形式存在(b/__init__.pyi),则对应ModuleKind::Package。二者在类型检查中的行为一致,区别仅在于解析路径的形态。
解析模式:Typing vs Runtime
resolve.rs 中的ModuleResolveMode是理解“stub 优先”的关键:
pub enum ModuleResolveMode { /// Resolve modules for type checking, preferring stubs over runtime implementations. Typing, /// Resolve modules to their runtime implementations without considering stubs. Runtime, /// Like Runtime, but permits some modules to be shadowed. RuntimeSomeShadowingAllowed, }Typing:默认的检查模式,优先选择 stub。from b import x若同时存在b.pyi与b.py,解析结果将是b.pyi;关联文档用例一只有b.pyi,自然解析到 stub。Runtime:即“goto definition”模式,忽略 stub 直接找真实实现,在查询搜索路径时还会用真实 stdlib 替换 typeshed。stub_file_to_real_module(resolve.rs)正是用Runtime模式从 stub 反查其运行时模块,用于实现“跳转到定义”。
解析流程的顶层入口是resolve_module(resolve.rs),它在常规搜索路径失败后会退到desperately_resolve_module(“绝望解析”):以导入文件所在目录的祖先目录作为临时搜索路径,模拟 Python 运行时把脚本目录临时加入sys.path的行为。由于这一退化路径很少触发,ty 把它独立成查询以便缓存命中的常规路径。
搜索路径与导入解析顺序
SearchPaths::from_settings(resolve.rs)实现了 typing 规范中的导入解析顺序(import resolution ordering),其分层为:
- extra paths(额外路径):用户手动配置、优先级最高、完全由用户控制的搜索层,例如
extra-paths = ["/stubs"]; - src roots(一/三方源码根):项目自身的一/三方源码目录;
- stdlib / typeshed:标准库 stub(ty 内置于
ruff_db的 vendored typeshed,位于 ruff_db/src/vendored.rs),支持通过配置替换为自定义 typeshed; - site-packages:安装的第三方包目录,其中还包含对
.pth文件中 editable 安装路径的探测(site_packages_editables)。
同时SearchPaths维护了stdlib_path(typing 模式使用的 typeshed)与real_stdlib_path(Runtime 模式使用的真实 stdlib)两份标准库路径,二者在ModuleResolveMode不同时切换(resolve.rs)。
Stub 包的专项支持
除单文件 stub 外,ty 还完整支持 PEP 561 的 stub-only 包(foo-stubs/目录)与 partial stub 包,相关实现证据集中在:
StubPackageIndex与stub_package_index(resolve.rs):索引可能包含-stubs顶层目录的搜索路径,并保留它们相对于 stdlib 的解析顺序,用于 stub 包的 overlay 解析;search_path_may_contain_stub_package(resolve.rs):扫描目录中是否存在以-stubs结尾的条目;Module::is_type_check_only(module.rs):判断模块是否为仅用于类型检查的捆绑 stub(_typeshed、typing_extensions、ty_extensions)。
ty 在 user-controlled 的 extra-path 层内给予foo-stubsstub 包对普通foo包的优先权(无论搜索路径先后),且 namespace stub 包总是被视为 partial,普通 stub 包仅当py.typed内含partial标记时视为 partial——这些行为细节可见于 import/stub_packages.md 与 import/partial_stub_packages.md 这两份同目录测试夹具(后者也是关联文档stubs.md的姊妹篇,解释了 partial stub 的 fall-through 合并语义:stub 缺失的模块会回落到底层实现包继续查找)。
这些用例如何被验证:mdtest 测试框架
关联文档中的reveal_type与# revealed: int注释并非普通注释,而是 mdtest 框架的断言语法。
mdtest 的目录组织
mdtest 测试夹具位于 crates/ty_python_semantic/resources/mdtest,按主题分子目录(import/、class/、narrow/、generics/等),每个.md文件即一组相关用例;mdtest/snapshots 目录存放相应运行快照。
运行机制
执行器位于 crates/ruff_mdtest/src/lib.rs,其run_test函数(lib.rs)的大致流程是:
- 切换到内存文件系统(
db.use_in_memory_system()),以/src为项目根; - 解析 Markdown 中所有内嵌代码块(支持的语言为
py、pyi、python、ipynb、toml及被跳过的ignore),把它们写入内存文件系统——这正是stubs.md里b.py/b.pyi代码块会被真实落盘的原因; - 以测试头部可选的
[environment]/[configuration]TOML 块构建配置; - 对每个测试文件运行类型检查(
attempt_test),收集诊断; - 由 crates/mdtest/src/matcher.rs 的匹配器将
reveal_type/revealed:/error:等内联断言与实际诊断结果比对,不一致即测试失败; - 快照诊断输出到 snapshots 目录。
stubs.md没有携带[environment]配置块,意味着测试在默认环境下运行:main文件位于项目根,b.pyi/b.py与被检查文件同目录,属于默认的一/三方搜索路径覆盖范围。借助import/stub_packages.md、import/partial_stub_packages.md中的写法可以看到,一旦涉及自定义路径,用例会通过如下 TOML 声明环境:
[environment] extra-paths = ["/packages"]这也解释了关联文档为何如此精简——它刻意剥离了环境配置,聚焦于“stub 声明”与“实现模块”两种导入来源的类型推导等价性。
测试语言支持与 fixture 解析
同目录其他测试文件展示了 mdtest 支持的全部语言标签,例如partial_stub_packages.md中的py.typed文件使用text块、stub_packages.md中 editable 安装使用pth块,而lib.rs中assert_matches!显式断言支持py/pyi/python/ipynb/toml(lib.rs)。stubs.md用到的pyi标签是 stub 文件的声明入口,mdtest 解析器会据此将代码块按SourceType::Python与is_stub标记处理(lib.rs)。
实战:如何读懂与扩展这类用例
阅读方法
- 先看被检查文件(通常命名为
main.py或以文件名命名的代码块),reveal_type(...)是你需要关注的目标,# revealed: T是期望结果; - 再看被导入模块的文件块与文件名——
.pyi表示 stub 声明、.py表示真实实现; - 若有
# error: [code]注释,则表示该行必须产生对应诊断(例如 import/stub_packages.md 中的# error: [unresolved-import]); - 最后看文件头部的
[environment]/[configuration]块,确认搜索路径、Python 版本等前提。
扩展方法
新增用例只需在resources/mdtest对应主题目录下新建/追加 Markdown 块,用py/pyi/toml等标签声明文件,用reveal_type+revealed:描述期望类型,再运行 mdtest 套件即可自动验证与更新快照。新增文件的落盘根目录固定为/src,因此测试内引用绝对路径(如/packages、/.venv)时可任意规划目录结构。
小结
Ruff 内置类型检查器 ty 的 stub 支持遵循“声明优先于实现”的类型检查原则:
- 关联文档 import/stubs.md 用两个对照用例锁定了两条基本事实:从
.pyistub 声明导入可推导出完整类型;从带注解声明与定义的.py导入同样可推导出完整类型; - 底层 ty_module_resolver 通过
ModuleResolveMode::Typing优先选择 stub、按 typing 规范的导入解析顺序组织搜索路径,并以resolve_module→desperately_resolve_module的二级解析兜底; - 这些行为通过 ruff_mdtest 以 Markdown 内联断言的形式固化,保证类型检查器对 stub、实现、stub-only 包、partial stub 包等各种形态的导入语义始终如一。
如果你想进一步深入,可以依次阅读 crates/ty_module_resolver/src/resolve.rs、crates/ty_module_resolver/src/module.rs,再对照 import/stub_packages.md、import/partial_stub_packages.md 与 crates/ruff_mdtest/src/lib.rs 逐步验证。
【免费下载链接】ruffAn extremely fast Python linter and code formatter, written in Rust.项目地址: https://gitcode.com/GitHub_Trending/ru/ruff
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考