update
This commit is contained in:
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