Ty 类型检查器中的省略号字面量:Ruff 对 stub 文件...占位符语义的完整解析
【免费下载链接】ruffAn extremely fast Python linter and code formatter, written in Rust.项目地址: https://gitcode.com/GitHub_Trending/ru/ruff
...(Ellipsis 字面量)在普通 Python 代码里通常只是表示"未完成"的占位,但在类型标注 stub 文件(.pyi)中,它被赋予了特殊的类型语义:可以无条件作为参数默认值、符号赋值,甚至参与解包。本文以 Ruff 仓库中 Ty 类型检查器的官方测试文档 crates/ty_python_semantic/resources/mdtest/stubs/ellipsis.md 为骨架,结合 类型推断实现 与 函数签名推断 等源码,系统讲解省略号字面量在 stub 文件内外的全部规则、诊断行为与底层实现,读完即可准确掌握.pyi文件中...的正确写法和报错边界。
省略号字面量在类型检查中的双重身份
要理解 Ty 的处理逻辑,首先要区分两个概念:省略号字面量...与Ellipsis符号。
...是 Python 语法层面的字面量表达式,在 Ty 的 AST 中对应ast::ExprEllipsisLiteral;Ellipsis是内建名称,求值结果与...相同,但它是名字引用,而非字面量。
Ty 在推断表达式类型时,对省略号字面量统一给出内建类型EllipsisType。这一点可以直接从源码中得到印证:类型推断实现 中的infer_ellipsis_literal_expression直接返回KnownClass::EllipsisType.to_instance(...)。而在 内建类型表 中,EllipsisType被标注为"Python >=3.10 时以types.EllipsisType形式暴露"。
EllipsisType是一个单例类型(singleton type),与None、NotImplemented类似。Ty 的类型系统中存在"将顶层单例类型提升为T | Unknown"的机制,类型系统核心 的注释里明确把None、EllipsisType并列为例。
然而,EllipsisType本身既不可赋值给int、str等普通类型,也不可迭代。因此,省略号字面量在类型检查中能否被"豁免",完全取决于是否处于 stub 文件(.pyi)上下文。这个上下文判断由 类型检查上下文 的in_stub()方法提供,其实现为self.file.is_stub(self.db())——即根据当前文件是否以.pyi结尾(stub 文件)来决定。
下面逐条展开 Ty 对省略号字面量的六类处理规则。
函数与方法的参数默认值:stub 文件中的万能占位
在 stub 文件中,...可以作为函数参数的默认值,且不要求其类型与参数注解兼容。这是 stub 文件最典型、最高频的用法——它表示"该参数在运行时由实现提供,stub 中不关心具体默认值"。
def f(x: int = ...) -> None: reveal_type(x) # revealed: int def f2(x: str = ...) -> None: reveal_type(x) # revealed: str注意一个关键细节:虽然默认值本身是...,但reveal_type(x)推断出的参数类型仍然是注解声明的类型(int、str),而不是EllipsisType。也就是说,...作为占位符被"吸收"了,参数的公开类型完全由注解决定。
从源码看,这一豁免逻辑位于 函数签名与默认值推断:当默认值类型不可赋值给注解类型时,Ty 本应报告invalid-parameter-default诊断,但以下条件组合可以抑制该诊断:
(self.in_stub() || self.in_function_overload_or_abstractmethod() || self.is_in_type_checking_block(self.scope(), default_expr) || /* protocol 类方法 */) && default.as_ref().is_some_and(|d| d.is_ellipsis_literal_expr())也就是说,除了 stub 文件之外,@overload重载函数、抽象方法(abc.abstractmethod)、TYPE_CHECKING块以及Protocol类方法中,省略号字面量作为参数默认值同样被豁免。这与 Ty 的@overload处理逻辑一致——在 函数推断 中,@overload装饰的函数体只允许pass、字符串字面量或省略号字面量,否则会触发USELESS_OVERLOAD_BODY诊断,并建议"用...或pass替换函数体"。
类与模块符号赋值:...作为声明占位
在 stub 文件中,省略号字面量还可以赋值给模块级符号或类属性,同样不要求与声明的类型匹配:
y: bytes = ... reveal_type(y) # revealed: bytes x = ... reveal_type(x) # revealed: Unknown class Foo: y: int = ... reveal_type(Foo.y) # revealed: int这里的语义分两种情形:
- 有注解的赋值(如
y: bytes = ...):最终推断类型就是注解声明的类型(bytes)。因为...只是占位,真正的类型信息来自注解。 - 无注解的赋值(如
x = ...):由于没有注解可参考,占位符无法提供任何类型信息,推断结果退化为Unknown。
对于类属性Foo.y,reveal_type(Foo.y)得到int,同样取自注解而非默认值。
这一行为在源码中有两处直接对应:
- 类型推断实现 的
stub_placeholder_binding_type:当in_stub()且值为省略号字面量时,返回Type::unknown()——这就是无注解赋值得出Unknown的机制。 - 注解赋值推断:当目标是有注解的赋值、且
in_stub()且值为省略号字面量时,直接采用declared.inner_type()(注解声明的类型),而忽略字面量本身的EllipsisType。
此外,stub 文件中对TYPE_CHECKING常量赋值也有特殊规则:在 类型检查常量处理 中,stub 文件里TYPE_CHECKING: bool = ...(甚至完全不赋值)都是合法的,而普通文件里赋True以外的值则报invalid-type-checking-constant错误——...在这里同样是"合法占位"。
赋值语句中的解包:x, y = ...不报错
stub 文件中,省略号字面量还可以出现在赋值语句的解包场景:
x, y = ... reveal_type(x) # revealed: Unknown reveal_type(y) # revealed: Unknownx, y = ...这种写法在普通代码中不可行(EllipsisType不可迭代),但在 stub 文件中不会产生任何诊断,且每个解包目标的类型都是Unknown。
该逻辑位于 解包器实现:在UnpackKind::Assign(赋值解包)分支中,如果self.context.in_stub()且值表达式是省略号字面量,就直接把值类型替换为Type::unknown(),从而绕开后续的可迭代性检查。这里还可以看出 Ty 的解包分为三种UnpackKind:Assign(赋值解包)、Iterable(for 循环迭代)和ContextManager(with 上下文管理器),只有Assign得到省略号豁免。
for 循环中的解包:for a, b in ...报错
与赋值解包相反,在 stub 文件里对省略号字面量做for 循环迭代是无效的,会产生诊断:
# error: [not-iterable] "Object of type `EllipsisType` is not iterable" for a, b in ...: reveal_type(a) # revealed: Unknown reveal_type(b) # revealed: Unknown原因是 for 循环走的是UnpackKind::Iterable分支(见上文 解包器实现),该分支不享受省略号豁免,而是调用try_iterate_with_mode尝试迭代。EllipsisType本身不可迭代,于是触发not-iterable诊断;诊断消息为"Object of typeEllipsisTypeis not iterable"。同时,错误分支还会提供一个fallback_element_type作为兜底元素类型(即Unknown),因此诊断之后reveal_type(a)、reveal_type(b)依然能被推断为Unknown,程序不会因错误而中断推断。
not-iterable诊断对应的完整定义可参见 诊断定义,其文档通过include_str!内嵌于resources/lint_docs/not-iterable.md。
非 stub 文件:省略号字面量没有特殊待遇
一旦离开 stub 文件,...就回归普通字面量的身份,上述所有豁免全部失效,必须满足真实的类型兼容性:
# error: [invalid-parameter-default] "Default value of type `EllipsisType` is not assignable to annotated parameter type `int`" def f(x: int = ...) -> None: ... # error: [invalid-assignment] "Object of type `EllipsisType` is not assignable to `int`" a: int = ... b = ... reveal_type(b) # revealed: EllipsisType这里有两个值得注意的推断差异:
def f(x: int = ...):默认值...的类型是EllipsisType,不可赋值给注解类型int,因此报告invalid-parameter-default诊断。这正是 函数签名与默认值推断 中in_stub()为假时所走的常规报错路径。a: int = ...:同理,注解赋值时值类型与注解不匹配,报告invalid-assignment诊断(定义见 诊断定义)。b = ...:无注解赋值没有类型约束,因此不报错,且reveal_type(b)如实给出EllipsisType——注意这里不再是Unknown,因为在非 stub 文件中...不会被当作占位符,而是真实的单例值。
invalid-parameter-default与invalid-assignment两条诊断的文档同样以 Markdown 形式内嵌在resources/lint_docs/目录下(见 诊断定义)。
内建Ellipsis符号:不享受字面量待遇
最后一条规则至关重要:stub 文件中的特殊语义只适用于...字面量,不适用于内建名称Ellipsis。即使二者求值结果相同,Ellipsis作为名字引用走的是普通表达式推断,得到的就是EllipsisType,没有任何豁免:
# error: [invalid-parameter-default] "Default value of type `EllipsisType` is not assignable to annotated parameter type `int`" def f(x: int = Ellipsis) -> None: ...这条规则的底层原因是:上文所有豁免分支(参数默认值、符号赋值、赋值解包)的判定条件无一例外都是value.is_ellipsis_literal_expr()——即只认 AST 节点类型为ast::ExprEllipsisLiteral的字面量表达式。Ellipsis名字引用对应的 AST 节点是ExprName,因此永远无法命中豁免条件。这也是"以字面量语法为准、而非以运行值为准"的静态分析原则的典型体现。
这些规则如何被测试验证
ellipsis.md文档本身并不是孤立的说明性文档,而是 Ty 类型检查器的mdtest 测试套件的组成部分。其所在目录resources/mdtest/stubs/下还包含 class.md 与 locals.md 等同类用例。
mdtest 的运行机制可以参考 测试运行器脚本:它通过cargo test --package ty_python_semantic --test=mdtest编译并运行测试(mdtest.py),支持按文件名过滤(如--filter stubs/ellipsis)与快照更新(--update)。文档中的reveal_type(...) # revealed: ...注释即测试断言,# error: [not-iterable] "..."则是对诊断代码与消息的精确匹配;这些内容会被解析后与类型检查器的实际输出逐一比对。因此,本文引用的所有示例中的revealed类型与error诊断,都是经过测试验证的确定性结论。
总结:stub 文件内省略号字面量的判定速查
| 场景 | stub 文件(.pyi) | 普通文件(.py) |
|---|---|---|
参数默认值def f(x: int = ...) | 允许,参数类型取注解int | 报invalid-parameter-default |
有注解赋值y: bytes = ... | 允许,类型取注解bytes | 报invalid-assignment |
无注解赋值x = ... | 允许,类型为Unknown | 允许,类型为EllipsisType |
赋值解包x, y = ... | 允许,各目标为Unknown | 报不可迭代错误 |
for 迭代for a, b in ... | 报not-iterable | 报not-iterable |
内建名def f(x: int = Ellipsis) | 报invalid-parameter-default | 报invalid-parameter-default |
核心结论可以浓缩为一句话:stub 文件中只有...字面量享有"占位符豁免",其语义是让声明优先于默认值,而Ellipsis名称与迭代场景不在此列。理解并善用这一规则,能让你的.pyi文件更简洁、更符合类型检查器的预期。
【免费下载链接】ruffAn extremely fast Python linter and code formatter, written in Rust.项目地址: https://gitcode.com/GitHub_Trending/ru/ruff
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考