Skip to content

Commit 1f112f0

Browse files
gh-161299: Resolve Gecko profiler resources to module names
1 parent 405f127 commit 1f112f0

3 files changed

Lines changed: 161 additions & 2 deletions

File tree

‎Lib/profiling/sampling/gecko_collector.py‎

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import time
1010

1111
from .collector import Collector, filter_internal_frames
12+
from .module_utils import extract_module_name, get_python_path_info
1213
from .opcode_utils import get_opcode_info, format_opcode
1314
try:
1415
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):
144145
self.opcodes_enabled = opcodes
145146
self.start_time = time.monotonic() * 1000 # milliseconds since start
146147

148+
self._path_info = get_python_path_info()
149+
147150
# Global string table (shared across all threads)
148151
self.global_strings = ["(root)"] # Start with root
149152
self.global_string_map = {"(root)": 0}
@@ -643,9 +646,41 @@ def _get_or_create_resource(self, thread_data, filename):
643646
resource_types = resource_table["type"]
644647

645648
resource_idx = len(resource_names)
646-
resource_name = (
647-
os.path.basename(filename) if "/" in filename else filename
649+
norm_filename = (
650+
filename.replace("\\", "/") if "\\" in filename else filename
648651
)
652+
if not norm_filename or norm_filename.startswith("<"):
653+
resource_name = filename
654+
else:
655+
module_name, module_type = extract_module_name(
656+
norm_filename, self._path_info
657+
)
658+
if module_type != "other":
659+
resource_name = module_name
660+
else:
661+
is_abs = norm_filename.startswith("/") or (
662+
len(norm_filename) > 1 and norm_filename[1] == ":"
663+
)
664+
if is_abs:
665+
parts = norm_filename.rstrip("/").rsplit("/", 2)
666+
if len(parts) >= 2:
667+
parent = parts[-2]
668+
basename = parts[-1]
669+
if (
670+
basename == "__init__.py"
671+
and parent
672+
and not parent.endswith(":")
673+
):
674+
resource_name = parent
675+
elif parent and not parent.endswith(":"):
676+
resource_name = f"{parent}/{basename}"
677+
else:
678+
resource_name = basename
679+
else:
680+
resource_name = os.path.basename(filename) or filename
681+
else:
682+
resource_name = module_name
683+
649684
name_idx = self._intern_string(resource_name)
650685

651686
resource_libs.append(None)

‎Lib/test/test_profiling/test_sampling_profiler/test_collectors.py‎

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -741,6 +741,128 @@ def test_gecko_collector_basic(self):
741741
self.assertGreater(stack_table["length"], 0)
742742
self.assertGreater(len(stack_table["frame"]), 0)
743743

744+
def test_gecko_collector_resource_module_names(self):
745+
"""Test that GeckoCollector resolves resources to module names and
746+
distinguishes files with the same basename in different packages."""
747+
collector = GeckoCollector(1000)
748+
749+
# 1. Package __init__.py files
750+
file_pkg_a = "pkg_a/__init__.py"
751+
file_pkg_b = "pkg_b/__init__.py"
752+
753+
# 2. Submodule names
754+
file_submod = "pkg_a/utils.py"
755+
756+
# 3. Standard library file (using collector._path_info)
757+
stdlib_dir = collector._path_info.get("stdlib")
758+
file_stdlib = (
759+
str(stdlib_dir / "os.py")
760+
if stdlib_dir
761+
else "/usr/lib/python3.15/os.py"
762+
)
763+
764+
# 4. Synthetic files
765+
file_stdin = "<stdin>"
766+
file_string = "<string>"
767+
768+
# 5. Unresolved external paths (absolute paths outside sys.path)
769+
file_ext_pkg = "/opt/external/deep/pkg_x/__init__.py"
770+
file_ext_tool = "/opt/external/deep/pkg_x/tool.py"
771+
file_root_tool = "/root_tool.py"
772+
773+
# 6. Windows-style backslash paths (relative and absolute)
774+
file_win_pkg = r"pkg_w\__init__.py"
775+
file_win_tool = r"pkg_w\utils.py"
776+
file_win_ext_pkg = r"C:\external\pkg_x\__init__.py"
777+
file_win_ext_tool = r"C:\external\pkg_x\tool.py"
778+
file_win_root_tool = r"C:\tool.py"
779+
780+
test_cases = [
781+
(file_pkg_a, 10, "func_pkg_a"),
782+
(file_pkg_b, 20, "func_pkg_b"),
783+
(file_submod, 30, "func_submod"),
784+
(file_stdlib, 40, "func_stdlib"),
785+
(file_stdin, 1, "func_stdin"),
786+
(file_string, 2, "func_string"),
787+
(file_ext_pkg, 10, "func_ext_pkg"),
788+
(file_ext_tool, 20, "func_ext_tool"),
789+
(file_root_tool, 30, "func_root_tool"),
790+
(file_win_pkg, 10, "func_win_pkg"),
791+
(file_win_tool, 20, "func_win_tool"),
792+
(file_win_ext_pkg, 10, "func_win_ext_pkg"),
793+
(file_win_ext_tool, 20, "func_win_ext_tool"),
794+
(file_win_root_tool, 30, "func_win_root_tool"),
795+
]
796+
797+
frames = [MockFrameInfo(fn, line, name) for fn, line, name in test_cases]
798+
test_frames = [
799+
MockInterpreterInfo(
800+
0,
801+
[
802+
MockThreadInfo(
803+
1,
804+
frames,
805+
status=THREAD_STATUS_MAIN_THREAD,
806+
)
807+
],
808+
)
809+
]
810+
collector.collect(test_frames)
811+
profile_data = collector._build_profile()
812+
813+
string_array = profile_data["shared"]["stringArray"]
814+
thread_data = profile_data["threads"][0]
815+
func_table = thread_data["funcTable"]
816+
resource_table = thread_data["resourceTable"]
817+
818+
self.assertEqual(resource_table["length"], len(resource_table["name"]))
819+
for r_type in resource_table["type"]:
820+
self.assertEqual(r_type, gecko_collector.RESOURCE_TYPE_LIBRARY)
821+
822+
funcs_by_name = {
823+
string_array[func_table["name"][i]]: (
824+
func_table["resource"][i],
825+
string_array[
826+
resource_table["name"][func_table["resource"][i]]
827+
],
828+
)
829+
for i in range(func_table["length"])
830+
}
831+
832+
# 1. Distinct resource table entries and names for duplicate packages
833+
res_idx_a, res_name_a = funcs_by_name["func_pkg_a"]
834+
res_idx_b, res_name_b = funcs_by_name["func_pkg_b"]
835+
self.assertNotEqual(res_idx_a, res_idx_b)
836+
self.assertEqual(res_name_a, "pkg_a")
837+
self.assertEqual(res_name_b, "pkg_b")
838+
name_idx_a = resource_table["name"][res_idx_a]
839+
name_idx_b = resource_table["name"][res_idx_b]
840+
self.assertNotEqual(name_idx_a, name_idx_b)
841+
self.assertEqual(string_array[name_idx_a], "pkg_a")
842+
self.assertEqual(string_array[name_idx_b], "pkg_b")
843+
844+
# 2. Submodule names resolve to dotted names
845+
self.assertEqual(funcs_by_name["func_submod"][1], "pkg_a.utils")
846+
847+
# 3. Standard library resolves to exact module name
848+
self.assertEqual(funcs_by_name["func_stdlib"][1], "os")
849+
850+
# 4. Synthetic files preserve sensible names
851+
self.assertEqual(funcs_by_name["func_stdin"][1], "<stdin>")
852+
self.assertEqual(funcs_by_name["func_string"][1], "<string>")
853+
854+
# 5. Unresolved external paths provide readable fallbacks
855+
self.assertEqual(funcs_by_name["func_ext_pkg"][1], "pkg_x")
856+
self.assertEqual(funcs_by_name["func_ext_tool"][1], "pkg_x/tool.py")
857+
self.assertEqual(funcs_by_name["func_root_tool"][1], "root_tool.py")
858+
859+
# 6. Windows-style paths resolve platform-independently
860+
self.assertEqual(funcs_by_name["func_win_pkg"][1], "pkg_w")
861+
self.assertEqual(funcs_by_name["func_win_tool"][1], "pkg_w.utils")
862+
self.assertEqual(funcs_by_name["func_win_ext_pkg"][1], "pkg_x")
863+
self.assertEqual(funcs_by_name["func_win_ext_tool"][1], "pkg_x/tool.py")
864+
self.assertEqual(funcs_by_name["func_win_root_tool"][1], "tool.py")
865+
744866
def test_gecko_collector_async_aware(self):
745867
collector = GeckoCollector(1000)
746868

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Record Python module names for resources in the Gecko sampling profiler
2+
(:mod:`profiling.sampling`) where available.

0 commit comments

Comments
 (0)