From 1f112f002b8fb9e2c74571d5e1bc884bf73e952b Mon Sep 17 00:00:00 2001 From: MAKWANA AKSHAT Date: Wed, 4 Nov 2026 18:24:32 +0530 Subject: [PATCH 1/2] gh-161299: Resolve Gecko profiler resources to module names --- Lib/profiling/sampling/gecko_collector.py | 39 +++++- .../test_sampling_profiler/test_collectors.py | 122 ++++++++++++++++++ ...-11-04-17-37-00.gh-issue-161299.m0duLe.rst | 2 + 3 files changed, 161 insertions(+), 2 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-11-04-17-37-00.gh-issue-161299.m0duLe.rst diff --git a/Lib/profiling/sampling/gecko_collector.py b/Lib/profiling/sampling/gecko_collector.py index 2de8cce387e7f2..f9d6ce1d4787ef 100644 --- a/Lib/profiling/sampling/gecko_collector.py +++ b/Lib/profiling/sampling/gecko_collector.py @@ -9,6 +9,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 +145,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 +646,41 @@ 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 + norm_filename = ( + filename.replace("\\", "/") if "\\" in filename else filename ) + if not norm_filename or norm_filename.startswith("<"): + resource_name = filename + else: + module_name, module_type = extract_module_name( + norm_filename, self._path_info + ) + if module_type != "other": + resource_name = module_name + else: + is_abs = norm_filename.startswith("/") or ( + len(norm_filename) > 1 and norm_filename[1] == ":" + ) + if is_abs: + parts = norm_filename.rstrip("/").rsplit("/", 2) + if len(parts) >= 2: + parent = parts[-2] + basename = parts[-1] + if ( + basename == "__init__.py" + and parent + and not parent.endswith(":") + ): + resource_name = parent + elif parent and not parent.endswith(":"): + resource_name = f"{parent}/{basename}" + else: + resource_name = basename + else: + resource_name = os.path.basename(filename) 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 d4ba2df5044559..fa21a82c084303 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 00000000000000..80dd3c7c71cab6 --- /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. From 8a0d3328d20888af5ea5805d84ffb00f8e59657f Mon Sep 17 00:00:00 2001 From: MAKWANA AKSHAT Date: Thu, 5 Nov 2026 15:01:33 +0530 Subject: [PATCH 2/2] gh-161299: Simplify Gecko resource path handling --- Lib/profiling/sampling/gecko_collector.py | 38 +++++++++-------------- 1 file changed, 15 insertions(+), 23 deletions(-) diff --git a/Lib/profiling/sampling/gecko_collector.py b/Lib/profiling/sampling/gecko_collector.py index f9d6ce1d4787ef..769350e13efdf6 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 @@ -646,38 +647,29 @@ def _get_or_create_resource(self, thread_data, filename): resource_types = resource_table["type"] resource_idx = len(resource_names) - norm_filename = ( - filename.replace("\\", "/") if "\\" in filename else filename - ) - if not norm_filename or norm_filename.startswith("<"): + if not filename or filename.startswith("<"): resource_name = filename else: module_name, module_type = extract_module_name( - norm_filename, self._path_info + filename.replace("\\", "/"), self._path_info ) if module_type != "other": resource_name = module_name else: - is_abs = norm_filename.startswith("/") or ( - len(norm_filename) > 1 and norm_filename[1] == ":" + path = ( + PureWindowsPath(filename) + if ("\\" in filename or (len(filename) > 1 and filename[1] == ":")) + else Path(filename) ) - if is_abs: - parts = norm_filename.rstrip("/").rsplit("/", 2) - if len(parts) >= 2: - parent = parts[-2] - basename = parts[-1] - if ( - basename == "__init__.py" - and parent - and not parent.endswith(":") - ): - resource_name = parent - elif parent and not parent.endswith(":"): - resource_name = f"{parent}/{basename}" - else: - resource_name = basename + 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 = os.path.basename(filename) or filename + resource_name = name or filename else: resource_name = module_name