【Bug已解决】Test failures under Python 3.14 on x86_64-linux 解决方案

【Bug已解决】Test failures under Python 3.14 on x86_64-linux 解决方案

【Bug已解决】Test failures under Python 3.14 on x86_64-linux 解决方案

一、现象长什么样

项目在 CI 矩阵里加入了 Python 3.14(x86_64-linux),结果一批测试挂了,而 Python 3.10~3.13 全部通过:

FAILED tests/test_types.py::test_union_introspection FAILED tests/test_hints.py::test_get_origin ... Python 3.14 专属失败,3.13 及以下全绿

典型报错:

AssertionError: assert types.UnionType is typing.Union # 或 TypeError: unsupported operand type(s) for |: ...

最小判据:

触发:在 Python 3.14 上跑测试 现象:类型 introspection / 语法相关测试失败,低版本正常 根因:代码对 PEP 604 的 X | Y 联合类型用了旧的 typing.Union 比较, 3.14 下其 runtime 表示为 types.UnionType,旧比较失败 影响:CI 在 3.14 红,无法声明支持新 Python

最迷惑的是:失败只在 3.14 出现,本地 3.12 全过。这指向"代码依赖了某个在 3.14 行为变化的运行时细节",而非逻辑错。

二、背景

PEP 604 引入了X | Y联合类型语法(自 3.10 可用)。它的运行时类型types.UnionType

import types assert isinstance(int | str, types.UnionType)

但很多"类型 introspection"代码写于 PEP 604 之前,习惯用typing.Union来判断联合:

import typing def is_union(t): return typing.get_origin(t) is typing.Union # 对 X | Y 返回 None!

关键差异:

  • typing.get_origin(int | str)返回types.UnionType(不是typing.Union);
  • typing.get_origin(typing.Union[int, str])返回typing.Union
  • 于是typing.get_origin(X | Y) is typing.Union永远是False

为什么偏偏 3.14 才暴露?因为 3.10~3.13 期间,可能测试数据都用typing.Union[...]老写法(碰巧对),或代码路径在旧版本没被走到;而 3.14 的某些库 / 类型注解默认走X | Y新语法,或 CI 升级后更多注解用新语法,于是is typing.Union的比较第一次大规模失败。也可能是 3.14 对typing做了清理,移除了某些旧别名兼容,让新老写法差异更明显。

根因是"用typing.Union去比较types.UnionType的运行时联合",两套表示没统一。

三、根因

抽象成代码(示意):

import typing, types def is_union_buggy(t): # BUG:只认 typing.Union,不认 X | Y 的 types.UnionType return typing.get_origin(t) is typing.Union # 行为 is_union_buggy(typing.Union[int, str]) # True (老写法) is_union_buggy(int | str) # False (新语法,3.14 大量出现)

根因链条:

  1. X | Y语法(PEP 604)的运行时类型是types.UnionType
  2. 旧 introspection 代码用is typing.Union判断联合;
  3. typing.get_origin(int | str)返回types.UnionType,不等于typing.Union
  4. 比较永远 False,走错分支 / 断言失败;
  5. 3.14 下更多注解用X | Y,该 bug 首次大规模触发;
  6. 低版本测试数据偏老写法,侥幸通过——典型的"版本相关 silent 错判"。

一句话:typing.Union比较X | Ytypes.UnionType运行时表示,3.14 下因新语法普及而失败。

四、最小可运行复现

用纯 Python 模拟"typing.Union vs types.UnionType 比较失败":

# repro_py314_union.py import typing import types def is_union_buggy(t): return typing.get_origin(t) is typing.Union def is_union_fixed(t): origin = typing.get_origin(t) return origin is typing.Union or origin is types.UnionType def main(): old = typing.Union[int, str] new = int | str print("老写法 is_union? ", is_union_buggy(old)) # True print("新语法 is_union(buggy)? ", is_union_buggy(new)) # False -> 失败 print("新语法 is_union(fixed)? ", is_union_fixed(new)) # True assert is_union_buggy(new) is False, "复现:新语法被旧比较误判" if __name__ == "__main__": main()

