
ty 类型检查器成员测试语义解析in/not in的类型推断与回退链【免费下载链接】ruffAn extremely fast Python linter and code formatter, written in Rust.项目地址: https://gitcode.com/GitHub_Trending/ru/ruff本篇文章基于 Astral 开源仓库中 ty 类型检查器位于crates/ty_python_semantic的 mdtest 文档与源码实现系统讲解 Python 成员测试运算符in/not in的完整语义以及 ty 如何对它们进行类型推断。读完本文你将掌握__contains__/__iter__/__getitem__的回退规则、TypedDict键成员测试的静态判定、字面量结果Literal[True]的推导原理以及unsupported-operator、unsupported-bool-conversion等诊断的触发条件。成员测试的语义基础与回退链在 Python 中成员测试运算符membership test operators指的是in与not in。类可以通过实现__contains__、__iter__或__getitem__三个特殊方法之一来自定义成员测试行为其优先级回退链严格固定为__contains__如果类定义了该方法直接以它作为判定依据__iter__如果未定义__contains__但定义了__iter__则在迭代产生的元素中查找目标值__getitem__最后的兜底方案——按整数下标0, 1, 2, ...依次调用直到命中结果为True或抛出IndexError异常被解释器吞掉结果转为False。该文档 membership_test.md 正是用 mdtest 断言框架逐条验证这些规则的测试规范。每个代码块中的reveal_type(...)与注释# revealed: bool配对构成期望值断言# error: [unsupported-operator] ...则断言在指定位置必须产生带特定消息的诊断。核心实现infer_membership_test_comparison的推断流程ty 对成员测试的类型推断入口位于 comparisons.rs 中的infer_membership_test_comparison函数。它接收左操作数needle被查找值与右操作数容器MembershipOperator区分In与NotIn两种操作。推断流程大致如下字符串字面量键 TypedDict特判当左操作数是字符串字面量、右操作数是TypedDict时直接调用TypedDict::key_membership_truthiness得到静态真值not in则取反——这一步绕过了对__contains__的模拟因为TypedDict的键成员测试完全由 schema 静态决定详见后文。尝试调用__contains__通过try_call_dunder(__contains__, [left])模拟运行时调用。若调用成功取其返回类型。回退到迭代语义若__contains__方法不可用CallDunderError::MethodNotAvailable或可能未绑定PossiblyUnbound则调用try_iterate尝试迭代右操作数只要可迭代结果类型即为bool。调用失败即报错若__contains__存在但参数不匹配CallError返回None最终抛出UnsupportedComparisonError由上层生成unsupported-operator诊断。结果强制转 bool对__contains__的返回类型执行try_bool转换若该类型无法布尔求值则报告unsupported-bool-conversion诊断见错误返回类型章节。此外在infer_binary_type_comparison_inner中成员测试还有一处固定元组特判comparisons.rs当右操作数是定长元组时ty 会借助TupleEqualityEvaluator逐元素与左操作数比较真值——只要任一元素必定相等则in恒为Literal[True]若全部元素必定不相等则恒为Literal[False]存在歧义则退化为普通bool。实现__contains__最直接的自定义路径类实现__contains__后即可直接支持成员测试in与not in都会按该方法求值class A: def __contains__(self, item: str) - bool: return True reveal_type(hello in A()) # revealed: bool reveal_type(hello not in A()) # revealed: bool # error: [unsupported-operator] Operator in is not supported between objects of type Literal[42] and A reveal_type(42 in A()) # revealed: bool # error: [unsupported-operator] Operator not in is not supported between objects of type Literal[42] and A reveal_type(42 not in A()) # revealed: bool注意尽管42 in A()被标记为unsupported-operator错误因为__contains__只接受str42类型不匹配reveal_type的结果仍然是bool。这印证了实现逻辑——调用失败时诊断照常上报但结果类型仍以bool兜底保证类型流不断裂。仅实现__iter__基于迭代元素的查找未实现__contains__但实现了__iter__的类同样支持包含性检查目标值会在其迭代产物中被查找。ty 通过try_iterate见 iteration.rs判定可迭代性class StringIterator: def __next__(self) - str: return foo class A: def __iter__(self) - StringIterator: return StringIterator() reveal_type(hello in A()) # revealed: bool reveal_type(hello not in A()) # revealed: bool reveal_type(42 in A()) # revealed: bool reveal_type(42 not in A()) # revealed: bool与__contains__路径不同__iter__路径不会对 needle 类型与元素类型做严格匹配只要容器可迭代结果一律是bool。这符合 CPython 运行时的宽松行为——运行时42 in iterable总可以执行可能返回False因此静态上不做unsupported-operator报错。仅实现__getitem__旧式迭代兜底最后一级回退是__getitem__。Python 会以0, 1, 2...依次调用它直到命中结果为True或抛出IndexError异常被吞掉结果为Falseclass A: def __getitem__(self, key: int) - str: return foo reveal_type(hello in A()) # revealed: bool reveal_type(hello not in A()) # revealed: bool reveal_type(42 in A()) # revealed: bool reveal_type(42 not in A()) # revealed: bool在源码实现中__getitem__兜底实际是通过try_iterate的旧式迭代支持完成的iteration.rs内部处理了基于__getitem__的序列迭代因此其静态结果同样统一为bool。通过描述符实现__contains__描述符协议的正确触发当__contains__以描述符形式实现即某个带有__get__的类其__get__返回一个可调用对象时描述符协议应被正确触发。ty 会先解析描述符的__get__再对返回的可调用对象执行调用class Target: def __call__(self, item: object) - bool: return True class Descriptor: def __get__(self, instance: object, owner: type) - Target: return Target() class Container: __contains__: Descriptor Descriptor() reveal_type(1 in Container()) # revealed: bool reveal_type(hello not in Container()) # revealed: bool这要求成员测试的 dunder 调用路径必须与属性查找、描述符绑定机制打通try_call_dunder才能拿到正确的可调用对象。错误的返回类型强制 bool 转换Python 会把包含性检查的结果强制转换为bool即使__contains__返回非 bool 类型class A: def __contains__(self, item: str) - str: return foo reveal_type(hello in A()) # revealed: bool reveal_type(hello not in A()) # revealed: bool__contains__声明返回str但in的静态结果依然是bool。这正是实现中对返回类型执行try_bool转换这一步的体现——bool(str 实例)在运行时总能成功非空字符串为真所以这里既不报错也不缩窄为字面量。返回类型无法布尔求值unsupported-bool-conversion与能转换但结果非字面量不同若__contains__返回的类型根本无法进行布尔求值in/not in在运行时必然失败。这是因为x in y在 Python 解释器中会被脱糖为contains(y, x)调用其语义近似于def contains(y, x): return bool(type(y).__contains__(y, x))其中bool()转换内部隐式调用__bool__。若__bool__本身不可调用则整个表达式抛异常。ty 对这类情况上报unsupported-bool-conversion诊断class NotBoolable: __bool__: int 3 class WithContains: def __contains__(self, item) - NotBoolable: return NotBoolable() # snapshot: unsupported-bool-conversion 10 in WithContains()对应的快照诊断输出为error[unsupported-bool-conversion]: Boolean conversion is not supported for type NotBoolable -- src/mdtest_snippet.py:9:1 | 9 | 10 in WithContains() | ^^^^^^^^^^^^^^^^^^^^ info: __bool__ on NotBoolable must be callablenot in产生完全一致的诊断仅行号与操作符不同# snapshot: unsupported-bool-conversion 10 not in WithContains()error[unsupported-bool-conversion]: Boolean conversion is not supported for type NotBoolable -- src/mdtest_snippet.py:11:1 | 11 | 10 not in WithContains() | ^^^^^^^^^^^^^^^^^^^^^^^^ info: __bool__ on NotBoolable must be callable该诊断与 diagnostic.rs 中的布尔转换检查相关联。文档末尾也以 TODO 形式指出了理想化的改进方向当__contains__返回类型不可布尔求值时报错信息应串联出完整原因链例如__contains__返回了NotBoolable实例 → 其__bool__属性不可调用并可能改用unsupported-operator作为错误码这些属于诊断消息质量的后续优化空间。字面量结果in/not in的BooleanLiteral推断当__contains__的返回类型本身是字面量时成员测试的结果可以静态收敛为BooleanLiteralfrom typing import Literal class AlwaysTrue: def __contains__(self, item: int) - Literal[1]: return 1 class AlwaysFalse: def __contains__(self, item: int) - Literal[]: return reveal_type(42 in AlwaysTrue()) # revealed: Literal[True] reveal_type(42 not in AlwaysTrue()) # revealed: Literal[False] reveal_type(42 in AlwaysFalse()) # revealed: Literal[False] reveal_type(42 not in AlwaysFalse()) # revealed: Literal[True]原理链条非常清晰__contains__的返回类型是Literal[1]bool(1)恒为真于是in得到Literal[True]、not in取反得到Literal[False]Literal[]则相反空字符串恒假。这正是 comparisons.rs 中try_boolTruthiness映射 negate组合的结果in直接由Type::from_truthiness生成not in则对真值取反后再生成。TypedDict键成员测试静态可判定的特例TypedDict的键成员测试与普通对象完全不同——它由 schema 静态决定无需模拟任何 dunder 调用。核心实现在 typed_dict.rs 的key_membership_truthiness其判定逻辑为键已声明且为required字段 →Truthiness::AlwaysTrue必在键已声明且可能缺失optional即NotRequired且值类型可被占据→Truthiness::Ambiguous可能在与不在键已声明但值类型不可占据如NotRequired[Never]→Truthiness::AlwaysFalse永远不存在键未声明且TypedDict是closed关闭的→AlwaysFalse不可能存在键未声明且TypedDict是open或带 extra items →Ambiguous。mdtest 文档用整整六个小节覆盖了这一维度的所有组合。required 与 optional 键from typing_extensions import NotRequired, TypedDict class Items(TypedDict): required: int optional: NotRequired[int] def membership(items: Items) - None: reveal_type(required in items) # revealed: Literal[True] reveal_type(required not in items) # revealed: Literal[False] reveal_type(optional in items) # revealed: bool reveal_type(optional not in items) # revealed: boolrequired 键一定存在因此in静态恒真optional 键可能在也可能不在因此结果退化为bool。closedTypedDict中的缺失键封闭的TypedDict不能包含未声明的键也不能包含值类型不可占据的 optional 键。声明extra_itemsNever与closedTrue具有等价的封闭效果from typing_extensions import Never, NotRequired, TypedDict class Closed(TypedDict, closedTrue): present: int impossible: NotRequired[Never] class ClosedByExtraItems(TypedDict, extra_itemsNever): present: int def closed_membership(closed: Closed, closed_by_extra_items: ClosedByExtraItems) - None: reveal_type(missing in closed) # revealed: Literal[False] reveal_type(missing not in closed) # revealed: Literal[True] reveal_type(impossible in closed) # revealed: Literal[False] reveal_type(impossible not in closed) # revealed: Literal[True] reveal_type(missing in closed_by_extra_items) # revealed: Literal[False] reveal_type(missing not in closed_by_extra_items) # revealed: Literal[True]注意impossible键虽然它被显式声明但NotRequired[Never]意味着它永远不会出现在实例中因此成员测试同样恒为Literal[False]——这正是key_membership_truthiness中键已声明但may_be_present为假分支的处理结果。openTypedDict与 extra items 中的未声明键开放式的TypedDict、以及带非空 extra items 的TypedDict其实际实例可能包含 schema 未声明的键因此未声明键的成员测试无法静态确定from typing_extensions import TypedDict class Open(TypedDict): present: int class ExtraItems(TypedDict, extra_itemsint): present: int def open_membership(open_items: Open, extra_items: ExtraItems) - None: reveal_type(missing in open_items) # revealed: bool reveal_type(missing not in open_items) # revealed: bool reveal_type(missing in extra_items) # revealed: bool reveal_type(missing not in extra_items) # revealed: bool联合类型与非字面量键当键或TypedDict本身可能在存在 / 缺失的分支间变化时成员测试保持歧义但当某个键在所有 closed 分支中都缺失时它恒定为缺失from typing_extensions import Literal, TypedDict class Left(TypedDict, closedTrue): left: int class Right(TypedDict, closedTrue): right: int def union_membership( left: Left, either: Left | Right, literal_key: Literal[left, missing], unknown_key: str, ) - None: reveal_type(missing in either) # revealed: Literal[False] reveal_type(missing not in either) # revealed: Literal[True] reveal_type(left in either) # revealed: bool reveal_type(literal_key in left) # revealed: bool reveal_type(unknown_key in left) # revealed: boolmissing在Left | Right两个分支中都不存在 → 联合后依然恒假left只在Left分支存在 → 整体歧义退化为bool字面量联合键Literal[left, missing]与未知键str落在Left上时由于Left是 closedmissing分支必然缺失 → 结果也是bool。函数式TypedDict定义函数式TypedDictTypedDict(Closed, {...}, closedTrue)暴露的键存在性信息与基于类的定义完全一致from typing_extensions import TypedDict Closed TypedDict(Closed, {present: int}, closedTrue) def functional_membership(closed: Closed) - None: reveal_type(present in closed) # revealed: Literal[True] reveal_type(missing in closed) # revealed: Literal[False]无回退规则__contains__一旦存在即独占一个极易被误解的规则只要类实现了__contains__成员测试就完全由它决定即使传入的类型它不接受也不会回退到__iter__或__getitem__。文档用一组对照实验严格验证了这一点class CheckContains: ... class CheckIter: ... class CheckGetItem: ... class CheckIterIterator: def __next__(self) - CheckIter: return CheckIter() class A: def __contains__(self, item: CheckContains) - bool: return True def __iter__(self) - CheckIterIterator: return CheckIterIterator() def __getitem__(self, key: int) - CheckGetItem: return CheckGetItem() reveal_type(CheckContains() in A()) # revealed: bool # error: [unsupported-operator] Operator in is not supported between objects of type CheckIter and A reveal_type(CheckIter() in A()) # revealed: bool # error: [unsupported-operator] Operator in is not supported between objects of type CheckGetItem and A reveal_type(CheckGetItem() in A()) # revealed: bool class B: def __iter__(self) - CheckIterIterator: return CheckIterIterator() def __getitem__(self, key: int) - CheckGetItem: return CheckGetItem() reveal_type(CheckIter() in B()) # revealed: bool # Always use __iter__, regardless of iterated type; theres no NotImplemented # in this case, so theres no fallback to __getitem__ reveal_type(CheckGetItem() in B()) # revealed: bool三个关键结论类A同时实现了三种协议但CheckIter()与CheckGetItem()作为 needle 时都报unsupported-operator——因为__contains__只接受CheckContains且不存在回退类B只有__iter__与__getitem__此时任何 needle 都直接走__iter__路径返回bool代码注释特别指出__iter__路径始终优先于__getitem__且由于这种情况下不存在NotImplemented返回值也不会发生向__getitem__的回退。这一行为在 containment.rs 的containment_behavior中也有印证它沿 MRO 查找__contains__一旦找到就标记为Custom自定义行为而不再考虑内置容器的迭代语义。非法的旧式迭代__getitem__不接受整数如果类实现了__getitem__但参数不是整数则成员测试不被支持ty 必须上报诊断——因为以0, 1, 2...调用旧式迭代的前提是接受整数下标class A: def __getitem__(self, key: str) - str: return foo # error: [unsupported-operator] Operator in is not supported between objects of type Literal[42] and A reveal_type(42 in A()) # revealed: bool # error: [unsupported-operator] Operator in is not supported between objects of type Literal[hello] and A reveal_type(hello in A()) # revealed: bool与无回退规则章节中的场景类似诊断照常产生但reveal_type的结果仍然以bool兜底。从 mdtest 到源码测试框架如何驱动这些语义这些示例全部以 mdtest 文档形式存放于 resources/mdtest/comparison/instances/membership_test.md与rich_comparison.md富比较并列为instances目录下对实例比较语义的测试规范。mdtest 是 ty 团队的 Markdown 驱动测试体系reveal_type(x) # revealed: T断言表达式类型# error: [code] message断言诊断位置与内容# snapshot: name与 snapshot 块则固化了诊断输出的渲染快照。整套用例与 comparisons.rs、typed_dict.rs、iteration.rs 及 containment.rs 的实现一一对应构成文档即测试、测试即规范的闭环。总结成员测试推断的完整决策树综合文档与源码ty 对left in right及取反后的not in的推断可以归纳为如下决策树left是字符串字面量且right是TypedDict→ 由key_membership_truthiness静态判定required 恒真、closed 缺失键恒假、其余歧义为boolright是定长元组 → 逐元素相等性判定全部可判定时收敛为Literal[True]/Literal[False]right存在可调用的__contains__→ 调用之对其返回类型做try_bool转换可布尔求值则得bool或BooleanLiteral不可求值则报unsupported-bool-conversion参数类型不匹配则报unsupported-operator绝不回退right无__contains__但可迭代__iter__或合法的__getitem__→ 结果为bool以上均不满足 → 报unsupported-operator。这套语义既忠实地模拟了 CPython 运行时行为又在TypedDict键检查与字面量结果上实现了超越运行时精度的静态判定是类型检查器在运行时语义保真与静态可判定性之间取得平衡的典型范例。【免费下载链接】ruffAn extremely fast Python linter and code formatter, written in Rust.项目地址: https://gitcode.com/GitHub_Trending/ru/ruff创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考