diff --git a/Lib/profiling/sampling/gecko_collector.py b/Lib/profiling/sampling/gecko_collector.py index 2de8cce387e7f26..769350e13efdf67 100644 --- a/Lib/profiling/sampling/gecko_collector.py +++ b/Lib/profiling/sampling/gecko_collector.py @@ -2,6 +2,7 @@ import io import json import os +from pathlib import Path, PureWindowsPath import platform import sys import tempfile @@ -9,6 +10,7 @@ import time from .collector import Collector, filter_internal_frames +from .module_utils import extract_module_name, get_python_path_info from .opcode_utils import get_opcode_info, format_opcode try: from _remote_debugging import THREAD_STATUS_HAS_GIL, THREAD_STATUS_ON_CPU, THREAD_STATUS_UNKNOWN, THREAD_STATUS_GIL_REQUESTED, THREAD_STATUS_HAS_EXCEPTION, THREAD_STATUS_MAIN_THREAD @@ -144,6 +146,8 @@ def __init__(self, sample_interval_usec, *, skip_idle=False, opcodes=False): self.opcodes_enabled = opcodes self.start_time = time.monotonic() * 1000 # milliseconds since start + self._path_info = get_python_path_info() + # Global string table (shared across all threads) self.global_strings = ["(root)"] # Start with root self.global_string_map = {"(root)": 0} @@ -643,9 +647,32 @@ def _get_or_create_resource(self, thread_data, filename): resource_types = resource_table["type"] resource_idx = len(resource_names) - resource_name = ( - os.path.basename(filename) if "/" in filename else filename - ) + if not filename or filename.startswith("<"): + resource_name = filename + else: + module_name, module_type = extract_module_name( + filename.replace("\\", "/"), self._path_info + ) + if module_type != "other": + resource_name = module_name + else: + path = ( + PureWindowsPath(filename) + if ("\\" in filename or (len(filename) > 1 and filename[1] == ":")) + else Path(filename) + ) + if path.is_absolute() or bool(path.anchor): + parent = path.parent.name + name = path.name + if name == "__init__.py" and parent: + resource_name = parent + elif parent: + resource_name = f"{parent}/{name}" + else: + resource_name = name or filename + else: + resource_name = module_name + name_idx = self._intern_string(resource_name) resource_libs.append(None) diff --git a/Lib/test/test_profiling/test_sampling_profiler/test_collectors.py b/Lib/test/test_profiling/test_sampling_profiler/test_collectors.py index d4ba2df5044559f..fa21a82c0843036 100644 --- a/Lib/test/test_profiling/test_sampling_profiler/test_collectors.py +++ b/Lib/test/test_profiling/test_sampling_profiler/test_collectors.py @@ -741,6 +741,128 @@ def test_gecko_collector_basic(self): self.assertGreater(stack_table["length"], 0) self.assertGreater(len(stack_table["frame"]), 0) + def test_gecko_collector_resource_module_names(self): + """Test that GeckoCollector resolves resources to module names and + distinguishes files with the same basename in different packages.""" + collector = GeckoCollector(1000) + + # 1. Package __init__.py files + file_pkg_a = "pkg_a/__init__.py" + file_pkg_b = "pkg_b/__init__.py" + + # 2. Submodule names + file_submod = "pkg_a/utils.py" + + # 3. Standard library file (using collector._path_info) + stdlib_dir = collector._path_info.get("stdlib") + file_stdlib = ( + str(stdlib_dir / "os.py") + if stdlib_dir + else "/usr/lib/python3.15/os.py" + ) + + # 4. Synthetic files + file_stdin = "" + file_string = "" + + # 5. Unresolved external paths (absolute paths outside sys.path) + file_ext_pkg = "/opt/external/deep/pkg_x/__init__.py" + file_ext_tool = "/opt/external/deep/pkg_x/tool.py" + file_root_tool = "/root_tool.py" + + # 6. Windows-style backslash paths (relative and absolute) + file_win_pkg = r"pkg_w\__init__.py" + file_win_tool = r"pkg_w\utils.py" + file_win_ext_pkg = r"C:\external\pkg_x\__init__.py" + file_win_ext_tool = r"C:\external\pkg_x\tool.py" + file_win_root_tool = r"C:\tool.py" + + test_cases = [ + (file_pkg_a, 10, "func_pkg_a"), + (file_pkg_b, 20, "func_pkg_b"), + (file_submod, 30, "func_submod"), + (file_stdlib, 40, "func_stdlib"), + (file_stdin, 1, "func_stdin"), + (file_string, 2, "func_string"), + (file_ext_pkg, 10, "func_ext_pkg"), + (file_ext_tool, 20, "func_ext_tool"), + (file_root_tool, 30, "func_root_tool"), + (file_win_pkg, 10, "func_win_pkg"), + (file_win_tool, 20, "func_win_tool"), + (file_win_ext_pkg, 10, "func_win_ext_pkg"), + (file_win_ext_tool, 20, "func_win_ext_tool"), + (file_win_root_tool, 30, "func_win_root_tool"), + ] + + frames = [MockFrameInfo(fn, line, name) for fn, line, name in test_cases] + test_frames = [ + MockInterpreterInfo( + 0, + [ + MockThreadInfo( + 1, + frames, + status=THREAD_STATUS_MAIN_THREAD, + ) + ], + ) + ] + collector.collect(test_frames) + profile_data = collector._build_profile() + + string_array = profile_data["shared"]["stringArray"] + thread_data = profile_data["threads"][0] + func_table = thread_data["funcTable"] + resource_table = thread_data["resourceTable"] + + self.assertEqual(resource_table["length"], len(resource_table["name"])) + for r_type in resource_table["type"]: + self.assertEqual(r_type, gecko_collector.RESOURCE_TYPE_LIBRARY) + + funcs_by_name = { + string_array[func_table["name"][i]]: ( + func_table["resource"][i], + string_array[ + resource_table["name"][func_table["resource"][i]] + ], + ) + for i in range(func_table["length"]) + } + + # 1. Distinct resource table entries and names for duplicate packages + res_idx_a, res_name_a = funcs_by_name["func_pkg_a"] + res_idx_b, res_name_b = funcs_by_name["func_pkg_b"] + self.assertNotEqual(res_idx_a, res_idx_b) + self.assertEqual(res_name_a, "pkg_a") + self.assertEqual(res_name_b, "pkg_b") + name_idx_a = resource_table["name"][res_idx_a] + name_idx_b = resource_table["name"][res_idx_b] + self.assertNotEqual(name_idx_a, name_idx_b) + self.assertEqual(string_array[name_idx_a], "pkg_a") + self.assertEqual(string_array[name_idx_b], "pkg_b") + + # 2. Submodule names resolve to dotted names + self.assertEqual(funcs_by_name["func_submod"][1], "pkg_a.utils") + + # 3. Standard library resolves to exact module name + self.assertEqual(funcs_by_name["func_stdlib"][1], "os") + + # 4. Synthetic files preserve sensible names + self.assertEqual(funcs_by_name["func_stdin"][1], "") + self.assertEqual(funcs_by_name["func_string"][1], "") + + # 5. Unresolved external paths provide readable fallbacks + self.assertEqual(funcs_by_name["func_ext_pkg"][1], "pkg_x") + self.assertEqual(funcs_by_name["func_ext_tool"][1], "pkg_x/tool.py") + self.assertEqual(funcs_by_name["func_root_tool"][1], "root_tool.py") + + # 6. Windows-style paths resolve platform-independently + self.assertEqual(funcs_by_name["func_win_pkg"][1], "pkg_w") + self.assertEqual(funcs_by_name["func_win_tool"][1], "pkg_w.utils") + self.assertEqual(funcs_by_name["func_win_ext_pkg"][1], "pkg_x") + self.assertEqual(funcs_by_name["func_win_ext_tool"][1], "pkg_x/tool.py") + self.assertEqual(funcs_by_name["func_win_root_tool"][1], "tool.py") + def test_gecko_collector_async_aware(self): collector = GeckoCollector(1000) diff --git a/Misc/NEWS.d/next/Library/2026-11-04-17-37-00.gh-issue-161299.m0duLe.rst b/Misc/NEWS.d/next/Library/2026-11-04-17-37-00.gh-issue-161299.m0duLe.rst new file mode 100644 index 000000000000000..80dd3c7c71cab68 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-11-04-17-37-00.gh-issue-161299.m0duLe.rst @@ -0,0 +1,2 @@ +Record Python module names for resources in the Gecko sampling profiler +(:mod:`profiling.sampling`) where available.