Ruff Ty 类型检查器 Sentinels 支持解析:从typing_extensions.Sentinel到builtins.sentinel
【免费下载链接】ruffAn extremely fast Python linter and code formatter, written in Rust.项目地址: https://gitcode.com/GitHub_Trending/ru/ruff
导读
本文以 crates/ty_python_semantic/resources/mdtest/sentinels.md 为核心骨架,系统讲解 Ruff 新一代类型检查器 Ty 对 Python "哨兵(sentinel)" 对象的完整类型建模:包括typing_extensions.Sentinel的构造规则、在类型表达式与默认参数中的用法、类作用域声明、is收窄(narrowing)与联合类型推断,以及 Python 3.15 引入builtins.sentinel后的版本分派逻辑。读完本文,你将掌握如何在 Ty 中正确声明与使用哨兵类型,理解其底层的源码实现路径,并能用 mdtest 测试框架验证这些行为。
Sentinels 是什么:类型系统中的"唯一标记"模式
哨兵对象是 Python 中一种经典的设计模式:用一个独一无二的对象实例来代表"某个参数没有被显式提供",从而区别于None、False、0或空字符串等"合法但可能被误传"的值。典型应用包括inspect.Parameter.empty、argparse.SUPPRESS等。
传统做法需要手动编写一个类并覆写__repr__,非常繁琐。而typing_extensions.Sentinel提供了一行式构造方式,让哨兵既能作为运行时值,又能作为类型注解直接使用。Ty 类型检查器对它有专门支持,相关行为全部由 mdtest 文档驱动测试覆盖(mdtest 框架实现在 crates/mdtest/src/lib.rs,负责把 Markdown 中的 Python 代码块作为可执行断言运行)。
基础用法:用Sentinel(...)构造类型级哨兵
环境前提
Sentinel 支持不依赖特定的 Python 版本即可用于类型表达式,文档中给出的基准环境为 Python 3.10:
[environment] python-version = "3.10"从源码看,Ty 在 Python 3.15 之前将Sentinel映射到typing_extensions模块,3.15 起才切换到builtins(见 crates/ty_python_semantic/src/types/class/known.rs):
Self::Sentinel => { if python_version >= PythonVersion::PY315 { KnownModule::Builtins } else { KnownModule::TypingExtensions } }构造语法与 reveal_type 结果
Sentinel接受一个字符串字面量名称,并可选的第二个位置参数或repr=关键字参数来定制其repr展示:
from typing_extensions import Sentinel, assert_type MISSING = Sentinel("MISSING") OTHER = Sentinel("OTHER") WITH_REPR = Sentinel("WITH_REPR", "<with repr>") WITH_REPR_KEYWORD = Sentinel("WITH_REPR_KEYWORD", repr="<with repr keyword>") reveal_type(MISSING) # revealed: MISSING reveal_type(OTHER) # revealed: OTHER reveal_type(WITH_REPR) # revealed: WITH_REPR reveal_type(WITH_REPR_KEYWORD) # revealed: WITH_REPR_KEYWORD注意reveal_type的结果是哨兵自身的名称(MISSING、OTHER等),而不是Sentinel类本身。这来自 Ty 将每个哨兵建模为独立"已知实例类型"(KnownInstance)的设计:KnownInstanceType::Sentinel(SentinelInstance),其中SentinelInstance以 salsa 内部化结构保存name和definition(声明位置),见 crates/ty_python_semantic/src/types/known_instance.rs。
哨兵类型的显示名称也直接使用声明时的名字,见 crates/ty_python_semantic/src/types/display.rs:
KnownInstanceType::Sentinel(sentinel) => { f.with_type(ty).write_str(sentinel.name(db).as_str()) }构造调用的底层识别路径
Ty 并不是把Sentinel(...)当作普通函数调用处理。在 crates/ty_python_semantic/src/types/infer/builder.rs 中可以看到分派逻辑:
Some(KnownClass::Sentinel) => self .infer_sentinel_expression(target, call_expr, definition) .unwrap_or_else(|| { self.infer_call_expression_impl(call_expr, callable_type, tcx) }),即:当被调用的可调用对象被识别为内置已知类KnownClass::Sentinel时,会先尝试走专用路径infer_sentinel_expression;只有该路径返回None(无法识别为合法哨兵声明)时,才回退到普通调用推断。
infer_sentinel_expression(见 crates/ty_python_semantic/src/types/infer/builder.rs)内部的具体约束包括:
- 赋值目标必须是一个
Name表达式(简单变量名); - 参数列表中不能出现
*args星号展开; - 位置参数只能是 1 个(
name)或 2 个(name, repr); - 关键字参数只允许
repr,且不能与位置形式的 repr 同时出现; name参数必须是字符串字面量;repr参数必须是字符串字面量或None。
一旦满足条件,就构造SentinelInstance并把该哨兵作为类型返回。
哨兵在函数签名中的使用:类型表达式与默认值
参数注解中的唯一类型
每个哨兵都是一个独一无二的类型,因此可以被直接用作参数注解,并且互相不兼容:
def accepts_missing(x: MISSING) -> None: ... def accepts_other(x: OTHER) -> None: ... accepts_missing(MISSING) accepts_missing(OTHER) # error: [invalid-argument-type] accepts_other(OTHER) accepts_other(MISSING) # error: [invalid-argument-type]传入"错误"的哨兵会触发invalid-argument-type错误,说明 Ty 依据is_same_sentinel判断同一性——两个哨兵只有在同一文件、同一文件作用域、同一位置(即同一个声明语句)时才视为同一个类型,见 crates/ty_python_semantic/src/types/known_instance.rs。
默认值的合法性校验
哨兵不能作为不兼容类型的默认值。普通int参数如果默认值是哨兵,会报invalid-parameter-default:
def bad_default(x: int = MISSING) -> None: # error: [invalid-parameter-default] pass正确姿势是把哨兵纳入参数类型的联合中,让默认值成为联合的成员之一:
def good_default(x: int | MISSING | OTHER = MISSING) -> None: if x is MISSING: assert_type(x, MISSING) reveal_type(x) # revealed: MISSING else: assert_type(x, int | OTHER) reveal_type(x) # revealed: int | OTHER good_default(1) good_default(MISSING) good_default(OTHER)这里的assert_type(x, ...)与reveal_type(x)断言验证了核心能力:is比较可以对哨兵联合类型进行收窄(narrowing)——在x is MISSING为真的分支中x被收窄为精确的MISSING,在else分支中则收窄为int | OTHER。
四种is收窄方向:正反向与嵌套分支
Ty 对哨兵的收窄支持四种写法,且收窄方向全部正确(对应的收窄实现参与逻辑位于 crates/ty_python_semantic/src/types/infer/comparisons.rs,其中Sentinel被列为参与比较的类型之一):
def reverse_check(x: int | MISSING | OTHER) -> None: if MISSING is x: # 反写 is:哨兵在左 assert_type(x, MISSING) reveal_type(x) # revealed: MISSING else: assert_type(x, int | OTHER) reveal_type(x) # revealed: int | OTHER def negative_check(x: int | MISSING | OTHER) -> None: if x is not MISSING: # 否定形式 is not assert_type(x, int | OTHER) reveal_type(x) # revealed: int | OTHER else: assert_type(x, MISSING) reveal_type(x) # revealed: MISSING def reverse_negative_check(x: int | MISSING | OTHER) -> None: if MISSING is not x: # 反写 + 否定 assert_type(x, int | OTHER) reveal_type(x) # revealed: int | OTHER else: assert_type(x, MISSING) reveal_type(x) # revealed: MISSING这四种组合(is/is not× 哨兵在左/在右)覆盖了实际代码中常见的哨兵判断写法,保证if x is MISSING:与if MISSING is x:在类型层面行为一致。
哨兵对象的运行时属性:真值、元数据与禁止继承
哨兵对象在运行时遵循以下约定,Ty 均给出了对应的类型断言:
MISSING = Sentinel("MISSING") reveal_type(bool(MISSING)) # revealed: Literal[True] reveal_type(MISSING.__module__) # revealed: str class MissingSubclass(MISSING): # error: [invalid-base] pass- 总是真值:
bool(MISSING)的类型被推断为Literal[True],绝不会是False; - 标准元数据属性:
__module__等标准哨兵属性可正常访问,类型为str; - 禁止作为基类:试图
class MissingSubclass(MISSING):会报invalid-base。从源码看,crates/ty_python_semantic/src/types/class_base.rs 参与了基类校验,KnownClass::Sentinel也在 class 相关检查中被特别处理(见 crates/ty_python_semantic/src/types/class/known.rs 中多处Sentinel枚举分支)。
类作用域中的哨兵:C.MARKER形态
哨兵不仅可以在模块顶层声明,也可以声明在类体内,并通过C.MARKER的形式引用:
class C: MARKER = Sentinel("C.MARKER") def accepts_marker(x: C.MARKER) -> None: ... accepts_marker(C.MARKER) def class_default(x: int | C.MARKER = C.MARKER) -> None: if x is C.MARKER: assert_type(x, C.MARKER) reveal_type(x) # revealed: MARKER else: assert_type(x, int) reveal_type(x) # revealed: int def class_reverse_negative(x: int | C.MARKER) -> None: if C.MARKER is not x: assert_type(x, int) reveal_type(x) # revealed: int else: assert_type(x, C.MARKER) reveal_type(x) # revealed: MARKER注意这里reveal_type显示的名称是MARKER而非C.MARKER——类型展示使用哨兵声明时的name,而注解写法仍是限定名C.MARKER。类作用域哨兵同样支持默认值联合与is/is not收窄。
底层作用域限制
为什么只支持模块与类作用域?这与infer_sentinel_expression前置调用的sentinel_definition_scope_is_supported检查直接相关(crates/ty_python_semantic/src/types/infer/builder.rs):
fn sentinel_definition_scope_is_supported(&self) -> bool { let db = self.db(); let mut scope_id = self.scope.file_scope_id(db); loop { let scope = self.index.scope(scope_id); match scope.node().scope_kind() { ScopeKind::Module => return true, ScopeKind::Class => {} ScopeKind::Function | ScopeKind::Lambda | ScopeKind::Comprehension | ScopeKind::TypeAlias | ScopeKind::TypeParams => return false, } let Some(parent) = scope.parent() else { return false; }; scope_id = parent; } }从源码结构看,这是对声明位置的白名单式校验:从当前作用域向上遍历,只要遇到函数、Lambda、推导式、类型别名或类型参数作用域就拒绝,只有模块与类作用域(含嵌套类)允许。因此:
def outer(): LOCAL = Sentinel("LOCAL") def inner(x: LOCAL) -> None: ... # error: [invalid-type-form]在函数内部声明的哨兵不会被识别为哨兵类型,注解x: LOCAL报invalid-type-form。
哨兵不是泛型:禁止下标特化
哨兵类型不能被下标特化:
MISSING = Sentinel("MISSING") def f(x: MISSING[int]) -> None: ... # error: [invalid-type-form]这源于类型表达式推断中对KnownInstanceType::Sentinel的专门分支处理(crates/ty_python_semantic/src/types/infer/builder/type_expression.rs):
KnownInstanceType::Sentinel(sentinel) => { if !self.in_string_annotation() { self.infer_expression(&subscript.slice, TypeContext::default()); } if let Some(builder) = self.context.report_lint(&INVALID_TYPE_FORM, subscript) { builder.into_diagnostic(format_args!( "`{}` is a sentinel and cannot be specialized", sentinel.name(self.db()) )); } Type::unknown() }在字符串注解(如"MISSING[int]")中,Ty 仍会尝试推断下标切片内容,但同样会报告invalid-type-form并把该类型当作unknown处理。
非法构造回退:非字面量参数走普通调用路径
Sentinel(...)的识别要求 name 与 repr 都是字符串字面量。一旦参数不是字面量,构造表达式就"降级"为普通函数调用,此时不会产生哨兵类型,而是暴露出常规的调用检查错误:
NAME = "NAME" NON_LITERAL_NAME = Sentinel(NAME) UNKNOWN_NAME = Sentinel(UNKNOWN) # error: [unresolved-reference] NON_LITERAL_REPR = Sentinel("NON_LITERAL_REPR", repr=NAME) UNKNOWN_REPR = Sentinel("UNKNOWN_REPR", repr=UNKNOWN) # error: [unresolved-reference] UNKNOWN_KEYWORD = Sentinel("UNKNOWN_KEYWORD", unknown=NAME) # error: [unknown-argument]Sentinel(NAME):NAME不是字符串字面量,回退普通调用,不报错但也不产生哨兵类型;Sentinel(UNKNOWN):UNKNOWN未解析,报unresolved-reference;repr=NAME:repr 非字面量,回退普通调用;repr=UNKNOWN:未解析引用,报unresolved-reference;unknown=NAME:非法关键字参数,报unknown-argument。
这与源码中"专用路径返回None则回退infer_call_expression_impl"的分派设计完全对应。
Python 3.15:builtins.sentinel与版本分派
新环境下的等价行为
从 Python 3.15 起,标准库新增了builtins.sentinel,typing_extensions.Sentinel变为它的再导出。在 mdtest 中通过环境切换验证:
[environment] python-version = "3.15"from typing import assert_type MISSING = sentinel("MISSING") OTHER = sentinel("OTHER") WITH_REPR = sentinel("WITH_REPR", "<with repr>") WITH_REPR_KEYWORD = sentinel("WITH_REPR_KEYWORD", repr="<with repr keyword>") reveal_type(MISSING) # revealed: MISSING reveal_type(OTHER) # revealed: OTHER reveal_type(WITH_REPR) # revealed: WITH_REPR reveal_type(WITH_REPR_KEYWORD) # revealed: WITH_REPR_KEYWORDbuiltins.sentinel的构造语法与typing_extensions.Sentinel完全一致:位置参数name、可选的位置repr或repr=关键字,reveal_type同样显示哨兵名称。其余行为(参数注解唯一性、默认值校验、四种is收窄、类作用域、真值/元数据/禁止继承、非泛型、非法构造回退)在 3.15 下与 3.10 下逐条一致,mdtest 文档对其完整复述了一遍,确保两个版本的行为不产生回归。
底层模块映射
Ty 对Sentinel的已知类定义在 Python 3.15 前后指向不同的模块,相关映射见 crates/ty_python_semantic/src/types/class/known.rs 与 crates/ty_python_semantic/src/types/class/known.rs:
Self::Sentinel => python_version >= PythonVersion::PY315,在已知类列表中,Sentinel被标记为"仅在 Python 3.15 及以上才属于 builtins";模块归属同样按版本切换:3.15 之前归TypingExtensions,3.15 起归Builtins。这保证了import builtins; sentinel(...)与import typing_extensions; Sentinel(...)在各自版本下都能被正确识别为同一概念。
3.15 下typing_extensions.Sentinel依旧可用
即便在 Python 3.15 下,typing_extensions.Sentinel作为再导出依然可以正常使用:
import typing_extensions EXTENSIONS_MISSING = typing_extensions.Sentinel("EXTENSIONS_MISSING") def f(x: int | EXTENSIONS_MISSING): ... f(42) f(EXTENSIONS_MISSING) f(None) # error: [invalid-argument-type]x: int | EXTENSIONS_MISSING的联合类型正常工作:42与哨兵本身可传参,None则报invalid-argument-type。这验证了版本迁移的向后兼容性——升级到 3.15 后既可用新语法sentinel(...),也不必立刻改掉已有的typing_extensions.Sentinel代码。
设计要点总结
围绕上述文档与源码,可以把 Ty 的哨兵类型支持归纳为以下设计要点:
| 设计维度 | 行为 | 证据位置 |
|---|---|---|
| 类型建模 | 每个哨兵是独立KnownInstanceType::Sentinel,携带name与definition | known_instance.rs |
| 同一性判定 | 同文件、同文件作用域、同位置的声明才视为同一哨兵 | known_instance.rs |
| 构造识别 | 专用路径infer_sentinel_expression,失败回退普通调用 | builder.rs |
| 声明作用域 | 仅模块与类作用域,函数内不识别 | builder.rs |
| 联合收窄 | is/is not四种写法均正确收窄 | comparisons.rs |
| 禁止特化 | MISSING[int]报invalid-type-form | type_expression.rs |
| 版本分派 | 3.15 前归typing_extensions,3.15 起归builtins | known.rs |
如何在 Ty 中运行本文的全部示例
本文所有代码示例均来自 crates/ty_python_semantic/resources/mdtest/sentinels.md,它们不是普通文档,而是可执行的类型检查测试。mdtest 是 Ty 生态自带的 Markdown 测试框架(crates/mdtest/src/lib.rs),会把 Markdown 中的 Python 代码块解析为断言——reveal_type/assert_type断言期望的类型,# error: [code]断言期望的诊断错误码(如invalid-argument-type、invalid-parameter-default、invalid-base、invalid-type-form、unresolved-reference、unknown-argument)。通过[environment] python-version配置块还可以切换 Python 版本以覆盖 3.10 与 3.15 两条行为分支。
若要在本地复现这些类型检查结果,可以借助仓库中的相关测试基础设施运行 mdtest 测试套件;也可以在支持 Ty 的编辑器环境中直接尝试这些代码片段,观察reveal_type与错误诊断的实时输出。文档中标注# revealed:与# error:的行即为权威预期结果,可作为校验实现是否正确的基准。
【免费下载链接】ruffAn extremely fast Python linter and code formatter, written in Rust.项目地址: https://gitcode.com/GitHub_Trending/ru/ruff
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考