This commit is contained in:
2026-09-12 14:19:56 +08:00
commit 87ab0b794b
130 changed files with 30042 additions and 0 deletions

0
engine/__init__.py Normal file
View File

186
engine/harness.py Normal file
View File

@@ -0,0 +1,186 @@
import gc
import platform
import sys
import time
import types
import cProfile
from engine.schema import Calibration, Environment
def collect_environment() -> Environment:
"""采集运行环境指纹,用于结果可复现性与 UI 展示。"""
return Environment(
python=platform.python_version(),
platform=sys.platform,
processor=platform.processor() or platform.machine(),
timerResolution=time.get_clock_info("perf_counter").resolution,
)
def load_source(script_path: str) -> str:
"""读取脚本文本,剥离 BOM。
utf-8-sig 自动剥离 BOMWindows Notepad 默认带 BOM 的 UTF-8 文件);
用 utf-8 会留下  前缀compile() 抛 SyntaxError: invalid character。
引擎内所有读源码的地方都必须走这里,否则会出现"harness 能跑但
runner 的语法预检先失败"这类不一致(历史 bug
"""
with open(script_path, "r", encoding="utf-8-sig") as f:
return f.read()
def _load_code(script_path: str):
return compile(load_source(script_path), script_path, "exec")
def _build_user_globals(script_path: str) -> dict:
"""构造用户代码 exec 的 globals。
两件事一起处理:
1) sys.argv 临时清成 [script_path] —— 防用户代码读 --out 注册 atexit 改写
result.json(关键安全洞 C1)。exec 完后恢复。
2) 装一个 __main__ module —— 让 pickle / multiprocessing / joblib 这类
依赖 sys.modules['__main__'] 的库能正常工作。
之前只把 __name__ 设成 '__main__' 但 sys.modules['__main__'] 仍指向
engine.runner,直接 break pickle.dumps(本地定义类) ——
PicklingError: ... not found as __main__.Point。
"""
# 装 synthetic __main__ module,跟 exec 的 globals 共享同一份 dict
main_mod = types.ModuleType("__main__")
main_mod.__file__ = script_path
main_mod.__spec__ = None
g = main_mod.__dict__
g["__name__"] = "__main__"
g["__file__"] = script_path
g["__builtins__"] = __builtins__
sys.modules["__main__"] = main_mod
return g
def _scrub_argv_for_user_code(script_path: str) -> list:
"""把 sys.argv 收成 [script_path] 后备一份原值,exec 完后由调用方还原。
关键:用户代码能 sys.argv.index('--out') 拿到结果文件路径然后
atexit.register(lambda: open(out, 'w').write('{...forged...}')),engine
写完 result.json 紧接着被覆盖。父进程拿到的就是 attacker-controlled JSON。
"""
saved = sys.argv
sys.argv = [script_path]
return saved
def _restore_argv(saved):
sys.argv = saved
# 校准负载tight-loop,典型的 CPU-bound 工作量。
# 选 50_000 次是因为典型机器上耗时 ~10-30ms —— 既让 cProfile 开销稳定可测,
# 又不会让用户感到"启动变慢"。如果换成 1 次循环,ratio 会被调度噪声主导;
# 换成 1M 次,校准本身就要 ~1s,用户能感知。
_CALIB_SOURCE = (
"def _pyrof_calib():\n"
" s = 0\n"
" for i in range(50_000):\n"
" s += i\n"
" return s\n"
"_pyrof_calib()\n"
)
# 在 module load 时编译一次 —— 校准负载是常量字符串,之前 calibrate 跑两遍
# (clean + instrumented) 就 compile 两次,纯浪费。co_filename 用合成路径,
# 不需要落到磁盘 —— _build_user_globals 只取它当 __file__,不校验文件存在。
_CALIB_CODE = compile(_CALIB_SOURCE, "<pyrof_calibration>", "exec")
# 同样用于 _build_user_globals(script_path) —— synthetic __main__.__file__
# 用同一条合成路径,跟 code.co_filename 保持一致。
_CALIB_PATH = "<pyrof_calibration>"
def _exec_once_for_calibration() -> None:
"""校准负载的纯 exec —— 不开 cProfile。
GC 状态由 caller 负责 —— calibrate_cprofile_overhead 已经 disable 后
re-enable,这里再包一层是冗余 no-op,徒增两个 isenabled() 系统调用。
"""
g = _build_user_globals(_CALIB_PATH)
saved_argv = _scrub_argv_for_user_code(_CALIB_PATH)
try:
exec(_CALIB_CODE, g)
finally:
_restore_argv(saved_argv)
def _exec_once_under_cprofile() -> None:
"""同一段校准负载,套 cProfile 跑一次 —— 用于测 cProfile 自身开销。
GC 状态由 caller 负责(同上理由)。"""
g = _build_user_globals(_CALIB_PATH)
saved_argv = _scrub_argv_for_user_code(_CALIB_PATH)
pr = cProfile.Profile()
pr.enable()
try:
exec(_CALIB_CODE, g)
finally:
pr.disable()
_restore_argv(saved_argv)
def calibrate_cprofile_overhead(workdir: str | None = None) -> Calibration:
"""跑同一段 tight-loop 各一次(裸 vs 仪器化),返回膨胀系数。
为什么需要这步:
- 用户脚本只 exec 一次 (单跑架构,v4 起),这次 exec 必须开 cProfile 才能拿归因
- 但 cProfile 是插桩式的,wall-time 会膨胀 1.5~3x;直接报给用户不真实
- 思路是「cProfile 开销 ≈ 跟用户代码调用次数成正比」,跟代码时间无关。
所以可以在一段**已知调用次数**的 tight-loop 上测一次 ratio,再把
instrumented_user / ratio 当作估计的干净耗时。
- 这一步与用户代码 exec 解耦:校准不会被用户的 import / 全局副作用干扰,
也不会因为校准改了用户的状态而报错。
边界情况:
- 校准本身耗时 <5ms (机器极快):perf_counter 精度+调度抖动会让 ratio
跳到 0.5x/5x 这种离谱值,此时回退 ratio=1.0 (即相信 instrumented 时间)。
- 校准本身耗时 5~50ms:正常,ratio 在 1.5~3 之间。
workdir:历史参数,现已废弃 —— 校准脚本不再落盘(改成内存里 compile +
合成 __file__),workdir 不再被本函数读取。为保持 profiler-service /
老调用方不破,参数保留并忽略。
"""
del workdir # 明确workdir 已废弃,调用方传过来也不再消费
try:
# 干净跑 —— 不开 cProfile
gc_was = gc.isenabled(); gc.disable()
try:
t0 = time.perf_counter()
_exec_once_for_calibration()
clean = time.perf_counter() - t0
finally:
if gc_was: gc.enable()
# 仪器化跑 —— 开 cProfile
gc_was = gc.isenabled(); gc.disable()
try:
t0 = time.perf_counter()
_exec_once_under_cprofile()
inst = time.perf_counter() - t0
finally:
if gc_was: gc.enable()
finally:
pass # 无文件要清
if clean < 0.005 or inst <= 0 or clean <= 0:
# 校准耗时过短或退化:ratio 不可信,回退 1.0 (即不补偿,UI 显示原始 instrumented 时间)
return Calibration(
ratio=1.0,
workloadName="tight-loop",
instrumentedWorkloadSec=inst,
cleanWorkloadSec=clean,
)
return Calibration(
ratio=inst / clean,
workloadName="tight-loop",
instrumentedWorkloadSec=inst,
cleanWorkloadSec=clean,
)

257
engine/runner.py Normal file
View File

