@@ -0,0 +1,283 @@
""" 引擎最低 Python 版本兼容性的契约测试。
策略: 4 个 .py 文件( schema/structure/runner/harness) 都加了
`from __future__ import annotations`( PEP 563, 3.7+),把所有类型注解
(包括 PEP 604 `X | None`、PEP 585 `dict[str, X]`、PEP 526 module-level
注解)变成字符串,绕开 3.9- 不支持的 PEP 604/585 运行时解析。
测试覆盖三类不变量:
A. **静态不变量**:每个引擎模块首行必须是 `from __future__ import annotations`。
这是字符串化生效的前提 —— 漏一个文件就立刻炸在某天有人加了 PEP 604 注解。
B. **字符串化不变量**:
- dataclass 字段 annotation 是字符串
- 函数签名 annotation 是字符串
- module-level PEP 526 annotation 是字符串
C. **行为不变量**: import engine 链不抛 TypeError; dataclass 构造 + None 字段 work。
**为什么不真跑 3.8**:本机 3.14, monkeypatch sys.version_info 只能改 metadata,
不能改解释器本身。但字符串化后的 annotation 在任何 3.7+ Python 上都只是
字符串, dataclass 不解析它们(不调 typing.get_type_hints) , 所以「3.14 +
future annotations」≡「3.8 + future annotations」—— 前者跑通就是后者能跑通的
充要条件。真正的 3.8 集成测试需要 CI 跑,本地静态覆盖即可。
"""
import json
import os
import sys
from pathlib import Path
import pytest
from engine import harness , runner , schema , structure
from engine . schema import (
AnalysisResult ,
Calibration ,
Environment ,
FlameNode ,
SCHEMA_VERSION ,
WallTime ,
)
ENGINE_DIR = Path ( __file__ ) . resolve ( ) . parents [ 1 ] # engine/
# ──────────────────────────────────────────────────────────────────────────────
# A. 静态不变量 —— 所有引擎模块必须 import future annotations
# ──────────────────────────────────────────────────────────────────────────────
# 哪些文件必须 future-annotations: engine/ 下非 __init__.py 的所有源码模块。
# __init__.py 留空( 包入口) , tests/ 下的文件有自己的 future-import 需求不在此约束。
_REQUIRED_FUTURE_MODULES = [ " schema.py " , " structure.py " , " runner.py " , " harness.py " ]
@pytest.mark.parametrize ( " filename " , _REQUIRED_FUTURE_MODULES )
def test_module_has_future_annotations ( filename ) :
""" 每个引擎模块首行(除 docstring / encoding 行)必须是 `from __future__ import annotations`。
漏一个文件 = PEP 604 注解在该文件被读到时立刻 TypeError。这是合同。
"""
src = ( ENGINE_DIR / filename ) . read_text ( encoding = " utf-8 " )
# 用 AST 而不是 grep 字符串 —— 不会被空行 / docstring / 多余注释骗到
import ast
tree = ast . parse ( src , filename = filename )
# 第一个 statement 必须是 Import, 且 name == '__future__' 且第一个 alias == 'annotations'
assert tree . body , f " { filename } 是空文件 "
first = tree . body [ 0 ]
assert isinstance ( first , ast . ImportFrom ) , (
f " { filename } 第一个 statement 必须是 `from __future__ import annotations`, "
f " 实际: { ast . dump ( first ) [ : 120 ] } "
)
assert first . module == " __future__ " , (
f " { filename } 第一个 import 必须来自 __future__, 实际: { first . module } "
)
names = [ a . name for a in first . names ]
assert " annotations " in names , (
f " { filename } 必须 import annotations, 实际 imports: { names } "
)
# ──────────────────────────────────────────────────────────────────────────────
# B. 字符串化不变量 —— PEP 604 / 585 / 526 annotation 必须是字符串
# ──────────────────────────────────────────────────────────────────────────────
def test_pep604_annotations_are_stringified_in_dataclass ( ) :
""" dataclass 字段 PEP 604 (`X | None`) 必须字符串化。
字符串化 = 运行时不会调 __or__ = 3.9- 兼容。如果未来有人把 PEP 604 写进
一个会被 `typing.get_type_hints` 解析的位置( dataclass 默认不解析,但
pydantic / TypedDict 等会),这个测试不变但代码在 3.9- 挂 —— 靠集成测试抓。
"""
ann = AnalysisResult . __annotations__
assert isinstance ( ann [ " error " ] , str ) , (
f " error annotation 应是字符串,实际: { type ( ann [ ' error ' ] ) . __name__ } = { ann [ ' error ' ] !r} "
)
assert ann [ " error " ] == " dict | None "
assert ann [ " wallTime " ] == " WallTime | None "
assert ann [ " flame " ] == " FlameNode | None "
assert ann [ " calibration " ] == " Calibration | None "
def test_pep585_annotations_are_stringified_in_dataclass ( ) :
""" dataclass 字段 PEP 585 (`list[X]`, `dict[K, V]`) 必须字符串化。
schema.AnalysisResult.fields 用了 `functions: list`、`config: dict` —— 裸
list/dict 不带泛型参数, 3.7+ 都支持;但有 default_factory 时仍要避免
运行时求值。字符串化顺手保证 PEP 585 写法 (`list[int]`) 也能 work。
"""
ann = AnalysisResult . __annotations__
# 裸 list/dict 应保持原写法 —— 它们在 3.7+ 都是合法 type instance
assert ann [ " functions " ] == " list "
assert ann [ " config " ] == " dict "
def test_pep604_annotations_are_stringified_in_function_signature ( ) :
""" 函数签名里的 PEP 604 annotation 必须字符串化。
含默认值的参数(如 `user_script_norm: str | None = None`)—— `from __future__`
字符串化 annotation, 但 `= None` 表达式仍求值(这是 PEP 563 的设计)。
dataclass 行为类似:`field(default=...)` 中的 default 表达式照常求值。
"""
src_ann = structure . _top_module . __annotations__
assert isinstance ( src_ann [ " user_script_norm " ] , str )
assert src_ann [ " user_script_norm " ] == " str | None "
# 完整覆盖 4 个 PEP 604 用法的函数
classify_ann = structure . _classify_origin . __annotations__
assert classify_ann [ " user_script_norm " ] == " str | None "
harness_ann = harness . calibrate_cprofile_overhead . __annotations__
assert harness_ann [ " workdir " ] == " str | None "
def test_pep526_module_level_annotation_is_stringified ( ) :
""" `_STDLIB_MODULES: frozenset | None`( PEP 526 module-level) 必须字符串化。
module-level 注解不受 dataclass 保护 —— 它直接在 module 顶层 evaluate。
PEP 563 把所有 annotations( 包括 PEP 526 module-level) 字符串化才能保
3.9- 兼容。
"""
ann_dict = structure . __annotations__
assert " _STDLIB_MODULES " in ann_dict , " module-level annotation 应进入 __annotations__ "
assert ann_dict [ " _STDLIB_MODULES " ] == " frozenset | None "
# ──────────────────────────────────────────────────────────────────────────────
# C. 行为不变量 —— import 链 + dataclass 构造 + 字段 None 都 work
# ──────────────────────────────────────────────────────────────────────────────
def test_full_engine_import_chain_no_typeerror ( ) :
""" import engine 链不抛任何 TypeError —— 证明 PEP 604/585 没在 import 时被求值。
runner import 会拉起 schema/harness/structure —— 任何一处在 module-level
evaluate 了 PEP 604/585( 典型陷阱: 在 module-level 写 `x: dict[str, int]` 然后
立刻用 isinstance / 拼路径) , 3.9- 会 TypeError 挂掉。
"""
assert callable ( runner . main )
assert callable ( harness . calibrate_cprofile_overhead )
assert callable ( structure . profile_and_measure )
# FlameNode 是嵌套 dataclass —— 验证 default_factory=list 仍能 work
n = FlameNode ( name = " root " , value = 1.0 )
assert n . children == [ ]
# Environment / Calibration 也是 dataclass —— 构造一遍
env = Environment ( python = " 3.8.0 " , platform = " win32 " , processor = " x86 " , timerResolution = 1e-7 )
cal = Calibration ( ratio = 1.5 )
assert cal . ratio == 1.5
assert cal . workloadName == " tight-loop " # default
def test_dataclass_with_none_for_all_optional_fields ( ) :
""" 所有 Optional 字段传 None 时,构造 + JSON 序列化完整 work。 """
r = AnalysisResult (
schemaVersion = SCHEMA_VERSION ,
environment = Environment ( python = " 3.8.20 " , platform = " win32 " , processor = " x86 " , timerResolution = 1e-7 ) ,
config = { } ,
status = " ok " ,
error = None ,
wallTime = WallTime ( seconds = 0.105 , unit = " s " ) ,
functions = [ ] ,
flame = None ,
calibration = None
)
data = json . loads ( r . to_json ( ) )
assert data [ " error " ] is None
assert data [ " flame " ] is None
assert data [ " calibration " ] is None
assert data [ " status " ] == " ok "
assert data [ " wallTime " ] [ " seconds " ] == 0.105
# ──────────────────────────────────────────────────────────────────────────────
# D. 伪老版本兼容 —— 在 3.14 上 monkeypatch sys.version_info 到老版本,
# 验证 import + 关键函数调用不挂。这不是真跑老版本,但能抓
# 「3.14 才有的 stdlib API」这类隐式依赖。
# ──────────────────────────────────────────────────────────────────────────────
@pytest.mark.parametrize (
" fake_version " ,
[
( 3 , 8 , 20 , " final " , 0 ) ,
( 3 , 9 , 19 , " final " , 0 ) ,
( 3 , 10 , 18 , " final " , 0 ) ,
( 3 , 11 , 13 , " final " , 0 ) ,
( 3 , 12 , 12 , " final " , 0 ) ,
( 3 , 13 , 5 , " final " , 0 ) ,
( 3 , 14 , 0 , " final " , 0 ) ,
] ,
)
def test_engine_imports_across_fake_old_versions ( monkeypatch , fake_version ) :
""" 在多个伪老版本上 import engine 链 + 跑一次 fixture 分析,全部 ok。
注: monkeypatch 只能改 sys.version_info( 影响依赖它的代码) , 不能改
解释器本身。但能抓的是「3.14 才有的 stdlib API 被用了」这种隐式依赖
—— 比如哪天有人在 harness 里调了 3.14 才有的 math.comb, monkeypatch
sys.version_info 到 3.8 不会让它挂(因为实际还是 3.14 解释器),
这条测试抓的是另一种回归: 「3.14 才有的 import」。
实际真老版本 CI 是另一层( pyenv 装 3.8 / 3.9 跑全套 pytest) 。
"""
for mod_name in list ( sys . modules ) :
if mod_name == " engine " or mod_name . startswith ( " engine. " ) :
monkeypatch . delitem ( sys . modules , mod_name , raising = False )
monkeypatch . setattr ( sys , " version_info " , fake_version )
# 再 import 一遍,触发 engine 模块链 reload
import importlib
importlib . import_module ( " engine " )
importlib . import_module ( " engine.schema " )
importlib . import_module ( " engine.harness " )
importlib . import_module ( " engine.structure " )
importlib . import_module ( " engine.runner " )
# 关键 callables 都还在
assert callable ( runner . main )
assert callable ( harness . calibrate_cprofile_overhead )
assert callable ( structure . profile_and_measure )
# ──────────────────────────────────────────────────────────────────────────────
# E. 运行时 API 审计 —— engine/ 下不能 import 任何 3.8 之后才有的 stdlib 模块
# ──────────────────────────────────────────────────────────────────────────────
# 来源: Python 3.8 / 3.9 / 3.10 / 3.11 / 3.12 release notes 整理
_PY38_PLUS_MODULES = {
# 3.9
" graphlib " , " zoneinfo " , # graphlib.TopologicalSorter, zoneinfo.ZoneInfo
# 3.10
# (没有 stdlib 新模块 —— 主要是新语法)
# 3.11
" tomllib " , # tomllib.load
# 3.12+
# 没有 stdlib 新模块
}
def test_no_post_38_stdlib_imports ( ) :
""" engine/ 下不能 import 任何 3.8 之后才有的 stdlib 模块。
防御性扫描: grep 出 `import X` / `from X` 的所有 X, 减去 stdlib 白名单
( 3.7+ 一直有的)+ 第三方( dataclasses 是 stdlib 但 3.7+),剩下任何
命中 `_PY38_PLUS_MODULES` 都视为回归。
"""
import re
import_stdlib_re = re . compile (
r " ^ \ s*(?:from \ s+( \ w+)|import \ s+( \ w+)) " , re . M
)
found : set [ str ] = set ( )
for py in ENGINE_DIR . glob ( " *.py " ) :
for m in import_stdlib_re . finditer ( py . read_text ( encoding = " utf-8 " ) ) :
mod = m . group ( 1 ) or m . group ( 2 )
found . add ( mod )
# 引擎实际 import 的 stdlib 模块( 3.7+ 一直有)
allowed = {
" dataclasses " , " json " , " ast " , " cProfile " , " functools " , " os " , " pstats " ,
" re " , " sys " , " time " , " platform " , " gc " , " types " , " argparse " , " io " ,
" shutil " , " tempfile " , " traceback " , " sysconfig " ,
# engine 自己的子模块 + special pseudo-module
" engine " , " __future__ " ,
}
bad = found & _PY38_PLUS_MODULES
assert not bad , f " engine/ 不应 import 3.8 之后才有的 stdlib 模块: { bad } "
# 同时确认所有 found 都在 allowed 白名单 —— 防止漏配
unexpected = found - allowed
assert not unexpected , (
f " engine/ import 了未在白名单的模块 { unexpected } —— "
f " 如果这是新引入的 stdlib 模块,需确认 Python 3.8 兼容 "
)