运行输出:

老写法 is_union? True 新语法 is_union(buggy)? False 新语法 is_union(fixed)? True

int | str被旧is typing.Union误判为"非联合",正是 3.14 测试失败的抽象。

五、解决方案(第一层:最小直接修复)

最小且必须的一步:把联合判断同时覆盖types.UnionType

# fix_layer1.py import typing import types def is_union(t): origin = typing.get_origin(t) # 修复:同时识别 typing.Union 与 X | Y 的 types.UnionType return origin is typing.Union or origin is types.UnionType

这一层改动最小:让判断兼容两种联合表示。但散落在多处的 introspection 都要改,易漏。

六、解决方案(第二层:结构性改进)

把"类型判断"收敛成统一工具,集中处理所有 3.14 相关的类型表示差异,杜绝散落判断:

# fix_layer2.py import typing import types from dataclasses import dataclass @dataclass(frozen=True) class TypeProbe: @staticmethod def is_union(t) -> bool: origin = typing.get_origin(t) return origin in (typing.Union, types.UnionType) @staticmethod def is_optional(t) -> bool: # Optional[X] = Union[X, None],需同时识别 None | X 新语法 if not TypeProbe.is_union(t): return False return type(None) in typing.get_args(t) @staticmethod def normalize(t): # 统一归一成 typing.Union[..., None] 形态,便于比较 if TypeProbe.is_union(t): args = typing.get_args(t) return typing.Union[args] return t # 用法 assert TypeProbe.is_union(int | str) assert TypeProbe.is_optional(int | None)

要点:

  • TypeProbe集中所有类型判断,新版本差异只改这一处;
  • is_optional也兼容X | None新语法;
  • normalizeX | YUnion[X, Y]归一成同一形态,比较不再受写法影响。

七、解决方案(第三层:断言 / CI 守护)

写 pytest 验证"3.14 的新语法写法被正确识别",且 CI 矩阵必须包含 3.14:

# test_py314_types.py import typing import types import pytest def is_union(t): origin = typing.get_origin(t) return origin in (typing.Union, types.UnionType) def test_new_union_syntax(): assert is_union(int | str) is True def test_old_union_still_works(): assert is_union(typing.Union[int, str]) is True def test_optional_new_syntax(): args = typing.get_args(int | None) assert type(None) in args def test_nested_union(): assert is_union(int | str | float) is True

并把 3.14 加进 CI 矩阵(GitHub Actionsmatrix.python-version包含"3.14"),确保任何版本相关回归都能被红。

八、排查清单

CI 在 Python 3.14 失败时:

  1. 确认失败是否"仅 3.14、低版本全过"——是则高度怀疑版本相关行为变化;
  2. 看报错是否围绕typing/types/ 语法(联合类型、泛型、get_origin);
  3. is typing.Union/typing.get_origin(...) is这类脆弱比较,补上types.UnionType
  4. 按第五 / 六节用TypeProbe统一类型判断;
  5. 把 3.14 加进 CI 矩阵,挡住未来的版本相关回归;
  6. 同理检查inspectasyncioimportlib.resources等 3.14 有变动的模块;
  7. 用第七节的 pytest 守护新语法写法被识别。

九、小结

Python 3.14 测试失败,根因常是类型 introspection 用typing.get_origin(t) is typing.Union判断联合,而 PEP 604 的X | Y语法运行时是types.UnionType,两者不相等。旧typing.Union[...]写法侥幸通过,3.14 下新语法普及后误判爆发。

三层层级:

  • 第一层:联合判断同时覆盖typing.Uniontypes.UnionType
  • 第二层:用TypeProbe集中所有类型判断与归一化,版本差异只改一处;
  • 第三层:pytest 验证新语法被识别,并把 3.14 加进 CI 矩阵。

核心教训:任何"依赖运行时类型标识做比较"的代码,在 Python 大版本升级时都易碎。用in (A, B)覆盖多种表示、并收口到单一工具,比散落的is X比较稳得多。CI 矩阵永远要包含最新 Python,让版本相关回归当场变红。