@@ -0,0 +1,257 @@
import argparse
import io
import shutil
import sys
import tempfile
import traceback
from engine.harness import calibrate_cprofile_overhead, collect_environment, load_source
from engine.schema import SCHEMA_VERSION, AnalysisResult, Calibration, FlameNode, WallTime
from engine.structure import profile_and_measure
# Result 文件大小防护(防用户脚本异常把磁盘撑爆)
# - error.message 上限 1KB:1KB 一般足够覆盖正常异常说明
# - error.traceback 上限 8KB:100 帧左右 traceback 的体量,UI 可读范围
# - functions 列表上限 5000:UI 表格默认只看前 200,再多只是给后续解析加负载
_MAX_ERROR_MESSAGE_BYTES = 1024
_MAX_TRACEBACK_BYTES = 8 * 1024
_MAX_FUNCTIONS = 5000
def _truncate_text(text: str, max_bytes: int) -> str:
"""把文本按 UTF-8 字节截断到 max_bytes 以内,补一个明确截断标记。
直接 [:max_chars] 在多字节字符中间切会破坏 UTF-8,UI 解析挂。encode → 截
→ 走 errors='ignore' 让不完整尾部自然掉落 → decode,保证结果仍是合法 UTF-8。
"""
if not text:
return text
encoded = text.encode("utf-8", errors="replace")
if len(encoded) <= max_bytes:
return text
truncated = encoded[:max_bytes].decode("utf-8", errors="ignore")
# 留 64 字节给标记(总长仍 ≤ max_bytes)
suffix = f"\n\n[... 截断:共 {len(encoded)} 字节,仅保留前 {max_bytes} 字节 ...]"
# 如果 suffix 太长导致总超,再砍一次
return (truncated + suffix)[:max_bytes]
def _cap_functions(functions: list, flame: FlameNode | None) -> tuple:
"""截断 functions 列表到 _MAX_FUNCTIONS,并把超出部分累加到 flame 的 root。
为什么不只截 functions 不动 flame?
- flame 是 UI 看到的"时间分布",丢了 functions 里那些小函数会让 flame
总和 < 用户报的总耗时,UI 显示出现 100% 但 sum 不到 80% 的诡异情况。
- 把被丢函数的 tottime 加到 root.value 上,UI 那边仍按 flame 内部占比显示,
多出来的部分归到 root 节点(最大最宽的 tile,用户视觉上只是「其他」变
大了一点 —— 总和不撒谎。
"""
if len(functions) <= _MAX_FUNCTIONS:
return functions, flame
kept = functions[:_MAX_FUNCTIONS]
dropped_tottime = sum(f.tottime for f in functions[_MAX_FUNCTIONS:])
if flame is None:
flame = FlameNode(name="root", value=dropped_tottime, children=[])
else:
flame.value += dropped_tottime
return kept, flame
def _compute_wall_time(instrumented_sec: float, calibration: Calibration) -> float:
"""用校准 ratio 折算 instrumented 时间为估计的干净耗时。
思路:cProfile 是插桩式的,wall-time 会被膨胀 ratio 倍(instrumented / clean);
用同一段 tight-loop 测出 ratio 后,把用户的 instrumented 时间除回去,就是
「去掉 cProfile 开销后的估计耗时」。
边界:calibration.ratio 是 0 / 负数 / NaN 时直接信 instrumented 时间 —— 跟
calibrate_cprofile_overhead 在「校准耗时过短」时回退 ratio=1.0 的兜底策略
保持一致,UI 显示真实测得的 instrumented 时间而不是除以无效值。
"""
ratio = calibration.ratio
# `ratio == ratio` 排除 NaN;`ratio > 0` 排除 0 / 负数
if ratio == ratio and ratio > 0:
return instrumented_sec / ratio
return instrumented_sec
def _cap_result(result: AnalysisResult) -> AnalysisResult:
"""给 result.json 上一道防线,防止异常情况把磁盘/IPC 通道撑爆。
触发场景:
- 用户代码 raise 一个带 50MB 文件内容的 ValueError → error.message 撑爆
- 用户代码触发深递归异常,Python 自动 dump 几千层 traceback → 撑爆
- 用户脚本 import 一个庞大的库(numpy/pandas)scope=all 把整棵树都归因 →
38MB+ 的 functions 列表
截断而非拒绝:UI 仍能看到「有错」「发生了什么」,只是具体细节截断;functions
截断后仍按 tottime 排序,前 5000 一定是最值得用户关注的热点。
"""
if result.error:
msg = result.error.get("message")
if isinstance(msg, str) and msg:
result.error["message"] = _truncate_text(msg, _MAX_ERROR_MESSAGE_BYTES)
tb = result.error.get("traceback")
if isinstance(tb, str) and tb:
result.error["traceback"] = _truncate_text(tb, _MAX_TRACEBACK_BYTES)
if result.functions:
result.functions, result.flame = _cap_functions(result.functions, result.flame)
return result
def _progress(phase, pct):
# 前导 \ntqdm / 进度条类库会写"working..."到 stderr 但不换行,
# 直接接 PROGRESS 会拼成 "working...PROGRESS running 10",主进程正则匹配挂、
# 进度事件掉一档且把工程协议行混进 stderrTail 让用户看到 \r 进度条残片。
print(f"\nPROGRESS {phase} {pct}", file=sys.stderr, flush=True)
def _emit(result: AnalysisResult, out_path=None):
"""发出结果。
out_path 给定时结果写入独立文件stdout/stderr 就完整留给用户代码 ——
否则用户代码里一句 sys.stdout.write("done") 或 print(..., end='') 就会和
JSON 挤在同一行,主进程按"最后一行"取 JSON 时解析失败,
用户看到的却是"结果解析失败"。不给 out_path 时退回 stdout直接跑 CLI / 老测试)。
"""
payload = result.to_json()
if out_path:
with io.open(out_path, "w", encoding="utf-8", newline="\n") as f:
f.write(payload)
else:
print(payload, flush=True)
def _base(env, config, status, error=None, wall_time=None, functions=None, flame=None, calibration=None):
return AnalysisResult(
schemaVersion=SCHEMA_VERSION,
environment=env,
config=config,
status=status,
error=error,
wallTime=WallTime(seconds=wall_time, unit="s") if wall_time is not None else None,
functions=functions or [],
flame=flame,
calibration=calibration,
)
def main(argv=None):
ap = argparse.ArgumentParser()
ap.add_argument("--script", required=True)
ap.add_argument("--out", default=None,
help="结果 JSON 的输出文件;不给则打到 stdout")
ap.add_argument("--scope", choices=["user", "all"], default="user",
help="剖析范围user默认只归因用户脚本里的函数"
"all 包含所有非 cProfile 内部帧(标准库 + 第三方包 + 用户代码),"
"让耗时可以下钻到 import 的包里。")
ap.add_argument("--workdir", default=None,
help="校准文件落盘的临时目录;不给则 runner 自己 mkdtemp。"
"profiler-service 一般传过来(它创建的 pyprof-xxx),"
"直跑 CLI 不传也行。")
ap.add_argument("--hide-internal", dest="hide_internal",
action=argparse.BooleanOptionalAction, default=True,
help="过滤掉测试代码(tests/ / test_*.py / _pyrof_calib 等)+ "
"tottime=0 的量化噪声帧。默认 on,用户原话:「软件内部的测试部分"
"默认百分百过滤掉」。--no-hide-internal 关掉这条过滤,看完整数据。")
args = ap.parse_args(argv)
# 统一走这个 emit结果去 --out 指定的文件stdout 留给用户代码。
# 每条 emit 之前过 _cap_result 给 result.json 上一道防线 —— error.traceback /
# error.message / functions 列表都设硬上限,防止用户异常把磁盘 / IPC 撑爆。
def emit(result):
_emit(_cap_result(result), args.out)
env = collect_environment()
config: dict = {}
# 版本预检:主进程的 validateInterpreter 已经挡过一层,这里是纵深防御——
# 用户可能绕过 UI 直接调引擎,或 PATH 上的 python 在探测后被换掉。
# 低于 3.9 时后面的 list[X] 泛型注解会直接 SyntaxError拿不到结构化错误。
if sys.version_info < (3, 9):
got = ".".join(str(p) for p in sys.version_info[:3])
emit(_base(env, config, "runtime_error",
error={"type": "PythonTooOld",
"message": f"引擎需要 Python 3.9 或更高版本,当前为 {got}"}))
return
# 一次 IO 拿到 src语法预检和后续 profile 都用这份文本
# 之前没 try/except临时目录被删 / 路径权限不够 → 进程崩,无 JSON 输出 → UI 显示 "引擎无输出"
# UnicodeDecodeError 不是 OSError 的子类:二进制文件 / 非 UTF-8 脚本会绕过这层保护再次崩溃。
try:
src = load_source(args.script)
except (OSError, UnicodeDecodeError) as e:
emit(_base(env, config, "runtime_error",
error={"type": type(e).__name__, "message": f"无法读取脚本: {e}"}))
return
# Syntax precheck (用文本而非再读一次)
# compile() 还会在源码含 null 字节时抛 ValueError也一并按 syntax_error 归类
# 把 code object 留起来 —— profile_and_measure 还需要再 exec 一次,
# 直接复用这份省一次 read + compile。
try:
code = compile(src, args.script, "exec")
except (SyntaxError, ValueError) as e:
emit(_base(env, config, "syntax_error", error={"type": type(e).__name__, "message": str(e)}))
return
# v4 单跑架构:校准 + 用户代码 under cProfile 一次拿到归因 + instrumented 时间。
# 跟 v3 的「先裸跑测 wall_time 再 cProfile 跑」相比,
# 用户脚本的副作用(plot / file write / etc)只发生一次,语义更直观。
# workdir 优先用 caller 传的(profiler-service 已经 mkdir pyprof-xxx 落 _pyrof_calib.py
# 和用户脚本);caller 不传则自己 mkdtemp —— 这种情况跑完自己清掉。
workdir = args.workdir
workdir_owned = False
if not workdir:
workdir = tempfile.mkdtemp(prefix="pyprof-")
workdir_owned = True
try:
try:
_progress("calibrating", 5)
# 校准脚本落在 caller 提供的 workdir 里 —— profiler-service 已经 mkdir
# pyrof-xxx 装用户脚本,把 _pyrof_calib.py 放一起能让 rmtree(workdir) 一次
# 清干净。runner 自己 mkdtemp 时(直跑 CLI 场景)workdir_owned=True,
# calibration 拿到的是同一个目录,临时文件一起被 finally 兜底删。
calibration = calibrate_cprofile_overhead(workdir)
_progress("running", 30)
st, instrumented = profile_and_measure(
args.script, scope=args.scope, hide_internal=args.hide_internal,
code=code, src=src,
)
wall_time = _compute_wall_time(instrumented, calibration)
_progress("done", 100)
emit(_base(env, config, "ok",
wall_time=wall_time,
functions=st.functions,
flame=st.flame,
calibration=calibration))
return
except SystemExit as e:
# sys.exit() 抛 SystemExitBaseException 的子类)—— 不被下面的 except BaseException 捕获,
# 子进程直接退出无 JSONUI 就会显示 "引擎无输出 (exit N)"。转成 runtime_error。
# e.code 默认 None无参 sys.exit()),文案要单独处理,否则会出现"sys.exit(None)"
code = e.code if isinstance(e.code, int) else (0 if e.code is None else f"非整数 {e.code!r}")
emit(_base(env, config, "runtime_error",
error={"type": "SystemExit", "message": f"用户代码调用 sys.exit({code})"}))
except BaseException as e:
# KeyboardInterrupt / GeneratorExit / 自定义 BaseException 子类 以及
# 普通 Exception 一并兜底 —— 之前再写一条 `except Exception` 是 dead code
# BaseException 是 Exception 的父类,先匹配的赢)。父进程端有 cancel +
# 进程树 kill 在管 Ctrl+C,这里只负责兜底写 JSON。
emit(_base(env, config, "runtime_error",
error={"type": type(e).__name__, "message": str(e),
"traceback": traceback.format_exc()}))
finally:
# runner 自己 mkdtemp 的目录要自己清 —— profiler-service 已经传 --workdir 时
# 整个 workdir 由 caller 管(主进程清理临时目录的逻辑在 before-quit),这里只兜
# 「直接 python -m engine.runner」的直跑场景。
if workdir_owned:
shutil.rmtree(workdir, ignore_errors=True)
if __name__ == "__main__":
main()

87
engine/schema.py Normal file
View File

@@ -0,0 +1,87 @@
from dataclasses import dataclass, field, asdict
import json
SCHEMA_VERSION = 4
@dataclass
class Environment:
python: str
platform: str
processor: str
timerResolution: float
@dataclass
class WallTime:
seconds: float
unit: str = "s"
@dataclass
class Calibration:
# v4 新增cProfile 开销校准系数,用于把 instrumented wall-time 折算为干净耗时。
# 用户脚本只 exec 一次(单跑架构)的代价是 wall-time 带 cProfile 开销;启动期
# 用同一段 tight-loop 各跑一次(裸 vs 仪器化ratio = instrumented / clean
# 用户 wall_time = instrumented_user / ratio。
ratio: float
# 校准负载名:方便 debug 时看出是哪段负载产生的系数;目前固定为 "tight-loop"
workloadName: str = "tight-loop"
# 校准负载在 cProfile 下的耗时(秒)—— 透传给 UI 方便 debug
instrumentedWorkloadSec: float = 0.0
# 校准负载裸跑的耗时(秒)
cleanWorkloadSec: float = 0.0
@dataclass
class FunctionNode:
id: str # "file:line:name"
file: str
line: int
name: str
cumtime: float
tottime: float
ncalls: int
percallTot: float
# 顶层模块名(用于 scope=all 时的 UI 分类):
# "<user>" 用户脚本 / "json" / "numpy" / "<frozen>" 等。
# 提取逻辑见 engine/structure._top_module这一层做归类
# 让 UI 不必重新解析文件路径(路径 normalize 在 OS 间不一致)。
module: str = ""
# 帧来源v3 新增;用于 UI 按 origin 分组):
# "user" 用户脚本 / "stdlib" 标准库 / "third_party" 第三方包 /
# "builtin" 内置C 实现的 builtin / "frozen" frozen importlib 等 /
# "other" 兜底(未匹配任何已知来源,例如奇怪的 <string> 帧)
#
# 优先用 sys.stdlib_module_names 校准3.10+),降级用路径启发式:
# /Lib/ 或 /lib/pythonX.Y/ → stdlib含 site-packages/dist-packages → third_party。
# 推导逻辑见 engine/structure._classify_origin。
origin: str = ""
@dataclass
class FlameNode:
name: str
value: float
children: list = field(default_factory=list)
@dataclass
class AnalysisResult:
schemaVersion: int
environment: Environment
config: dict
status: str # ok|syntax_error|runtime_error|timeout
error: dict | None
wallTime: WallTime | None
functions: list
flame: FlameNode | None
# v4 新增ok 状态下必有;非 ok语法错 / 运行时错 / 超时)下为 None。
# 见 Calibration 字段注释 —— 折算 wallTime 时需要,存到 result 里给 UI 看。
calibration: Calibration | None = None
def to_json(self) -> str:
# ensure_ascii=True默认在 GBK locale / 没设 PYTHONUTF8 的环境下
# stdout 不是合法 UTF-8UI 解析会挂。转义成纯 ASCII 后逐字节一致,
# JSON.parse 原生还原。主进程仍然会设那两个环境变量,这里是纵深防御。
return json.dumps(asdict(self), ensure_ascii=True)

576
engine/structure.py Normal file
View File

