【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 大量出现)根因链条:
X | Y语法(PEP 604)的运行时类型是types.UnionType;- 旧 introspection 代码用
is typing.Union判断联合; typing.get_origin(int | str)返回types.UnionType,不等于typing.Union;- 比较永远 False,走错分支 / 断言失败;
- 3.14 下更多注解用
X | Y,该 bug 首次大规模触发; - 低版本测试数据偏老写法,侥幸通过——典型的"版本相关 silent 错判"。
一句话:用typing.Union比较X | Y的types.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)? Trueint | 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新语法;normalize把X | Y和Union[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 失败时:
- 确认失败是否"仅 3.14、低版本全过"——是则高度怀疑版本相关行为变化;
- 看报错是否围绕
typing/types/ 语法(联合类型、泛型、get_origin); - 搜
is typing.Union/typing.get_origin(...) is这类脆弱比较,补上types.UnionType; - 按第五 / 六节用
TypeProbe统一类型判断; - 把 3.14 加进 CI 矩阵,挡住未来的版本相关回归;
- 同理检查
inspect、asyncio、importlib.resources等 3.14 有变动的模块; - 用第七节的 pytest 守护新语法写法被识别。
九、小结
Python 3.14 测试失败,根因常是类型 introspection 用typing.get_origin(t) is typing.Union判断联合,而 PEP 604 的X | Y语法运行时是types.UnionType,两者不相等。旧typing.Union[...]写法侥幸通过,3.14 下新语法普及后误判爆发。
三层层级:
- 第一层:联合判断同时覆盖
typing.Union与types.UnionType; - 第二层:用
TypeProbe集中所有类型判断与归一化,版本差异只改一处; - 第三层:pytest 验证新语法被识别,并把 3.14 加进 CI 矩阵。
核心教训:任何"依赖运行时类型标识做比较"的代码,在 Python 大版本升级时都易碎。用in (A, B)覆盖多种表示、并收口到单一工具,比散落的is X比较稳得多。CI 矩阵永远要包含最新 Python,让版本相关回归当场变红。