update
This commit is contained in:
0
engine/__init__.py
Normal file
0
engine/__init__.py
Normal file
186
engine/harness.py
Normal file
186
engine/harness.py
Normal 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 自动剥离 BOM(Windows 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
257
engine/runner.py
Normal 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):
|
||||
# 前导 \n:tqdm / 进度条类库会写"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() 抛 SystemExit(BaseException 的子类)—— 不被下面的 except BaseException 捕获,
|
||||
# 子进程直接退出无 JSON,UI 就会显示 "引擎无输出 (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
87
engine/schema.py
Normal 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-8,UI 解析会挂。转义成纯 ASCII 后逐字节一致,
|
||||
# JSON.parse 原生还原。主进程仍然会设那两个环境变量,这里是纵深防御。
|
||||
return json.dumps(asdict(self), ensure_ascii=True)
|
||||
576
engine/structure.py
Normal file
576
engine/structure.py
Normal 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 shim:v3 时期暴露的「只拿归因、不读 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
0
engine/tests/__init__.py
Normal file
27
engine/tests/fixtures/demo_sort.py
vendored
Normal file
27
engine/tests/fixtures/demo_sort.py
vendored
Normal 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
16
engine/tests/fixtures/nested_calls.py
vendored
Normal 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
19
engine/tests/fixtures/with_imports.py
vendored
Normal 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()
|
||||
125
engine/tests/test_contract.py
Normal file
125
engine/tests/test_contract.py
Normal 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
|
||||
77
engine/tests/test_harness.py
Normal file
77
engine/tests/test_harness.py
Normal 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
158
engine/tests/test_runner.py
Normal 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() 抛 SystemExit(BaseException 子类),之前 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
|
||||
21
engine/tests/test_schema.py
Normal file
21
engine/tests/test_schema.py
Normal 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"
|
||||
761
engine/tests/test_structure.py
Normal file
761
engine/tests/test_structure.py
Normal 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=all:C 扩展函数帧(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=user:C 帧不会进入用户脚本 —— 也过滤掉(与历史行为一致)
|
||||
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-packages(Debian 系)
|
||||
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 算 module:fixture 是用户脚本,全部 <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=all:fn.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-packages(Debian 系)
|
||||
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 —— 即使模块名未知。
|
||||
|
||||
之前 bug(M5):只看路径里含 "/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 算 origin:fixture 是用户脚本,全部 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:用户脚本帧归 user,import 的 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]} 出现了"
|
||||
)
|
||||
Reference in New Issue
Block a user