update
This commit is contained in:
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
## 从源码运行
|
## 从源码运行
|
||||||
|
|
||||||
需 Node 20+ 和 Python 3.9+:`npm install` 后 `npm run dev`。
|
需 Node 20+ 和 Python 3.8+:`npm install` 后 `npm run dev`。
|
||||||
|
|
||||||
## 用法
|
## 用法
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
from __future__ import annotations
|
||||||
import gc
|
import gc
|
||||||
import platform
|
import platform
|
||||||
import sys
|
import sys
|
||||||
|
|||||||
@@ -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.8:4 个引擎模块全用了 `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 都用这份文本
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
from __future__ import annotations
|
||||||
from dataclasses import dataclass, field, asdict
|
from dataclasses import dataclass, field, asdict
|
||||||
import json
|
import json
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
from __future__ import annotations
|
||||||
import ast
|
import ast
|
||||||
import cProfile
|
import cProfile
|
||||||
import functools
|
import functools
|
||||||
|
|||||||
283
engine/tests/test_py_compat.py
Normal file
283
engine/tests/test_py_compat.py
Normal file
@@ -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 兼容"
|
||||||
|
)
|
||||||
12
package.json
12
package.json
@@ -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": [
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user