@@ -0,0 +1,576 @@
import ast
import cProfile
import functools
import os
import pstats
import re
import sys
import time
from dataclasses import dataclass
from engine.schema import FlameNode, FunctionNode
from engine.harness import _build_user_globals, _load_code, _restore_argv, _scrub_argv_for_user_code, load_source
@dataclass
class StructureResult:
functions: list
flame: FlameNode
# ──────────────────────────────────────────────────────────────────────────────
# 内部测试代码识别 + 量化噪声过滤
# ──────────────────────────────────────────────────────────────────────────────
#
# 用户原话:「软件内部的测试部分默认百分百过滤掉,不在统计范围内容。完全不显示。」
# —— 所以测试代码 + tottime=0 的帧默认不进 result.json / functions[] / flame。
#
# 这是 v5 才加的过滤。在 App 入口(filterAnalysis)也有一份,这里再加一份是为了
# 「不打开 UI 也想看干净数据」的场景:用户拿 result.json 跑自己的聚合脚本时,
# 拿到的就是过滤后的数据,而不是 774 帧 + 90% 是噪声。
#
# 跟 App 层契约一致:只匹配 CONTEXT(文件路径 / 模块路径) + 引擎内部硬编码白名单,
# 完全不看函数名 —— TS 端 isInternalTest 的同样设计原则,误伤 = bug。
import re as _re_noise
# 文件名是 test_*.py / *_test.py —— 任意位置都算测试代码pytest 文件命名约定)
_INTERNAL_TEST_FILE_RE = _re_noise.compile(r'[\\/](?:test_[^\\/]+|[^\\/]+_test)\.py$')
# 在 tests/ / test/ / __tests__/ **直接下面**的文件 —— pytest 目录约定。
# 收紧到要求「tests/ 后面紧接一个文件名,不能再有 / 子目录」:
# * tests/foo.py → 算 (foo.py 直接在 tests/ 下)
# * tests/fixtures/foo.py → 不算 (fixtures 是子目录,foo.py 不直接挂在 tests/ 下)
# 之前用 `[\\/](tests?|__tests__)[\\/]` 任何含 `/tests/` 的路径都中招,把 fixtures 这类
# 测试数据夹具也当成测试代码误伤 —— 用户脚本只要住在带 tests/ 子目录的路径下,
# 函数表直接被过滤成 0 行。
_INTERNAL_TEST_DIR_RE = _re_noise.compile(r'[\\/](tests?|__tests__)[\\/][^\\/]+$')
_INTERNAL_TEST_MODULE_RE = _re_noise.compile(r'^(tests?|__tests__)([._]|$)')
_INTERNAL_NAMES: frozenset = frozenset(["_pyrof_calib"])
def _is_internal_test(name: str, file: str, module: str) -> bool:
"""Python 版的 isInternalTest —— 命中即视作「噪声帧」,不进 functions[]。
设计原则(同 TS 版 utils/origin.ts):
- 只看语境(文件 / 模块 / 引擎白名单),不看函数名 —— 函数名匹配太容易误伤
(test_helper() / TestCase.test_login() 都是合法业务函数)
- 引擎内部硬编码白名单(_pyrof_calib)是兜底防御,任何情况下都不该出现在用户 stats 里
"""
if name in _INTERNAL_NAMES:
return True
# 文件名是 test_*.py / *_test.py —— 任意位置
if _INTERNAL_TEST_FILE_RE.search(file):
return True
# 在 tests/ / test/ / __tests__/ 直接下面的文件(不能是子目录)
if _INTERNAL_TEST_DIR_RE.search(file):
return True
# module 一定非空_top_module 不会返回空字符串),不用做 truthy 兜底
if _INTERNAL_TEST_MODULE_RE.match(module):
return True
return False
def _extract_user_imports(src: str) -> set:
"""从用户脚本源码里抽出顶层显式 import 的模块名集合。
只看模块级 Import / ImportFrom —— 函数/类内部的 import 是延迟副作用,不是
「我需要分析的目标」。返回集合里保留顶级包名(numpy / json / os 等),不展开
as 后的别名(as np → numpy 也在集合里,别名不进集合)。
解析失败(syntax error 等)时返回空集合 —— 调用方已经独立做了 compile 预检,
走到 profile_and_measure 的代码一定是合法 Python,这里只是防御。
"""
try:
tree = ast.parse(src)
except SyntaxError:
return set()
modules: set = set()
for node in tree.body:
if isinstance(node, ast.Import):
for alias in node.names:
# 'import a.b.c' → 'a' 就够了;子包自然跟着顶级包一起保留。
modules.add(alias.name.split(".")[0])
elif isinstance(node, ast.ImportFrom):
# 'from . import x' 的 level > 0 是相对导入,跳过 —— 它们依附于某个
# 已知包(用户脚本 / 已导入的第三方),不展开根模块。
# node.level 是 int(0/1/2/...),0 是 falsy,这里只需 if node.level。
if node.level:
continue
if node.module:
modules.add(node.module.split(".")[0])
return modules
def _fid(func):
file, line, name = func
return f"{file}:{line}:{name}"
@functools.lru_cache(maxsize=4096)
def _norm(path):
"""规范化路径 —— Windows NTFS 大小写不敏感 + 跨斜杠风格统一。
包 lru_cache典型 profile 5000 帧 / 50 unique file path —— 之前每个
frame 都跑 abspath + normcase,scope=user 的 hot loop 里反复调;
缓存后只有 50 次真正的 abspath。同一路径跨多次 _top_module /
_classify_origin / _make_frame_filter 复用同一结果。
maxsize=4096 覆盖任何真实 profile 看不到的 unique file 数。
"""
try:
return os.path.normcase(os.path.abspath(path))
except Exception:
return path
def _make_frame_filter(script_path, scope="user"):
"""构造帧过滤器。
scope=user默认只保留用户脚本里的函数 — 历史上一直如此。
scope=all保留所有非 cProfile 内部帧(包含标准库和第三方包)— 让耗时
可以"进入到 import 的包里",否则 `pandas.read_csv` 永远是一个黑盒,
看到 3s 也不知道是序列化慢、IO 慢还是解析慢。
`~` 帧的过滤有讲究 —— 之前一刀切 file == '~' 都丢,实际上:
- cProfile 用 `~` 作为**所有 C 函数帧**的文件名time.sleep / numpy C 核
/ json 加速 / re 等),丢这些直接打瞎 scope=all
- 真正的 cProfile 内部帧靠 name 区分:`'_lsprof.Profiler'` /
`'Profiler' / '<method 'disable' of '_lsprof.Profiler' objects>'` 之类
只剔除 name 含 '_lsprof.Profiler' 的帧,其它 C 函数保留 —— schema 里
origin='builtin' / module='<built-in>' 也终于能命中真实 builtin 帧。
"""
target = _norm(script_path)
def _is_kept(func):
file, _, name = func
# cProfile 内部帧 —— file='~',name 含 "_lsprof.Profiler" 子串
# (实际形态:'Profiler' / '_lsprof.Profiler' /
# "<method 'disable' of '_lsprof.Profiler' objects>"
if file == "~" and ("_lsprof.Profiler" in name or name == "Profiler"):
return False
if scope == "all":
return True
# 虚拟路径(<frozen ...> / <built-in ...> / <string> 等)不可能是用户脚本,
# 直接 False 省一次 _norm —— _norm("<frozen ...>") 会跑 abspath 拼成
# "<cwd>/<frozen ...>" 然后 normcase,跟用户脚本路径比必然不等,但白做一次
# 文件系统查询。~ 在 cProfile 内部已上面短路,这里同等处理。
if file.startswith("<") or file == "~":
return False
return _norm(file) == target
return _is_kept
def _top_module(file_path: str, user_script_path: str, user_script_norm: str | None = None) -> str:
"""把 cProfile 给的 file 路径归类成「顶层模块名」,给 UI 做分类用。
输出样例:
- 用户脚本(==user_script_path"<user>"
- "/usr/lib/python3.11/json/decoder.py""json"
- "C:\\Python39\\Lib\\json\\decoder.py""json"
- "C:\\Python39\\Lib\\functools.py""functools" ← 顶层 .py 必须剥掉后缀
- "/.../site-packages/numpy/core/array.py""numpy"
- "<frozen importlib._bootstrap>""<frozen>"
- "<built-in method xxx>""<built-in>"
- 其他 → 取倒数第二个目录段作 fallback基本不会走到
user_script_norm: 调用方预计算的 `_norm(user_script_path)` —— 同一进程内
同一脚本会查几百次,提到外面省 abspath不传则本函数内现算保持单点调用方兼容
"""
if file_path == "~":
# C 函数time.sleep / numpy 加速 / json C decoder 等):
# file==~ + name 是 "<built-in method ...>"
# 之前丢光后这里 dead code,现在 _make_frame_filter 不再丢 C 帧,
# module 字段需要给出有意义分类 —— 用 "<built-in>" 跟 origin 字段对齐。
return "<built-in>"
if _norm(file_path) == (user_script_norm if user_script_norm is not None else _norm(user_script_path)):
return "<user>"
# frozen / built-in / <string> 这种「虚拟」文件:整段作为标签
if file_path.startswith("<"):
if file_path.startswith("<frozen"):
return "<frozen>"
if file_path.startswith("<built-in"):
return "<built-in>"
return file_path
# 路径规范化:跨平台 + 跨斜杠
norm = file_path.replace("\\", "/")
parts = [p for p in norm.split("/") if p]
# site-packages / dist-packages标记之后的第一个目录段就是包名
for marker in ("site-packages", "dist-packages"):
if marker in parts:
idx = parts.index(marker) + 1
if idx < len(parts):
return parts[idx]
# 标准库Windows 安装布局C:\Python39\Lib\<pkg>\... 或顶层 .py
# `Lib\\functools.py` → "functools"(不是 "functools.py"),用 _strip_py 兜底
if "Lib" in parts:
idx = parts.index("Lib") + 1
if idx < len(parts):
return _strip_py(parts[idx])
# 标准库Linux / macOS 安装布局):/usr/lib/python3.X/<pkg>\...
# 之前用 p.startswith("python") + p[6:7].isdigit() 太松散 —— "python3-extra"
# 这种目录会被误识别;收紧到严格的 `pythonX(.Y)?` 形式。
for i, p in enumerate(parts):
if re.fullmatch(r"python\d+(\.\d+)?", p):
if i + 1 < len(parts):
return _strip_py(parts[i + 1])
# 兜底:取倒数第二个目录段(例如 ".../myproj/src/utils/helper.py" → "utils"
if len(parts) >= 2:
return _strip_py(parts[-2])
return _strip_py(file_path)
def _strip_py(name: str) -> str:
"""顶层 .py 文件剥掉扩展名 —— `functools.py` → `functools`。
只剥 `.py` 后缀;其它段('__init__''site-packages' 等)原样保留。
非顶层文件不会被这个函数触碰 —— _top_module 把它包在 `parts[idx]` 之外的位置时
返回的就是 `parts[-2]` 这种目录段,永远不带 `.py`;只有顶层 `<name>.py` 才走到这里。"""
if name.endswith(".py"):
return name[:-3]
return name
# sys.stdlib_module_names 是 3.10+ 才有的3.9 及之前要走路径兜底。
# 提前 frozen 一次 —— 同一进程内不变,反复判 in 走 frozenset 是 O(1)。
_STDLIB_MODULES: frozenset | None
try:
_STDLIB_MODULES = frozenset(getattr(sys, "stdlib_module_names", set()))
except Exception:
_STDLIB_MODULES = None
# 路径兜底"是不是真的在 stdlib 根下"用 sysconfig —— sysconfig.get_paths() 是
# Python 官方给出的 stdlib 根解析工具,远比手算 <prefix> + 'Lib' / '<prefix>/lib/pythonX.Y'
# 靠谱venv / embed / framework 几种安装布局都覆盖)。
#
# M5 fix:之前只看路径里是否含 "/Lib/" 或 "/lib/pythonX.Y/" —— 用户项目
# 里有 `myproject/Lib/foo.py` 这种就会被错认成 stdlib。现在锚到 sysconfig
# 算出的真 stdlib 根上:不是真正的 Python 安装根下面的,一律不算 stdlib。
#
# 一次性算好缓存,frozen 之后 hot path 上 O(1) prefix 比对。
def _stdlib_roots() -> tuple:
roots: list = []
try:
import sysconfig
stdlib_path = sysconfig.get_paths().get("stdlib", "")
if stdlib_path:
roots.append(_norm(stdlib_path))
except Exception:
pass
# 兜底再放 sys.prefix/Lib —— 有些 embedded 安装 sysconfig 拿不到
fallback = os.path.join(sys.prefix, "Lib")
roots.append(_norm(fallback))
return tuple(roots)
_STDLIB_ROOTS_NORM: tuple = _stdlib_roots()
def _is_under_real_stdlib(file_path: str) -> bool:
"""判断 file_path 是否在真正的 Python stdlib 根下。
只走"路径兜底"分支 (3.9 / 未知模块名 兜底),已有 _STDLIB_MODULES 命中时不调用本函数,无谓开销。
"""
fp = _norm(file_path)
for root in _STDLIB_ROOTS_NORM:
if fp == root or fp.startswith(root + os.sep):
return True
return False
def _classify_origin(file_path: str, user_script_path: str, module_name: str, user_script_norm: str | None = None) -> str:
"""给一帧函数归类来源v3 新增字段 origin
返回值(即 JSON 里的字面量UI 端按这个 group:
"user" 用户脚本(路径与 user_script_path 一致)
"frozen" <frozen importlib._bootstrap> 等冻结帧
"builtin" <built-in method exec> 等 C 实现的 builtin
"stdlib" Python 标准库(按 sys.stdlib_module_names 校准3.10+
才生效,老版本退化为路径启发式)
"third_party" site-packages / dist-packages 下的第三方包
"other" 兜底 —— 例如 <string>、未匹配任何已知布局的奇怪路径
顺序很关键:
1) user / frozen / builtin 用文件路径前缀直接判O(1)
2) stdlib 先查 sys.stdlib_module_names权威命中即返回
3) 路径里出现 site-packages / dist-packages → third_party
4) 路径里出现 /Lib/ 或 /lib/pythonX.Y/ → stdlib启发式兜底
5) 其它 → other
user_script_norm: 调用方预计算的 `_norm(user_script_path)` —— 同 _top_module,
热路径上几百次调用,提到外面省一次 abspath。
"""
# C 扩展函数time.sleep / numpy C 核 / json C 加速器 等cProfile 把 file 标成 "~"。
# _top_module 已经把 module 字段定为 "<built-in>"(与 origin 字段对齐的契约见那里),
# 这里也要走 builtin 分支,否则 origin = "other" 与 module = "<built-in>" 错位,UI
# 端按 origin 分组时这条帧会落到别的桶里 —— 之前一直漏到这里。
if file_path == "~":
return "builtin"
# 虚拟文件路径(<frozen ...> / <built-in ...> / <string> 等)优先短路 —— 之前
# 先 _norm 再判 < 是浪费 abspath,而且 "<..." 这种路径跟用户脚本路径无论如何
# 都不可能相等,白调一次 norm。顺序调成「<... 优先」后 hot path 上少 100+
# 次 _norm 调用(典型 scope=all 的 profile 里 <frozen>/<built-in> 帧占大头)。
if file_path.startswith("<"):
if file_path.startswith("<frozen"):
return "frozen"
if file_path.startswith("<built-in"):
return "builtin"
return "other"
if _norm(file_path) == (user_script_norm if user_script_norm is not None else _norm(user_script_path)):
return "user"
# sys.stdlib_module_names 校准module_name 已由 _top_module 算好,
# 直接问「这个包名是不是 stdlib」。
if _STDLIB_MODULES is not None and module_name in _STDLIB_MODULES:
return "stdlib"
# 路径兜底(兼容 3.9 + 处理 _STDLIB_MODULES 偶发漏判的边角包)
norm = file_path.replace("\\", "/")
if "site-packages" in norm or "dist-packages" in norm:
return "third_party"
# 必须锚到真正的 stdlib 根下 —— 之前只看 "Lib/" 子串会把用户项目里的
# /home/x/myproject/Lib/foo.py 误认成 stdlib。
# 锚点用 sysconfig.get_paths()['stdlib'] (官方权威,覆盖 venv/framework/embed),
# 拿不到时回退到 sys.prefix + "Lib"。
if _is_under_real_stdlib(file_path):
return "stdlib"
return "other"
def profile_and_measure(
script_path: str,
scope: str = "user",
hide_internal: bool = True,
code=None,
src: str | None = None,
) -> tuple:
"""v4 单跑架构:一次 exec(code) under cProfile同时拿到函数归因和 instrumented wall-time。
替代 v3 的两阶段执行(先裸跑测 wall_time 再 cProfile 跑):
- 用户脚本只 exec 一次 → plot / print / file-write 等副作用只发生一次
- 返回的 instrumented_wall 含 cProfile 自身开销(典型 1.5~3x 膨胀)
- 调用方需要配合 calibrate_cprofile_overhead 折算wall_time = instrumented / ratio
scope: "user"(默认)只归因用户脚本里的函数;"all" 包含所有非 cProfile 内部帧
(标准库 + 第三方包 + 用户代码),让用户能下钻到 import 的包里。
hide_internal (v5 新增,默认 True):过滤掉测试代码 + tottime=0 的量化噪声帧。
- 测试代码tests/ / test_*.py / _pyrof_calib 等(见 _is_internal_test)
- tottime=0:scope=all 时 typing / inspect / functools 等内部展开常被 cProfile 量化精度截到 0,
这些不是优化目标,默认剔除能让 result.json 干净到「只剩真正在跑的代码」
- 设为 False 时不过滤 —— 给想排查调用栈 / 自定义聚合的用户留一条后路
code / src:可选的预读 code object 和源码文本。runner.py 已经在做 syntax
precheck 时 read + compile 过一份,这里直接复用 —— 避免重复 IO(原来
profile_and_measure 自己又 read 两次 + AST parse 一次)。这两个参数
给 None 时回退到「自己 load_source + compile」,供老调用方 / 单测继续工作。
NOTE: 不在 cProfile exec 周围禁用 GC —— cProfile 应该看到真实的执行环境
(包括 GC 暂停),折算后的 wall-time 才能反映真实耗时。calibration 那两次
tight-loop 跑各跑各的 GC 策略(详见 harness.calibrate_cprofile_overhead
"""
# 复用 caller 读好的 code / src —— runner.py 的 syntax precheck 已经 read+compile 过一次,
# 再读一次等于把同样的字节流从磁盘捞 2 次 + AST parse 一次。None 时退回到旧的「自己读」路径。
if code is None:
code = _load_code(script_path)
if src is None and hide_internal:
src = load_source(script_path)
g = _build_user_globals(script_path)
saved_argv = _scrub_argv_for_user_code(script_path)
pr = cProfile.Profile()
pr.enable()
try:
t0 = time.perf_counter()
exec(code, g)
instrumented = time.perf_counter() - t0
finally:
pr.disable()
_restore_argv(saved_argv)
stats = pstats.Stats(pr)
is_kept = _make_frame_filter(script_path, scope)
# 规范化一次:每个函数帧都会把 file_path 与 script_path 比对;规范化结果
# 与具体帧无关 —— 提到循环外,几百行的 functions 表能省几百次 abspath 调用。
script_norm = _norm(script_path)
# 解析用户脚本里显式 import 的模块集合。stdlib 内部帧如果来自「用户没
# 显式 import 的包」(typing/inspect/functools/re/_py_warnings/...),
# 一律视为 numpy/pandas 这类第三方包触发的「间接调用链」—— 用户无法优化,
# 默认剔除。结果scope=all 时 result.json 也只剩用户代码 + 显式导入的
# 第三方包 + 真正大头的 stdlib 模块(json/os/etc.,用户写了 `import json`
# 就看 json,否则不看)。
# 之前无条件 ast.parse 整个 src —— 即便用户脚本 module 全是 "<user>"(scope=user)
# 根本进不到这条 stdlib 过滤分支,几百行的 fixture 也走一遍 AST。改成只在真正会
# 消费 user_imports 的组合里算(scope=all + hide_internal=True)。
if hide_internal and scope == "all":
user_imports = _extract_user_imports(src)
else:
user_imports = set()
# file → (module, origin) 缓存同一文件的多个函数帧numpy 几百帧共享一个 file
# 只算一次 module + origin。_top_module 和 _classify_origin 各自又会再调一次
# _norm(file),加 cache 后这两个调用也都省了 —— 典型 profile 5000 帧 / 50 文件,
# _top_module 从 5000 次降到 50 次,_classify_origin 同。
file_info_cache: dict[str, tuple] = {}
def _classify_file(file_path: str) -> tuple:
cached = file_info_cache.get(file_path)
if cached is not None:
return cached
module = _top_module(file_path, script_path, script_norm)
origin = _classify_origin(file_path, script_path, module, script_norm)
cached = (module, origin)
file_info_cache[file_path] = cached
return cached
# ── v6 「用户直接调用」过滤 ──
# 用户原话:「只要代码的本身和import 调用的耗时统计,其他的不需要」/「目前好像
# 仍然统计到内部测试的代码了,不合理」—— 之前虽然过滤掉了 stdlib 内部噪声帧
# (typing/inspect/functools 等),但 487 帧里仍有:
# - numpy 内部 250+ 帧(np.array 调用的 _core.fromnumeric 等)→ 间接
# - <frozen> importlib._bootstrap 90 帧 → 间接导入机制
# - <built-in> C 函数 131 帧(len / numpy C 核 / _warnings / dict.keys 等)
# - _distutils_hack 2 帧、mkl 7 帧 → setup machinery,非用户调用
# 用户其实只要:
# 1) 自己写的函数(模块名 == "<user>")
# 2) 自己「直接调用」的 import 入口(np.array / np.mean / json.dumps 等)
# 实现:cProfile 的 callers 字段自带调用方信息。「某帧的 caller 含用户脚本
# 里的帧」=「用户直接调用」。再加 origin 闸门:仅 third_party / 用户显式 import
# 的 stdlib 才算「import 调用」—— builtin / frozen / other / 没显式 import 的
# stdlib 一律不保留,即使技术上确实被用户代码调用到(len / print / numpy C 核)。
# ──────────────────────────────────────────────────────────────────────────────
functions = []
if hide_internal:
# Pass 1:收集用户帧 + 把通过基础过滤的帧的 (module, origin) 缓存下来。
# 注意:这里不再做 tt <= 0 过滤 —— 用户代码 + import 入口里常有纯 C 分派的
# 薄包装(np.random.rand / numpy.__getattr__ 等),cumtime 远大于 0 但 tottime
# 恰好压在 cProfile 量化精度地板上,被滤掉就把"用户调用了哪个 API"这条信息丢了。
# 用户代码 + import 入口自然就少,即便有 tt=0 也只是干净 user-function 占位,
# 全保留就行。
user_frames: set = set()
func_info_cache: dict = {}
for func, (cc, nc, tt, ct, callers) in stats.stats.items():
if not is_kept(func):
continue
file = func[0]
module_name, origin = _classify_file(file)
if _is_internal_test(func[2], file, module_name):
continue
func_info_cache[func] = (module_name, origin)
if module_name == "<user>":
user_frames.add(func)
# Pass 2:边判断「是不是 import_callee」边构造 FunctionNode(原版是分两个独立
# pass 跑 import_callees 再跑 functions,合一遍能省掉一次 stats.stats 全量迭代。
# import_callees 设单独 set 也不必要 —— 这里用 include 标志位本地决定,跳出本
# 帧判断后立即 append / continue。
for func, (cc, nc, tt, ct, callers) in stats.stats.items():
info = func_info_cache.get(func)
if info is None:
continue # pass 1 已过滤
module_name, origin = info
if func in user_frames:
include = True
else:
include = False
for caller in callers:
if caller in user_frames:
if origin == "third_party" or (
origin == "stdlib" and module_name in user_imports
):
# 用户代码确实调到了这个 import 入口 —— 但还要看 ct:
# 低于 cProfile 量化精度(1µs)的「被调到的帧」(典型:
# numpy._mean_dispatcher 这种注册期被触发的辅助分发器,
# cProfile 把调用方记成 <module> 但实际不干活)是噪声。
# 用户代码写的空函数另算(user_frames 不受这条约束)。
include = ct >= 1e-6
break
if not include:
continue
file = func[0]
functions.append(
FunctionNode(
id=_fid(func),
file=file,
line=func[1],
name=func[2],
cumtime=ct,
tottime=tt,
ncalls=nc,
percallTot=(tt / nc if nc else 0.0),
module=module_name,
origin=origin,
)
)
else:
# hide_internal=False:用户要的是全量原始数据 —— 不做 import_callee / 测试代码 / 零耗时过滤,
# 直接把 is_kept 通过的帧全收下来。
for func, (cc, nc, tt, ct, callers) in stats.stats.items():
if not is_kept(func):
continue
file = func[0]
module_name, origin = _classify_file(file)
functions.append(
FunctionNode(
id=_fid(func),
file=file,
line=func[1],
name=func[2],
cumtime=ct,
tottime=tt,
ncalls=nc,
percallTot=(tt / nc if nc else 0.0),
module=module_name,
origin=origin,
)
)
functions.sort(key=lambda f: f.tottime, reverse=True)
total = sum(f.tottime for f in functions)
flame = _build_flame(functions, total)
return StructureResult(functions=functions, flame=flame), instrumented
def profile_structure(
script_path: str,
scope: str = "user",
hide_internal: bool = True,
) -> StructureResult:
"""Backward-compat shimv3 时期暴露的「只拿归因、不读 wall-time」接口。
v4 起实际工作在 profile_and_measure 里完成;保留这个包装是为了不破坏直接
import engine.structure.profile_structure 的测试 / 旧调用方。语义跟 v3 一样:
只跑一次 cProfile exec、返回 functions + flame。
"""
result, _ = profile_and_measure(script_path, scope=scope, hide_internal=hide_internal)
return result
def _build_flame(functions: list, total: float) -> FlameNode:
"""构造火焰图根节点。
火焰图第一层有两个 layout 选项:
- 单模块scope=user 或刚好只 import 一个包):保持扁平(函数列表),
和 v2 之前完全一致,向后兼容。
- 多模块scope=all 且命中 ≥2 个不同的顶层模块):按 module 聚合——
用户问「时间花在了哪个包」时第一眼就能看到;点模块 tile 下钻看内部函数。
"""
# 单模块时维持扁平 —— 同名兄弟不会被 module 节点挤占,截断阈值不变
modules = {f.module for f in functions}
if len(modules) <= 1:
return FlameNode(
name="root",
value=total,
children=[FlameNode(name=f.name, value=f.tottime) for f in functions],
)
# 多模块:按 module 聚合;模块自身 value 是该模块下所有函数 tottime 之和
by_module: dict = {}
for f in functions:
node = by_module.get(f.module)
if node is None:
node = FlameNode(name=f.module, value=0.0, children=[])
by_module[f.module] = node
node.value += f.tottime
node.children.append(FlameNode(name=f.name, value=f.tottime))
# 模块按总 tottime 降序让最贵的包排最左icicle 布局的视觉约定)
sorted_modules = sorted(by_module.values(), key=lambda m: m.value, reverse=True)
return FlameNode(name="root", value=total, children=sorted_modules)

