From e0e7d03c37de3d5d07ac7b409935d08116a768de Mon Sep 17 00:00:00 2001 From: Ivona Stojanovic Date: Mon, 16 Mar 2026 18:06:25 +0000 Subject: [PATCH 1/8] Extract module name utilities to module_utils.py Move module name extraction logic from heatmap_collector to shared module_utils to enable flamegraph to display module names instead of full file paths. --- Lib/profiling/sampling/heatmap_collector.py | 121 +------------------ Lib/profiling/sampling/module_utils.py | 122 ++++++++++++++++++++ 2 files changed, 123 insertions(+), 120 deletions(-) create mode 100644 Lib/profiling/sampling/module_utils.py diff --git a/Lib/profiling/sampling/heatmap_collector.py b/Lib/profiling/sampling/heatmap_collector.py index ea1beec70d39f8..5c36d78f5535e7 100644 --- a/Lib/profiling/sampling/heatmap_collector.py +++ b/Lib/profiling/sampling/heatmap_collector.py @@ -20,6 +20,7 @@ from .collector import normalize_location, extract_lineno from .opcode_utils import get_opcode_info, format_opcode from .stack_collector import StackTraceCollector +from .module_utils import extract_module_name, get_python_path_info # ============================================================================ @@ -49,126 +50,6 @@ class TreeNode: children: Dict[str, 'TreeNode'] = field(default_factory=dict) -# ============================================================================ -# Module Path Analysis -# ============================================================================ - -def get_python_path_info(): - """Get information about Python installation paths for module extraction. - - Returns: - dict: Dictionary containing stdlib path, site-packages paths, and sys.path entries. - """ - info = { - 'stdlib': None, - 'site_packages': [], - 'sys_path': [] - } - - # Get standard library path from os module location - try: - if hasattr(os, '__file__') and os.__file__: - info['stdlib'] = Path(os.__file__).parent - except (AttributeError, OSError): - pass # Silently continue if we can't determine stdlib path - - # Get site-packages directories - site_packages = [] - try: - site_packages.extend(Path(p) for p in site.getsitepackages()) - except (AttributeError, OSError): - pass # Continue without site packages if unavailable - - # Get user site-packages - try: - user_site = site.getusersitepackages() - if user_site and Path(user_site).exists(): - site_packages.append(Path(user_site)) - except (AttributeError, OSError): - pass # Continue without user site packages - - info['site_packages'] = site_packages - info['sys_path'] = [Path(p) for p in sys.path if p] - - return info - - -def extract_module_name(filename, path_info): - """Extract Python module name and type from file path. - - Args: - filename: Path to the Python file - path_info: Dictionary from get_python_path_info() - - Returns: - tuple: (module_name, module_type) where module_type is one of: - 'stdlib', 'site-packages', 'project', or 'other' - """ - if not filename: - return ('unknown', 'other') - - try: - file_path = Path(filename) - except (ValueError, OSError): - return (str(filename), 'other') - - # Check if it's in stdlib - if path_info['stdlib'] and _is_subpath(file_path, path_info['stdlib']): - try: - rel_path = file_path.relative_to(path_info['stdlib']) - return (_path_to_module(rel_path), 'stdlib') - except ValueError: - pass - - # Check site-packages - for site_pkg in path_info['site_packages']: - if _is_subpath(file_path, site_pkg): - try: - rel_path = file_path.relative_to(site_pkg) - return (_path_to_module(rel_path), 'site-packages') - except ValueError: - continue - - # Check other sys.path entries (project files) - if not str(file_path).startswith(('<', '[')): # Skip special files - for path_entry in path_info['sys_path']: - if _is_subpath(file_path, path_entry): - try: - rel_path = file_path.relative_to(path_entry) - return (_path_to_module(rel_path), 'project') - except ValueError: - continue - - # Fallback: just use the filename - return (_path_to_module(file_path), 'other') - - -def _is_subpath(file_path, parent_path): - try: - file_path.relative_to(parent_path) - return True - except (ValueError, OSError): - return False - - -def _path_to_module(path): - if isinstance(path, str): - path = Path(path) - - # Remove .py extension - if path.suffix == '.py': - path = path.with_suffix('') - - # Convert path separators to dots - parts = path.parts - - # Handle __init__ files - they represent the package itself - if parts and parts[-1] == '__init__': - parts = parts[:-1] - - return '.'.join(parts) if parts else path.stem - - # ============================================================================ # Helper Classes # ============================================================================ diff --git a/Lib/profiling/sampling/module_utils.py b/Lib/profiling/sampling/module_utils.py new file mode 100644 index 00000000000000..6d838d411902af --- /dev/null +++ b/Lib/profiling/sampling/module_utils.py @@ -0,0 +1,122 @@ +"""Utilities for extracting module names from file paths.""" + +import os +import site +import sys +from pathlib import Path + + +def get_python_path_info(): + """Get information about Python's search paths. + + Returns: + dict: Dictionary containing stdlib path, site-packages paths, and sys.path entries. + """ + info = { + 'stdlib': None, + 'site_packages': [], + 'sys_path': [] + } + + # Get standard library path from os module location + try: + if hasattr(os, '__file__') and os.__file__: + info['stdlib'] = Path(os.__file__).parent + except (AttributeError, OSError): + pass # Silently continue if we can't determine stdlib path + + # Get site-packages directories + site_packages = [] + try: + site_packages.extend(Path(p) for p in site.getsitepackages()) + except (AttributeError, OSError): + pass # Continue without site packages if unavailable + + # Get user site-packages + try: + user_site = site.getusersitepackages() + if user_site and Path(user_site).exists(): + site_packages.append(Path(user_site)) + except (AttributeError, OSError): + pass # Continue without user site packages + + info['site_packages'] = site_packages + info['sys_path'] = [Path(p) for p in sys.path if p] + + return info + + +def extract_module_name(filename, path_info): + """Extract Python module name and type from file path. + + Args: + filename: Path to the Python file + path_info: Dictionary from get_python_path_info() + + Returns: + tuple: (module_name, module_type) where module_type is one of: + 'stdlib', 'site-packages', 'project', or 'other' + """ + if not filename: + return ('unknown', 'other') + + try: + file_path = Path(filename) + except (ValueError, OSError): + return (str(filename), 'other') + + # Check if it's in stdlib + if path_info['stdlib'] and _is_subpath(file_path, path_info['stdlib']): + try: + rel_path = file_path.relative_to(path_info['stdlib']) + return (_path_to_module(rel_path), 'stdlib') + except ValueError: + pass + + # Check site-packages + for site_pkg in path_info['site_packages']: + if _is_subpath(file_path, site_pkg): + try: + rel_path = file_path.relative_to(site_pkg) + return (_path_to_module(rel_path), 'site-packages') + except ValueError: + continue + + # Check other sys.path entries (project files) + if not str(file_path).startswith(('<', '[')): # Skip special files + for path_entry in path_info['sys_path']: + if _is_subpath(file_path, path_entry): + try: + rel_path = file_path.relative_to(path_entry) + return (_path_to_module(rel_path), 'project') + except ValueError: + continue + + # Fallback: just use the filename + return (_path_to_module(file_path), 'other') + + +def _is_subpath(file_path, parent_path): + try: + file_path.relative_to(parent_path) + return True + except (ValueError, OSError): + return False + + +def _path_to_module(path): + if isinstance(path, str): + path = Path(path) + + # Remove .py extension + if path.suffix == '.py': + path = path.with_suffix('') + + # Convert path separators to dots + parts = path.parts + + # Handle __init__ files - they represent the package itself + if parts and parts[-1] == '__init__': + parts = parts[:-1] + + return '.'.join(parts) if parts else path.stem From 75e120b2a39c81c03b5573f6b1a4fd9cca52fb17 Mon Sep 17 00:00:00 2001 From: Ivona Stojanovic Date: Mon, 16 Mar 2026 19:32:05 +0000 Subject: [PATCH 2/8] Show module names instead of file paths in flamegraph MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Display module names instead of full file paths (/home/user/project/pkg/mod.py → pkg.mod) in flamegraph for readability. --- .../sampling/_flamegraph_assets/flamegraph.js | 39 +++++++++++++------ Lib/profiling/sampling/stack_collector.py | 29 +++++++++----- .../test_sampling_profiler/test_collectors.py | 4 +- 3 files changed, 48 insertions(+), 24 deletions(-) diff --git a/Lib/profiling/sampling/_flamegraph_assets/flamegraph.js b/Lib/profiling/sampling/_flamegraph_assets/flamegraph.js index d7a8890d4a1ad9..e5262469daca04 100644 --- a/Lib/profiling/sampling/_flamegraph_assets/flamegraph.js +++ b/Lib/profiling/sampling/_flamegraph_assets/flamegraph.js @@ -64,6 +64,9 @@ function resolveStringIndices(node, table) { if (typeof resolved.funcname === 'number') { resolved.funcname = resolveString(resolved.funcname, table); } + if (typeof resolved.module_name === 'number') { + resolved.module_name = resolveString(resolved.module_name); + } if (Array.isArray(resolved.source)) { resolved.source = resolved.source.map(index => @@ -78,6 +81,11 @@ function resolveStringIndices(node, table) { return resolved; } +// Escape HTML special characters +function escapeHtml(str) { + return str.replace(/&/g, "&").replace(//g, ">"); +} + function selectFlamegraphData() { const baseData = isShowingElided ? elidedFlamegraphData : normalData; @@ -228,6 +236,7 @@ function setupLogos() { function updateStatusBar(nodeData, rootValue) { const funcname = resolveString(nodeData.funcname) || resolveString(nodeData.name) || "--"; const filename = resolveString(nodeData.filename) || ""; + const moduleName = resolveString(nodeData.module_name) || ""; const lineno = nodeData.lineno; const timeMs = (nodeData.value / 1000).toFixed(2); const percent = rootValue > 0 ? ((nodeData.value / rootValue) * 100).toFixed(1) : "0.0"; @@ -249,8 +258,7 @@ function updateStatusBar(nodeData, rootValue) { const fileEl = document.getElementById('status-file'); if (fileEl && filename && filename !== "~") { - const basename = filename.split('/').pop(); - fileEl.textContent = lineno ? `${basename}:${lineno}` : basename; + fileEl.textContent = lineno ? `${moduleName}:${lineno}` : moduleName; } const funcEl = document.getElementById('status-func'); @@ -301,6 +309,7 @@ function createPythonTooltip(data) { const funcname = resolveString(d.data.funcname) || resolveString(d.data.name); const filename = resolveString(d.data.filename) || ""; + const moduleName = escapeHtml(resolveString(d.data.module_name) || ""); const isSpecialFrame = filename === "~"; // Build source section @@ -309,7 +318,7 @@ function createPythonTooltip(data) { const sourceLines = source .map((line) => { const isCurrent = line.startsWith("→"); - const escaped = line.replace(/&/g, "&").replace(//g, ">"); + const escaped = escapeHtml(line); return `
${escaped}
`; }) .join(""); @@ -369,7 +378,7 @@ function createPythonTooltip(data) { } const fileLocationHTML = isSpecialFrame ? "" : ` -
${filename}${d.data.lineno ? ":" + d.data.lineno : ""}
`; +
${moduleName}${d.data.lineno ? ":" + d.data.lineno : ""}
`; // Differential stats section let diffSection = ""; @@ -628,24 +637,24 @@ function updateSearchHighlight(searchTerm, searchInput) { const name = resolveString(d.data.name) || ""; const funcname = resolveString(d.data.funcname) || ""; const filename = resolveString(d.data.filename) || ""; + const moduleName = resolveString(d.data.module_name) || ""; const lineno = d.data.lineno; const term = searchTerm.toLowerCase(); - // Check if search term looks like file:line pattern + // Check if search term looks like module:line pattern const fileLineMatch = term.match(/^(.+):(\d+)$/); let matches = false; if (fileLineMatch) { - // Exact file:line matching const searchFile = fileLineMatch[1]; const searchLine = parseInt(fileLineMatch[2], 10); - const basename = filename.split('/').pop().toLowerCase(); - matches = basename.includes(searchFile) && lineno === searchLine; + matches = moduleName.toLowerCase().includes(searchFile) && lineno === searchLine; } else { // Regular substring search matches = name.toLowerCase().includes(term) || funcname.toLowerCase().includes(term) || + moduleName.toLowerCase().includes(term) || filename.toLowerCase().includes(term); } @@ -1047,6 +1056,7 @@ function populateStats(data) { let filename = resolveString(node.filename); let funcname = resolveString(node.funcname); + let moduleName = resolveString(node.module_name); if (!filename || !funcname) { const nameStr = resolveString(node.name); @@ -1061,6 +1071,7 @@ function populateStats(data) { filename = filename || 'unknown'; funcname = funcname || 'unknown'; + moduleName = moduleName || 'unknown'; if (filename !== 'unknown' && funcname !== 'unknown' && node.value > 0) { let childrenValue = 0; @@ -1077,12 +1088,14 @@ function populateStats(data) { existing.directPercent = (existing.directSamples / totalSamples) * 100; if (directSamples > existing.maxSingleSamples) { existing.filename = filename; + existing.module_name = moduleName; existing.lineno = node.lineno || '?'; existing.maxSingleSamples = directSamples; } } else { functionMap.set(funcKey, { filename: filename, + module_name: moduleName, lineno: node.lineno || '?', funcname: funcname, directSamples, @@ -1117,6 +1130,7 @@ function populateStats(data) { const h = hotSpots[i]; const filename = h.filename || 'unknown'; const lineno = h.lineno ?? '?'; + const moduleName = h.module_name || 'unknown'; const isSpecialFrame = filename === '~' && (lineno === 0 || lineno === '?'); let funcDisplay = h.funcname || 'unknown'; @@ -1127,8 +1141,7 @@ function populateStats(data) { if (isSpecialFrame) { fileEl.textContent = '--'; } else { - const basename = filename !== 'unknown' ? filename.split('/').pop() : 'unknown'; - fileEl.textContent = `${basename}:${lineno}`; + fileEl.textContent = `${moduleName}:${lineno}`; } } if (percentEl) percentEl.textContent = `${h.directPercent.toFixed(1)}%`; @@ -1144,8 +1157,9 @@ function populateStats(data) { if (card) { if (i < hotSpots.length && hotSpots[i]) { const h = hotSpots[i]; - const basename = h.filename !== 'unknown' ? h.filename.split('/').pop() : ''; - const searchTerm = basename && h.lineno !== '?' ? `${basename}:${h.lineno}` : h.funcname; + const moduleName = h.module_name || 'unknown'; + const hasValidLocation = moduleName !== 'unknown' && h.lineno !== '?'; + const searchTerm = hasValidLocation ? `${moduleName}:${h.lineno}` : h.funcname; card.dataset.searchterm = searchTerm; card.onclick = () => searchForHotspot(searchTerm); card.style.cursor = 'pointer'; @@ -1281,6 +1295,7 @@ function accumulateInvertedNode(parent, stackFrame, leaf, isDifferential) { self: 0, children: {}, filename: stackFrame.filename, + module_name: stackFrame.module_name, lineno: stackFrame.lineno, funcname: stackFrame.funcname, source: stackFrame.source, diff --git a/Lib/profiling/sampling/stack_collector.py b/Lib/profiling/sampling/stack_collector.py index 461ce95a25874b..cd38d59bd3ec66 100644 --- a/Lib/profiling/sampling/stack_collector.py +++ b/Lib/profiling/sampling/stack_collector.py @@ -12,6 +12,7 @@ from .collector import Collector, extract_lineno from .opcode_utils import get_opcode_mapping from .string_table import StringTable +from .module_utils import extract_module_name, get_python_path_info class StackTraceCollector(Collector): @@ -72,6 +73,7 @@ def __init__(self, *args, **kwargs): self._sample_count = 0 # Track actual number of samples (not thread traces) self._func_intern = {} self._string_table = StringTable() + self._module_cache = {} self._all_threads = set() # Thread status statistics (similar to LiveStatsCollector) @@ -168,19 +170,21 @@ def export(self, filename): @staticmethod @functools.lru_cache(maxsize=None) - def _format_function_name(func): + def _format_function_name(func, module_name): filename, lineno, funcname = func # Special frames like and should not show file:line if filename == "~" and lineno == 0: return funcname - if len(filename) > 50: - parts = filename.split("/") - if len(parts) > 2: - filename = f".../{'/'.join(parts[-2:])}" + return f"{funcname} ({module_name}:{lineno})" - return f"{funcname} ({filename}:{lineno})" + def _get_module_name(self, filename, path_info): + module_name = self._module_cache.get(filename) + if module_name is None: + module_name, _ = extract_module_name(filename, path_info) + self._module_cache[filename] = module_name + return module_name def _convert_to_flamegraph_format(self): if self._total_samples == 0: @@ -192,7 +196,7 @@ def _convert_to_flamegraph_format(self): "strings": self._string_table.get_strings() } - def convert_children(children, min_samples): + def convert_children(children, min_samples, path_info): out = [] for func, node in children.items(): samples = node["samples"] @@ -202,7 +206,10 @@ def convert_children(children, min_samples): # Intern all string components for maximum efficiency filename_idx = self._string_table.intern(func[0]) funcname_idx = self._string_table.intern(func[2]) - name_idx = self._string_table.intern(self._format_function_name(func)) + module_name = self._get_module_name(func[0], path_info) + + module_name_idx = self._string_table.intern(module_name) + name_idx = self._string_table.intern(self._format_function_name(func, module_name)) child_entry = { "name": name_idx, @@ -210,6 +217,7 @@ def convert_children(children, min_samples): "self": node.get("self", 0), "children": [], "filename": filename_idx, + "module_name": module_name_idx, "lineno": func[1], "funcname": funcname_idx, "threads": sorted(list(node.get("threads", set()))), @@ -228,7 +236,7 @@ def convert_children(children, min_samples): # Recurse child_entry["children"] = convert_children( - node["children"], min_samples + node["children"], min_samples, path_info ) out.append(child_entry) @@ -239,8 +247,9 @@ def convert_children(children, min_samples): # Filter out very small functions (less than 0.1% of total samples) total_samples = self._total_samples min_samples = max(1, int(total_samples * 0.001)) + path_info = get_python_path_info() - root_children = convert_children(self._root["children"], min_samples) + root_children = convert_children(self._root["children"], min_samples, path_info) if not root_children: return { "name": self._string_table.intern("No significant data"), 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 503430ddf02163..6a61a87a0c4fb8 100644 --- a/Lib/test/test_profiling/test_sampling_profiler/test_collectors.py +++ b/Lib/test/test_profiling/test_sampling_profiler/test_collectors.py @@ -435,12 +435,12 @@ def test_flamegraph_collector_basic(self): strings = data.get("strings", []) name = resolve_name(data, strings) self.assertTrue(name.startswith("Program Root: ")) - self.assertIn("func2 (file.py:20)", name) + self.assertIn("func2 (file:20)", name) self.assertEqual(data["self"], 0) # non-leaf: no self time children = data.get("children", []) self.assertEqual(len(children), 1) child = children[0] - self.assertIn("func1 (file.py:10)", resolve_name(child, strings)) + self.assertIn("func1 (file:10)", resolve_name(child, strings)) self.assertEqual(child["value"], 1) self.assertEqual(child["self"], 1) # leaf: all time is self From 44599a475d6a512ac875dddda66242f37dcaacc3 Mon Sep 17 00:00:00 2001 From: Ivona Stojanovic Date: Fri, 20 Mar 2026 19:22:52 +0000 Subject: [PATCH 3/8] Add 'Full Paths/Module Names' toggle for flamegraph display Users can now switch between module names and file paths using the toggle in the View Mode sidebar. Module names are concise, while file paths help locate the exact source file, both are useful depending on the debugging context. --- .../_flamegraph_assets/flamegraph.css | 9 ++- .../sampling/_flamegraph_assets/flamegraph.js | 58 +++++++++++++++---- .../flamegraph_template.html | 6 ++ Lib/profiling/sampling/stack_collector.py | 22 ++++++- 4 files changed, 82 insertions(+), 13 deletions(-) diff --git a/Lib/profiling/sampling/_flamegraph_assets/flamegraph.css b/Lib/profiling/sampling/_flamegraph_assets/flamegraph.css index c4da169d15de88..c93ee1e9dd470e 100644 --- a/Lib/profiling/sampling/_flamegraph_assets/flamegraph.css +++ b/Lib/profiling/sampling/_flamegraph_assets/flamegraph.css @@ -315,6 +315,12 @@ body.resizing-sidebar { } /* View Mode Section */ +.view-mode-section { + display: flex; + flex-direction: column; + gap: 8px; +} + .view-mode-section .section-content { display: flex; flex-direction: column; @@ -1067,7 +1073,8 @@ body.resizing-sidebar { -------------------------------------------------------------------------- */ #toggle-invert .toggle-track.on, -#toggle-elided .toggle-track.on { +#toggle-elided .toggle-track.on, +#toggle-path-display .toggle-track.on { background: #8e44ad; border-color: #8e44ad; box-shadow: 0 0 8px rgba(142, 68, 173, 0.3); diff --git a/Lib/profiling/sampling/_flamegraph_assets/flamegraph.js b/Lib/profiling/sampling/_flamegraph_assets/flamegraph.js index e5262469daca04..7dd4030dbc1a23 100644 --- a/Lib/profiling/sampling/_flamegraph_assets/flamegraph.js +++ b/Lib/profiling/sampling/_flamegraph_assets/flamegraph.js @@ -6,6 +6,7 @@ let normalData = null; let invertedData = null; let currentThreadFilter = 'all'; let isInverted = false; +let useModuleNames = true; // Heat colors are now defined in CSS variables (--heat-1 through --heat-8) // and automatically switch with theme changes - no JS color arrays needed! @@ -67,6 +68,9 @@ function resolveStringIndices(node, table) { if (typeof resolved.module_name === 'number') { resolved.module_name = resolveString(resolved.module_name); } + if (typeof resolved.name_module === 'number') { + resolved.name_module = resolveString(resolved.name_module); + } if (Array.isArray(resolved.source)) { resolved.source = resolved.source.map(index => @@ -86,6 +90,14 @@ function escapeHtml(str) { return str.replace(/&/g, "&").replace(//g, ">"); } +// Get display path based on user preference (module name or basename) +function getDisplayName(moduleName, filename) { + if (useModuleNames) { + return moduleName || filename; + } + return filename ? filename.split('/').pop() : filename; +} + function selectFlamegraphData() { const baseData = isShowingElided ? elidedFlamegraphData : normalData; @@ -258,7 +270,8 @@ function updateStatusBar(nodeData, rootValue) { const fileEl = document.getElementById('status-file'); if (fileEl && filename && filename !== "~") { - fileEl.textContent = lineno ? `${moduleName}:${lineno}` : moduleName; + const displayName = getDisplayName(moduleName, filename); + fileEl.textContent = lineno ? `${displayName}:${lineno}` : displayName; } const funcEl = document.getElementById('status-func'); @@ -309,7 +322,8 @@ function createPythonTooltip(data) { const funcname = resolveString(d.data.funcname) || resolveString(d.data.name); const filename = resolveString(d.data.filename) || ""; - const moduleName = escapeHtml(resolveString(d.data.module_name) || ""); + const moduleName = resolveString(d.data.module_name) || ""; + const displayName = escapeHtml(useModuleNames ? (moduleName || filename) : filename); const isSpecialFrame = filename === "~"; // Build source section @@ -378,7 +392,7 @@ function createPythonTooltip(data) { } const fileLocationHTML = isSpecialFrame ? "" : ` -
${moduleName}${d.data.lineno ? ":" + d.data.lineno : ""}
`; +
${displayName}${d.data.lineno ? ":" + d.data.lineno : ""}
`; // Differential stats section let diffSection = ""; @@ -595,6 +609,7 @@ function createFlamegraph(tooltip, rootValue, data) { .minFrameSize(1) .tooltip(tooltip) .inverted(true) + .getName(d => resolveString(useModuleNames ? d.data.name_module : d.data.name) || resolveString(d.data.name) || '') .setColorMapper(function (d) { if (d.depth === 0) return 'transparent'; @@ -638,24 +653,24 @@ function updateSearchHighlight(searchTerm, searchInput) { const funcname = resolveString(d.data.funcname) || ""; const filename = resolveString(d.data.filename) || ""; const moduleName = resolveString(d.data.module_name) || ""; + const displayName = getDisplayName(moduleName, filename); const lineno = d.data.lineno; const term = searchTerm.toLowerCase(); - // Check if search term looks like module:line pattern + // Check if search term looks like path:line pattern const fileLineMatch = term.match(/^(.+):(\d+)$/); let matches = false; if (fileLineMatch) { const searchFile = fileLineMatch[1]; const searchLine = parseInt(fileLineMatch[2], 10); - matches = moduleName.toLowerCase().includes(searchFile) && lineno === searchLine; + matches = displayName.toLowerCase().includes(searchFile) && lineno === searchLine; } else { // Regular substring search matches = name.toLowerCase().includes(term) || funcname.toLowerCase().includes(term) || - moduleName.toLowerCase().includes(term) || - filename.toLowerCase().includes(term); + displayName.toLowerCase().includes(term); } if (matches) { @@ -1141,7 +1156,8 @@ function populateStats(data) { if (isSpecialFrame) { fileEl.textContent = '--'; } else { - fileEl.textContent = `${moduleName}:${lineno}`; + const displayName = getDisplayName(moduleName, filename); + fileEl.textContent = `${displayName}:${lineno}`; } } if (percentEl) percentEl.textContent = `${h.directPercent.toFixed(1)}%`; @@ -1158,8 +1174,10 @@ function populateStats(data) { if (i < hotSpots.length && hotSpots[i]) { const h = hotSpots[i]; const moduleName = h.module_name || 'unknown'; - const hasValidLocation = moduleName !== 'unknown' && h.lineno !== '?'; - const searchTerm = hasValidLocation ? `${moduleName}:${h.lineno}` : h.funcname; + const filename = h.filename || 'unknown'; + const displayName = getDisplayName(moduleName, filename); + const hasValidLocation = displayName !== 'unknown' && h.lineno !== '?'; + const searchTerm = hasValidLocation ? `${displayName}:${h.lineno}` : h.funcname; card.dataset.searchterm = searchTerm; card.onclick = () => searchForHotspot(searchTerm); card.style.cursor = 'pointer'; @@ -1291,6 +1309,7 @@ function accumulateInvertedNode(parent, stackFrame, leaf, isDifferential) { if (!parent.children[key]) { const newNode = { name: stackFrame.name, + name_module: stackFrame.name_module, value: 0, self: 0, children: {}, @@ -1390,6 +1409,7 @@ function generateInvertedFlamegraph(data) { const invertedRoot = { name: data.name, + name_module: data.name_module, value: data.value, children: {}, stats: data.stats, @@ -1414,6 +1434,19 @@ function toggleInvert() { updateFlamegraphView(); } +function togglePathDisplay() { + useModuleNames = !useModuleNames; + updateToggleUI('toggle-path-display', useModuleNames); + const dataToRender = isInverted ? invertedData : normalData; + const filteredData = currentThreadFilter !== 'all' + ? filterDataByThread(dataToRender, parseInt(currentThreadFilter)) + : dataToRender; + + const tooltip = createPythonTooltip(filteredData); + const chart = createFlamegraph(tooltip, filteredData.value); + renderFlamegraph(chart, filteredData); +} + // ============================================================================ // Initialization // ============================================================================ @@ -1461,6 +1494,11 @@ function initFlamegraph() { if (toggleInvertBtn) { toggleInvertBtn.addEventListener('click', toggleInvert); } + + const togglePathDisplayBtn = document.getElementById('toggle-path-display'); + if (togglePathDisplayBtn) { + togglePathDisplayBtn.addEventListener('click', togglePathDisplay); + } } // Keyboard shortcut: Enter/Space activates toggle switches diff --git a/Lib/profiling/sampling/_flamegraph_assets/flamegraph_template.html b/Lib/profiling/sampling/_flamegraph_assets/flamegraph_template.html index 9a77178aeff7ec..03f09447d0db65 100644 --- a/Lib/profiling/sampling/_flamegraph_assets/flamegraph_template.html +++ b/Lib/profiling/sampling/_flamegraph_assets/flamegraph_template.html @@ -117,6 +117,12 @@

