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 自身;不应出现标准库路径( / / 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 == "" 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' / '' 等。""" 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, "")) 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, "")) 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, "")) 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)→ ""。""" fx = os.path.join(os.path.dirname(__file__), "fixtures", "nested_calls.py") assert _top_module(fx, fx) == "" 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) == "" def test_top_module_stdlib_windows(): """Windows 标准库:C:\\Python39\\Lib\\json\\decoder.py → json""" assert _top_module(r"C:\Python39\Lib\json\decoder.py", "") == "json" assert _top_module(r"C:\Python39\Lib\json\__init__.py", "") == "json" # 多层嵌套:Lib/site-packages 这种 dev 布局也行 assert _top_module(r"C:\Python39\Lib\site-packages\foo\bar.py", "") == "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", "") == "functools" # Linux 顶层 stdlib assert _top_module("/usr/lib/python3.11/functools.py", "") == "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", "") assert not result.endswith(".py"), f"module 名不能带 .py 后缀,实际 {result!r}" def test_top_module_stdlib_linux(): """Linux/macOS 标准库:/usr/lib/python3.X//... → """ assert _top_module("/usr/lib/python3.11/json/decoder.py", "") == "json" assert _top_module( "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/json/decoder.py", "", ) == "json" def test_top_module_site_packages(): """site-packages / dist-packages:标记后的第一个目录段就是包名。""" assert _top_module( "/usr/lib/python3.11/site-packages/numpy/core/array.py", "" ) == "numpy" assert _top_module( "C:\\Python39\\Lib\\site-packages\\pandas\\core\\frame.py", "" ) == "pandas" # dist-packages(Debian 系) assert _top_module( "/usr/lib/python3.11/dist-packages/requests/api.py", "" ) == "requests" def test_top_module_frozen(): """frozen / built-in / 虚拟帧 → 整段保留作为标签。""" assert _top_module("", "") == "" assert _top_module("", "") == "" assert _top_module("", "") == "" def test_top_module_c_extension(): """`~` 在 cProfile 里代表所有 C 扩展函数(numpy C 核 / json 加速器 / time.sleep 等)。这些 frame 现在 scope=all 会留下(不再被一刀切),需要给个 有意义的 module 标签 —— 用 "" 跟 _classify_origin 的 builtin 字段保持一致。""" assert _top_module("~", "") == "" # user_script_norm 也要匹配上 user 脚本 —— 防御性 fx = os.path.join(os.path.dirname(__file__), "fixtures", "nested_calls.py") assert _top_module("~", fx, _norm(fx)) == "" def test_top_module_fallback_dirname(): """未匹配任何标记 → 取倒数第二段目录。""" # 例如:".../someproj/src/utils/helper.py" → "utils" assert _top_module("/path/to/someproj/src/utils/helper.py", "") == "utils" def test_profile_structure_populates_module(): """profile_structure 给每条 fn 算 module:fixture 是用户脚本,全部 。""" 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 == {""}, f"默认 scope 下应只有 ,实际 {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 "" in modules, f"用户脚本应在模块集合里,实际 {modules}" assert any(m in ("json", "time", "", "") 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", "", 0.5), _mk_fn("mid", "", 0.3), _mk_fn("leaf", "", 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", "", 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 降序:(0.5) > json(0.5) > time(0.1) # 和 json 都是 0.5,排序稳定时顺序取决于字典遍历顺序 —— 这里不强制 # 顺序,只验证排序结果一致(用 sorted() 拿到一组) module_nodes = flame.children assert len(module_nodes) == 3 # 第一个必须是最大的 (0.5 严格大于 json 的 0.5,因为总和 tie-break # 由 sorted 的 stable 行为兜底: 在 fns 里排前面 → 同值时排前) assert module_nodes[0].name == "" 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" swapped = fx.replace("\\", "/") if "\\" in fx else fx.replace("/", "\\") if swapped != fx: assert _classify_origin(swapped, fx, "") == "user" def test_classify_origin_frozen_and_builtin(): """frozen / built-in 虚拟帧 → frozen / builtin。""" assert _classify_origin("", "/x.py", "") == "frozen" assert _classify_origin("", "/x.py", "") == "builtin" # 其它 <...> 兜底 other assert _classify_origin("", "/x.py", "") == "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", "") 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", "", "a.py") assert not _is_internal_test("test_visualization", "", "/p/app/main.py") assert not _is_internal_test("TestFoo.test_foo", "", "/p/myapp/svc.py") assert not _is_internal_test("MyClass.test_count", "", "a.py") assert not _is_internal_test("UserService.test_login", "", "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", "", "/p/test.py") assert not _is_internal_test("main", "", "/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+ 帧、 C 函数 131 帧、 importlib # 90 帧等「不是用户调用的」帧。v7 把规则收紧到「caller 链含用户帧才算 import 调用」, # 并加 cumtime >= 1µs 闸门(剔除纯注册/分发占位函数如 _mean_dispatcher)。 # # 测试矩阵: # - 用户写的函数 → 保留 # - 用户直接调用的第三方函数 → 保留 # - 用户调用的第三方函数内部又调的第三方函数 → 剔除 # - 用户调用的第三方函数内部又调的 stdlib(非用户 import)→ 剔除 # - C 函数 → 剔除(不是 import 调用) # - 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 "" in modules, f"用户代码应保留,实际 modules: {modules}" assert "" 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): """ C 函数 / 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} # 完全不应该出现 —— 不是 import 调用 assert "" not in modules, ( f" C 函数不应作为 import 调用保留,实际 modules: {modules}" ) # 也不应出现 assert "" not in modules, ( f" 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 == ""} 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 记成 ,但 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]} 出现了" )