0
engine/tests/__init__.py Normal file
View File

27
engine/tests/fixtures/demo_sort.py vendored Normal file
View File

@@ -0,0 +1,27 @@
"""Demo script for users: contains several detectable anti-patterns.
- membership test against a list inside a loop -> membership_in_list
- nested loops -> nested_loops
- string concatenation with += inside a loop -> str_concat_loop
"""
def build_report(rows, allow):
out = ""
for r in rows:
if r in allow: # O(n) membership test on a list
out += str(r) + "," # string += accumulation
for other in rows: # nested loop -> O(n^2)
if r == other:
pass
return out
def main():
rows = list(range(400))
allow = list(range(0, 400, 2))
for _ in range(30):
build_report(rows, allow)
main()

16
engine/tests/fixtures/nested_calls.py vendored Normal file
View File

@@ -0,0 +1,16 @@
def leaf():
s = 0
for i in range(20000):
s += i
return s
def mid():
return sum(leaf() for _ in range(3))
def main():
return mid()
main()

19
engine/tests/fixtures/with_imports.py vendored Normal file
View File

@@ -0,0 +1,19 @@
"""scope=all 的测试 fixture —— 故意 import 一些标准库并实际用它们,
让 cProfile 能归因到非用户脚本里的帧。"""
import json
import time
def payload():
# 强制 json.loads / json.dumps 进 cProfile 栈
return json.loads(json.dumps({"a": 1, "b": [1, 2, 3]}))
def run():
for _ in range(50):
payload()
# 让 time.sleep / 调度器也进栈 —— 注意 sleep < cProfile tick 的话归不到
# 它自己头上,但 time 模块的辅助函数会被经过
run()

View File