View Mode

Elided +
+ Full Paths +
+ Module Names +
+
Flamegraph
diff --git a/Lib/profiling/sampling/stack_collector.py b/Lib/profiling/sampling/stack_collector.py index cd38d59bd3ec66..5cd44682b2f1ab 100644 --- a/Lib/profiling/sampling/stack_collector.py +++ b/Lib/profiling/sampling/stack_collector.py @@ -170,7 +170,23 @@ def export(self, filename): @staticmethod @functools.lru_cache(maxsize=None) - def _format_function_name(func, module_name): + def _format_function_name(func): + filename, lineno, funcname = func + + # Special frames like and should not show file:line + if filename == "~" and lineno == 0: + return funcname + + if len(filename) > 50: + parts = filename.split("/") + if len(parts) > 2: + filename = f".../{'/'.join(parts[-2:])}" + + return f"{funcname} ({filename}:{lineno})" + + @staticmethod + @functools.lru_cache(maxsize=None) + def _format_module_name(func, module_name): filename, lineno, funcname = func # Special frames like and should not show file:line @@ -209,10 +225,12 @@ def convert_children(children, min_samples, path_info): module_name = self._get_module_name(func[0], path_info) module_name_idx = self._string_table.intern(module_name) - name_idx = self._string_table.intern(self._format_function_name(func, module_name)) + name_idx = self._string_table.intern(self._format_function_name(func)) + name_module_idx = self._string_table.intern(self._format_module_name(func, module_name)) child_entry = { "name": name_idx, + "name_module": name_module_idx, "value": samples, "self": node.get("self", 0), "children": [], From 9b17680849a4e933a8b5e2a85f247f254cfd7d07 Mon Sep 17 00:00:00 2001 From: Ivona Stojanovic Date: Tue, 31 Mar 2026 15:19:18 +0100 Subject: [PATCH 4/8] Rename module_name/name_module to module/label in flamegraph data --- .../sampling/_flamegraph_assets/flamegraph.js | 43 ++++++++----------- Lib/profiling/sampling/stack_collector.py | 8 ++-- 2 files changed, 22 insertions(+), 29 deletions(-) diff --git a/Lib/profiling/sampling/_flamegraph_assets/flamegraph.js b/Lib/profiling/sampling/_flamegraph_assets/flamegraph.js index 7dd4030dbc1a23..bb77cd5e7e4a53 100644 --- a/Lib/profiling/sampling/_flamegraph_assets/flamegraph.js +++ b/Lib/profiling/sampling/_flamegraph_assets/flamegraph.js @@ -65,11 +65,11 @@ function resolveStringIndices(node, table) { if (typeof resolved.funcname === 'number') { resolved.funcname = resolveString(resolved.funcname, table); } - if (typeof resolved.module_name === 'number') { - resolved.module_name = resolveString(resolved.module_name); + if (typeof resolved.module === 'number') { + resolved.module = resolveString(resolved.module, table); } - if (typeof resolved.name_module === 'number') { - resolved.name_module = resolveString(resolved.name_module); + if (typeof resolved.label === 'number') { + resolved.label = resolveString(resolved.label, table); } if (Array.isArray(resolved.source)) { @@ -90,7 +90,7 @@ function escapeHtml(str) { return str.replace(/&/g, "&").replace(//g, ">"); } -// Get display path based on user preference (module name or basename) +// Get display path based on user preference (module or full path) function getDisplayName(moduleName, filename) { if (useModuleNames) { return moduleName || filename; @@ -248,7 +248,7 @@ function setupLogos() { function updateStatusBar(nodeData, rootValue) { const funcname = resolveString(nodeData.funcname) || resolveString(nodeData.name) || "--"; const filename = resolveString(nodeData.filename) || ""; - const moduleName = resolveString(nodeData.module_name) || ""; + const moduleName = resolveString(nodeData.module) || ""; const lineno = nodeData.lineno; const timeMs = (nodeData.value / 1000).toFixed(2); const percent = rootValue > 0 ? ((nodeData.value / rootValue) * 100).toFixed(1) : "0.0"; @@ -322,7 +322,7 @@ function createPythonTooltip(data) { const funcname = resolveString(d.data.funcname) || resolveString(d.data.name); const filename = resolveString(d.data.filename) || ""; - const moduleName = resolveString(d.data.module_name) || ""; + const moduleName = resolveString(d.data.module) || ""; const displayName = escapeHtml(useModuleNames ? (moduleName || filename) : filename); const isSpecialFrame = filename === "~"; @@ -609,7 +609,7 @@ function createFlamegraph(tooltip, rootValue, data) { .minFrameSize(1) .tooltip(tooltip) .inverted(true) - .getName(d => resolveString(useModuleNames ? d.data.name_module : d.data.name) || resolveString(d.data.name) || '') + .getName(d => resolveString(useModuleNames ? d.data.label : d.data.name) || resolveString(d.data.name) || '') .setColorMapper(function (d) { if (d.depth === 0) return 'transparent'; @@ -652,7 +652,7 @@ function updateSearchHighlight(searchTerm, searchInput) { const name = resolveString(d.data.name) || ""; const funcname = resolveString(d.data.funcname) || ""; const filename = resolveString(d.data.filename) || ""; - const moduleName = resolveString(d.data.module_name) || ""; + const moduleName = resolveString(d.data.module) || ""; const displayName = getDisplayName(moduleName, filename); const lineno = d.data.lineno; const term = searchTerm.toLowerCase(); @@ -1071,7 +1071,7 @@ function populateStats(data) { let filename = resolveString(node.filename); let funcname = resolveString(node.funcname); - let moduleName = resolveString(node.module_name); + let moduleName = resolveString(node.module); if (!filename || !funcname) { const nameStr = resolveString(node.name); @@ -1103,14 +1103,14 @@ function populateStats(data) { existing.directPercent = (existing.directSamples / totalSamples) * 100; if (directSamples > existing.maxSingleSamples) { existing.filename = filename; - existing.module_name = moduleName; + existing.module = moduleName; existing.lineno = node.lineno || '?'; existing.maxSingleSamples = directSamples; } } else { functionMap.set(funcKey, { filename: filename, - module_name: moduleName, + module: moduleName, lineno: node.lineno || '?', funcname: funcname, directSamples, @@ -1145,7 +1145,7 @@ function populateStats(data) { const h = hotSpots[i]; const filename = h.filename || 'unknown'; const lineno = h.lineno ?? '?'; - const moduleName = h.module_name || 'unknown'; + const moduleName = h.module || 'unknown'; const isSpecialFrame = filename === '~' && (lineno === 0 || lineno === '?'); let funcDisplay = h.funcname || 'unknown'; @@ -1173,7 +1173,7 @@ function populateStats(data) { if (card) { if (i < hotSpots.length && hotSpots[i]) { const h = hotSpots[i]; - const moduleName = h.module_name || 'unknown'; + const moduleName = h.module || 'unknown'; const filename = h.filename || 'unknown'; const displayName = getDisplayName(moduleName, filename); const hasValidLocation = displayName !== 'unknown' && h.lineno !== '?'; @@ -1309,12 +1309,12 @@ function accumulateInvertedNode(parent, stackFrame, leaf, isDifferential) { if (!parent.children[key]) { const newNode = { name: stackFrame.name, - name_module: stackFrame.name_module, + label: stackFrame.label, value: 0, self: 0, children: {}, filename: stackFrame.filename, - module_name: stackFrame.module_name, + module: stackFrame.module, lineno: stackFrame.lineno, funcname: stackFrame.funcname, source: stackFrame.source, @@ -1409,7 +1409,7 @@ function generateInvertedFlamegraph(data) { const invertedRoot = { name: data.name, - name_module: data.name_module, + label: data.label, value: data.value, children: {}, stats: data.stats, @@ -1437,14 +1437,7 @@ function toggleInvert() { function togglePathDisplay() { useModuleNames = !useModuleNames; updateToggleUI('toggle-path-display', useModuleNames); - const dataToRender = isInverted ? invertedData : normalData; - const filteredData = currentThreadFilter !== 'all' - ? filterDataByThread(dataToRender, parseInt(currentThreadFilter)) - : dataToRender; - - const tooltip = createPythonTooltip(filteredData); - const chart = createFlamegraph(tooltip, filteredData.value); - renderFlamegraph(chart, filteredData); + updateFlamegraphView(); } // ============================================================================ diff --git a/Lib/profiling/sampling/stack_collector.py b/Lib/profiling/sampling/stack_collector.py index 5cd44682b2f1ab..12ddec9024f183 100644 --- a/Lib/profiling/sampling/stack_collector.py +++ b/Lib/profiling/sampling/stack_collector.py @@ -224,18 +224,18 @@ def convert_children(children, min_samples, path_info): funcname_idx = self._string_table.intern(func[2]) module_name = self._get_module_name(func[0], path_info) - module_name_idx = self._string_table.intern(module_name) + module_idx = self._string_table.intern(module_name) name_idx = self._string_table.intern(self._format_function_name(func)) - name_module_idx = self._string_table.intern(self._format_module_name(func, module_name)) + label_idx = self._string_table.intern(self._format_module_name(func, module_name)) child_entry = { "name": name_idx, - "name_module": name_module_idx, + "label": label_idx, "value": samples, "self": node.get("self", 0), "children": [], "filename": filename_idx, - "module_name": module_name_idx, + "module": module_idx, "lineno": func[1], "funcname": funcname_idx, "threads": sorted(list(node.get("threads", set()))), From ee3aeaa6a8d05dda1bc4e51702512c74de4fc492 Mon Sep 17 00:00:00 2001 From: Ivona Stojanovic Date: Tue, 7 Apr 2026 15:26:37 +0100 Subject: [PATCH 5/8] Fix module name for files not on sys.path _path_to_module included the root separator (/ or drive letter) when converting absolute paths to dotted module names, producing /.tmp.project.main instead of tmp.project.main. --- Lib/profiling/sampling/module_utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Lib/profiling/sampling/module_utils.py b/Lib/profiling/sampling/module_utils.py index 6d838d411902af..5ca74f4081aed0 100644 --- a/Lib/profiling/sampling/module_utils.py +++ b/Lib/profiling/sampling/module_utils.py @@ -112,8 +112,8 @@ def _path_to_module(path): if path.suffix == '.py': path = path.with_suffix('') - # Convert path separators to dots - parts = path.parts + # Convert path separators to dots, stripping root/drive (e.g. "/" or "C:\") + parts = [p for p in path.parts if p != path.root and p != path.drive] # Handle __init__ files - they represent the package itself if parts and parts[-1] == '__init__': From eee3ce46d8febdf70a2eadc56eeae62d3773955c Mon Sep 17 00:00:00 2001 From: Ivona Stojanovic Date: Tue, 7 Apr 2026 15:36:08 +0100 Subject: [PATCH 6/8] Simplify module path resolution in module_utils Replace _is_subpath helper with Path.is_relative_to() (available since 3.9), removing the double relative_to() call. --- Lib/profiling/sampling/module_utils.py | 32 +++++--------------------- 1 file changed, 6 insertions(+), 26 deletions(-) diff --git a/Lib/profiling/sampling/module_utils.py b/Lib/profiling/sampling/module_utils.py index 5ca74f4081aed0..dfde2b28ab29a4 100644 --- a/Lib/profiling/sampling/module_utils.py +++ b/Lib/profiling/sampling/module_utils.py @@ -66,44 +66,24 @@ def extract_module_name(filename, path_info): return (str(filename), 'other') # Check if it's in stdlib - if path_info['stdlib'] and _is_subpath(file_path, path_info['stdlib']): - try: - rel_path = file_path.relative_to(path_info['stdlib']) - return (_path_to_module(rel_path), 'stdlib') - except ValueError: - pass + if path_info['stdlib'] and file_path.is_relative_to(path_info['stdlib']): + return (_path_to_module(file_path.relative_to(path_info['stdlib'])), 'stdlib') # Check site-packages for site_pkg in path_info['site_packages']: - if _is_subpath(file_path, site_pkg): - try: - rel_path = file_path.relative_to(site_pkg) - return (_path_to_module(rel_path), 'site-packages') - except ValueError: - continue + if file_path.is_relative_to(site_pkg): + return (_path_to_module(file_path.relative_to(site_pkg)), 'site-packages') # Check other sys.path entries (project files) if not str(file_path).startswith(('<', '[')): # Skip special files for path_entry in path_info['sys_path']: - if _is_subpath(file_path, path_entry): - try: - rel_path = file_path.relative_to(path_entry) - return (_path_to_module(rel_path), 'project') - except ValueError: - continue + if file_path.is_relative_to(path_entry): + return (_path_to_module(file_path.relative_to(path_entry)), 'project') # Fallback: just use the filename return (_path_to_module(file_path), 'other') -def _is_subpath(file_path, parent_path): - try: - file_path.relative_to(parent_path) - return True - except (ValueError, OSError): - return False - - def _path_to_module(path): if isinstance(path, str): path = Path(path) From 26b4f1864103374100bd0a2b40d0fa978602ebbe Mon Sep 17 00:00:00 2001 From: Ivona Stojanovic Date: Tue, 7 Apr 2026 15:52:33 +0100 Subject: [PATCH 7/8] Add label field to root nodes for module-name toggle --- Lib/profiling/sampling/stack_collector.py | 11 +++++++---- .../test_sampling_profiler/test_collectors.py | 2 ++ 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/Lib/profiling/sampling/stack_collector.py b/Lib/profiling/sampling/stack_collector.py index 12ddec9024f183..04622a8c1e89ef 100644 --- a/Lib/profiling/sampling/stack_collector.py +++ b/Lib/profiling/sampling/stack_collector.py @@ -309,10 +309,11 @@ def convert_children(children, min_samples, path_info): # If we only have one root child, make it the root to avoid redundant level if len(root_children) == 1: main_child = root_children[0] - # Update the name to indicate it's the program root + # Update name and label to indicate it's the program root old_name = self._string_table.get_string(main_child["name"]) - new_name = f"Program Root: {old_name}" - main_child["name"] = self._string_table.intern(new_name) + main_child["name"] = self._string_table.intern(f"Program Root: {old_name}") + old_label = self._string_table.get_string(main_child["label"]) + main_child["label"] = self._string_table.intern(f"Program Root: {old_label}") main_child["stats"] = { **self.stats, "thread_stats": thread_stats, @@ -323,8 +324,10 @@ def convert_children(children, min_samples, path_info): main_child["opcode_mapping"] = opcode_mapping return main_child + program_root_idx = self._string_table.intern("Program Root") return { - "name": self._string_table.intern("Program Root"), + "name": program_root_idx, + "label": program_root_idx, "value": total_samples, "children": root_children, "stats": { 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 6a61a87a0c4fb8..c6eb4cbcdc1930 100644 --- a/Lib/test/test_profiling/test_sampling_profiler/test_collectors.py +++ b/Lib/test/test_profiling/test_sampling_profiler/test_collectors.py @@ -436,6 +436,8 @@ def test_flamegraph_collector_basic(self): name = resolve_name(data, strings) self.assertTrue(name.startswith("Program Root: ")) self.assertIn("func2 (file:20)", name) + label = strings[data["label"]] + self.assertTrue(label.startswith("Program Root: ")) self.assertEqual(data["self"], 0) # non-leaf: no self time children = data.get("children", []) self.assertEqual(len(children), 1) From 36764cb0459d6389c8fb59f5afbe08c2c4b055bb Mon Sep 17 00:00:00 2001 From: Ivona Stojanovic Date: Tue, 7 Apr 2026 16:18:03 +0100 Subject: [PATCH 8/8] Fix display name consistency in flamegraph toggle Rename "Full Paths" to "File Paths", remove basename-only .split('/').pop() so tooltip, hotspots, and search all show the same path in file-paths mode. --- Lib/profiling/sampling/_flamegraph_assets/flamegraph.js | 2 +- .../sampling/_flamegraph_assets/flamegraph_template.html | 2 +- .../test_profiling/test_sampling_profiler/test_collectors.py | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Lib/profiling/sampling/_flamegraph_assets/flamegraph.js b/Lib/profiling/sampling/_flamegraph_assets/flamegraph.js index bb77cd5e7e4a53..395f42688734de 100644 --- a/Lib/profiling/sampling/_flamegraph_assets/flamegraph.js +++ b/Lib/profiling/sampling/_flamegraph_assets/flamegraph.js @@ -95,7 +95,7 @@ function getDisplayName(moduleName, filename) { if (useModuleNames) { return moduleName || filename; } - return filename ? filename.split('/').pop() : filename; + return filename; } function selectFlamegraphData() { diff --git a/Lib/profiling/sampling/_flamegraph_assets/flamegraph_template.html b/Lib/profiling/sampling/_flamegraph_assets/flamegraph_template.html index 03f09447d0db65..f1c5bb0300679a 100644 --- a/Lib/profiling/sampling/_flamegraph_assets/flamegraph_template.html +++ b/Lib/profiling/sampling/_flamegraph_assets/flamegraph_template.html @@ -118,7 +118,7 @@

View Mode

- Full Paths + File Paths
Module Names
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 c6eb4cbcdc1930..240ec8a195c43b 100644 --- a/Lib/test/test_profiling/test_sampling_profiler/test_collectors.py +++ b/Lib/test/test_profiling/test_sampling_profiler/test_collectors.py @@ -435,14 +435,14 @@ def test_flamegraph_collector_basic(self): strings = data.get("strings", []) name = resolve_name(data, strings) self.assertTrue(name.startswith("Program Root: ")) - self.assertIn("func2 (file:20)", name) + self.assertIn("func2 (file.py:20)", name) label = strings[data["label"]] self.assertTrue(label.startswith("Program Root: ")) self.assertEqual(data["self"], 0) # non-leaf: no self time children = data.get("children", []) self.assertEqual(len(children), 1) child = children[0] - self.assertIn("func1 (file:10)", resolve_name(child, strings)) + self.assertIn("func1 (file.py:10)", resolve_name(child, strings)) self.assertEqual(child["value"], 1) self.assertEqual(child["self"], 1) # leaf: all time is self