如何为自研库用 stubtest 验证 .pyi 存根与实现的一致性?
【免费下载链接】mypyOptional static typing for Python项目地址: https://gitcode.com/GitHub_Trending/my/mypy
如果你给自研 Python 库维护了一份.pyi存根文件,最常见的风险是:实现改了,存根没跟上,两者逐渐脱节。mypy 自带的stubtest工具可以自动检查存根与实现之间的差异——它会导入你的代码并在运行时通过内省(例如inspect模块的能力)检查代码对象,再解析存根文件,把两者不一致的地方逐条报告出来。
前提条件:
- 已通过
python3 -m pip install mypy安装 mypy,并且 mypy 与被测库安装在同一个 Python 环境中(stubtest 必须能导入被测代码); - 存根文件已按 Python 模块命名规则写好在库旁边(下一节说明)。
一个需要注意的副作用:stubtest 会导入并执行被检查包中的 Python 代码,所以被测代码的 import 阶段必须能在当前环境安全运行。
stubtest 能查什么、查不了什么
stubtest 只依赖运行时动态内省,不会静态分析你的实现代码。它适合确保存根与实现的基本一致性、检查存根完整性(官方就是用它测试 typeshed 中的标准库存根),且对扩展模块(C 扩展)同样有效。
它明确做不到的事:
- 不做类型检查——用
mypy本身; - 不生成存根——用
stubgen; - 无法判断存根里函数返回值类型写得是否准确,因为它看不到运行时返回值的类型。
因此 stubtest 不是运行 mypy 的替代品,两者互补:mypy 检查调用方的类型使用,stubtest 检查存根与实现的对应关系。
准备存根文件:命名与位置
存根文件就是模块公共接口的骨架:类、变量、函数以及它们的类型,函数体用...省略(语法详见 存根文件文档)。放置方式有两种:
- 把
.pyi文件放在与库模块同一目录下,命名沿用普通 Python 模块规则,例如模块csv对应csv.pyi;包则用带__init__.pyi的子目录。 - 把
.pyi文件集中放在一个目录(例如myproject/stubs),并设置MYPYPATH指向该目录:
export MYPYPATH=~/work/myproject/stubs同一目录下若同时存在foo.py和foo.pyi,.pyi优先于.py被 mypy 使用,运行时解释器仍然使用.py。如果你的库以包形式发布且包含.pyi,按 PEP 561 还需要在包目录中放py.typed标记文件(参见 已安装包文档)。
运行检查
最小用法就是stubtest <模块名>,等价写法是用模块方式调用:
python3 -m mypy.stubtest library其中library是被检查的模块名,对应上面示例中的library.py/library.pyi。查看完整选项用:
stubtest --help下面用 stubtest 文档 中的例子演示一次完整检查。实现文件library.py:
x = "hello, stubtest" def foo(x=None): print(x)存根library.pyi:
x: int def foo(x: int) -> None: ...运行python3 -m mypy.stubtest library,文档给出的示例输出:
error: library.foo is inconsistent, runtime argument "x" has a default value but stub argument does not Stub: at line 3 def (x: builtins.int) Runtime: in file ~/library.py:3 def (x=None) error: library.x variable differs from runtime type Literal['hello, stubtest'] Stub: at line 1 builtins.int Runtime: 'hello, stubtest'每条错误都同时给出Stub:(存根侧的定义及位置)和Runtime:(运行时内省到的实际情况及来源文件位置),据此就能定位存根哪里与实现脱节。错误汇总行格式为Found N error (checked N module)(上文Found 1 error为文档示例);没有 error 输出即表示存根与实现当前一致。每条错误对应一个具体修法:要么改存根与运行时对齐,要么改实现——以哪边是预期接口为准,stubtest 本身不替你做判断。
用 allowlist 豁免已知差异
有些差异是环境性的,无法简单消除。典型例子:模块ex依赖一个可选重依赖,装不上时对应变量不存在:
try: import optional_expensive_dep except ImportError: optional_expensive_dep = None first = 1 if optional_expensive_dep: second = 2存根里写了second: int,但在没装该依赖的 CI 上 stubtest 会报(文档示例输出):
error: ex.second is not present at runtime Stub: in file /.../ex.pyi:2 builtins.int Runtime: MISSING Found 1 error (checked 1 module)在allowlist.txt中写入豁免条目,每行一个定义名,支持注释:
# 当 optional_expensive_dep 未安装时该名称不存在: ex.second再运行时加上--allowlist=allowlist.txt,该错误即被忽略。要点:
--allowlist FILE可传多次以合并多个 allowlist 文件;- allowlist 条目支持正则表达式,可以一次忽略一批类似错误;
- 默认情况下,未被用到的 allowlist 条目本身会报错(文档示例:
note: unused allowlist entry ex.second)。这在你有的 CI worker 装了该依赖、有的没装时很麻烦——此时把条目从ex.second改成(ex\.second)?(可匹配空串的正则),该条目就被视为可选,装与不装依赖两种环境下 stubtest 都能通过; - 若你就是想让所有未用条目都静默,加
--ignore-unused-allowlist; - 给存量项目引入 stubtest 时,可用
--generate-allowlist把当前所有错误直接打印成一份 allowlist(输出到 stdout),快速让检查整体变绿,之后逐条清理。
其他常用 CLI 参数
| 参数 | 用途 |
|---|---|
--concise | 输出更精简,每个错误一行 |
--ignore-missing-stub | 忽略"存根缺少运行时存在的东西"这类错误 |
--ignore-positional-only | 忽略参数是否应为 positional-only 的错误 |
--strict-type-check-only | 要求运行时不存在的私有类型标注typing.type_check_only |
--mypy-config-file FILE | 用指定 mypy 配置文件来确定 mypy 插件和 mypy 路径 |
--custom-typeshed-dir DIR | 使用自定义 typeshed 目录 |
--check-typeshed | 检查 typeshed 中所有标准库模块 |
排查运行不起来的三种情况
- 报"failed to find stubs"一类错误:stubtest 找不到存根文件。stubtest 遵循
MYPYPATH环境变量,把存根目录设置进去即可(存根与实现不在同一目录时尤其需要)。 - 报找不到被测代码:stubtest 必须能 import 被测库,确认 mypy 与库在同一环境;必要时设置
PYTHONPATH帮助它定位代码。 - 报"not checking stubs due to mypy build errors"一类错误:stubtest 依赖 mypy 来分析存根,mypy 无法解析存根时 stubtest 拒绝运行。此时需要先修好存根自身的 mypy 报错,stubtest 才会继续。注意这里的错误与 mypy 直接检查时的错误可能有重叠,但 stubtest 不是运行 mypy 的替代。
局限与后续
stubtest 的结论只覆盖运行时能内省到的部分:默认参数、参数名、模块级变量的存在性与值类型、名称是否缺失等;函数返回值类型是否标注准确,stubtest 给不出结论。所以完整的存根维护流程是:用 stubtest 保证存根与实现不脱节,同时继续用 mypy 做类型检查。stubtest 的完整选项说明见 docs/source/stubtest.rst,存根写法见 docs/source/stubs.rst。
【免费下载链接】mypyOptional static typing for Python项目地址: https://gitcode.com/GitHub_Trending/my/mypy
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考