@@ -0,0 +1,125 @@
"""跨语言契约测试engine/schema.py 的输出必须与 src/shared/analysis.ts 的类型对齐。
之前这个文件把 key 集合硬编码在 Python 里,从不读 analysis.ts —— 也就是说
TS 侧加一个字段、或者两边字段名写歧了,这个"契约测试"一律绿灯,
而 README 声称的"用契约测试保证一致"并不成立。
现在两边都是解析出来的Python 侧从 dataclass 的注解拿TS 侧从 interface 声明拿。
"""
import dataclasses
import io
import json
import os
import re
import subprocess
import sys
from engine import schema
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
FX = os.path.join(os.path.dirname(__file__), "fixtures")
ANALYSIS_TS = os.path.join(ROOT, "src", "shared", "analysis.ts")
def _ts_interface_fields(name):
"""从 analysis.ts 里抽出某个 interface 的字段名集合。
只处理"一行一个字段"的写法(本仓库的风格),够用且不需要引 TS parser。
可选字段的 `?` 去掉后再比较。
"""
src = io.open(ANALYSIS_TS, encoding="utf-8").read()
m = re.search(r"export interface %s\s*\{(.*?)\n\}" % re.escape(name), src, re.S)
assert m, "analysis.ts 里找不到 interface %s" % name
fields = set()
for line in m.group(1).splitlines():
line = line.strip()
if not line or line.startswith("//") or line.startswith("*"):
continue
fm = re.match(r"([A-Za-z_][A-Za-z0-9_]*)\??\s*:", line)
if fm:
fields.add(fm.group(1))
assert fields, "interface %s 解析出 0 个字段,解析器该修了" % name
return fields
def _py_dataclass_fields(cls):
return {f.name for f in dataclasses.fields(cls)}
def _run_engine(extra=(), env=None):
# 默认关掉 hide_internal契约测试要断言 keys / shapes,
# filtered 之后的 functions 列表可能为空IndexError 挂掉。
p = subprocess.run(
[sys.executable, "-m", "engine.runner",
"--script", os.path.join(FX, "nested_calls.py"),
"--no-hide-internal", *extra],
capture_output=True, cwd=ROOT, env=env,
)
return p
def _engine_result():
p = _run_engine()
return json.loads(p.stdout.decode("utf-8").strip().splitlines()[-1])
def test_top_level_keys_match_typescript():
data = _engine_result()
assert set(data.keys()) == _ts_interface_fields("AnalysisResult")
def test_top_level_keys_match_python_dataclass():
data = _engine_result()
assert set(data.keys()) == _py_dataclass_fields(schema.AnalysisResult)
def test_wall_time_keys_match_both_sides():
data = _engine_result()
keys = set(data["wallTime"].keys())
assert keys == _ts_interface_fields("WallTime")
assert keys == _py_dataclass_fields(schema.WallTime)
def test_function_keys_match_both_sides():
data = _engine_result()
keys = set(data["functions"][0].keys())
assert keys == _ts_interface_fields("FunctionNode")
assert keys == _py_dataclass_fields(schema.FunctionNode)
def test_output_is_pure_ascii():
"""输出必须是纯 ASCII。
反模式说明现在是历史的;保留 ensure_ascii=True 后输出应当逐字节 ASCII
跨 locale 一致。这个回归保护防止有人"为可读性"改回 ensure_ascii=False。
"""
env = {k: v for k, v in os.environ.items()
if k not in ("PYTHONUTF8", "PYTHONIOENCODING")}
p = _run_engine(env=env)
raw = p.stdout
bad = [b for b in raw if b > 127]
assert not bad, "引擎输出含 %d 个非 ASCII 字节" % len(bad)
raw.decode("ascii") # 不该抛
def test_out_flag_writes_result_to_file(tmp_path):
"""--out结果走独立文件stdout 留给用户代码。
锁的是那个回归 —— 结果曾和用户输出共用 stdout一句不带换行的
sys.stdout.write 就能让合法 Python 报"结果解析失败"
"""
out = os.path.join(str(tmp_path), "result.json")
script = os.path.join(str(tmp_path), "chatty.py")
io.open(script, "w", encoding="utf-8", newline="\n").write(
'import sys\ndef f():\n sys.stdout.write("done")\n return 1\nf()\n'
)
p = subprocess.run(
[sys.executable, "-m", "engine.runner", "--script", script, "--out", out],
capture_output=True, cwd=ROOT,
)
assert os.path.exists(out), "引擎没写出结果文件stderr=%s" % p.stderr[-400:]
data = json.loads(io.open(out, encoding="utf-8").read())
assert data["status"] == "ok"
# 用户的输出确实进了 stdout而且没污染结果
assert b"done" in p.stdout
assert b"schemaVersion" not in p.stdout

View File

@@ -0,0 +1,77 @@
import pytest
from engine.harness import calibrate_cprofile_overhead, collect_environment, load_source
def test_calibrate_returns_well_formed_calibration():
"""校准应该返回一个 well-formed Calibration:ratio > 0,两段耗时 > 0,workloadName 已知。
不强求 ratio > 1.0:在极快的机器上校准本身 < 5ms,会走 fallback ratio=1.0;
此时 instrumentedWorkloadSec 可能比 clean 略小(perf_counter 在 1ms 量级有 noise),
也不强求 instrumented >= clean。这条测试只锁住「返回结构对 + 数值非负」。
"""
calib = calibrate_cprofile_overhead()
assert calib.ratio > 0, f"ratio 必须正:{calib.ratio}"
assert calib.ratio <= 10.0, f"ratio 太大,可能校准失效:{calib.ratio}"
assert calib.cleanWorkloadSec > 0
assert calib.instrumentedWorkloadSec > 0
assert calib.workloadName == "tight-loop"
def test_calibrate_fallback_when_workload_too_fast(monkeypatch):
"""校准本身耗时 < 5ms 时应回退 ratio=1.0 —— 见 calibrate_cprofile_overhead 的边界处理。
直接 mock calibrate_cprofile_overhead 内部的 _exec_once_* 行为不可行(它们是模块内 helper),
改成 monkeypatch time.perf_counter 返回极短耗时:第一次返回 t0=0.0,第二次返回 t0+1e-6。
"""
from engine import harness
counter = {"n": 0}
base = {"t": 1000.0}
def fake_perf_counter():
counter["n"] += 1
# 第一次调用返回 0,之后每次 + 极小 delta,让 elapsed = ~3us (远小于 5ms)
idx = counter["n"]
return base["t"] + idx * 1e-6
monkeypatch.setattr(harness.time, "perf_counter", fake_perf_counter)
calib = harness.calibrate_cprofile_overhead()
assert calib.ratio == 1.0, f"短耗时下应回退 ratio=1.0,实际:{calib.ratio}"
def test_calibrate_does_not_leak_files(monkeypatch, tmp_path):
"""校准自己 scratch 一个临时文件,跑完应清干净 —— 不污染系统临时目录。"""
# 让 mkstemp 落到 tmp_path 方便断言
import tempfile
monkeypatch.setattr(tempfile, "gettempdir", lambda: str(tmp_path))
before = set(tmp_path.iterdir())
calibrate_cprofile_overhead()
after = set(tmp_path.iterdir())
# 不应有残留 —— 之前 / 之后 iterdir 集合应相等
assert after == before, f"残留文件: {after - before}"
def test_collect_environment():
env = collect_environment()
assert env.python.count(".") >= 2
assert env.timerResolution > 0
def test_load_source_strips_bom(tmp_path):
"""load_source 必须剥掉 BOM —— 引擎内所有读源码的地方都依赖这一点。"""
script = tmp_path / "bom.py"
script.write_text("x = 1\n", encoding="utf-8-sig")
assert script.read_bytes().startswith(b"\xef\xbb\xbf")
src = load_source(str(script))
assert not src.startswith("")
# 剥离后必须能直接过 compile(),这正是 runner 语法预检做的事
compile(src, str(script), "exec")
def test_load_source_raises_on_binary(tmp_path):
"""二进制文件抛 UnicodeDecodeError而不是 OSError—— runner 依赖这个类型来归类错误。"""
script = tmp_path / "bin.py"
script.write_bytes(b"\xff\xfe\x00\x01\x80\x81")
with pytest.raises(UnicodeDecodeError):
load_source(str(script))

158
engine/tests/test_runner.py Normal file
View File

@@ -0,0 +1,158 @@
import json
import os
import subprocess
import sys
FX = os.path.join(os.path.dirname(__file__), "fixtures")
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
def _run(script, *extra):
# 默认关掉 hide_internal这些测试是结构 / 状态 / 形状的契约测试,
# 要看到 fixture 里所有 frame 才能断言。hide_internal 是 UI 默认开的产品行为,
# 不是引擎协议的一部分 —— 测协议就别测产品默认。
p = subprocess.run(
[sys.executable, "-m", "engine.runner", "--script", script,
"--no-hide-internal", *extra],
capture_output=True, text=True, cwd=ROOT,
)
assert p.stdout.strip(), f"no stdout; stderr={p.stderr}"
return json.loads(p.stdout.strip().splitlines()[-1])
def test_ok_run():
data = _run(os.path.join(FX, "nested_calls.py"))
assert data["status"] == "ok"
assert data["wallTime"]["seconds"] > 0
assert data["wallTime"]["unit"] == "s"
assert len(data["functions"]) >= 3
def test_syntax_error(tmp_path):
bad = tmp_path / "syntax_error.py"
bad.write_text("def f(:\n pass\n", encoding="utf-8")
data = _run(str(bad))
assert data["status"] == "syntax_error"
assert data["error"]["type"]
def test_runtime_error(tmp_path):
bad = tmp_path / "runtime_error.py"
bad.write_text("raise ValueError('boom')\n", encoding="utf-8")
data = _run(str(bad))
assert data["status"] == "runtime_error"
assert "boom" in data["error"]["message"]
def test_sys_exit_becomes_runtime_error(tmp_path):
"""sys.exit() 抛 SystemExitBaseException 子类),之前 except Exception 抓不到,
子进程裸退出无 JSON → UI 显示 "引擎无输出"。现在应被显式捕获并归为 runtime_error。"""
bad = tmp_path / "exit.py"
bad.write_text("import sys\nsys.exit(7)\n", encoding="utf-8")
data = _run(str(bad))
assert data["status"] == "runtime_error"
assert "SystemExit" in data["error"]["type"]
assert "7" in data["error"]["message"]
def test_io_error_returns_runtime_error(tmp_path):
"""脚本文件不存在 / 不可读时,不要让子进程裸崩 — 应该回 runtime_error 给出有用信息。"""
missing = tmp_path / "does_not_exist.py"
data = _run(str(missing))
assert data["status"] == "runtime_error"
assert "FileNotFoundError" in data["error"]["type"] or "无法读取" in data["error"]["message"]
def test_bom_script_runs_ok(tmp_path):
"""带 BOM 的 UTF-8 脚本Windows 记事本默认保存格式)必须能正常跑。
回归测试runner 曾用 encoding="utf-8" 读源码BOM 的  前缀残留下来,
语法预检的 compile() 直接抛 SyntaxError: invalid character用户看到的是
"语法错误"而不是正常结果 —— 而 harness.py 早就用 utf-8-sig 处理对了,
两处不一致导致这个 bug 只在预检阶段出现。
"""
script = tmp_path / "bom.py"
script.write_text("x = sum(range(1000))\n", encoding="utf-8-sig")
# 确认 fixture 真的带 BOM否则这个测试会静默失效
assert script.read_bytes().startswith(b"\xef\xbb\xbf")
data = _run(str(script))
assert data["status"] == "ok", f"BOM 脚本被误判: {data.get('error')}"
def test_binary_script_returns_runtime_error(tmp_path):
"""非 UTF-8 / 二进制文件应回结构化 runtime_error而不是让引擎裸崩。
回归测试UnicodeDecodeError 不是 OSError 的子类,之前只 except OSError
的那层保护抓不到它,子进程崩溃且无 JSON 输出 → UI 显示"引擎无输出"
"""
script = tmp_path / "binary.py"
script.write_bytes(b"\x00\x01\x02\xff\xfe\xfd binary garbage \x80\x81")
data = _run(str(script))
assert data["status"] in ("runtime_error", "syntax_error")
assert data["error"]["type"]
def test_scope_default_is_user():
"""默认 --scope=user —— 不给 scope 参数时只看到用户脚本里的函数。"""
script = os.path.join(FX, "with_imports.py")
data = _run(script)
assert data["status"] == "ok"
files = {f["file"] for f in data["functions"]}
# user 模式:不应出现 json 模块的内部帧(虽然 fixture 里 import 了 json
json_internal = {f for f in files if "json" in f and "decoder" in f or "json" in f and "encoder" in f}
assert not json_internal, f"默认 scope 应为 user但出现了 json 内部帧: {json_internal}"
def test_scope_all_includes_library_frames():
"""--scope=all让耗时可以下钻到 import 的包里 —— json 模块的函数也应进入结果。"""
script = os.path.join(FX, "with_imports.py")
data = _run(script, "--scope", "all")
assert data["status"] == "ok"
files = {f["file"] for f in data["functions"]}
# scope=all 时至少有一个 json 相关的文件路径进来 —— 说明 import 的包被归因了
has_json = any("json" in f for f in files)
assert has_json, f"scope=all 应包含 json 模块帧,实际 files: {files}"
def test_user_side_effects_run_once(tmp_path):
"""v4 单跑架构核心回归:用户脚本里的副作用只执行一次。
v3 时期 runner 跑两次(裸跑测 wall_time + cProfile 跑),
用户脚本里 plot()、print()、写文件等副作用也跟着跑两次 —— 经典"plot 弹两个窗口"
bug 的根因。v4 改成单次 exec(code) under cProfile + 校准系数折算 wall-time 后,
副作用应只发生 1 次。
检测手段:让用户脚本 append 一行到文件exec 一次就一行,跑两次就两行。
读 marker 文件的行数即可判断 exec 次数。
"""
marker = tmp_path / "marker.txt"
script = tmp_path / "s.py"
# 用 raw 字符串避 Windows 反斜杠麻烦append 模式保证两次跑不会被覆盖
script.write_text(
"with open(r'" + str(marker) + "', 'a', encoding='utf-8') as f:\n"
" f.write('X\\n')\n",
encoding="utf-8",
)
# 直接 spawn runner 子进程(不走 _run helper —— 那个 helper 是从 stdout 解析 JSON
# 的,我们这次主要观察 marker 文件)
out_path = tmp_path / "result.json"
p = subprocess.run(
[sys.executable, "-m", "engine.runner",
"--script", str(script), "--out", str(out_path)],
capture_output=True, text=True, cwd=ROOT,
)
assert p.returncode == 0, f"runner 失败:{p.stderr}"
assert marker.exists(), "用户脚本副作用根本没执行"
lines = marker.read_text(encoding="utf-8").splitlines()
assert len(lines) == 1, (
f"副作用被多次执行(找到 {len(lines)}v4 单跑架构回归:{lines}"
)
# 同时验证 result.json 里 calibration 字段在 + wallTime 被校准
result = json.loads(out_path.read_text(encoding="utf-8"))
assert result["status"] == "ok"
assert "calibration" in result and result["calibration"] is not None
assert result["calibration"]["ratio"] >= 1.0
assert result["wallTime"]["seconds"] > 0

