This commit is contained in:
2026-09-12 17:00:41 +08:00
parent 87ab0b794b
commit 2992148fb3
7 changed files with 305 additions and 4 deletions

View File

@@ -8,7 +8,7 @@
## 从源码运行 ## 从源码运行
需 Node 20+ 和 Python 3.9+`npm install``npm run dev` 需 Node 20+ 和 Python 3.8+`npm install``npm run dev`
## 用法 ## 用法

View File

@@ -1,3 +1,4 @@
from __future__ import annotations
import gc import gc
import platform import platform
import sys import sys

View File

@@ -1,3 +1,4 @@
from __future__ import annotations
import argparse import argparse
import io import io
import shutil import shutil
@@ -168,12 +169,14 @@ def main(argv=None):
# 版本预检:主进程的 validateInterpreter 已经挡过一层,这里是纵深防御—— # 版本预检:主进程的 validateInterpreter 已经挡过一层,这里是纵深防御——
# 用户可能绕过 UI 直接调引擎,或 PATH 上的 python 在探测后被换掉。 # 用户可能绕过 UI 直接调引擎,或 PATH 上的 python 在探测后被换掉。
# 低于 3.9 时后面的 list[X] 泛型注解会直接 SyntaxError拿不到结构化错误。 # 最低版本 3.84 个引擎模块全用了 `from __future__ import annotations`
if sys.version_info < (3, 9): # (PEP 563, 3.7+),PEP 604 / PEP 585 注解都字符串化 —— 3.8 也 import 得住。
# 3.7 故意不锁dataclass + 字符串化 PEP 585 的边界 case 多,且 3.7 已 EOL 2 年。
if sys.version_info < (3, 8):
got = ".".join(str(p) for p in sys.version_info[:3]) got = ".".join(str(p) for p in sys.version_info[:3])
emit(_base(env, config, "runtime_error", emit(_base(env, config, "runtime_error",
error={"type": "PythonTooOld", error={"type": "PythonTooOld",
"message": f"引擎需要 Python 3.9 或更高版本,当前为 {got}"})) "message": f"引擎需要 Python 3.8 或更高版本,当前为 {got}"}))
return return
# 一次 IO 拿到 src语法预检和后续 profile 都用这份文本 # 一次 IO 拿到 src语法预检和后续 profile 都用这份文本

View File

@@ -1,3 +1,4 @@
from __future__ import annotations
from dataclasses import dataclass, field, asdict from dataclasses import dataclass, field, asdict
import json import json

View File

@@ -1,3 +1,4 @@
from __future__ import annotations
import ast import ast
import cProfile import cProfile
import functools import functools

View File

@@ -0,0 +1,283 @@
"""引擎最低 Python 版本兼容性的契约测试。
策略4 个 .py 文件schema/structure/runner/harness都加了
`from __future__ import annotations`PEP 5633.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 链不抛 TypeErrordataclass 构造 + None 字段 work。
**为什么不真跑 3.8**:本机 3.14monkeypatch 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-annotationsengine/ 下非 __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.combmonkeypatch
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 兼容"
)

View File

@@ -36,6 +36,18 @@
"package.json", "package.json",
"icon.ico" "icon.ico"
], ],
"extraResources": [
{
"from": "engine",
"to": "engine",
"filter": [
"**/*.py",
"!**/tests/**",
"!**/__pycache__/**"
]
}
],
"asar": true,
"win": { "win": {
"target": [ "target": [
{ {