ty 的 type-assertion-failure 规则:assert_type 与 assert_never 类型断言失败的检测原理、实现与诊断输出
type-assertion-failure 是 ty(Astral 出品的 Python 类型检查器,随 ruff 仓库一起维护)中最核心的静态断言类诊断之一。本文围绕该规则的官方文档展开,说明它的检查对象(typing.assert_type() 与 typing.assert_never())、判定标准(类型“等价”而非“兼容”)、在源码中的完整实现链路(从规则注册到 check_call 中的分支逻辑),以及诊断消息的具体构成与真实快照输出,帮助你在阅读 ty 报错时准确区分它与易混淆的 assert-type-unspellable-subtype 规则。
1. 规则概览:检查对象与动机
规则文档位于 type-assertion-failure.md,其官方定义如下:
- What it does:检查那些“实际类型与断言类型不一致”的
assert_type()和assert_never()调用; - Why is this bad:
assert_type()的用途是确认某个值被推断出的类型(inferred type)是否符合预期,一旦断言失败,通常意味着类型标注、类型收窄(narrowing)或泛型推断出了与开发者预期不符的结果。
官方文档给出的最小示例(python-version = "3.11" 环境下):
[environment]
python-version = "3.11"
from typing import assert_type
def _(x: int):
assert_type(x, int) # fine
# Actual type does not match asserted type
assert_type(x, str) # error
从规则注册代码看(diagnostic.rs),该规则的关键元信息为:
- 规则名(标识符):
TYPE_ASSERTION_FAILURE,对外暴露的规则代码即type-assertion-failure; - 摘要:
detects failed type assertions; - 状态:自
0.0.1-alpha.1起即为 stable,是 ty 最老的一批规则之一; - 默认级别:
Level::Error,即默认开启、默认按错误报告。
值得注意的实现细节是:lint 文档文件并非独立维护的 Markdown,而是通过 declare_lint! 宏中的 #[doc = include_str!("../../resources/lint_docs/type-assertion-failure.md")] 直接内嵌进规则定义,规则文档、summary 与默认级别在编译期就被绑定在一起(diagnostic.rs)。规则随后在注册表中登记(registry.register_lint(&TYPE_ASSERTION_FAILURE),见 diagnostic.rs),并在 ty.schema.json 中作为可配置的规则代码出现,这意味着它可以在 ty 的规则级别配置中按名称被调整。
2. 实现原理:assert_type 与 assert_never 的两条检测路径
两条检测路径都实现在已知函数(known function)的调用检查逻辑中:function.rs 的 KnownFunction::check_call。ty 对 typing 模块中的特殊函数做了专门处理,assert_type 与 assert_never 各自对应一个 KnownFunction 分支。
2.1 assert_type:基于“类型等价”而非“可赋值性”
对应实现为 KnownFunction::AssertType 分支(function.rs),其判定流程可以归纳为三步:
-
取实际类型与断言类型。从重载绑定的参数类型中取出
val的推断类型actual_ty与typ参数asserted_ty;断言侧的类型形式会先经过project_type_form投影(例如把Type[int]这类写法归一化,见下文测试中的Type[int]示例)。 -
等价性判定。若
actual_ty.is_equivalent_to(db, env, asserted_ty)成立,直接放行——这正是文档中“必须精确匹配(precisely match)”语义的落点:是等价(equivalent),不是子类型(subtype),也不是可赋值(assignable)。 -
失败后区分两种诊断。如果不等价,源码会进一步判断:
let diagnostic = if actual_ty.is_spellable(db) || !actual_ty.is_subtype_of(db, env, asserted_ty) { &TYPE_ASSERTION_FAILURE } else { &ASSERT_TYPE_UNSPELLABLE_SUBTYPE };也就是说:只要实际类型是“可拼写的”(spellable,能用标准 Python 类型标注表达),或者实际类型根本不是断言类型的子类型,就报告
type-assertion-failure;只有当实际类型既是断言类型的子类型、又无法用标准标注表达(比如类型收窄产生的Foo & Bar交集类型)时,才改报兄弟规则assert-type-unspellable-subtype。该文档明确解释了这样拆分的原因:便于用户区分“断言写错了、可以修复”与“ty 的非标准类型系统推导出更精确类型”这两类场景。
诊断消息由四部分拼装而成(function.rs):
- 主消息:
Argument does not have asserted type '{断言类型}'; - 次级标注(secondary annotation):指向
val实参位置,消息为Inferred type is '{推断类型}'; - info 行:若实际类型是断言类型的子类型,输出
'{推断类型}' is a subtype of '{断言类型}', but they are not equivalent;否则输出'{断言类型}' and '{推断类型}' are not equivalent types; - 简洁消息(concise message):
Type '{推断类型}' does not match asserted type '{断言类型}',用于需要单行摘要的展示场景(如 LSP 悬浮或行内提示)。
2.2 assert_never:与 Never 的等价判定
KnownFunction::AssertNever 分支(function.rs)的逻辑更直接:取第一个参数 arg 的推断类型,若其与 Type::Never 等价则放行;否则报告同一个 TYPE_ASSERTION_FAILURE 规则,消息模板为:
- 主消息:
Argument does not have asserted typeNever``; - 次级标注:
Inferred type of argument is '{推断类型}'; - info 行:
'Never' and '{推断类型}' are not equivalent types; - 简洁消息:
Type '{推断类型}' is not equivalent toNever``。
这与文档“Checks for assert_type() and assert_never() calls where the actual type is not the same as the asserted type”的表述完全对应:assert_never 只是把“断言类型”固定为 Never 的特例,共用同一条规则码。
3. 诊断输出与测试证据
ty 采用 mdtest(Markdown 内嵌测试 + 快照断言)来固化每个规则的行为,type-assertion-failure 的权威测试集中在 assert_type.md 与 assert_never.md。以下示例均可在测试文件中逐字复现。
3.1 基本失败:int 断言成 str
from typing_extensions import assert_type
def _(x: int, y: bool):
assert_type(x, int) # fine
assert_type(x, str)
快照输出(摘自测试文件):
error[type-assertion-failure]: Argument does not have asserted type `str`
--> src/mdtest_snippet.py:6:5
|
6 | assert_type(x, str)
| ^^^^^^^^^^^^-^^^^^^
| |
| Inferred type is `int`
info: `str` and `int` are not equivalent types
下划线同时覆盖整条调用与被断言的类型实参,与 2.1 节描述的“主消息 + secondary 标注 + info 行”结构一一对应。
3.2 子类型不算通过:bool 断言成 int
def _(x: int, y: bool):
assert_type(assert_type(x, int), int)
assert_type(y, int)
error[type-assertion-failure]: Argument does not have asserted type `int`
--> src/mdtest_snippet.py:10:5
|
10 | assert_type(y, int)
| ^^^^^^^^^^^^-^^^^^^
| |
| Inferred type is `bool`
info: `bool` is a subtype of `int`, but they are not equivalent
这里可以看到源码中“是子类型但非等价”那条 info 分支的真实输出(bool is a subtype of int, but they are not equivalent)。测试文件还验证了 type[int] 与 type[Any] 互断言同样失败,以及 Type[int] 与 type[int] 因 project_type_form 归一化而视为等价(# fine)。
3.3 对照收窄后的推断类型,而非声明类型
[environment]
python-version = "3.10"
def _(x: int | str):
if isinstance(x, int):
reveal_type(x) # revealed: int
assert_type(x, int) # fine
文档中的“实际类型 vs 声明类型”原则在这里得到验证:x 的声明类型是 int | str,但断言针对的是收窄后的推断类型 int,因此通过。
3.4 与 assert-type-unspellable-subtype 的分界
同组测试给出了三分支的完整演示:
class Foo: ...
class Bar: ...
class Baz: ...
def f(x: Foo):
assert_type(x, Bar) # error: type-assertion-failure(Foo 不是 Bar 的子类型)
if isinstance(x, Bar):
assert_type(x, Bar) # error: assert-type-unspellable-subtype
# 推断类型是 `Foo & Bar`,是 Bar 的子类型但无法用标准标注表达
assert_type(x, Baz) # error: type-assertion-failure
# `Foo & Bar` 不是 Baz 的子类型,仍走主规则
这正对应 function.rs 中 is_spellable || !is_subtype_of 的条件:交集类型 Foo & Bar 虽不可拼写,但它相对 Baz 不是子类型,所以依然报 type-assertion-failure。
3.5 其他被测试覆盖的等价性细节
assert_type.md 还固化了若干“等价”的边界行为,均与源码的 is_equivalent_to 判定直接相关:
- 渐进类型:
Any与 ty 内部的Unknown被视为等价,assert_type(a: Unknown, Any)与assert_type(b: Any, Unknown)都通过; - 元组:
tuple[int, str, bytes]与自身通过,元素顺序不同或长度不同(tuple[int, str]、tuple[int, str, bytes, None]、tuple[int, bytes, str])均报type-assertion-failure; - 联合类型:
str | int与int | str视为同一类型,顺序无关; - 交集类型:
Intersection[A, B, Not[C], Not[D]]与调换顺序的写法视为等价,与A & B & ~C & ~D的收窄结果匹配; - 枚举补集:
F(A, B, C)枚举排除F.A、F.B后剩余类型表示为Literal[F.C],断言F时失败并提示TypeLiteral[F.C]does not match asserted typeF``——测试注释特别指出“等价于字面量联合的紧凑枚举补集仍视为可拼写”,因此走主规则而不是 unspellable 分支。
4. 规则定位小结
| 维度 | 内容 | 依据 |
|---|---|---|
| 触发对象 | typing.assert_type(val, typ)、typing.assert_never(arg) 调用 |
function.rs |
| 判定标准 | 推断类型与断言类型必须等价(equivalent);子类型、可赋值均不通过 | 同文件 is_equivalent_to 判定 |
| 兄弟规则 | 实际类型为不可拼写的子类型时改报 assert-type-unspellable-subtype |
assert-type-unspellable-subtype.md、function.rs |
| 默认级别 / 状态 | Error / stable(自 0.0.1-alpha.1) |
diagnostic.rs |
| 行为测试 | mdtest 快照(基本失败、子类型、收窄、unspellable 分界、元组/联合/交集等价性) | assert_type.md、assert_never.md |
从源码结构看,type-assertion-failure 与 reveal_type、assert_never 一样,属于 ty 对 typing 模块“检查指令(directives)”做静态求值的一部分:这些调用在类型检查阶段就被专门解释,而不是按普通函数调用处理。理解了这一点,再阅读 2.1 节的三分支判定逻辑与 3 节中的快照输出,就能把每一条 ty 报错与 function.rs 中的具体代码路径对应起来。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0631
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
video-shotcraftAI宣传片skill,使用 Remotion 制作电影级产品视频:提供106 张镜头配方卡和可复用的视频魔板。适用于 Claude Code 与 Codex以及所有其他智能体Markdown00
HivisionIDPhotos⚡️HivisionIDPhotos: a lightweight and efficient AI ID photos tools. 一个轻量级的AI证件照制作算法。Python09
DragonOSDragonOS is an operating system developed from scratch using Rust, with Linux compatibility. It is designed for **Serverless** scenarios. 使用Rust从0自研内核,具有Linux兼容性的操作系统,面向云计算Serverless场景而设计。Rust00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00