View File

@@ -0,0 +1,21 @@
import json
from engine.schema import AnalysisResult, Environment, SCHEMA_VERSION, WallTime
def test_analysis_result_serializes_to_json():
r = AnalysisResult(
schemaVersion=SCHEMA_VERSION,
environment=Environment(python="3.9.19", platform="win32", processor="x86", timerResolution=1e-7),
config={},
status="ok",
error=None,
wallTime=WallTime(seconds=0.105, unit="s"),
functions=[],
flame=None,
)
data = json.loads(r.to_json())
assert data["schemaVersion"] == SCHEMA_VERSION
assert data["status"] == "ok"
assert data["wallTime"]["seconds"] == 0.105
assert data["environment"]["python"] == "3.9.19"

View File

@@ -0,0 +1,761 @@
import os
import pytest
from engine.schema import FunctionNode
from engine.structure import (
_build_flame,
_classify_origin,
_make_frame_filter,
_norm,
_top_module,
profile_structure,
)
def test_structure_finds_functions():
fx = os.path.join(os.path.dirname(__file__), "fixtures", "nested_calls.py")
res = profile_structure(fx, hide_internal=False)
names = {f.name for f in res.functions}
assert {"leaf", "mid", "main"} <= names
leaf = next(f for f in res.functions if f.name == "leaf")
assert leaf.tottime > 0 and leaf.ncalls >= 3
assert res.flame.value >= leaf.tottime
def test_structure_default_scope_is_user():
"""默认 scope=user —— 只看到用户脚本里的函数(向后兼容)。"""
fx = os.path.join(os.path.dirname(__file__), "fixtures", "nested_calls.py")
res = profile_structure(fx, hide_internal=False)
files = {f.file for f in res.functions}
# 只有 fixture 自身;不应出现标准库路径(<frozen ...> / <string> / site-packages 等)
non_user = {f for f in files if f not in (fx, "~") and not f.endswith("nested_calls.py")}
assert not non_user, f"user 模式下出现非用户脚本帧: {non_user}"
def test_structure_scope_all_includes_stdlib():
"""scope=all耗时可以下钻到 import 的包里,标准库 / 第三方包里的函数也进来。
注:`~` 在这里可能出现在 files 里 —— 它不再是"cProfile 内部"的同义词,
而是 cProfile 给所有 C 扩展函数json C accelerator / numpy C 核 / time.sleep
等)打的文件名。这些是真实工作,scope=all 必须保留。cProfile 内部帧靠 name
识别("_lsprof.Profiler" / "Profiler"),由 _make_frame_filter 剔除。"""
fx = os.path.join(os.path.dirname(__file__), "fixtures", "with_imports.py")
res = profile_structure(fx, scope="all", hide_internal=False)
files = {f.file for f in res.functions}
# fixture 里会 import json 和 time —— 至少其中一个 stdlib 路径应被纳入
has_stdlib = any("json" in f or "time" in f or f == "<frozen importlib>" for f in files)
assert has_stdlib, f"scope=all 应包含标准库帧,实际 files: {files}"
# cProfile 内部帧name 含 _lsprof.Profiler 子串)必须剔除 —— 但其它
# `~` 帧time.sleep / json C 加速器 / builtins.exec 等真实 C 扩展)必须保留。
cprofile_internal = [f for f in res.functions if "_lsprof.Profiler" in f.name]
assert not cprofile_internal, (
f"cProfile 内部帧不应漏进 functions{[(f.name, f.file) for f in cprofile_internal]}"
)
def test_norm_normalizes_case_and_slashes():
"""Windows (NTFS) 上同一文件可能被报成 `C:/Foo/Bar.py` 或 `c:\\foo\\BAR.py`
取决于谁产生的字符串。_norm 必须把两边都规范成同一种形式才能正确比
对脚本路径。"""
raw = "C:/Foo/Bar.py"
expected = os.path.normcase(os.path.abspath(raw))
assert _norm(raw) == expected
def test_user_frame_filter_is_case_and_slash_agnostic():
"""_make_frame_filter 对 frame.file 也要走 _norm —— 跨平台/跨斜杠风格
仍然能命中用户脚本。这条用例专门守回归:之前只用同一字符串匹配,
在 Windows 上大小写或斜杠不一致就会漏掉所有函数。"""
fx = os.path.join(os.path.dirname(__file__), "fixtures", "nested_calls.py")
is_kept = _make_frame_filter(fx)
# 把 frame.file 里的正反斜杠互换 —— 如果原始路径里有 '\\'
swapped = fx.replace("\\", "/") if "\\" in fx else fx.replace("/", "\\")
if swapped != fx:
assert is_kept((swapped, 1, "main")) is True
def test_frame_filter_excludes_cprofile_internal():
"""cProfile 内部帧_lsprof.Profiler 类成员file='~' 且 name 含 Profiler
永远被剔除 —— 不论 scope=user 还是 scope=all。
关键:「`~` == cProfile 内部」是错的 —— cProfile 用 `~` 表示所有 C 扩展函数
time.sleep / json C accelerator / numpy C 核这些必须留下。cProfile
自己靠 name 区分:'_lsprof.Profiler' / 'Profiler' / '<method ... of
'_lsprof.Profiler' objects>' 等。"""
fx = os.path.join(os.path.dirname(__file__), "fixtures", "nested_calls.py")
for scope in ("user", "all"):
is_kept = _make_frame_filter(fx, scope)
# 真实 cProfile 内部 name —— 必须被过滤
assert is_kept(("~", 0, "_lsprof.Profiler")) is False
assert is_kept(("~", 0, "Profiler")) is False
assert is_kept(("~", 0, "<method 'enable' of '_lsprof.Profiler' objects>")) is False
def test_frame_filter_keeps_c_extension_frames():
"""scope=allC 扩展函数帧file='~' + name 是真实 builtin必须留下 —
否则 numpy C 核 / json C 加速器 / time.sleep 等会从结果里消失,
scope=all 失去下钻意义。"""
fx = os.path.join(os.path.dirname(__file__), "fixtures", "nested_calls.py")
is_kept = _make_frame_filter(fx, "all")
# C 扩展(不是 cProfile 内部)—— 必须保留
assert is_kept(("~", 0, "<built-in method sleep of 'time' objects>")) is True
assert is_kept(("~", 0, "_default_encoder")) is True # json C accelerator
# scope=userC 帧不会进入用户脚本 —— 也过滤掉(与历史行为一致)
is_kept_user = _make_frame_filter(fx, "user")
assert is_kept_user(("~", 0, "<built-in method sleep of 'time' objects>")) is False
def test_frame_filter_scope_all_keeps_any_non_internal_frame():
"""scope=all只要不是 cProfile 内部(~)就保留 —— 用户脚本、stdlib、第三方都进。"""
fx = os.path.join(os.path.dirname(__file__), "fixtures", "nested_calls.py")
is_kept = _make_frame_filter(fx, "all")
# 任意"非 ~"路径都应通过
assert is_kept(("C:/Python39/Lib/json/decoder.py", 100, "decode")) is True
assert is_kept(("/some/site-packages/pandas/core/frame.py", 1, "from_records")) is True
def test_top_module_user_script():
"""用户脚本(==user_script_path"<user>""""
fx = os.path.join(os.path.dirname(__file__), "fixtures", "nested_calls.py")
assert _top_module(fx, fx) == "<user>"
def test_top_module_user_script_case_and_slash_agnostic():
"""_top_module 比较 user_script_path 时走 _norm —— Windows 大小写 / 斜杠差异不影响。"""
fx = os.path.join(os.path.dirname(__file__), "fixtures", "nested_calls.py")
swapped = fx.replace("\\", "/") if "\\" in fx else fx.replace("/", "\\")
if swapped != fx:
assert _top_module(swapped, fx) == "<user>"
def test_top_module_stdlib_windows():
"""Windows 标准库C:\\Python39\\Lib\\json\\decoder.py → json"""
assert _top_module(r"C:\Python39\Lib\json\decoder.py", "<user>") == "json"
assert _top_module(r"C:\Python39\Lib\json\__init__.py", "<user>") == "json"
# 多层嵌套Lib/site-packages 这种 dev 布局也行
assert _top_module(r"C:\Python39\Lib\site-packages\foo\bar.py", "<user>") == "foo"
def test_top_module_top_level_py_strips_extension():
"""顶层 .py 文件必须剥掉扩展名 —— `Lib\\functools.py` 是模块 functools 而不是 `functools.py`。
之前 bug直接返回 `parts[idx]`(即 `functools.py`UI 分组里看到一坨文件名而不是模块名。"""
assert _top_module(r"C:\Python39\Lib\functools.py", "<user>") == "functools"
# Linux 顶层 stdlib
assert _top_module("/usr/lib/python3.11/functools.py", "<user>") == "functools"
# __init__ 不剥(不是 .py 后缀)—— 顶层 `Lib\json\__init__.py` 走 _strip_py 时返回 "json"
# (这里 _strip_py 不会动 __init__但 parts[idx] 已经是 "json\__init__.py" 这种,
# 实际代码路径是先取 parts[idx] 再 _strip_py所以 "json\__init__.py" → "json\__init__"
# 这里的关键是 module 名不是 filename-with-py-extension
result = _top_module(r"C:\Python39\Lib\functools.py", "<user>")
assert not result.endswith(".py"), f"module 名不能带 .py 后缀,实际 {result!r}"
def test_top_module_stdlib_linux():
"""Linux/macOS 标准库:/usr/lib/python3.X/<pkg>/... → <pkg>"""
assert _top_module("/usr/lib/python3.11/json/decoder.py", "<user>") == "json"
assert _top_module(
"/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/json/decoder.py",
"<user>",
) == "json"
def test_top_module_site_packages():
"""site-packages / dist-packages标记后的第一个目录段就是包名。"""
assert _top_module(
"/usr/lib/python3.11/site-packages/numpy/core/array.py", "<user>"
) == "numpy"
assert _top_module(
"C:\\Python39\\Lib\\site-packages\\pandas\\core\\frame.py", "<user>"
) == "pandas"
# dist-packagesDebian 系)
assert _top_module(
"/usr/lib/python3.11/dist-packages/requests/api.py", "<user>"
) == "requests"
def test_top_module_frozen():
"""frozen / built-in / <string> 虚拟帧 → 整段保留作为标签。"""
assert _top_module("<frozen importlib._bootstrap>", "<user>") == "<frozen>"
assert _top_module("<built-in method exec>", "<user>") == "<built-in>"
assert _top_module("<string>", "<user>") == "<string>"
def test_top_module_c_extension():
"""`~` 在 cProfile 里代表所有 C 扩展函数numpy C 核 / json 加速器 /
time.sleep 等)。这些 frame 现在 scope=all 会留下(不再被一刀切),需要给个
有意义的 module 标签 —— 用 "<built-in>" 跟 _classify_origin 的 builtin
字段保持一致。"""
assert _top_module("~", "<user>") == "<built-in>"
# user_script_norm 也要匹配上 user 脚本 —— 防御性
fx = os.path.join(os.path.dirname(__file__), "fixtures", "nested_calls.py")
assert _top_module("~", fx, _norm(fx)) == "<built-in>"
def test_top_module_fallback_dirname():
"""未匹配任何标记 → 取倒数第二段目录。"""
# 例如:".../someproj/src/utils/helper.py" → "utils"
assert _top_module("/path/to/someproj/src/utils/helper.py", "<user>") == "utils"
def test_profile_structure_populates_module():
"""profile_structure 给每条 fn 算 modulefixture 是用户脚本,全部 <user>。"""
fx = os.path.join(os.path.dirname(__file__), "fixtures", "nested_calls.py")
res = profile_structure(fx, hide_internal=False)
assert res.functions
modules = {f.module for f in res.functions}
assert modules == {"<user>"}, f"默认 scope 下应只有 <user>,实际 {modules}"
def test_profile_structure_scope_all_populates_real_modules():
"""scope=allfn.module 应来自真实的 stdlib / 第三方包。"""
fx = os.path.join(os.path.dirname(__file__), "fixtures", "with_imports.py")
res = profile_structure(fx, scope="all", hide_internal=False)
modules = {f.module for f in res.functions}
# fixture 里 import 了 json / time至少其中一个 stdlib 应出现
assert "<user>" in modules, f"用户脚本应在模块集合里,实际 {modules}"
assert any(m in ("json", "time", "<frozen>", "<built-in>") for m in modules), (
f"scope=all 应有 stdlib 模块,实际 {modules}"
)
def _mk_fn(name: str, module: str, tottime: float) -> FunctionNode:
"""测试用 FunctionNode 工厂 —— 关注 module / tottime其他字段填占位。"""
return FunctionNode(
id=f"f.py:1:{name}",
file="f.py",
line=1,
name=name,
cumtime=tottime,
tottime=tottime,
ncalls=1,
percallTot=tottime,
module=module,
)
def test_build_flame_single_module_stays_flat():
"""单模块时scope=user 默认情况)保持扁平 —— 同名兄弟不会被 module
节点挤占,截断阈值不变,向后兼容。"""
fns = [
_mk_fn("main", "<user>", 0.5),
_mk_fn("mid", "<user>", 0.3),
_mk_fn("leaf", "<user>", 0.2),
]
flame = _build_flame(fns, total=1.0)
assert flame.name == "root"
assert flame.value == 1.0
# 扁平root.children 是函数名(不是 module
assert [c.name for c in flame.children] == ["main", "mid", "leaf"]
# 每个函数 tile 没有 children不会再下钻一层
for child in flame.children:
assert child.children == []
def test_build_flame_multi_module_groups_by_module():
"""多模块scope=all 且 import 多个包root.children 是 module 节点,
每个 module 节点的 children 是该模块下的函数。"""
fns = [
_mk_fn("main", "<user>", 0.5),
_mk_fn("decode", "json", 0.3),
_mk_fn("loads", "json", 0.2),
_mk_fn("sleep", "time", 0.1),
]
flame = _build_flame(fns, total=1.1)
assert flame.name == "root"
# module 节点按总 tottime 降序:<user>(0.5) > json(0.5) > time(0.1)
# <user> 和 json 都是 0.5,排序稳定时顺序取决于字典遍历顺序 —— 这里不强制
# 顺序,只验证排序结果一致(用 sorted() 拿到一组)
module_nodes = flame.children
assert len(module_nodes) == 3
# 第一个必须是最大的 <user>0.5 严格大于 json 的 0.5,因为总和 tie-break
# 由 sorted 的 stable 行为兜底:<user> 在 fns 里排前面 → 同值时排前)
assert module_nodes[0].name == "<user>"
assert module_nodes[0].value == pytest.approx(0.5)
# json 模块 value = 0.3 + 0.2 = 0.5
json_node = next(c for c in module_nodes if c.name == "json")
assert json_node.value == pytest.approx(0.5)
assert sorted(c.name for c in json_node.children) == ["decode", "loads"]
# time 模块只有 sleep
time_node = next(c for c in module_nodes if c.name == "time")
assert time_node.value == pytest.approx(0.1)
assert [c.name for c in time_node.children] == ["sleep"]
def test_build_flame_empty_returns_empty_root():
"""没函数时不爆 —— root.value=0、children=空列表。"""
flame = _build_flame([], total=0.0)
assert flame.name == "root"
assert flame.value == 0.0
assert flame.children == []
# --- origin 分类v3 新增) -----------------------------------------------------
def test_classify_origin_user_script_matches_norm():
"""用户脚本路径_norm 后)与 user_script_path 一致 → user。"""
fx = os.path.join(os.path.dirname(__file__), "fixtures", "nested_calls.py")
assert _classify_origin(fx, fx, "<user>") == "user"
swapped = fx.replace("\\", "/") if "\\" in fx else fx.replace("/", "\\")
if swapped != fx:
assert _classify_origin(swapped, fx, "<user>") == "user"
def test_classify_origin_frozen_and_builtin():
"""frozen / built-in 虚拟帧 → frozen / builtin。"""
assert _classify_origin("<frozen importlib._bootstrap>", "/x.py", "<frozen>") == "frozen"
assert _classify_origin("<built-in method exec>", "/x.py", "<built-in>") == "builtin"
# 其它 <...> 兜底 other
assert _classify_origin("<string>", "/x.py", "<string>") == "other"
def test_classify_origin_stdlib_via_sys_stdlib_module_names():
"""module_name 在 sys.stdlib_module_names 里 → stdlib权威路径"""
# json / os / re / sys / pathlib 都肯定在 stdlib 里
assert _classify_origin("C:/Python311/Lib/json/decoder.py", "/x.py", "json") == "stdlib"
assert _classify_origin("/usr/lib/python3.11/pathlib/__init__.py", "/x.py", "pathlib") == "stdlib"
def test_classify_origin_third_party_site_packages():
"""site-packages / dist-packages 下的包 → third_party即使包名可能撞车"""
assert _classify_origin(
"/usr/lib/python3.11/site-packages/numpy/core/array.py", "/x.py", "numpy"
) == "third_party"
assert _classify_origin(
"C:\\Python39\\Lib\\site-packages\\pandas\\core\\frame.py", "/x.py", "pandas"
) == "third_party"
# dist-packagesDebian 系)
assert _classify_origin(
"/usr/lib/python3.11/dist-packages/requests/api.py", "/x.py", "requests"
) == "third_party"
def test_classify_origin_stdlib_path_heuristic_fallback():
"""sys.stdlib_module_names 漏判时,只要路径真的在 sysconfig 给的 stdlib 根
下,就该归为 stdlib —— 即使模块名未知。
之前 bugM5:只看路径里含 "/Lib/" 子串就当 stdlib,结果用户项目里
/home/x/myproject/Lib/foo.py 被误认成 stdlib。现在锚到 sysconfig 的真根。
"""
import sysconfig
real_stdlib = sysconfig.get_paths().get("stdlib") or os.path.join(os.sep, "Lib")
# 构造一个 fake 包路径,放在真实 stdlib 根下面 —— 必须真在那个目录里
fake_pkg = os.path.join(real_stdlib, "_weirdstdlib_pkg_for_test")
# 文件不存在也没事 —— _is_under_real_stdlib 只比对 abspath,不要求文件真存在
assert _classify_origin(fake_pkg, "/x.py", "_weirdstdlib_pkg_for_test") == "stdlib"
# 反向用例:用户在 /home/x/myproject/Lib/foo.py 下,即使路径含 "/Lib/" 也不该
# 误判成 stdlib —— 必须真的在 sysconfig 那个根下面
assert _classify_origin(
"/home/user/myproject/Lib/foo.py", "/x.py", "foo"
) == "other"
assert _classify_origin(
"/home/user/myproject/lib/python3.11/foo.py", "/x.py", "foo"
) == "other"
def test_classify_origin_unknown_path_is_other():
"""未匹配的奇怪路径 → other。"""
assert _classify_origin(
"/path/to/someproj/src/utils/helper.py", "/x.py", "utils"
) == "other"
def test_profile_structure_populates_origin():
"""profile_structure 给每条 fn 算 originfixture 是用户脚本,全部 user。"""
fx = os.path.join(os.path.dirname(__file__), "fixtures", "nested_calls.py")
res = profile_structure(fx, hide_internal=False)
origins = {f.origin for f in res.functions}
assert origins == {"user"}, f"默认 scope 下 origin 应全是 user实际 {origins}"
def test_profile_structure_scope_all_classifies_by_origin():
"""scope=all用户脚本帧归 userimport 的 json 帧归 stdlib —— 至少这两个 origin 都有。"""
fx = os.path.join(os.path.dirname(__file__), "fixtures", "with_imports.py")
res = profile_structure(fx, scope="all", hide_internal=False)
origins = {f.origin for f in res.functions}
# 用户脚本帧必有
assert "user" in origins
# fixture 显式 import json → 必有 stdlib 帧
assert "stdlib" in origins, f"json import 后应有 stdlib 帧,实际 origins: {origins}"
# 没 origin 字段缺失 / 其它 origin 兜底值(如 cprofile混进来的情况
assert "cprofile" not in origins, f"cProfile 内部帧不应漏进 origin实际 {origins}"
# 所有 origin 必须是已知值之一
KNOWN = {"user", "stdlib", "third_party", "builtin", "frozen", "other"}
assert origins <= KNOWN, f"未知 origin 混进来:{origins - KNOWN}"
# ──────────────────────────────────────────────────────────────────────────────
# v5 噪声过滤 —— 测试代码 + tottime=0 默认剔除
# ──────────────────────────────────────────────────────────────────────────────
#
# 用户场景:scope=all 时 cProfile 报告里夹带大量测试代码(numpy.tests.test_xxx /
# pytest fixture / unittest runner 等)和 tottime=0 的内部调用展开(typing / inspect /
# functools 等)。这些不是优化目标,默认应该剔除,result.json 一开始就是干净的。
# 用户原话:"软件内部的测试部分默认百分百过滤掉,不在统计范围内容"。
from engine.structure import _is_internal_test
def test_is_internal_test_recognizes_pytest_tests_dir():
# /tests/ /test/ /__tests__/ 目录下的函数 —— pytest 标准约定
assert _is_internal_test("helper", "/p/foo/tests/x.py", "foo.tests")
assert _is_internal_test("helper", "/p/foo/test/x.py", "foo.test")
assert _is_internal_test("helper", "/p/foo/__tests__/x.py", "foo.__tests__")
def test_is_internal_test_recognizes_pytest_file_naming():
# test_xxx.py / xxx_test.py —— pytest 文件命名约定
assert _is_internal_test("f", "/p/test_foo.py", "foo")
assert _is_internal_test("f", "/p/foo_test.py", "foo")
assert _is_internal_test("f", "/p/tests/test_bar.py", "foo")
def test_is_internal_test_recognizes_tests_module_path():
# 模块路径就是 tests / test / __tests__,或它们的子模块
assert _is_internal_test("f", "p/x.py", "tests")
assert _is_internal_test("f", "p/x.py", "test")
assert _is_internal_test("f", "p/x.py", "__tests__")
assert _is_internal_test("f", "p/x.py", "tests.foo")
assert _is_internal_test("f", "p/x.py", "test.bar")
assert _is_internal_test("f", "p/x.py", "__tests__.baz")
def test_is_internal_test_hardcoded_engine_whitelist():
# _pyrof_calib —— 引擎内部白名单,任何情况下都算内部
assert _is_internal_test("_pyrof_calib", "/p/anything.py", "<user>")
assert _is_internal_test("_pyrof_calib", "/p/", "weird_module")
def test_is_internal_test_does_not_match_function_names():
# 关键:只看语境不看函数名 —— 函数名匹配太容易误伤
assert not _is_internal_test("test_helper", "<user>", "a.py")
assert not _is_internal_test("test_visualization", "<user>", "/p/app/main.py")
assert not _is_internal_test("TestFoo.test_foo", "<user>", "/p/myapp/svc.py")
assert not _is_internal_test("MyClass.test_count", "<user>", "a.py")
assert not _is_internal_test("UserService.test_login", "<user>", "a.py")
def test_is_internal_test_does_not_match_test_substring_in_module():
# testing / dataset / contest 等含 test 子串的合法模块名
assert not _is_internal_test("f", "p/x.py", "testing")
assert not _is_internal_test("f", "p/x.py", "dataset")
assert not _is_internal_test("f", "p/x.py", "contest")
def test_is_internal_test_does_not_match_standalone_test_py():
# test.py / tests.py —— 项目入口常这么命名
assert not _is_internal_test("main", "<user>", "/p/test.py")
assert not _is_internal_test("main", "<user>", "/p/tests.py")
def test_is_internal_test_does_not_match_tests_subdir_non_test_files():
"""回归测试:tests/ 子目录下的非测试文件不应当成测试代码剔除。
之前用宽匹配 `[\\/](tests?|__tests__)[\\/]` —— 任何含 `/tests/` 的路径都中招,
包括 `tests/fixtures/x.py` 这种测试数据夹具。结果用户脚本只要住在带 tests/
子目录的路径下,functions 表直接被过滤成 0 行。
收紧到「tests/ 后面紧接文件名,不能再有 / 子目录」后:
- tests/foo.py → 算测试代码 (foo.py 直接在 tests/ 下)
- tests/fixtures/foo.py → 不算 (fixtures 是子目录,foo.py 不挂在 tests/ 下)
- tests/data/sample.py → 不算 (data 是子目录)
- tests/__init__.py / conftest.py → 仍走模块兜底命中 (module == "tests")
"""
# 子目录下的非测试文件 —— 不应被过滤
assert not _is_internal_test("f", "/p/tests/fixtures/x.py", "fixtures")
assert not _is_internal_test("f", "/p/foo/tests/data/sample.py", "data")
assert not _is_internal_test("f", "/p/mine/tests/integration/helper.py", "integration")
# tests/ 直接下面的文件 —— 仍走 dir 兜底命中
assert _is_internal_test("f", "/p/tests/foo.py", "tests")
def test_profile_structure_default_filters_zero_time_and_test_code():
# scope=all + 默认过滤:测试代码 + tottime=0 不进 functions[]
fx = os.path.join(os.path.dirname(__file__), "fixtures", "nested_calls.py")
res = profile_structure(fx, scope="all")
assert all(f.tottime > 0 for f in res.functions), (
f"默认过滤下不应有 tottime=0 的帧: {[f.name for f in res.functions if f.tottime == 0]}"
)
for f in res.functions:
assert not _is_internal_test(f.name, f.file, f.module), (
f"默认过滤下不应有测试代码帧: {f.name} ({f.module})"
)
def test_profile_structure_hide_internal_false_keeps_more():
# hide_internal=False 时不过滤 —— result 含全量数据
fx = os.path.join(os.path.dirname(__file__), "fixtures", "nested_calls.py")
res_full = profile_structure(fx, scope="all", hide_internal=False)
res_filtered = profile_structure(fx, scope="all", hide_internal=True)
assert len(res_full.functions) >= len(res_filtered.functions)
# ──────────────────────────────────────────────────────────────────────────────
# v6 stdlib 间接调用链过滤 —— typing/inspect/functools 等默认剔除
# ──────────────────────────────────────────────────────────────────────────────
#
# 用户场景scope=all 时 numpy 这类第三方包会通过类型注解 / importlib 钩子触发
# typing / inspect / functools / re / _py_warnings / ctypes / enum 等几十个 stdlib
# 内部帧。这些帧 tottime 通常在 1µs~1ms 之间(高于 cProfile 量化地板但低于噪声线),
# 既不是优化目标也不是用户写的代码。默认剔除,只保留用户脚本里显式 import 的
# stdlib 模块帧 —— 「用户能看到『自己 import 了什么』,而不是『numpy 的依赖图』」。
from engine.structure import _extract_user_imports
def test_extract_user_imports_simple_import():
src = "import json\nimport os\nx = 1\n"
assert _extract_user_imports(src) == {"json", "os"}
def test_extract_user_imports_from_import():
src = "from collections import OrderedDict\nfrom os.path import join\nx = 1\n"
# 顶级包名进集合,不展开子模块
assert _extract_user_imports(src) == {"collections", "os"}
def test_extract_user_imports_dotted():
src = "import numpy as np\nimport a.b.c\nx = 1\n"
# 取顶级包名,as 别名不进集合
assert _extract_user_imports(src) == {"numpy", "a"}
def test_extract_user_imports_inside_function_ignored():
"""函数/类内部的 import 不算 —— 它们是延迟副作用,不是「我需要分析的目标」。"""
src = "import json\ndef f():\n import typing\n return typing\n"
assert _extract_user_imports(src) == {"json"}
def test_extract_user_imports_relative_skipped():
"""from . import x 是相对导入 —— 依附于已知包,跳过。"""
src = "from . import utils\nimport json\n"
assert _extract_user_imports(src) == {"json"}
def test_extract_user_imports_syntax_error_returns_empty():
# 语法错误的源码(走不到 profile_and_measure,这里只是防御)
assert _extract_user_imports("def f(:\n pass") == set()
def test_profile_structure_default_filters_indirect_stdlib(tmp_path):
"""scope=all 默认应剔除间接 stdlib 调用链 —— 用户显式 import 的保留,其他剔除。
fixture 设计:用户只 import json + os.path + collections,显式调用 list() +
json.dumps + os.path.join。间接触发 typing/inspect/functools/_py_warnings 的
帧(来自 cProfile 内部 / 解释器启动 / json 自身依赖)不应出现在 result 里。
"""
script = tmp_path / "user.py"
script.write_text(
"import json\n"
"import os.path\n"
"from collections import OrderedDict\n"
"x = json.dumps(OrderedDict([('a', 1)]))\n"
"y = os.path.join('a', 'b')\n"
"z = list(range(10))\n",
encoding="utf-8",
)
res = profile_structure(str(script), scope="all")
modules = {f.module for f in res.functions}
# 用户显式 import 的 stdlib 模块保留
assert "json" in modules, f"json 应保留(用户显式 import实际 {modules}"
# 间接触发的 stdlib 内部模块剔除
leak = modules & {"typing", "inspect", "functools", "_py_warnings", "re",
"annotationlib", "ctypes", "enum", "warnings"}
assert not leak, f"默认应剔除间接 stdlib 调用链,但 {leak} 漏进来了"
def test_profile_structure_hide_internal_false_keeps_indirect_stdlib(tmp_path):
"""--no-hide-internal间接 stdlib 帧也保留,排查调用栈时用得到。
fixture 用 traceback.print_exc() —— 它在 CPython 3.11+ 实现里会触发 inspect /
annotationlib / linecache / tokenize / dataclasses / enum / reprlib / textwrap /
ast / codeop / contextlib 等十几 stdlib 内部模块(都是 Python 层面的,而非 C)。
fixture 只显式 import traceback,其他都是「间接调用链」—— hide_internal=True
时它们被剔除,hide_internal=False 时重新出现。对照组验证 v6 过滤真的生效。
"""
script = tmp_path / "user.py"
script.write_text(
"import traceback\n"
"def f():\n"
" return 1 / 0\n"
"for i in range(50):\n"
" try:\n"
" f()\n"
" except Exception:\n"
" traceback.print_exc()\n",
encoding="utf-8",
)
res_on = profile_structure(str(script), scope="all", hide_internal=True)
res_off = profile_structure(str(script), scope="all", hide_internal=False)
on_modules = {f.module for f in res_on.functions}
off_modules = {f.module for f in res_off.functions}
# 关掉过滤时应恢复间接 stdlib 模块 —— inspect 是 traceback 的核心依赖,必然出现
extra = off_modules - on_modules
stdlib_noise = {"inspect", "annotationlib", "linecache", "tokenize",
"dataclasses", "enum", "reprlib", "textwrap", "ast",
"codeop", "contextlib"}
assert extra & stdlib_noise, (
f"--no-hide-internal 应恢复 traceback 的间接 stdlib 调用链帧,"
f"但 on vs off 的 module 差异 {extra} 里没出现 {stdlib_noise}"
f"v6 过滤可能没生效,或者测试 fixture 没触发干扰。"
)
# hide_internal=True 时不应漏出 inspect / annotationlib / linecache 等间接帧
leak = on_modules & stdlib_noise
assert not leak, (
f"hide_internal=True 时不应漏出间接 stdlib 噪声,但 {leak} 出现了。"
f"这意味着 fixture 没显式 import 它们,却进了 result.json。"
)
# ──────────────────────────────────────────────────────────────────────────────
# v7 「用户直接调用」过滤 —— 只保留用户代码 + 用户直接调用的 import 入口
# ──────────────────────────────────────────────────────────────────────────────
#
# 用户原话:「只要代码的本身和import 调用的耗时统计,其他的不需要」/「目前好像
# 仍然统计到内部测试的代码了,不合理」—— 之前的间接 stdlib 过滤解决了 stdlib 噪声,
# 但 487 帧里仍有 numpy 内部 250+ 帧、<built-in> C 函数 131 帧、<frozen> importlib
# 90 帧等「不是用户调用的」帧。v7 把规则收紧到「caller 链含用户帧才算 import 调用」,
# 并加 cumtime >= 1µs 闸门(剔除纯注册/分发占位函数如 _mean_dispatcher)。
#
# 测试矩阵:
# - 用户写的函数 → 保留
# - 用户直接调用的第三方函数 → 保留
# - 用户调用的第三方函数内部又调的第三方函数 → 剔除
# - 用户调用的第三方函数内部又调的 stdlib(非用户 import)→ 剔除
# - <built-in> C 函数 → 剔除(不是 import 调用)
# - <frozen> importlib → 剔除(不是 import 调用)
# - 用户没 import 的 stdlib 帧 → 剔除
def test_profile_structure_default_keeps_only_user_and_direct_calls(tmp_path):
"""默认过滤:只保留用户代码 + 用户直接调用的 third-party / 用户 import 的 stdlib。
fixture 用 json + 一个明显干活的 helper(json.dumps 大循环),结果应只有:
- 用户模块(脚本本身)
- 用户函数 helper
- 用户直接调的 json.dumps
不应出现:json.decoder / json.scanner / re / typing / inspect 等内部帧。
"""
script = tmp_path / "user.py"
script.write_text(
"import json\n"
"def helper(data):\n"
" return json.dumps(data)\n"
"result = helper({'a': list(range(100))})\n",
encoding="utf-8",
)
res = profile_structure(str(script), scope="all")
modules = {f.module for f in res.functions}
names = {f.name for f in res.functions}
# 1) 用户脚本本身
assert "<user>" in modules, f"用户代码应保留,实际 modules: {modules}"
assert "<module>" in names and "helper" in names
# 2) 用户直接调用的 json.dumps
assert "json" in modules, f"json 是用户显式 import 且被直接调用,应保留"
assert "dumps" in names, "用户直接调用 json.dumps,应保留"
# 3) json 内部帧被剔除(json.decoder / encoder / scanner 等)
json_internal_leak = {"decode", "encode", "scanstring", "JSONDecoder",
"JSONEncoder", "__init__"}
bad = json_internal_leak & names
assert not bad, f"json 内部帧不应漏出,但 {bad} 出现了"
# 4) stdlib 内部展开(typing/inspect/re 等)不应出现
leak = modules & {"typing", "inspect", "re", "_py_warnings", "functools"}
assert not leak, f"间接 stdlib 噪声应剔除,但 {leak} 漏进了"
def test_profile_structure_default_drops_indirect_nested_calls(tmp_path):
"""用户调用 numpy.mean,但 numpy.mean 内部调的 numpy._core._methods 不应出现。
这是 v7 的核心语义:只看「直接被用户调用」,不传递。哪怕是同包内部的辅助函数,
只要不是用户写的,就不在 result 里。
"""
script = tmp_path / "user.py"
script.write_text(
"import numpy as np\n"
"x = np.array([1, 2, 3, 4, 5])\n"
"y = np.mean(x)\n",
encoding="utf-8",
)
res = profile_structure(str(script), scope="all")
names = {f.name for f in res.functions}
# 用户直接调用的入口应保留
assert "mean" in names, f"用户直接调用 np.mean,应保留,实际 names: {names}"
# numpy 内部辅助方法不应出现(_methods / fromnumeric 之类的子帧)
# 注:numpy.mean 实际上调用 numpy._core.fromnumeric.mean,我们想要的是 fromnumeric.mean
# 出现在 result 里(因为它是 user-direct call 的代理),但其内部又调了 _methods
# 这种,不该出现。
bad = {"_methods", "_sum", "_mean", "_std", "_var"}
bad &= names
assert not bad, f"numpy 内部辅助函数不应漏出,但 {bad} 出现了"
def test_profile_structure_default_drops_builtin_and_frozen(tmp_path):
"""<built-in> C 函数 / <frozen> importlib 即使被用户代码用到也不保留。
用户原意是「import 调用」,builtin C 函数(len / dict.keys / numpy C 核等)
和 frozen import machinery 不属于「import 调用」—— 即使技术上被用户代码调到。
"""
script = tmp_path / "user.py"
script.write_text(
"import numpy as np\n"
"data = [1, 2, 3, 4, 5]\n"
"x = np.array(data)\n"
"y = len(data)\n"
"z = sorted(data)\n",
encoding="utf-8",
)
res = profile_structure(str(script), scope="all")
modules = {f.module for f in res.functions}
# <built-in> 完全不应该出现 —— 不是 import 调用
assert "<built-in>" not in modules, (
f"<built-in> C 函数不应作为 import 调用保留,实际 modules: {modules}"
)
# <frozen> 也不应出现
assert "<frozen>" not in modules, (
f"<frozen> importlib 帧不应保留,实际 modules: {modules}"
)
def test_profile_structure_default_keeps_user_function_with_no_args(tmp_path):
"""用户写的不带参数的函数(只调用 import)仍应保留 —— 用户代码一律保留。
这是 v7 的关键 invariant:用户帧不管 tottime/cumtime 多小,不管有没有调用
别的东西,都进 result —— 这是「代码本身」的定义。
"""
script = tmp_path / "user.py"
script.write_text(
"import json\n"
"def tiny():\n"
" return json.dumps({})\n"
"tiny()\n",
encoding="utf-8",
)
res = profile_structure(str(script), scope="all")
user_fns = {f.name for f in res.functions if f.module == "<user>"}
assert "tiny" in user_fns, f"用户函数 tiny 应保留,实际 user fns: {user_fns}"
def test_profile_structure_default_cumtime_floor_drops_dispatcher(tmp_path):
"""cumtime < 1µs 的 entry point 视为注册期/分发期占位 → 剔除。
numpy 在用户 import 时会触发 _mean_dispatcher / _std_dispatcher 等纯注册函数
(cProfile 把 caller 记成 <module>,但 cumtime 几乎为 0)。这些不是真正的
「用户 import 调用」,默认剔除。
"""
script = tmp_path / "user.py"
script.write_text(
"import numpy as np\n"
"y = np.mean([1, 2, 3])\n",
encoding="utf-8",
)
res = profile_structure(str(script), scope="all")
# _mean_dispatcher 是注册期占位,cumtime 应 < 1µs
dispatcher_frames = [f for f in res.functions if "_dispatcher" in f.name]
assert not dispatcher_frames, (
f"_dispatcher 注册占位函数应剔除(它们是 import 期触发的分发器,"
f"cumtime < 1µs),但 {[(f.name, f.cumtime) for f in dispatcher_frames]} 出现了"
)