Add tracing profiler and Python 3.15 offline profiling tools (#9271)

* Add deterministic profiler and canonical Python 3.15 tools

Use the merged operation critical sections with interpreter-bound profiler
entry checks and short payload borrows. Trace owned code references for GC.
Import the canonical tracing and offline profiling output closure via the
repository updater, retaining existing test markers.

Assisted-by: dot:GPT-6.1

* Clarify standalone profiling license cross-reference

Preserve license terms and copyright notices; point to the reproduced
Apache text below instead of CPython's original OpenSSL section above.

Assisted-by: dot:GPT-6.1

* Defer unsupported sampling package from profiler extraction

Keep the deterministic tracing profiler and its canonical tools together.
Defer the entirely new sampling production, tests, assets and their license
as one closure until native record and binary-collector support is ready.
Retain canonical pstats for deterministic empty-profile handling; its
upstream sampled-file sorting issue is not patched here.

Assisted-by: dot:GPT-6.1

* Stage profiling root as a namespace until sampling is available

Omit the whole newly added canonical package initializer rather than
advertise an absent sampling module or hand-edit canonical __all__.
Explicit profiling.tracing and cProfile imports remain available.
The namespace root intentionally lacks canonical metadata/automatic exports.

This addresses test___all__ failures in the prior Linux, macOS and Windows
CI runs. No test assertions, skips or canonical file contents are changed.

Assisted-by: dot:GPT-6.1

* Match canonical static builtin profiler labels and metadata

Normalize the raw builtin receiver instead of its call-argument accessor.
Snapshot static method module metadata at construction, matching CPython,
so explicit module mutation and deletion remain observable. Avoid a
profiler-time fallback that would obscure deleted metadata.

Correct float.__getformat__ to CPython's classmethod calling convention,
including subclass binding, rather than special-casing its profiler label.

Assisted-by: dot:GPT-6.1

* Support collision-safe frozen package containers

Generate empty frozen packages only for source namespace directories with
real frozen descendants. Preserve genuine modules and packages across
frozen-library registrations while retaining real-real replacement order.

Record the container marker in FrozenModule and its library envelope;
legacy module/package encodings remain unchanged. This is closed-tree
frozen packaging, not full PEP 420 namespace merging.

Add structural codec, mock generator, and registration-priority checks.

* Revert frozen package containers before restoring canonical profiling root

The container implementation changed filesystem namespace precedence.
Restore the existing importer and frozen envelope unchanged; the following
offline sampling closure restores the canonical regular package root.

This reverses d5a8049234.

Assisted-by: dot:GPT-6.1

* Restore canonical profiling package with offline sampling codec

Provide native records, status constants and genuine uncompressed binary
reader/writer APIs for canonical offline tools. Keep the unsupported live
process capability absent and preserve existing canonical test gates.
Restore the complete selected sampling production/test/assets closure via
update_lib, including its asyncio frame-schema dependency. The prior frozen
container extension is reverted separately; canonical packages work with
both filesystem and frozen imports without changing namespace semantics.

Assisted-by: dot:GPT-6.1

* Keep platform-dependent C long normalization explicit

Allow only the c_long-to-i32 conversion lint: Windows uses the same type,
while LP64 requires the existing truncation to match CPython normalization.
No runtime behavior or test assertions change.

Assisted-by: dot:GPT-6.1

* Include profiling notices in library and desktop packages

Bundle a byte-identical copy of the root profiling notice in rustpython-pylib
and include the root notice in configured desktop resources. Avoid a symlink
so Windows checkouts preserve the actual notice text. Terms are unchanged.

Assisted-by: dot:GPT-6.1

* Keep profiling asset license notice in pylib crate

* Serve frozen profiling assets through native resource readers

* Preserve canonical profiling asset line endings on checkout

---------

Co-authored-by: youknowdot <dot@youknowone.org>
This commit is contained in:
Jeong, YunWonandyouknowdot authored and GitHub committed 2026-10-22 11:18:03 +09:00
1 parent 6b3f29f055
commit f0b1e82652
80 files changed
+23512 -215

No files matched your search

+17
View File
@@ -71,3 +71,20 @@ Lib/test/certdata/*.0 generated
Lib/test/levenshtein_examples.json generated Lib/test/levenshtein_examples.json generated
Lib/test/test_stable_abi_ctypes.py generated Lib/test/test_stable_abi_ctypes.py generated
Lib/token.py generated Lib/token.py generated
# Preserve canonical profiling resource bytes for frozen asset hashing.
Lib/profiling/sampling/_flamegraph_assets/flamegraph.css text eol=lf
Lib/profiling/sampling/_flamegraph_assets/flamegraph.js text eol=lf
Lib/profiling/sampling/_flamegraph_assets/flamegraph_template.html text eol=lf
Lib/profiling/sampling/_heatmap_assets/heatmap.css text eol=lf
Lib/profiling/sampling/_heatmap_assets/heatmap.js text eol=lf
Lib/profiling/sampling/_heatmap_assets/heatmap_index.js text eol=lf
Lib/profiling/sampling/_heatmap_assets/heatmap_index_template.html text eol=lf
Lib/profiling/sampling/_heatmap_assets/heatmap_pyfile_template.html text eol=lf
Lib/profiling/sampling/_heatmap_assets/heatmap_shared.js text eol=lf
Lib/profiling/sampling/_shared_assets/base.css text eol=lf
Lib/profiling/sampling/_shared_assets/base.js text eol=lf
Lib/profiling/sampling/_vendor/d3/7.8.5/d3.min.js text eol=lf
Lib/profiling/sampling/_vendor/d3-flame-graph/4.1.3/d3-flamegraph-tooltip.min.js text eol=lf
Lib/profiling/sampling/_vendor/d3-flame-graph/4.1.3/d3-flamegraph.css text eol=lf
Lib/profiling/sampling/_vendor/d3-flame-graph/4.1.3/d3-flamegraph.min.js text eol=lf
Generated
+1
View File
@@ -3921,6 +3921,7 @@ dependencies = [
"glob", "glob",
"rustpython-compiler-core", "rustpython-compiler-core",
"rustpython-derive", "rustpython-derive",
"sha2 0.11.1",
] ]
[[package]] [[package]]
+1 -1
View File
@@ -155,7 +155,7 @@ homepage = "https://rustpython.github.io/"
license_file = "LICENSE" license_file = "LICENSE"
authors = ["RustPython Team"] authors = ["RustPython Team"]
publisher = "RustPython Team" publisher = "RustPython Team"
resources = ["LICENSE", "README.md", "Lib"] resources = ["LICENSE", "crates/pylib/LICENSE-profiling", "README.md", "Lib"]
icons = ["32x32.png"] icons = ["32x32.png"]
[package.metadata.packager.nsis] [package.metadata.packager.nsis]
+29 -18
View File
@@ -1,6 +1,6 @@
"""Tools to analyze tasks running in asyncio programs.""" """Tools to analyze tasks running in asyncio programs."""
from collections import defaultdict, namedtuple from collections import defaultdict
from itertools import count from itertools import count
from enum import Enum from enum import Enum
import sys import sys
@@ -27,10 +27,10 @@ class CycleFoundException(Exception):
# ─── indexing helpers ─────────────────────────────────────────── # ─── indexing helpers ───────────────────────────────────────────
def _format_stack_entry(elem: str|FrameInfo) -> str: def _format_stack_entry(elem: str|FrameInfo) -> str:
if not isinstance(elem, str): if not isinstance(elem, str):
if elem.lineno == 0 and elem.filename == "": if elem.location.lineno == 0 and elem.filename == "":
return f"{elem.funcname}" return f"{elem.funcname}"
else: else:
return f"{elem.funcname} {elem.filename}:{elem.lineno}" return f"{elem.funcname} {elem.filename}:{elem.location.lineno}"
return elem return elem
@@ -231,27 +231,38 @@ def exit_with_permission_help_text():
print( print(
"Error: The specified process cannot be attached to due to insufficient permissions.\n" "Error: The specified process cannot be attached to due to insufficient permissions.\n"
"See the Python documentation for details on required privileges and troubleshooting:\n" "See the Python documentation for details on required privileges and troubleshooting:\n"
"https://docs.python.org/3.14/howto/remote_debugging.html#permission-requirements\n" "https://docs.python.org/3/howto/remote_debugging.html#permission-requirements\n",
file=sys.stderr,
) )
sys.exit(1) sys.exit(1)
def _get_awaited_by_tasks(pid: int) -> list: _TRANSIENT_ERRORS = (RuntimeError, OSError, UnicodeDecodeError, MemoryError)
try:
return get_all_awaited_by(pid)
except RuntimeError as e:
while e.__context__ is not None:
e = e.__context__
print(f"Error retrieving tasks: {e}")
sys.exit(1)
except PermissionError as e:
exit_with_permission_help_text()
def display_awaited_by_tasks_table(pid: int) -> None: def _get_awaited_by_tasks(pid: int, retries: int = 3) -> list:
for attempt in range(retries + 1):
try:
return get_all_awaited_by(pid)
except PermissionError:
exit_with_permission_help_text()
except ProcessLookupError:
print(f"Error: process {pid} not found.", file=sys.stderr)
sys.exit(1)
except _TRANSIENT_ERRORS as e:
if attempt < retries:
continue
if isinstance(e, RuntimeError):
while e.__context__ is not None:
e = e.__context__
print(f"Error retrieving tasks: {e}", file=sys.stderr)
sys.exit(1)
def display_awaited_by_tasks_table(pid: int, retries: int = 3) -> None:
"""Build and print a table of all pending tasks under `pid`.""" """Build and print a table of all pending tasks under `pid`."""
tasks = _get_awaited_by_tasks(pid) tasks = _get_awaited_by_tasks(pid, retries=retries)
table = build_task_table(tasks) table = build_task_table(tasks)
# Print the table in a simple tabular format # Print the table in a simple tabular format
print( print(
@@ -262,10 +273,10 @@ def display_awaited_by_tasks_table(pid: int) -> None:
print(f"{row[0]:<10} {row[1]:<20} {row[2]:<20} {row[3]:<50} {row[4]:<50} {row[5]:<15} {row[6]:<15}") print(f"{row[0]:<10} {row[1]:<20} {row[2]:<20} {row[3]:<50} {row[4]:<50} {row[5]:<15} {row[6]:<15}")
def display_awaited_by_tasks_tree(pid: int) -> None: def display_awaited_by_tasks_tree(pid: int, retries: int = 3) -> None:
"""Build and print a tree of all pending tasks under `pid`.""" """Build and print a tree of all pending tasks under `pid`."""
tasks = _get_awaited_by_tasks(pid) tasks = _get_awaited_by_tasks(pid, retries=retries)
try: try:
result = build_async_tree(tasks) result = build_async_tree(tasks)
except CycleFoundException as e: except CycleFoundException as e:
+13
View File
@@ -0,0 +1,13 @@
"""Compatibility wrapper for cProfile module.
This module maintains backward compatibility by importing from the new
profiling.tracing module.
"""
from profiling.tracing import run, runctx, Profile
__all__ = ["run", "runctx", "Profile"]
if __name__ == "__main__":
from profiling.tracing.__main__ import main
main()
+9 -1
View File
@@ -28,9 +28,18 @@ import io
import sys import sys
import time import time
import marshal import marshal
import warnings
__all__ = ["run", "runctx", "Profile"] __all__ = ["run", "runctx", "Profile"]
# Emit deprecation warning as per PEP 799
warnings.warn(
"The profile module is deprecated and will be removed in Python 3.17. "
"Use profiling.tracing (or cProfile) for tracing profilers instead.",
DeprecationWarning,
stacklevel=2
)
# Sample timer for use with # Sample timer for use with
#i_count = 0 #i_count = 0
#def integer_timer(): #def integer_timer():
@@ -598,7 +607,6 @@ def main():
'__file__': spec.origin, '__file__': spec.origin,
'__name__': spec.name, '__name__': spec.name,
'__package__': None, '__package__': None,
'__cached__': None,
} }
try: try:
runctx(code, globs, None, options.outfile, options.sort) runctx(code, globs, None, options.outfile, options.sort)
+13
View File
@@ -0,0 +1,13 @@
"""Python profiling tools.
This package provides two types of profilers:
- profiling.tracing: Deterministic tracing profiler that instruments every
function call and return. Higher overhead but provides exact call counts
and timing.
- profiling.sampling: Statistical sampling profiler that periodically samples
the call stack. Low overhead and suitable for production use.
"""
__all__ = ("tracing", "sampling")
+23
View File
@@ -0,0 +1,23 @@
"""Statistical sampling profiler for Python.
This module provides low-overhead profiling by periodically sampling the
call stack rather than tracing every function call.
"""
from .collector import Collector
from .pstats_collector import PstatsCollector
from .stack_collector import CollapsedStackCollector
from .heatmap_collector import HeatmapCollector
from .gecko_collector import GeckoCollector
from .jsonl_collector import JsonlCollector
from .string_table import StringTable
__all__ = (
"Collector",
"PstatsCollector",
"CollapsedStackCollector",
"HeatmapCollector",
"GeckoCollector",
"JsonlCollector",
"StringTable",
)
Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

+22
View File
@@ -0,0 +1,22 @@
import importlib.resources
def get_combined_css(component: str) -> str:
template_dir = importlib.resources.files(__package__)
base_css = (template_dir / "_shared_assets" / "base.css").read_text(encoding="utf-8")
if component == "flamegraph":
component_css = (
template_dir / "_flamegraph_assets" / "flamegraph.css"
).read_text(encoding="utf-8")
elif component == "heatmap":
component_css = (template_dir / "_heatmap_assets" / "heatmap.css").read_text(
encoding="utf-8"
)
else:
raise ValueError(
f"Unknown component: {component}. Expected 'flamegraph' or 'heatmap'."
)
return f"{base_css}\n\n{component_css}"
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,464 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{{TITLE}}</title>
<!-- INLINE_VENDOR_D3_JS -->
<!-- INLINE_VENDOR_FLAMEGRAPH_CSS -->
<!-- INLINE_VENDOR_FLAMEGRAPH_JS -->
<!-- INLINE_VENDOR_FLAMEGRAPH_TOOLTIP_JS -->
<!-- INLINE_CSS -->
</head>
<body>
<div class="app-layout">
<!-- Top Bar -->
<header class="top-bar">
<div class="brand">
<div class="brand-logo" id="navbar-logo"></div>
<span class="brand-text">Tachyon</span>
<span class="brand-divider"></span>
<span class="brand-subtitle">{{SUBTITLE}}</span>
</div>
<div class="search-wrapper">
<input
type="text"
id="search-input"
class="search-input"
placeholder="Search functions..."
/>
<button
class="search-clear"
id="search-clear"
onclick="clearSearch()"
title="Clear search"
>&times;</button>
</div>
<div class="toolbar">
<button
class="toolbar-btn"
onclick="resetZoom()"
title="Reset zoom"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
<path d="M8.707 1.5a1 1 0 0 0-1.414 0L.646 8.146a.5.5 0 0 0 .708.708L2 8.207V13.5A1.5 1.5 0 0 0 3.5 15h9a1.5 1.5 0 0 0 1.5-1.5V8.207l.646.647a.5.5 0 0 0 .708-.708L13 5.793V2.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.293zM13 7.207V13.5a.5.5 0 0 1-.5.5h-9a.5.5 0 0 1-.5-.5V7.207l5-5z"/>
</svg>
</button>
<button
class="toolbar-btn"
onclick="exportSVG()"
title="Export SVG"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
<path d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5"/><path d="M7.646 11.854a.5.5 0 0 0 .708 0l3-3a.5.5 0 0 0-.708-.708L8.5 10.293V1.5a.5.5 0 0 0-1 0v8.793L5.354 8.146a.5.5 0 1 0-.708.708z"/>
</svg>
</button>
<a
class="toolbar-btn"
href="https://docs.python.org/<!-- PYTHON_VERSION -->/library/profiling.sampling.html"
target="_blank"
title="Documentation"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
<path d="M1 2.828c.885-.37 2.154-.769 3.388-.893 1.33-.134 2.458.063 3.112.752v9.746c-.935-.53-2.12-.603-3.213-.493-1.18.12-2.37.461-3.287.811zm7.5-.141c.654-.689 1.782-.886 3.112-.752 1.234.124 2.503.523 3.388.893v9.923c-.918-.35-2.107-.692-3.287-.81-1.094-.111-2.278-.039-3.213.492zM8 1.783C7.015.936 5.587.81 4.287.94c-1.514.153-3.042.672-3.994 1.105A.5.5 0 0 0 0 2.5v11a.5.5 0 0 0 .707.455c.882-.4 2.303-.881 3.68-1.02 1.409-.142 2.59.087 3.223.877a.5.5 0 0 0 .78 0c.633-.79 1.814-1.019 3.222-.877 1.378.139 2.8.62 3.681 1.02A.5.5 0 0 0 16 13.5v-11a.5.5 0 0 0-.293-.455c-.952-.433-2.48-.952-3.994-1.105C10.413.809 8.985.936 8 1.783"/>
</svg>
</a>
<button
class="toolbar-btn theme-toggle"
onclick="toggleTheme()"
title="Toggle theme"
id="theme-btn"
>
<svg class="icon-moon" xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
<path d="M6 .278a.77.77 0 0 1 .08.858 7.2 7.2 0 0 0-.878 3.46c0 4.021 3.278 7.277 7.318 7.277q.792-.001 1.533-.16a.79.79 0 0 1 .81.316.73.73 0 0 1-.031.893A8.35 8.35 0 0 1 8.344 16C3.734 16 0 12.286 0 7.71 0 4.266 2.114 1.312 5.124.06A.75.75 0 0 1 6 .278M4.858 1.311A7.27 7.27 0 0 0 1.025 7.71c0 4.02 3.279 7.276 7.319 7.276a7.32 7.32 0 0 0 5.205-2.162q-.506.063-1.029.063c-4.61 0-8.343-3.714-8.343-8.29 0-1.167.242-2.278.681-3.286"/>
</svg>
<svg class="icon-sun" xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16" style="display:none">
<path d="M8 11a3 3 0 1 1 0-6 3 3 0 0 1 0 6m0 1a4 4 0 1 0 0-8 4 4 0 0 0 0 8M8 0a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-1 0v-2A.5.5 0 0 1 8 0m0 13a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-1 0v-2A.5.5 0 0 1 8 13m8-5a.5.5 0 0 1-.5.5h-2a.5.5 0 0 1 0-1h2a.5.5 0 0 1 .5.5M3 8a.5.5 0 0 1-.5.5h-2a.5.5 0 0 1 0-1h2A.5.5 0 0 1 3 8m10.657-5.657a.5.5 0 0 1 0 .707l-1.414 1.415a.5.5 0 1 1-.707-.708l1.414-1.414a.5.5 0 0 1 .707 0m-9.193 9.193a.5.5 0 0 1 0 .707L3.05 13.657a.5.5 0 0 1-.707-.707l1.414-1.414a.5.5 0 0 1 .707 0m9.193 2.121a.5.5 0 0 1-.707 0l-1.414-1.414a.5.5 0 0 1 .707-.707l1.414 1.414a.5.5 0 0 1 0 .707M4.464 4.465a.5.5 0 0 1-.707 0L2.343 3.05a.5.5 0 1 1 .707-.707l1.414 1.414a.5.5 0 0 1 0 .708"/>
</svg>
</button>
</div>
</header>
<!-- Main Content -->
<div class="main-content">
<!-- Sidebar -->
<aside class="sidebar" id="sidebar">
<button
class="sidebar-toggle"
onclick="toggleSidebar()"
title="Toggle sidebar"
aria-label="Toggle sidebar"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M10 12L6 8L10 4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
<div class="sidebar-resize-handle" id="sidebar-resize-handle"></div>
<div class="sidebar-content">
<!-- Logo Section -->
<div class="sidebar-logo">
<div class="sidebar-logo-img"><!-- INLINE_LOGO --></div>
</div>
<!-- View Mode Section -->
<section class="sidebar-section view-mode-section collapsible" id="view-mode-section">
<button class="section-header" onclick="toggleSection('view-mode-section')">
<h3 class="section-title">View Mode</h3>
<svg class="section-chevron" width="12" height="12" viewBox="0 0 12 12" fill="none">
<path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
<div class="section-content">
<!-- Elided Stacks Toggle (only shown for diff flamegraphs with elided paths) -->
<div class="toggle-switch" id="toggle-elided" tabindex="0" style="display: none;">
<span class="toggle-label active" data-text="Differential">Differential</span>
<div class="toggle-track"></div>
<span class="toggle-label" data-text="Elided" title="Code paths that existed in baseline but are missing from current profile">Elided</span>
</div>
<div class="toggle-switch" id="toggle-path-display" title="Toggle between module names and full file paths" tabindex="0">
<span class="toggle-label" data-text="File Paths">File Paths</span>
<div class="toggle-track on"></div>
<span class="toggle-label active" data-text="Module Names">Module Names</span>
</div>
<div class="toggle-switch" id="toggle-invert" title="Toggle between standard and inverted flamegraph view" tabindex="0">
<span class="toggle-label active" data-text="Flamegraph">Flamegraph</span>
<div class="toggle-track"></div>
<span class="toggle-label" data-text="Inverted Flamegraph">Inverted Flamegraph</span>
</div>
</div>
</section>
<!-- Profile Summary Section -->
<section class="sidebar-section collapsible" id="summary-section">
<button class="section-header" onclick="toggleSection('summary-section')">
<h3 class="section-title">Profile Summary</h3>
<svg class="section-chevron" width="12" height="12" viewBox="0 0 12 12" fill="none">
<path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
<div class="section-content">
<div class="summary-grid">
<div class="summary-card" id="summary-samples">
<div class="summary-icon">📊</div>
<div class="summary-data">
<div class="summary-value" id="stat-total-samples">--</div>
<div class="summary-label">Total Samples</div>
</div>
</div>
<div class="summary-card" id="summary-duration">
<div class="summary-icon">⏱</div>
<div class="summary-data">
<div class="summary-value" id="stat-duration">--</div>
<div class="summary-label">Duration</div>
</div>
</div>
<div class="summary-card" id="summary-rate">
<div class="summary-icon">⚡</div>
<div class="summary-data">
<div class="summary-value" id="stat-sample-rate">--</div>
<div class="summary-label">Samples/sec</div>
</div>
</div>
<div class="summary-card" id="summary-functions">
<div class="summary-icon">λ</div>
<div class="summary-data">
<div class="summary-value" id="stat-functions">--</div>
<div class="summary-label">Functions</div>
</div>
</div>
</div>
<!-- Efficiency Bar -->
<div class="efficiency-section" id="efficiency-section" style="display: none;">
<div class="bar-header">
<span class="bar-label">Sampling Efficiency</span>
<span class="bar-value" id="stat-efficiency">--</span>
</div>
<div class="bar">
<div class="bar-fill" id="efficiency-fill"></div>
</div>
<div class="bar-header">
<span class="bar-label">Missed samples</span>
<span class="bar-value" id="stat-missed-samples">--</span>
</div>
<div class="bar">
<div class="bar-fill" id="missed-samples-fill"></div>
</div>
</div>
</div>
</section>
<!-- Hotspots Section -->
<section class="sidebar-section collapsible" id="hotspots-section">
<button class="section-header" onclick="toggleSection('hotspots-section')">
<h3 class="section-title">Hotspots</h3>
<svg class="section-chevron" width="12" height="12" viewBox="0 0 12 12" fill="none">
<path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
<div class="section-content">
<div class="hotspot" id="hotspot-1">
<div class="hotspot-rank hotspot-rank--1">1</div>
<div class="hotspot-info">
<div class="hotspot-func" id="hotspot-func-1">--</div>
<div class="hotspot-file" id="hotspot-file-1">--</div>
<div class="hotspot-stats">
<span class="hotspot-percent" id="hotspot-percent-1">--</span>
<span id="hotspot-samples-1"></span>
</div>
</div>
</div>
<div class="hotspot" id="hotspot-2">
<div class="hotspot-rank hotspot-rank--2">2</div>
<div class="hotspot-info">
<div class="hotspot-func" id="hotspot-func-2">--</div>
<div class="hotspot-file" id="hotspot-file-2">--</div>
<div class="hotspot-stats">
<span class="hotspot-percent" id="hotspot-percent-2">--</span>
<span id="hotspot-samples-2"></span>
</div>
</div>
</div>
<div class="hotspot" id="hotspot-3">
<div class="hotspot-rank hotspot-rank--3">3</div>
<div class="hotspot-info">
<div class="hotspot-func" id="hotspot-func-3">--</div>
<div class="hotspot-file" id="hotspot-file-3">--</div>
<div class="hotspot-stats">
<span class="hotspot-percent" id="hotspot-percent-3">--</span>
<span id="hotspot-samples-3"></span>
</div>
</div>
</div>
</div>
</section>
<!-- Thread Stats Section (GIL/GC) -->
<section class="sidebar-section thread-stats-section collapsible" id="thread-stats-bar" style="display: none;">
<button class="section-header" onclick="toggleSection('thread-stats-bar')">
<h3 class="section-title">Runtime Stats</h3>
<svg class="section-chevron" width="12" height="12" viewBox="0 0 12 12" fill="none">
<path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
<div class="section-content">
<div class="stats-container">
<div class="stat-item" id="gil-held-stat">
<div class="bar-header">
<span class="bar-label">GIL Held</span>
<span class="bar-value" id="gil-held-pct">--</span>
</div>
<div class="bar">
<div class="bar-fill bar-fill--green" id="gil-held-fill"></div>
</div>
</div>
<div class="stat-item" id="gil-released-stat">
<div class="bar-header">
<span class="bar-label">GIL Released</span>
<span class="bar-value" id="gil-released-pct">--</span>
</div>
<div class="bar">
<div class="bar-fill bar-fill--red" id="gil-released-fill"></div>
</div>
</div>
<div class="stat-item" id="gil-waiting-stat">
<div class="bar-header">
<span class="bar-label">Waiting GIL</span>
<span class="bar-value" id="gil-waiting-pct">--</span>
</div>
<div class="bar">
<div class="bar-fill bar-fill--yellow" id="gil-waiting-fill"></div>
</div>
</div>
<div class="stat-item" id="gc-stat">
<div class="bar-header">
<span class="bar-label">GC</span>
<span class="bar-value" id="gc-pct">--</span>
</div>
<div class="bar">
<div class="bar-fill bar-fill--purple" id="gc-fill"></div>
</div>
</div>
<div class="stat-item" id="exc-stat">
<div class="bar-header">
<span class="bar-label">Exception</span>
<span class="bar-value" id="exc-pct">--</span>
</div>
<div class="bar">
<div class="bar-fill bar-fill--red" id="exc-fill"></div>
</div>
</div>
</div>
</div>
</section>
<!-- Thread Filter Section -->
<section class="sidebar-section filter-section" id="thread-section" style="display: none;">
<label class="filter-label" for="thread-filter">Thread Filter</label>
<select
id="thread-filter"
class="filter-select"
onchange="filterByThread()"
>
<option value="all">All Threads</option>
</select>
</section>
<!-- Heat Map Legend (shown for normal flamegraphs) -->
<section class="sidebar-section legend-section collapsible" id="heatmap-legend-section">
<button class="section-header" onclick="toggleSection('heatmap-legend-section')">
<h3 class="section-title">Heat Map</h3>
<svg class="section-chevron" width="12" height="12" viewBox="0 0 12 12" fill="none">
<path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
<div class="section-content">
<div class="legend">
<div class="legend-item">
<div class="legend-color" style="background: var(--heat-8)"></div>
<span class="legend-label">Hottest</span>
<span class="legend-range">&ge;60%</span>
</div>
<div class="legend-item">
<div class="legend-color" style="background: var(--heat-7)"></div>
<span class="legend-label">Very Hot</span>
<span class="legend-range">35-60%</span>
</div>
<div class="legend-item">
<div class="legend-color" style="background: var(--heat-6)"></div>
<span class="legend-label">Hot</span>
<span class="legend-range">18-35%</span>
</div>
<div class="legend-item">
<div class="legend-color" style="background: var(--heat-5)"></div>
<span class="legend-label">Warm</span>
<span class="legend-range">12-18%</span>
</div>
<div class="legend-item">
<div class="legend-color" style="background: var(--heat-4)"></div>
<span class="legend-label">Medium</span>
<span class="legend-range">6-12%</span>
</div>
<div class="legend-item">
<div class="legend-color" style="background: var(--heat-3)"></div>
<span class="legend-label">Cool</span>
<span class="legend-range">3-6%</span>
</div>
<div class="legend-item">
<div class="legend-color" style="background: var(--heat-2)"></div>
<span class="legend-label">Cold</span>
<span class="legend-range">1-3%</span>
</div>
<div class="legend-item">
<div class="legend-color" style="background: var(--heat-1)"></div>
<span class="legend-label">Coldest</span>
<span class="legend-range">&lt;1%</span>
</div>
</div>
</div>
</section>
<!-- Differential Legend (shown for differential flamegraphs) -->
<section class="sidebar-section legend-section collapsible" id="diff-legend-section" style="display: none;">
<button class="section-header" onclick="toggleSection('diff-legend-section')">
<h3 class="section-title">Differential Colors</h3>
<svg class="section-chevron" width="12" height="12" viewBox="0 0 12 12" fill="none">
<path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
<div class="section-content">
<div class="legend">
<div class="legend-item">
<div class="legend-color" style="background: var(--diff-regression-deep)"></div>
<span class="legend-label">Deep Regression</span>
<span class="legend-range">&ge;100%</span>
</div>
<div class="legend-item">
<div class="legend-color" style="background: var(--diff-regression-medium)"></div>
<span class="legend-label">Medium Regression</span>
<span class="legend-range">50-100%</span>
</div>
<div class="legend-item">
<div class="legend-color" style="background: var(--diff-regression-light)"></div>
<span class="legend-label">Light Regression</span>
<span class="legend-range">30-50%</span>
</div>
<div class="legend-item">
<div class="legend-color" style="background: var(--diff-regression-verylight)"></div>
<span class="legend-label">Minor Regression</span>
<span class="legend-range">15-30%</span>
</div>
<div class="legend-item">
<div class="legend-color" style="background: var(--diff-neutral)"></div>
<span class="legend-label">Neutral</span>
<span class="legend-range">-15% to 15%</span>
</div>
<div class="legend-item">
<div class="legend-color" style="background: var(--diff-improvement-verylight)"></div>
<span class="legend-label">Minor Improvement</span>
<span class="legend-range">-30% to -15%</span>
</div>
<div class="legend-item">
<div class="legend-color" style="background: var(--diff-improvement-light)"></div>
<span class="legend-label">Light Improvement</span>
<span class="legend-range">-50% to -30%</span>
</div>
<div class="legend-item">
<div class="legend-color" style="background: var(--diff-improvement-medium)"></div>
<span class="legend-label">Strong Improvement</span>
<span class="legend-range">&lt;-50%</span>
</div>
<div class="legend-item">
<div class="legend-color" style="background: var(--diff-new)"></div>
<span class="legend-label">New</span>
<span class="legend-range">No counterpart in baseline</span>
</div>
<div class="legend-item">
<div class="legend-color" style="background: var(--diff-elided)"></div>
<span class="legend-label">Removed</span>
<span class="legend-range">No counterpart in current</span>
</div>
</div>
</div>
</section>
</div>
</aside>
<!-- Chart Area -->
<main class="chart-area">
<div id="chart"></div>
</main>
</div>
<!-- Status Bar -->
<footer class="status-bar">
<span class="status-item" id="status-brand">
<span class="status-value">Tachyon Profiler</span>
</span>
<span class="status-item" id="status-tagline">
<span class="status-label">Python Sampling Profiler</span>
</span>
<span class="status-item" id="status-location" style="display: none;">
<span class="status-label">File:</span>
<span class="status-value" id="status-file">--</span>
</span>
<span class="status-item" id="status-func-item" style="display: none;">
<span class="status-label">Func:</span>
<span class="status-value" id="status-func">--</span>
</span>
<span class="status-item" id="status-time-item" style="display: none;">
<span class="status-label">Time:</span>
<span class="status-value" id="status-time">--</span>
</span>
<span class="status-item" id="status-percent-item" style="display: none;">
<span class="status-value accent" id="status-percent">--</span>
</span>
</footer>
</div>
<!-- INLINE_JS -->
</body>
</html>
+5
View File
@@ -0,0 +1,5 @@
import locale
def fmt(value: int | float, decimals: int = 1) -> str:
return locale.format_string(f'%.{decimals}f', value, grouping=True)
File diff suppressed because it is too large. Load diff
+738
View File
@@ -0,0 +1,738 @@
// Tachyon Profiler - Heatmap JavaScript
// Interactive features for the heatmap visualization
// Aligned with Flamegraph viewer design patterns
// ============================================================================
// State Management
// ============================================================================
let currentMenu = null;
let colorMode = 'self'; // 'self' or 'cumulative' - default to self
let coldCodeHidden = false;
// ============================================================================
// Theme Support
// ============================================================================
function toggleTheme() {
toggleAndSaveTheme();
applyLineColors();
// Rebuild scroll marker with new theme colors
buildScrollMarker();
}
// ============================================================================
// Utility Functions
// ============================================================================
function createElement(tag, className, textContent = '') {
const el = document.createElement(tag);
if (className) el.className = className;
if (textContent) el.textContent = textContent;
return el;
}
function calculateMenuPosition(buttonRect, menuWidth, menuHeight) {
const viewport = { width: window.innerWidth, height: window.innerHeight };
const scroll = {
x: window.pageXOffset || document.documentElement.scrollLeft,
y: window.pageYOffset || document.documentElement.scrollTop
};
const left = buttonRect.right + menuWidth + 10 < viewport.width
? buttonRect.right + scroll.x + 10
: Math.max(scroll.x + 10, buttonRect.left + scroll.x - menuWidth - 10);
const top = buttonRect.bottom + menuHeight + 10 < viewport.height
? buttonRect.bottom + scroll.y + 5
: Math.max(scroll.y + 10, buttonRect.top + scroll.y - menuHeight - 10);
return { left, top };
}
// ============================================================================
// Menu Management
// ============================================================================
function closeMenu() {
if (currentMenu) {
currentMenu.remove();
currentMenu = null;
}
}
function showNavigationMenu(button, items, title) {
closeMenu();
const menu = createElement('div', 'callee-menu');
menu.appendChild(createElement('div', 'callee-menu-header', title));
items.forEach(linkData => {
const item = createElement('div', 'callee-menu-item');
const funcDiv = createElement('div', 'callee-menu-func');
funcDiv.textContent = linkData.func;
if (linkData.count !== undefined && linkData.count > 0) {
const countBadge = createElement('span', 'count-badge');
countBadge.textContent = linkData.count.toLocaleString();
countBadge.title = `${linkData.count.toLocaleString()} samples`;
funcDiv.appendChild(document.createTextNode(' '));
funcDiv.appendChild(countBadge);
}
item.appendChild(funcDiv);
item.appendChild(createElement('div', 'callee-menu-file', linkData.file));
item.addEventListener('click', () => navigateToLine(linkData.link));
menu.appendChild(item);
});
const pos = calculateMenuPosition(button.getBoundingClientRect(), 350, 300);
menu.style.left = `${pos.left}px`;
menu.style.top = `${pos.top}px`;
document.body.appendChild(menu);
currentMenu = menu;
}
// ============================================================================
// Navigation
// ============================================================================
function handleNavigationClick(button, e) {
e.stopPropagation();
const navData = button.getAttribute('data-nav');
if (navData) {
navigateToLine(JSON.parse(navData).link);
return;
}
const navMulti = button.getAttribute('data-nav-multi');
if (navMulti) {
const items = JSON.parse(navMulti);
const title = button.classList.contains('caller') ? 'Choose a caller:' : 'Choose a callee:';
showNavigationMenu(button, items, title);
}
}
function restartLineHighlight(target) {
target.style.animation = 'none';
// Force style recalculation so restoring the animation restarts it.
void target.offsetWidth;
target.style.animation = '';
}
function navigateToLine(link) {
const url = new URL(link, window.location.href);
if (url.href === window.location.href) {
scrollToTargetLine();
} else {
window.location.href = link;
}
}
function scrollToTargetLine() {
if (!window.location.hash) return;
const target = document.querySelector(window.location.hash);
if (target) {
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
restartLineHighlight(target);
}
}
// ============================================================================
// Sample Count & Intensity
// ============================================================================
function getSampleCount(line) {
let text;
if (colorMode === 'self') {
text = line.querySelector('.line-samples-self')?.textContent.trim().replace(/,/g, '');
} else {
text = line.querySelector('.line-samples-cumulative')?.textContent.trim().replace(/,/g, '');
}
return parseInt(text) || 0;
}
// ============================================================================
// Scroll Minimap
// ============================================================================
function buildScrollMarker() {
const existing = document.getElementById('scroll_marker');
if (existing) existing.remove();
if (document.body.scrollHeight <= window.innerHeight) return;
const allLines = document.querySelectorAll('.code-line');
const lines = Array.from(allLines).filter(line => line.style.display !== 'none');
const markerScale = window.innerHeight / document.body.scrollHeight;
const lineHeight = Math.min(Math.max(3, window.innerHeight / lines.length), 10);
const maxSamples = Math.max(...Array.from(lines, getSampleCount));
const scrollMarker = createElement('div', '');
scrollMarker.id = 'scroll_marker';
let prevLine = -99, lastMark, lastTop;
lines.forEach((line, index) => {
const samples = getSampleCount(line);
if (samples === 0) return;
const lineTop = Math.floor(line.offsetTop * markerScale);
const lineNumber = index + 1;
const intensityClass = maxSamples > 0 ? (intensityToClass(samples / maxSamples) || 'cold') : 'cold';
if (lineNumber === prevLine + 1 && lastMark?.classList.contains(intensityClass)) {
lastMark.style.height = `${lineTop + lineHeight - lastTop}px`;
} else {
lastMark = createElement('div', `marker ${intensityClass}`);
lastMark.style.height = `${lineHeight}px`;
lastMark.style.top = `${lineTop}px`;
scrollMarker.appendChild(lastMark);
lastTop = lineTop;
}
prevLine = lineNumber;
});
document.body.appendChild(scrollMarker);
}
function applyLineColors() {
const lines = document.querySelectorAll('.code-line');
lines.forEach(line => {
let intensity;
if (colorMode === 'self') {
intensity = parseFloat(line.getAttribute('data-self-intensity')) || 0;
} else {
intensity = parseFloat(line.getAttribute('data-cumulative-intensity')) || 0;
}
const color = intensityToColor(intensity);
line.style.background = color;
});
}
// ============================================================================
// Toggle Controls
// ============================================================================
function toggleColdCode() {
coldCodeHidden = !coldCodeHidden;
applyHotFilter();
updateToggleUI('toggle-cold', coldCodeHidden);
buildScrollMarker();
}
function applyHotFilter() {
const lines = document.querySelectorAll('.code-line');
lines.forEach(line => {
const selfSamples = line.querySelector('.line-samples-self')?.textContent.trim();
const cumulativeSamples = line.querySelector('.line-samples-cumulative')?.textContent.trim();
let isCold;
if (colorMode === 'self') {
isCold = !selfSamples || selfSamples === '';
} else {
isCold = !cumulativeSamples || cumulativeSamples === '';
}
if (isCold) {
line.style.display = coldCodeHidden ? 'none' : 'flex';
} else {
line.style.display = 'flex';
}
});
}
function toggleColorMode() {
colorMode = colorMode === 'self' ? 'cumulative' : 'self';
applyLineColors();
updateToggleUI('toggle-color-mode', colorMode === 'cumulative');
if (coldCodeHidden) {
applyHotFilter();
}
buildScrollMarker();
}
// ============================================================================
// Initialization
// ============================================================================
document.addEventListener('DOMContentLoaded', function() {
restoreUIState();
applyLineColors();
// Initialize navigation buttons
document.querySelectorAll('.nav-btn').forEach(button => {
button.addEventListener('click', e => handleNavigationClick(button, e));
});
// Initialize line number permalink handlers
document.querySelectorAll('.line-number').forEach(lineNum => {
lineNum.style.cursor = 'pointer';
lineNum.addEventListener('click', e => {
window.location.hash = `line-${e.target.textContent.trim()}`;
});
});
// Initialize toggle buttons
const toggleColdBtn = document.getElementById('toggle-cold');
if (toggleColdBtn) toggleColdBtn.addEventListener('click', toggleColdCode);
const colorModeBtn = document.getElementById('toggle-color-mode');
if (colorModeBtn) colorModeBtn.addEventListener('click', toggleColorMode);
// Initialize specialization view toggle (hide if no bytecode data)
const hasBytecode = document.querySelectorAll('.bytecode-toggle').length > 0;
const specViewBtn = document.getElementById('toggle-spec-view');
if (specViewBtn) {
if (hasBytecode) {
specViewBtn.addEventListener('click', toggleSpecView);
} else {
specViewBtn.style.display = 'none';
}
}
// Initialize expand-all bytecode button
const expandAllBtn = document.getElementById('toggle-all-bytecode');
if (expandAllBtn) {
if (hasBytecode) {
expandAllBtn.addEventListener('click', toggleAllBytecode);
} else {
expandAllBtn.style.display = 'none';
}
}
// Initialize span tooltips
initSpanTooltips();
// Build scroll marker and scroll to target
setTimeout(buildScrollMarker, 200);
setTimeout(scrollToTargetLine, 100);
});
// Close menu when clicking outside
document.addEventListener('click', e => {
if (currentMenu && !currentMenu.contains(e.target) && !e.target.classList.contains('nav-btn')) {
closeMenu();
}
});
// ========================================
// SPECIALIZATION VIEW TOGGLE
// ========================================
let specViewEnabled = false;
/**
* Calculate heat color for given intensity (0-1)
* Hot spans (>30%) get warm orange, cold spans get dimmed gray
* @param {number} intensity - Value between 0 and 1
* @returns {string} rgba color string
*/
function calculateHeatColor(intensity) {
// Hot threshold: only spans with >30% of max samples get color
if (intensity > 0.3) {
// Normalize intensity above threshold to 0-1
const normalizedIntensity = (intensity - 0.3) / 0.7;
// Warm orange-red with increasing opacity for hotter spans
const alpha = 0.25 + normalizedIntensity * 0.35; // 0.25 to 0.6
const hotColor = getComputedStyle(document.documentElement).getPropertyValue('--span-hot-base').trim();
return `rgba(${hotColor}, ${alpha})`;
} else if (intensity > 0) {
// Cold spans: very subtle gray, almost invisible
const coldColor = getComputedStyle(document.documentElement).getPropertyValue('--span-cold-base').trim();
return `rgba(${coldColor}, 0.1)`;
}
return 'transparent';
}
/**
* Apply intensity-based heat colors to source spans
* Hot spans get orange highlight, cold spans get dimmed
* @param {boolean} enable - Whether to enable or disable span coloring
*/
function applySpanHeatColors(enable) {
document.querySelectorAll('.instr-span').forEach(span => {
const samples = enable ? (parseInt(span.dataset.samples) || 0) : 0;
if (samples > 0) {
const intensity = samples / (parseInt(span.dataset.maxSamples) || 1);
span.style.backgroundColor = calculateHeatColor(intensity);
span.style.borderRadius = '2px';
span.style.padding = '0 1px';
span.style.cursor = 'pointer';
} else {
span.style.cssText = '';
}
});
}
// ========================================
// SPAN TOOLTIPS
// ========================================
let activeTooltip = null;
/**
* Create and show tooltip for a span
*/
function showSpanTooltip(span) {
hideSpanTooltip();
const samples = parseInt(span.dataset.samples) || 0;
const maxSamples = parseInt(span.dataset.maxSamples) || 1;
const pct = span.dataset.pct || '0';
const opcodes = span.dataset.opcodes || '';
if (samples === 0) return;
const intensity = samples / maxSamples;
const isHot = intensity > 0.7;
const isWarm = intensity > 0.3;
const hotnessText = isHot ? 'Hot' : isWarm ? 'Warm' : 'Cold';
const hotnessClass = isHot ? 'hot' : isWarm ? 'warm' : 'cold';
// Build opcodes rows - each opcode on its own row
let opcodesHtml = '';
if (opcodes) {
const opcodeList = opcodes.split(',').map(op => op.trim()).filter(op => op);
if (opcodeList.length > 0) {
opcodesHtml = `
<div class="span-tooltip-section">Opcodes:</div>
${opcodeList.map(op => `<div class="span-tooltip-opcode">${op}</div>`).join('')}
`;
}
}
const tooltip = document.createElement('div');
tooltip.className = 'span-tooltip';
tooltip.innerHTML = `
<div class="span-tooltip-header ${hotnessClass}">${hotnessText}</div>
<div class="span-tooltip-row">
<span class="span-tooltip-label">Samples:</span>
<span class="span-tooltip-value${isHot ? ' highlight' : ''}">${samples.toLocaleString()}</span>
</div>
<div class="span-tooltip-row">
<span class="span-tooltip-label">% of line:</span>
<span class="span-tooltip-value">${pct}%</span>
</div>
${opcodesHtml}
`;
document.body.appendChild(tooltip);
activeTooltip = tooltip;
// Position tooltip above the span
const rect = span.getBoundingClientRect();
const tooltipRect = tooltip.getBoundingClientRect();
let left = rect.left + (rect.width / 2) - (tooltipRect.width / 2);
let top = rect.top - tooltipRect.height - 8;
// Keep tooltip in viewport
if (left < 5) left = 5;
if (left + tooltipRect.width > window.innerWidth - 5) {
left = window.innerWidth - tooltipRect.width - 5;
}
if (top < 5) {
top = rect.bottom + 8; // Show below if no room above
}
tooltip.style.left = `${left + window.scrollX}px`;
tooltip.style.top = `${top + window.scrollY}px`;
}
/**
* Hide active tooltip
*/
function hideSpanTooltip() {
if (activeTooltip) {
activeTooltip.remove();
activeTooltip = null;
}
}
/**
* Initialize span tooltip handlers
*/
function initSpanTooltips() {
document.addEventListener('mouseover', (e) => {
const span = e.target.closest('.instr-span');
if (span && specViewEnabled) {
showSpanTooltip(span);
}
});
document.addEventListener('mouseout', (e) => {
const span = e.target.closest('.instr-span');
if (span) {
hideSpanTooltip();
}
});
}
function toggleSpecView() {
specViewEnabled = !specViewEnabled;
const lines = document.querySelectorAll('.code-line');
if (specViewEnabled) {
lines.forEach(line => {
const specColor = line.getAttribute('data-spec-color');
line.style.background = specColor || 'transparent';
});
} else {
applyLineColors();
}
applySpanHeatColors(specViewEnabled);
updateToggleUI('toggle-spec-view', specViewEnabled);
// Disable/enable color mode toggle based on spec view state
const colorModeToggle = document.getElementById('toggle-color-mode');
if (colorModeToggle) {
colorModeToggle.classList.toggle('disabled', specViewEnabled);
}
buildScrollMarker();
}
// ========================================
// BYTECODE PANEL TOGGLE
// ========================================
/**
* Toggle bytecode panel visibility for a source line
* @param {HTMLElement} button - The toggle button that was clicked
*/
function toggleBytecode(button) {
const lineDiv = button.closest('.code-line');
const lineId = lineDiv.id;
const lineNum = lineId.replace('line-', '');
const panel = document.getElementById(`bytecode-${lineNum}`);
const wrapper = document.getElementById(`bytecode-wrapper-${lineNum}`);
if (!panel || !wrapper) return;
const isExpanded = panel.classList.contains('expanded');
if (isExpanded) {
panel.classList.remove('expanded');
wrapper.classList.remove('expanded');
button.classList.remove('expanded');
button.innerHTML = '&#9654;'; // Right arrow
} else {
if (!panel.dataset.populated) {
populateBytecodePanel(panel, button);
}
panel.classList.add('expanded');
wrapper.classList.add('expanded');
button.classList.add('expanded');
button.innerHTML = '&#9660;'; // Down arrow
}
}
/**
* Populate bytecode panel with instruction data
* @param {HTMLElement} panel - The panel element to populate
* @param {HTMLElement} button - The button containing the bytecode data
*/
function populateBytecodePanel(panel, button) {
const bytecodeJson = button.getAttribute('data-bytecode');
if (!bytecodeJson) return;
// Get line number from parent
const lineDiv = button.closest('.code-line');
const lineNum = lineDiv ? lineDiv.id.replace('line-', '') : null;
try {
const instructions = JSON.parse(bytecodeJson);
if (!instructions.length) {
panel.innerHTML = '<div class="bytecode-empty">No bytecode data</div>';
panel.dataset.populated = 'true';
return;
}
const maxSamples = Math.max(...instructions.map(i => i.samples), 1);
// Calculate specialization stats
const totalSamples = instructions.reduce((sum, i) => sum + i.samples, 0);
const specializedSamples = instructions
.filter(i => i.is_specialized)
.reduce((sum, i) => sum + i.samples, 0);
const specPct = totalSamples > 0 ? Math.round(100 * specializedSamples / totalSamples) : 0;
const specializedCount = instructions.filter(i => i.is_specialized).length;
// Determine specialization level class
let specClass = 'low';
if (specPct >= 67) specClass = 'high';
else if (specPct >= 33) specClass = 'medium';
// Build specialization summary
const instruction_word = instructions.length === 1 ? 'instruction' : 'instructions';
const sample_word = totalSamples === 1 ? 'sample' : 'samples';
let html = `<div class="bytecode-spec-summary ${specClass}">
<span class="spec-pct">${specPct}%</span>
<span class="spec-label">specialized</span>
<span class="spec-detail">(${specializedCount}/${instructions.length} ${instruction_word}, ${specializedSamples.toLocaleString()}/${totalSamples.toLocaleString()} ${sample_word})</span>
</div>`;
html += '<div class="bytecode-header">' +
'<span class="bytecode-opname">Instruction</span>' +
'<span class="bytecode-samples">Samples</span>' +
'<span>Heat</span></div>';
for (const instr of instructions) {
const heatPct = (instr.samples / maxSamples) * 100;
const isHot = heatPct > 50;
const specializedClass = instr.is_specialized ? ' specialized' : '';
const baseOpHtml = instr.is_specialized
? `<span class="base-op">(${escapeHtml(instr.base_opname)})</span>` : '';
const badge = instr.is_specialized
? '<span class="specialization-badge">SPECIALIZED</span>' : '';
// Build location data attributes for cross-referencing with source spans
const hasLocations = instr.locations && instr.locations.length > 0;
const locationData = hasLocations
? `data-locations='${JSON.stringify(instr.locations)}' data-line="${lineNum}" data-opcode="${instr.opcode}"`
: '';
html += `<div class="bytecode-instruction" ${locationData}>
<span class="bytecode-opname${specializedClass}">${escapeHtml(instr.opname)}${baseOpHtml}${badge}</span>
<span class="bytecode-samples${isHot ? ' hot' : ''}">${instr.samples.toLocaleString()}</span>
<div class="bytecode-heatbar"><div class="bytecode-heatbar-fill" style="width:${heatPct}%"></div></div>
</div>`;
}
panel.innerHTML = html;
panel.dataset.populated = 'true';
// Add hover handlers for bytecode instructions to highlight source spans
panel.querySelectorAll('.bytecode-instruction[data-locations]').forEach(instrEl => {
instrEl.addEventListener('mouseenter', highlightSourceFromBytecode);
instrEl.addEventListener('mouseleave', unhighlightSourceFromBytecode);
});
} catch (e) {
panel.innerHTML = '<div class="bytecode-error">Error loading bytecode</div>';
console.error('Error parsing bytecode data:', e);
}
}
/**
* Highlight source spans when hovering over bytecode instruction
*/
function highlightSourceFromBytecode(e) {
const instrEl = e.currentTarget;
const lineNum = instrEl.dataset.line;
const locationsStr = instrEl.dataset.locations;
if (!lineNum) return;
const lineDiv = document.getElementById(`line-${lineNum}`);
if (!lineDiv) return;
// Parse locations and highlight matching spans by column range
try {
const locations = JSON.parse(locationsStr || '[]');
const spans = lineDiv.querySelectorAll('.instr-span');
spans.forEach(span => {
const spanStart = parseInt(span.dataset.colStart);
const spanEnd = parseInt(span.dataset.colEnd);
for (const loc of locations) {
// Match if span's range matches instruction's location
if (spanStart === loc.col_offset && spanEnd === loc.end_col_offset) {
span.classList.add('highlight-from-bytecode');
break;
}
}
});
} catch (err) {
console.error('Error parsing locations:', err);
}
// Also highlight the instruction row itself
instrEl.classList.add('highlight');
}
/**
* Remove highlighting from source spans
*/
function unhighlightSourceFromBytecode(e) {
const instrEl = e.currentTarget;
const lineNum = instrEl.dataset.line;
if (!lineNum) return;
const lineDiv = document.getElementById(`line-${lineNum}`);
if (!lineDiv) return;
const spans = lineDiv.querySelectorAll('.instr-span.highlight-from-bytecode');
spans.forEach(span => {
span.classList.remove('highlight-from-bytecode');
});
instrEl.classList.remove('highlight');
}
/**
* Escape HTML special characters
* @param {string} text - Text to escape
* @returns {string} Escaped HTML
*/
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
/**
* Toggle all bytecode panels at once
*/
function toggleAllBytecode() {
const buttons = document.querySelectorAll('.bytecode-toggle');
if (buttons.length === 0) return;
const someExpanded = Array.from(buttons).some(b => b.classList.contains('expanded'));
const expandAllBtn = document.getElementById('toggle-all-bytecode');
buttons.forEach(button => {
const isExpanded = button.classList.contains('expanded');
if (someExpanded ? isExpanded : !isExpanded) {
toggleBytecode(button);
}
});
// Update the expand-all button state
if (expandAllBtn) {
expandAllBtn.classList.toggle('expanded', !someExpanded);
}
}
// Keyboard shortcut: 'b' toggles all bytecode panels, Enter/Space activates toggle switches
document.addEventListener('keydown', function(e) {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') {
return;
}
if (e.key === 'b' && !e.ctrlKey && !e.altKey && !e.metaKey) {
toggleAllBytecode();
}
if ((e.key === 'Enter' || e.key === ' ') && e.target.classList.contains('toggle-switch')) {
e.preventDefault();
e.target.click();
}
});
// Handle hash changes
window.addEventListener('hashchange', () => setTimeout(scrollToTargetLine, 50));
// Rebuild scroll marker on resize
window.addEventListener('resize', buildScrollMarker);
+104
View File
@@ -0,0 +1,104 @@
// Tachyon Profiler - Heatmap Index JavaScript
// Index page specific functionality
// ============================================================================
// Heatmap Bar Coloring
// ============================================================================
function applyHeatmapBarColors() {
const bars = document.querySelectorAll('.heatmap-bar[data-intensity]');
bars.forEach(bar => {
const intensity = parseFloat(bar.getAttribute('data-intensity')) || 0;
const color = intensityToColor(intensity);
bar.style.backgroundColor = color;
});
}
// ============================================================================
// Theme Support
// ============================================================================
function toggleTheme() {
toggleAndSaveTheme();
applyHeatmapBarColors();
}
// ============================================================================
// Type Section Toggle (stdlib, project, etc)
// ============================================================================
function toggleTypeSection(header) {
const section = header.parentElement;
const content = section.querySelector('.type-content');
const icon = header.querySelector('.type-icon');
if (content.style.display === 'none') {
content.style.display = 'block';
icon.textContent = '\u25BC';
} else {
content.style.display = 'none';
icon.textContent = '\u25B6';
}
}
// ============================================================================
// Folder Toggle
// ============================================================================
function toggleFolder(header) {
const folder = header.parentElement;
const content = folder.querySelector('.folder-content');
const icon = header.querySelector('.folder-icon');
if (content.style.display === 'none') {
content.style.display = 'block';
icon.textContent = '\u25BC';
folder.classList.remove('collapsed');
} else {
content.style.display = 'none';
icon.textContent = '\u25B6';
folder.classList.add('collapsed');
}
}
// ============================================================================
// Expand/Collapse All
// ============================================================================
function expandAll() {
// Expand all type sections
document.querySelectorAll('.type-section').forEach(section => {
const content = section.querySelector('.type-content');
const icon = section.querySelector('.type-icon');
content.style.display = 'block';
icon.textContent = '\u25BC';
});
// Expand all folders
document.querySelectorAll('.folder-node').forEach(folder => {
const content = folder.querySelector('.folder-content');
const icon = folder.querySelector('.folder-icon');
content.style.display = 'block';
icon.textContent = '\u25BC';
folder.classList.remove('collapsed');
});
}
function collapseAll() {
document.querySelectorAll('.folder-node').forEach(folder => {
const content = folder.querySelector('.folder-content');
const icon = folder.querySelector('.folder-icon');
content.style.display = 'none';
icon.textContent = '\u25B6';
folder.classList.add('collapsed');
});
}
// ============================================================================
// Initialization
// ============================================================================
document.addEventListener('DOMContentLoaded', function() {
restoreUIState();
applyHeatmapBarColors();
});
@@ -0,0 +1,136 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Tachyon Profiler - Heatmap Report</title>
<!-- INLINE_CSS -->
</head>
<body>
<div class="app-layout">
<!-- Top Bar -->
<header class="top-bar">
<div class="brand">
<div class="brand-logo"><!-- PYTHON_LOGO --></div>
<span class="brand-text">Tachyon</span>
<span class="brand-divider"></span>
<span class="brand-subtitle">Heatmap Report</span>
</div>
<div class="toolbar">
<a
class="toolbar-btn"
href="https://docs.python.org/<!-- PYTHON_VERSION -->/library/profiling.sampling.html"
target="_blank"
title="Documentation"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
<path d="M1 2.828c.885-.37 2.154-.769 3.388-.893 1.33-.134 2.458.063 3.112.752v9.746c-.935-.53-2.12-.603-3.213-.493-1.18.12-2.37.461-3.287.811zm7.5-.141c.654-.689 1.782-.886 3.112-.752 1.234.124 2.503.523 3.388.893v9.923c-.918-.35-2.107-.692-3.287-.81-1.094-.111-2.278-.039-3.213.492zM8 1.783C7.015.936 5.587.81 4.287.94c-1.514.153-3.042.672-3.994 1.105A.5.5 0 0 0 0 2.5v11a.5.5 0 0 0 .707.455c.882-.4 2.303-.881 3.68-1.02 1.409-.142 2.59.087 3.223.877a.5.5 0 0 0 .78 0c.633-.79 1.814-1.019 3.222-.877 1.378.139 2.8.62 3.681 1.02A.5.5 0 0 0 16 13.5v-11a.5.5 0 0 0-.293-.455c-.952-.433-2.48-.952-3.994-1.105C10.413.809 8.985.936 8 1.783"/>
</svg>
</a>
<button
class="toolbar-btn theme-toggle"
onclick="toggleTheme()"
title="Toggle theme"
id="theme-btn"
>
<svg class="icon-moon" xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
<path d="M6 .278a.77.77 0 0 1 .08.858 7.2 7.2 0 0 0-.878 3.46c0 4.021 3.278 7.277 7.318 7.277q.792-.001 1.533-.16a.79.79 0 0 1 .81.316.73.73 0 0 1-.031.893A8.35 8.35 0 0 1 8.344 16C3.734 16 0 12.286 0 7.71 0 4.266 2.114 1.312 5.124.06A.75.75 0 0 1 6 .278M4.858 1.311A7.27 7.27 0 0 0 1.025 7.71c0 4.02 3.279 7.276 7.319 7.276a7.32 7.32 0 0 0 5.205-2.162q-.506.063-1.029.063c-4.61 0-8.343-3.714-8.343-8.29 0-1.167.242-2.278.681-3.286"/>
</svg>
<svg class="icon-sun" xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16" style="display:none">
<path d="M8 11a3 3 0 1 1 0-6 3 3 0 0 1 0 6m0 1a4 4 0 1 0 0-8 4 4 0 0 0 0 8M8 0a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-1 0v-2A.5.5 0 0 1 8 0m0 13a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-1 0v-2A.5.5 0 0 1 8 13m8-5a.5.5 0 0 1-.5.5h-2a.5.5 0 0 1 0-1h2a.5.5 0 0 1 .5.5M3 8a.5.5 0 0 1-.5.5h-2a.5.5 0 0 1 0-1h2A.5.5 0 0 1 3 8m10.657-5.657a.5.5 0 0 1 0 .707l-1.414 1.415a.5.5 0 1 1-.707-.708l1.414-1.414a.5.5 0 0 1 .707 0m-9.193 9.193a.5.5 0 0 1 0 .707L3.05 13.657a.5.5 0 0 1-.707-.707l1.414-1.414a.5.5 0 0 1 .707 0m9.193 2.121a.5.5 0 0 1-.707 0l-1.414-1.414a.5.5 0 0 1 .707-.707l1.414 1.414a.5.5 0 0 1 0 .707M4.464 4.465a.5.5 0 0 1-.707 0L2.343 3.05a.5.5 0 1 1 .707-.707l1.414 1.414a.5.5 0 0 1 0 .708"/>
</svg>
</button>
</div>
</header>
<!-- Main Content -->
<div class="main-content">
<!-- Stats Summary -->
<div class="stats-summary">
<div class="stat-card">
<div class="stat-icon">&#128196;</div>
<div class="stat-data">
<span class="stat-value"><!-- NUM_FILES --></span>
<span class="stat-label">Files Profiled</span>
</div>
<div class="stat-sparkline"></div>
</div>
<div class="stat-card">
<div class="stat-icon">&#128202;</div>
<div class="stat-data">
<span class="stat-value"><!-- TOTAL_SAMPLES --></span>
<span class="stat-label">Total Snapshots</span>
</div>
<div class="stat-sparkline"></div>
</div>
<div class="stat-card">
<div class="stat-icon">&#9201;</div>
<div class="stat-data">
<span class="stat-value"><!-- DURATION --></span>
<span class="stat-label">Duration</span>
</div>
<div class="stat-sparkline"></div>
</div>
<div class="stat-card">
<div class="stat-icon">&#9889;</div>
<div class="stat-data">
<span class="stat-value"><!-- SAMPLE_RATE --></span>
<span class="stat-label">Samples/sec</span>
</div>
<div class="stat-sparkline"></div>
</div>
<div class="rate-card">
<div class="rate-header">
<div class="rate-info">
<div class="rate-icon">&#9888;</div>
<span class="rate-label">Error Rate</span>
</div>
<span class="rate-value"><!-- ERROR_RATE --></span>
</div>
<div class="rate-bar">
<div class="rate-fill <!-- ERROR_RATE_CLASS -->" style="width: <!-- ERROR_RATE_WIDTH -->%;"></div>
</div>
</div>
<div class="rate-card">
<div class="rate-header">
<div class="rate-info">
<div class="rate-icon">&#128165;</div>
<span class="rate-label">Missed Samples</span>
</div>
<span class="rate-value"><!-- MISSED_SAMPLES --></span>
</div>
<div class="rate-bar">
<div class="rate-fill <!-- MISSED_SAMPLES_CLASS -->" style="width: <!-- MISSED_SAMPLES_WIDTH -->%;"></div>
</div>
</div>
</div>
<!-- File List Section -->
<div class="section-header">
<h2 class="section-title">Profiled Files</h2>
</div>
<div class="filter-controls">
<button onclick="expandAll()" class="control-btn">Expand All</button>
<button onclick="collapseAll()" class="control-btn">Collapse All</button>
</div>
<div class="module-sections">
<!-- SECTIONS_HTML -->
</div>
</div>
<!-- Status Bar -->
<footer class="status-bar">
<span class="status-item">
<span class="status-value">Tachyon Profiler</span>
</span>
<span class="status-item">
<span class="status-label">Python Sampling Profiler</span>
</span>
</footer>
</div>
<!-- INLINE_JS -->
</body>
</html>
@@ -0,0 +1,131 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><!-- FILENAME --> - Heatmap</title>
<!-- INLINE_CSS -->
</head>
<body class="code-view">
<div class="app-layout">
<!-- Top Bar (Code Header) -->
<header class="top-bar">
<div class="brand">
<div class="brand-logo"><!-- PYTHON_LOGO --></div>
<a href="index.html" class="brand-text">Tachyon</a>
<span class="brand-divider"></span>
<span class="brand-subtitle" style="font-family: var(--font-mono); font-size: 13px;"><!-- FILENAME --></span>
</div>
<div class="toolbar">
<a href="index.html" class="toolbar-btn" title="Back to Index" aria-label="Back to Index">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
<path d="M8.707 1.5a1 1 0 0 0-1.414 0L.646 8.146a.5.5 0 0 0 .708.708L2 8.207V13.5A1.5 1.5 0 0 0 3.5 15h9a1.5 1.5 0 0 0 1.5-1.5V8.207l.646.647a.5.5 0 0 0 .708-.708L13 5.793V2.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.293zM13 7.207V13.5a.5.5 0 0 1-.5.5h-9a.5.5 0 0 1-.5-.5V7.207l5-5z"/>
</svg>
</a>
<a
class="toolbar-btn"
href="https://docs.python.org/<!-- PYTHON_VERSION -->/library/profiling.sampling.html"
target="_blank"
title="Documentation"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
<path d="M1 2.828c.885-.37 2.154-.769 3.388-.893 1.33-.134 2.458.063 3.112.752v9.746c-.935-.53-2.12-.603-3.213-.493-1.18.12-2.37.461-3.287.811zm7.5-.141c.654-.689 1.782-.886 3.112-.752 1.234.124 2.503.523 3.388.893v9.923c-.918-.35-2.107-.692-3.287-.81-1.094-.111-2.278-.039-3.213.492zM8 1.783C7.015.936 5.587.81 4.287.94c-1.514.153-3.042.672-3.994 1.105A.5.5 0 0 0 0 2.5v11a.5.5 0 0 0 .707.455c.882-.4 2.303-.881 3.68-1.02 1.409-.142 2.59.087 3.223.877a.5.5 0 0 0 .78 0c.633-.79 1.814-1.019 3.222-.877 1.378.139 2.8.62 3.681 1.02A.5.5 0 0 0 16 13.5v-11a.5.5 0 0 0-.293-.455c-.952-.433-2.48-.952-3.994-1.105C10.413.809 8.985.936 8 1.783"/>
</svg>
</a>
<button
class="toolbar-btn theme-toggle"
onclick="toggleTheme()"
title="Toggle theme"
aria-label="Toggle theme"
id="theme-btn"
>
<svg class="icon-moon" xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
<path d="M6 .278a.77.77 0 0 1 .08.858 7.2 7.2 0 0 0-.878 3.46c0 4.021 3.278 7.277 7.318 7.277q.792-.001 1.533-.16a.79.79 0 0 1 .81.316.73.73 0 0 1-.031.893A8.35 8.35 0 0 1 8.344 16C3.734 16 0 12.286 0 7.71 0 4.266 2.114 1.312 5.124.06A.75.75 0 0 1 6 .278M4.858 1.311A7.27 7.27 0 0 0 1.025 7.71c0 4.02 3.279 7.276 7.319 7.276a7.32 7.32 0 0 0 5.205-2.162q-.506.063-1.029.063c-4.61 0-8.343-3.714-8.343-8.29 0-1.167.242-2.278.681-3.286"/>
</svg>
<svg class="icon-sun" xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16" style="display:none">
<path d="M8 11a3 3 0 1 1 0-6 3 3 0 0 1 0 6m0 1a4 4 0 1 0 0-8 4 4 0 0 0 0 8M8 0a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-1 0v-2A.5.5 0 0 1 8 0m0 13a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-1 0v-2A.5.5 0 0 1 8 13m8-5a.5.5 0 0 1-.5.5h-2a.5.5 0 0 1 0-1h2a.5.5 0 0 1 .5.5M3 8a.5.5 0 0 1-.5.5h-2a.5.5 0 0 1 0-1h2A.5.5 0 0 1 3 8m10.657-5.657a.5.5 0 0 1 0 .707l-1.414 1.415a.5.5 0 1 1-.707-.708l1.414-1.414a.5.5 0 0 1 .707 0m-9.193 9.193a.5.5 0 0 1 0 .707L3.05 13.657a.5.5 0 0 1-.707-.707l1.414-1.414a.5.5 0 0 1 .707 0m9.193 2.121a.5.5 0 0 1-.707 0l-1.414-1.414a.5.5 0 0 1 .707-.707l1.414 1.414a.5.5 0 0 1 0 .707M4.464 4.465a.5.5 0 0 1-.707 0L2.343 3.05a.5.5 0 1 1 .707-.707l1.414 1.414a.5.5 0 0 1 0 .708"/>
</svg>
</button>
</div>
</header>
<!-- File Stats Bar -->
<div class="file-stats">
<div class="stats-grid">
<div class="stat-item">
<div class="stat-value"><!-- TOTAL_SELF_SAMPLES --></div>
<div class="stat-label">Self Samples</div>
</div>
<div class="stat-item">
<div class="stat-value"><!-- TOTAL_SAMPLES --></div>
<div class="stat-label">Cumulative</div>
</div>
<div class="stat-item">
<div class="stat-value"><!-- NUM_LINES --></div>
<div class="stat-label">Lines Hit</div>
</div>
<div class="stat-item">
<div class="stat-value"><!-- PERCENTAGE -->%</div>
<div class="stat-label">% of Total</div>
</div>
<div class="stat-item">
<div class="stat-value"><!-- MAX_SELF_SAMPLES --></div>
<div class="stat-label">Max Self</div>
</div>
<div class="stat-item">
<div class="stat-value"><!-- MAX_SAMPLES --></div>
<div class="stat-label">Max Total</div>
</div>
</div>
</div>
<!-- Legend -->
<div class="legend">
<div class="legend-content">
<span class="legend-title">Intensity:</span>
<div class="legend-gradient"></div>
<div class="legend-labels">
<span>Cold</span>
<span aria-hidden="true">→</span>
<span>Hot</span>
</div>
<div class="legend-separator" aria-hidden="true"></div>
<div class="legend-controls">
<div class="toggle-switch" id="toggle-color-mode" title="Toggle between self time and total time coloring" tabindex="0">
<span class="toggle-label active" data-text="Self Time">Self Time</span>
<div class="toggle-track"></div>
<span class="toggle-label" data-text="Total Time">Total Time</span>
</div>
<div class="toggle-switch" id="toggle-cold" title="Toggle visibility of lines with zero samples" tabindex="0">
<span class="toggle-label active" data-text="Show All">Show All</span>
<div class="toggle-track"></div>
<span class="toggle-label" data-text="Hot Only">Hot Only</span>
</div>
<div class="toggle-switch" id="toggle-spec-view" title="Color lines by specialization level (requires bytecode data)" tabindex="0">
<span class="toggle-label active" data-text="Heat">Heat</span>
<div class="toggle-track"></div>
<span class="toggle-label" data-text="Specialization">Specialization</span>
</div>
<div class="legend-separator" aria-hidden="true"></div>
<button class="bytecode-expand-all" id="toggle-all-bytecode" title="Expand/collapse all bytecode panels (keyboard: b)">
<span class="expand-icon" aria-hidden="true">▶</span> Bytecode
</button>
</div>
</div>
</div>
<!-- Code Container -->
<div class="code-container">
<div class="code-header-row">
<div class="header-line-number">Line</div>
<div class="header-samples-self">Self</div>
<div class="header-samples-cumulative">Total</div>
<div class="header-content">Code</div>
</div>
<!-- CODE_LINES -->
</div>
</div>
<!-- INLINE_JS -->
</body>
</html>
@@ -0,0 +1,64 @@
// Tachyon Profiler - Shared Heatmap JavaScript
// Common utilities shared between index and file views
// ============================================================================
// Heat Level Mapping (Single source of truth for intensity thresholds)
// ============================================================================
// Maps intensity (0-1) to heat level (0-8). Level 0 = no heat, 1-8 = heat levels.
function intensityToHeatLevel(intensity) {
if (intensity <= 0) return 0;
if (intensity <= 0.125) return 1;
if (intensity <= 0.25) return 2;
if (intensity <= 0.375) return 3;
if (intensity <= 0.5) return 4;
if (intensity <= 0.625) return 5;
if (intensity <= 0.75) return 6;
if (intensity <= 0.875) return 7;
return 8;
}
// Class names corresponding to heat levels 1-8 (used by scroll marker)
const HEAT_CLASS_NAMES = ['cold', 'cool', 'mild', 'warm', 'hot', 'very-hot', 'intense', 'extreme'];
function intensityToClass(intensity) {
const level = intensityToHeatLevel(intensity);
return level === 0 ? null : HEAT_CLASS_NAMES[level - 1];
}
// ============================================================================
// Color Mapping (Intensity to Heat Color)
// ============================================================================
function intensityToColor(intensity) {
const level = intensityToHeatLevel(intensity);
if (level === 0) {
return 'transparent';
}
const rootStyle = getComputedStyle(document.documentElement);
return rootStyle.getPropertyValue(`--heat-${level}`).trim();
}
// ============================================================================
// Theme Support
// ============================================================================
// Restore theme from localStorage, or use browser preference
function restoreUIState() {
applyTheme(getPreferredTheme());
}
// ============================================================================
// Favicon (Reuse logo image as favicon)
// ============================================================================
(function() {
const logo = document.querySelector('.brand-logo img');
if (logo) {
const favicon = document.createElement('link');
favicon.rel = 'icon';
favicon.type = 'image/png';
favicon.href = logo.src;
document.head.appendChild(favicon);
}
})();
+514
View File
@@ -0,0 +1,514 @@
/* ==========================================================================
Python Profiler - Shared CSS Foundation
Design system shared between Flamegraph and Heatmap viewers
========================================================================== */
/* --------------------------------------------------------------------------
CSS Variables & Theme System
-------------------------------------------------------------------------- */
:root {
/* Typography */
--font-sans: "Source Sans Pro", "Lucida Grande", "Lucida Sans Unicode",
"Geneva", "Verdana", sans-serif;
--font-mono: 'SF Mono', 'Monaco', 'Consolas', 'Liberation Mono', monospace;
/* Python brand colors (theme-independent) */
--python-blue: #3776ab;
--python-blue-light: #4584bb;
--python-blue-lighter: #5592cc;
--python-gold: #ffd43b;
--python-gold-dark: #ffcd02;
--python-gold-light: #ffdc5c;
/* Heat palette - defined per theme below */
/* Layout */
--sidebar-width: 280px;
--sidebar-collapsed: 44px;
--topbar-height: 56px;
--statusbar-height: 32px;
/* Border radius */
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 12px;
/* Transitions */
--transition-fast: 0.15s ease;
--transition-normal: 0.25s ease;
--transition-slow: 0.3s ease;
}
/* Light theme (default) */
:root, [data-theme="light"] {
--bg-primary: #ffffff;
--bg-secondary: #f8f9fa;
--bg-tertiary: #e9ecef;
--border: #e9ecef;
--border-subtle: #f0f2f5;
--text-primary: #2e3338;
--text-secondary: #5a6c7d;
--text-muted: #6f767e;
--accent: #3776ab;
--accent-hover: #2d5aa0;
--accent-glow: rgba(55, 118, 171, 0.15);
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.08);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.1);
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.15);
--header-gradient: linear-gradient(135deg, #3776ab 0%, #4584bb 100%);
/* Light mode heat palette - blue to yellow to orange to red (cold to hot) */
--heat-1: #7ba3d1;
--heat-2: #a8d0ef;
--heat-3: #d6e9f8;
--heat-4: #ffe6a8;
--heat-5: #ffd43b;
--heat-6: #ffb84d;
--heat-7: #ff9966;
--heat-8: #ff6347;
/* Code view specific */
--code-bg: #ffffff;
--code-bg-line: #f8f9fa;
--code-border: #e9ecef;
--code-text: #2e3338;
--code-text-muted: #8b949e;
--code-accent: #3776ab;
/* Navigation colors */
--nav-caller: #2563eb;
--nav-caller-hover: #1d4ed8;
--nav-callee: #dc2626;
--nav-callee-hover: #b91c1c;
/* Specialization status colors */
--spec-high: #4caf50;
--spec-high-text: #2e7d32;
--spec-high-bg: rgba(76, 175, 80, 0.15);
--spec-medium: #ff9800;
--spec-medium-text: #e65100;
--spec-medium-bg: rgba(255, 152, 0, 0.15);
--spec-low: #9e9e9e;
--spec-low-text: #616161;
--spec-low-bg: rgba(158, 158, 158, 0.15);
/* Heatmap span highlighting colors */
--span-hot-base: 255, 100, 50;
--span-cold-base: 150, 150, 150;
/* Summary card colors - optimized for 4.5:1 contrast on light bg */
--card-blue: 44, 102, 149;
--card-green: 26, 116, 49;
--card-yellow: 134, 100, 4;
--card-purple: 102, 57, 166;
--card-red: 180, 40, 50;
--card-orange: 166, 90, 0;
}
/* Dark theme */
[data-theme="dark"] {
--bg-primary: #0d1117;
--bg-secondary: #161b22;
--bg-tertiary: #21262d;
--border: #30363d;
--border-subtle: #21262d;
--text-primary: #e6edf3;
--text-secondary: #8b949e;
--text-muted: #757e8a;
--accent: #58a6ff;
--accent-hover: #79b8ff;
--accent-glow: rgba(88, 166, 255, 0.15);
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4);
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.5);
--header-gradient: linear-gradient(135deg, #21262d 0%, #30363d 100%);
/* Dark mode heat palette - cool to warm gradient for visualization */
--heat-1: rgba(90, 123, 167, 1);
--heat-2: rgba(106, 148, 168, 1);
--heat-3: rgba(122, 172, 172, 1);
--heat-4: rgba(142, 196, 152, 1);
--heat-5: rgba(168, 216, 136, 1);
--heat-6: rgba(200, 222, 122, 1);
--heat-7: rgba(244, 212, 93, 1);
--heat-8: rgba(255, 122, 69, 1);
/* Code view specific - dark mode */
--code-bg: #0d1117;
--code-bg-line: #161b22;
--code-border: #30363d;
--code-text: #e6edf3;
--code-text-muted: #6e7681;
--code-accent: #58a6ff;
/* Navigation colors - dark theme friendly */
--nav-caller: #58a6ff;
--nav-caller-hover: #4184e4;
--nav-callee: #f87171;
--nav-callee-hover: #e53e3e;
/* Specialization status colors - dark theme */
--spec-high: #81c784;
--spec-high-text: #81c784;
--spec-high-bg: rgba(129, 199, 132, 0.2);
--spec-medium: #ffb74d;
--spec-medium-text: #ffb74d;
--spec-medium-bg: rgba(255, 183, 77, 0.2);
--spec-low: #bdbdbd;
--spec-low-text: #9e9e9e;
--spec-low-bg: rgba(189, 189, 189, 0.15);
/* Heatmap span highlighting colors - dark theme */
--span-hot-base: 255, 107, 53;
--span-cold-base: 189, 189, 189;
/* Summary card colors - optimized for 4.5:1 contrast on dark bg */
--card-blue: 88, 166, 255;
--card-green: 63, 185, 80;
--card-yellow: 210, 153, 34;
--card-purple: 163, 113, 247;
--card-red: 248, 113, 113;
--card-orange: 251, 146, 60;
}
/* --------------------------------------------------------------------------
Base Styles
-------------------------------------------------------------------------- */
*, *::before, *::after {
box-sizing: border-box;
}
html, body {
margin: 0;
padding: 0;
}
body {
font-family: var(--font-sans);
font-size: 14px;
line-height: 1.6;
color: var(--text-primary);
background: var(--bg-primary);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
transition: background var(--transition-normal), color var(--transition-normal);
}
/* --------------------------------------------------------------------------
Layout Structure
-------------------------------------------------------------------------- */
.app-layout {
display: flex;
flex-direction: column;
}
/* --------------------------------------------------------------------------
Top Bar
-------------------------------------------------------------------------- */
.top-bar {
height: var(--topbar-height);
background: var(--header-gradient);
display: flex;
align-items: center;
padding: 0 16px;
gap: 16px;
flex-shrink: 0;
box-shadow: 0 2px 10px rgba(55, 118, 171, 0.25);
border-bottom: 2px solid var(--python-gold);
}
/* Brand / Logo */
.brand {
display: flex;
align-items: center;
gap: 12px;
color: white;
text-decoration: none;
flex-shrink: 0;
}
.brand-logo {
display: flex;
align-items: center;
justify-content: center;
width: 48px;
height: 40px;
flex-shrink: 0;
}
/* Style the inlined SVG/img inside brand-logo */
.brand-logo svg,
.brand-logo img {
width: 48px;
height: 40px;
display: block;
object-fit: contain;
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.2));
}
.brand-info {
display: flex;
flex-direction: column;
line-height: 1.15;
}
.brand-text {
font-family: var(--font-sans);
font-weight: 700;
font-size: 16px;
letter-spacing: -0.3px;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.15);
color: inherit;
text-decoration: none;
}
.brand-subtitle {
font-weight: 500;
font-size: 10px;
opacity: 0.9;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.brand-divider {
width: 1px;
height: 16px;
background: rgba(255, 255, 255, 0.3);
}
/* Toolbar */
.toolbar {
display: flex;
align-items: center;
gap: 6px;
margin-left: auto;
}
.toolbar-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
padding: 0;
font-size: 15px;
color: white;
background: rgba(255, 255, 255, 0.12);
border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: 6px;
cursor: pointer;
text-decoration: none;
transition: all var(--transition-fast);
}
.toolbar-btn:hover {
background: rgba(255, 255, 255, 0.22);
border-color: rgba(255, 255, 255, 0.35);
}
.toolbar-btn:active {
transform: scale(0.95);
}
/* --------------------------------------------------------------------------
Status Bar
-------------------------------------------------------------------------- */
.status-bar {
height: var(--statusbar-height);
background: var(--bg-secondary);
border-top: 1px solid var(--border);
display: flex;
align-items: center;
padding: 0 16px;
gap: 16px;
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-secondary);
flex-shrink: 0;
}
.status-item {
display: flex;
align-items: center;
gap: 5px;
}
.status-item::before {
content: '';
width: 4px;
height: 4px;
background: var(--python-gold);
border-radius: 50%;
}
.status-item:first-child::before {
display: none;
}
.status-label {
color: var(--text-muted);
}
.status-value {
color: var(--text-primary);
font-weight: 500;
}
.status-value.accent {
color: var(--accent);
font-weight: 600;
}
/* --------------------------------------------------------------------------
Animations
-------------------------------------------------------------------------- */
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(12px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes shimmer {
0% { left: -100%; }
100% { left: 100%; }
}
/* --------------------------------------------------------------------------
Focus States (Accessibility)
-------------------------------------------------------------------------- */
button:focus-visible,
select:focus-visible,
input:focus-visible,
.toggle-switch:focus-visible,
a.toolbar-btn:focus-visible {
outline: 2px solid var(--python-gold);
outline-offset: 2px;
}
/* --------------------------------------------------------------------------
Shared Responsive
-------------------------------------------------------------------------- */
@media (max-width: 900px) {
.brand-subtitle {
display: none;
}
}
@media (max-width: 600px) {
.toolbar-btn:not(.theme-toggle) {
display: none;
}
}
/* --------------------------------------------------------------------------
Toggle Switch
-------------------------------------------------------------------------- */
.toggle-switch {
display: inline-flex;
align-items: center;
gap: 8px;
cursor: pointer;
user-select: none;
font-family: var(--font-sans);
transition: opacity var(--transition-fast);
flex-shrink: 0;
}
.toggle-switch:hover {
opacity: 0.85;
}
.toggle-switch .toggle-label {
font-size: 11px;
font-weight: 500;
color: var(--text-muted);
transition: color var(--transition-fast);
white-space: nowrap;
display: inline-flex;
flex-direction: column;
}
.toggle-switch .toggle-label.active {
color: var(--text-primary);
font-weight: 600;
}
/* Reserve space for bold text to prevent layout shift on toggle */
.toggle-switch .toggle-label::after {
content: attr(data-text);
font-weight: 600;
height: 0;
visibility: hidden;
}
.toggle-switch.disabled {
opacity: 0.4;
pointer-events: none;
cursor: not-allowed;
}
.toggle-track {
position: relative;
width: 36px;
height: 20px;
background: var(--bg-tertiary);
border: 2px solid var(--border);
border-radius: 12px;
transition: all var(--transition-fast);
box-shadow: inset var(--shadow-sm);
}
.toggle-track:hover {
border-color: var(--text-muted);
}
.toggle-track.on {
background: var(--accent);
border-color: var(--accent);
box-shadow: 0 0 8px var(--accent-glow);
}
.toggle-track::after {
content: '';
position: absolute;
top: 1px;
left: 1px;
width: 14px;
height: 14px;
background: white;
border-radius: 50%;
box-shadow: var(--shadow-sm);
transition: all var(--transition-fast);
}
.toggle-track.on::after {
transform: translateX(16px);
box-shadow: var(--shadow-md);
}
+58
View File
@@ -0,0 +1,58 @@
// Tachyon Profiler - Shared JavaScript
// Common utilities shared between flamegraph and heatmap views
// ============================================================================
// Theme Support
// ============================================================================
// Storage key for theme preference
const THEME_STORAGE_KEY = 'tachyon-theme';
// Get the preferred theme from localStorage or system preference
function getPreferredTheme() {
const saved = localStorage.getItem(THEME_STORAGE_KEY);
if (saved) return saved;
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
// Apply theme and update UI
function applyTheme(theme) {
document.documentElement.setAttribute('data-theme', theme);
const btn = document.getElementById('theme-btn');
if (btn) {
const moonIcon = btn.querySelector('.icon-moon');
const sunIcon = btn.querySelector('.icon-sun');
if (moonIcon) moonIcon.style.display = theme === 'dark' ? 'none' : '';
if (sunIcon) sunIcon.style.display = theme === 'dark' ? '' : 'none';
}
}
// Toggle theme and save preference. Returns the new theme.
function toggleAndSaveTheme() {
const current = document.documentElement.getAttribute('data-theme') || 'light';
const next = current === 'light' ? 'dark' : 'light';
applyTheme(next);
localStorage.setItem(THEME_STORAGE_KEY, next);
return next;
}
// ============================================================================
// Toggle Switch UI
// ============================================================================
function updateToggleUI(toggleId, isOn) {
const toggle = document.getElementById(toggleId);
if (toggle) {
const track = toggle.querySelector('.toggle-track');
const labels = toggle.querySelectorAll('.toggle-label');
if (isOn) {
track.classList.add('on');
labels[0].classList.remove('active');
labels[1].classList.add('active');
} else {
track.classList.remove('on');
labels[0].classList.add('active');
labels[1].classList.remove('active');
}
}
}
@@ -0,0 +1 @@
!function(t,n){"object"==typeof exports&&"object"==typeof module?module.exports=n():"function"==typeof define&&define.amd?define([],n):"object"==typeof exports?exports.flamegraph=n():(t.flamegraph=t.flamegraph||{},t.flamegraph.tooltip=n())}(self,(function(){return(()=>{"use strict";var t={d:(n,e)=>{for(var r in e)t.o(e,r)&&!t.o(n,r)&&Object.defineProperty(n,r,{enumerable:!0,get:e[r]})},o:(t,n)=>Object.prototype.hasOwnProperty.call(t,n),r:t=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(t,"__esModule",{value:!0})}},n={};function e(){}function r(t){return null==t?e:function(){return this.querySelector(t)}}function i(t){return null==t?[]:Array.isArray(t)?t:Array.from(t)}function o(){return[]}function u(t){return null==t?o:function(){return this.querySelectorAll(t)}}function a(t){return function(){return this.matches(t)}}function s(t){return function(n){return n.matches(t)}}t.r(n),t.d(n,{defaultFlamegraphTooltip:()=>Ae});var l=Array.prototype.find;function c(){return this.firstElementChild}var f=Array.prototype.filter;function h(){return Array.from(this.children)}function p(t){return new Array(t.length)}function d(t,n){this.ownerDocument=t.ownerDocument,this.namespaceURI=t.namespaceURI,this._next=null,this._parent=t,this.__data__=n}function y(t){return function(){return t}}function v(t,n,e,r,i,o){for(var u,a=0,s=n.length,l=o.length;a<l;++a)(u=n[a])?(u.__data__=o[a],r[a]=u):e[a]=new d(t,o[a]);for(;a<s;++a)(u=n[a])&&(i[a]=u)}function g(t,n,e,r,i,o,u){var a,s,l,c=new Map,f=n.length,h=o.length,p=new Array(f);for(a=0;a<f;++a)(s=n[a])&&(p[a]=l=u.call(s,s.__data__,a,n)+"",c.has(l)?i[a]=s:c.set(l,s));for(a=0;a<h;++a)l=u.call(t,o[a],a,o)+"",(s=c.get(l))?(r[a]=s,s.__data__=o[a],c.delete(l)):e[a]=new d(t,o[a]);for(a=0;a<f;++a)(s=n[a])&&c.get(p[a])===s&&(i[a]=s)}function _(t){return t.__data__}function m(t){return"object"==typeof t&&"length"in t?t:Array.from(t)}function w(t,n){return t<n?-1:t>n?1:t>=n?0:NaN}d.prototype={constructor:d,appendChild:function(t){return this._parent.insertBefore(t,this._next)},insertBefore:function(t,n){return this._parent.insertBefore(t,n)},querySelector:function(t){return this._parent.querySelector(t)},querySelectorAll:function(t){return this._parent.querySelectorAll(t)}};var b="http://www.w3.org/1999/xhtml";const x={svg:"http://www.w3.org/2000/svg",xhtml:b,xlink:"http://www.w3.org/1999/xlink",xml:"http://www.w3.org/XML/1998/namespace",xmlns:"http://www.w3.org/2000/xmlns/"};function A(t){var n=t+="",e=n.indexOf(":");return e>=0&&"xmlns"!==(n=t.slice(0,e))&&(t=t.slice(e+1)),x.hasOwnProperty(n)?{space:x[n],local:t}:t}function N(t){return function(){this.removeAttribute(t)}}function k(t){return function(){this.removeAttributeNS(t.space,t.local)}}function M(t,n){return function(){this.setAttribute(t,n)}}function E(t,n){return function(){this.setAttributeNS(t.space,t.local,n)}}function S(t,n){return function(){var e=n.apply(this,arguments);null==e?this.removeAttribute(t):this.setAttribute(t,e)}}function C(t,n){return function(){var e=n.apply(this,arguments);null==e?this.removeAttributeNS(t.space,t.local):this.setAttributeNS(t.space,t.local,e)}}function P(t){return t.ownerDocument&&t.ownerDocument.defaultView||t.document&&t||t.defaultView}function O(t){return function(){this.style.removeProperty(t)}}function q(t,n,e){return function(){this.style.setProperty(t,n,e)}}function j(t,n,e){return function(){var r=n.apply(this,arguments);null==r?this.style.removeProperty(t):this.style.setProperty(t,r,e)}}function T(t,n){return t.style.getPropertyValue(n)||P(t).getComputedStyle(t,null).getPropertyValue(n)}function X(t){return function(){delete this[t]}}function R(t,n){return function(){this[t]=n}}function I(t,n){return function(){var e=n.apply(this,arguments);null==e?delete this[t]:this[t]=e}}function L(t){return t.trim().split(/^|\s+/)}function D(t){return t.classList||new H(t)}function H(t){this._node=t,this._names=L(t.getAttribute("class")||"")}function Y(t,n){for(var e=D(t),r=-1,i=n.length;++r<i;)e.add(n[r])}function B(t,n){for(var e=D(t),r=-1,i=n.length;++r<i;)e.remove(n[r])}function $(t){return function(){Y(this,t)}}function V(t){return function(){B(this,t)}}function z(t,n){return function(){(n.apply(this,arguments)?Y:B)(this,t)}}function U(){this.textContent=""}function F(t){return function(){this.textContent=t}}function K(t){return function(){var n=t.apply(this,arguments);this.textContent=null==n?"":n}}function W(){this.innerHTML=""}function G(t){return function(){this.innerHTML=t}}function J(t){return function(){var n=t.apply(this,arguments);this.innerHTML=null==n?"":n}}function Q(){this.nextSibling&&this.parentNode.appendChild(this)}function Z(){this.previousSibling&&this.parentNode.insertBefore(this,this.parentNode.firstChild)}function tt(t){return function(){var n=this.ownerDocument,e=this.namespaceURI;return e===b&&n.documentElement.namespaceURI===b?n.createElement(t):n.createElementNS(e,t)}}function nt(t){retLine truncated
@@ -0,0 +1,46 @@
.d3-flame-graph rect {
stroke: #EEEEEE;
fill-opacity: .8;
}
.d3-flame-graph rect:hover {
stroke: #474747;
stroke-width: 0.5;
cursor: pointer;
}
.d3-flame-graph-label {
pointer-events: none;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
font-size: 12px;
font-family: Verdana;
margin-left: 4px;
margin-right: 4px;
line-height: 1.5;
padding: 0 0 0;
font-weight: 400;
color: black;
text-align: left;
}
.d3-flame-graph .fade {
opacity: 0.6 !important;
}
.d3-flame-graph .title {
font-size: 20px;
font-family: Verdana;
}
.d3-flame-graph-tip {
background-color: black;
border: none;
border-radius: 3px;
padding: 5px 10px 5px 10px;
min-width: 250px;
text-align: left;
color: white;
z-index: 10;
}
@@ -0,0 +1 @@
!function(t,n){"object"==typeof exports&&"object"==typeof module?module.exports=n():"function"==typeof define&&define.amd?define([],n):"object"==typeof exports?exports.flamegraph=n():t.flamegraph=n()}(self,(function(){return(()=>{"use strict";var t={d:(n,e)=>{for(var r in e)t.o(e,r)&&!t.o(n,r)&&Object.defineProperty(n,r,{enumerable:!0,get:e[r]})},o:(t,n)=>Object.prototype.hasOwnProperty.call(t,n)},n={};function e(){}function r(t){return null==t?e:function(){return this.querySelector(t)}}function i(t){return null==t?[]:Array.isArray(t)?t:Array.from(t)}function o(){return[]}function u(t){return null==t?o:function(){return this.querySelectorAll(t)}}function a(t){return function(){return this.matches(t)}}function l(t){return function(n){return n.matches(t)}}t.d(n,{default:()=>xr});var s=Array.prototype.find;function c(){return this.firstElementChild}var h=Array.prototype.filter;function f(){return Array.from(this.children)}function p(t){return new Array(t.length)}function d(t,n){this.ownerDocument=t.ownerDocument,this.namespaceURI=t.namespaceURI,this._next=null,this._parent=t,this.__data__=n}function g(t){return function(){return t}}function v(t,n,e,r,i,o){for(var u,a=0,l=n.length,s=o.length;a<s;++a)(u=n[a])?(u.__data__=o[a],r[a]=u):e[a]=new d(t,o[a]);for(;a<l;++a)(u=n[a])&&(i[a]=u)}function y(t,n,e,r,i,o,u){var a,l,s,c=new Map,h=n.length,f=o.length,p=new Array(h);for(a=0;a<h;++a)(l=n[a])&&(p[a]=s=u.call(l,l.__data__,a,n)+"",c.has(s)?i[a]=l:c.set(s,l));for(a=0;a<f;++a)s=u.call(t,o[a],a,o)+"",(l=c.get(s))?(r[a]=l,l.__data__=o[a],c.delete(s)):e[a]=new d(t,o[a]);for(a=0;a<h;++a)(l=n[a])&&c.get(p[a])===l&&(i[a]=l)}function m(t){return t.__data__}function w(t){return"object"==typeof t&&"length"in t?t:Array.from(t)}function _(t,n){return t<n?-1:t>n?1:t>=n?0:NaN}d.prototype={constructor:d,appendChild:function(t){return this._parent.insertBefore(t,this._next)},insertBefore:function(t,n){return this._parent.insertBefore(t,n)},querySelector:function(t){return this._parent.querySelector(t)},querySelectorAll:function(t){return this._parent.querySelectorAll(t)}};var b="http://www.w3.org/1999/xhtml";const x={svg:"http://www.w3.org/2000/svg",xhtml:b,xlink:"http://www.w3.org/1999/xlink",xml:"http://www.w3.org/XML/1998/namespace",xmlns:"http://www.w3.org/2000/xmlns/"};function M(t){var n=t+="",e=n.indexOf(":");return e>=0&&"xmlns"!==(n=t.slice(0,e))&&(t=t.slice(e+1)),x.hasOwnProperty(n)?{space:x[n],local:t}:t}function A(t){return function(){this.removeAttribute(t)}}function N(t){return function(){this.removeAttributeNS(t.space,t.local)}}function k(t,n){return function(){this.setAttribute(t,n)}}function E(t,n){return function(){this.setAttributeNS(t.space,t.local,n)}}function S(t,n){return function(){var e=n.apply(this,arguments);null==e?this.removeAttribute(t):this.setAttribute(t,e)}}function C(t,n){return function(){var e=n.apply(this,arguments);null==e?this.removeAttributeNS(t.space,t.local):this.setAttributeNS(t.space,t.local,e)}}function j(t){return t.ownerDocument&&t.ownerDocument.defaultView||t.document&&t||t.defaultView}function q(t){return function(){this.style.removeProperty(t)}}function O(t,n,e){return function(){this.style.setProperty(t,n,e)}}function P(t,n,e){return function(){var r=n.apply(this,arguments);null==r?this.style.removeProperty(t):this.style.setProperty(t,r,e)}}function H(t,n){return t.style.getPropertyValue(n)||j(t).getComputedStyle(t,null).getPropertyValue(n)}function L(t){return function(){delete this[t]}}function T(t,n){return function(){this[t]=n}}function D(t,n){return function(){var e=n.apply(this,arguments);null==e?delete this[t]:this[t]=e}}function B(t){return t.trim().split(/^|\s+/)}function X(t){return t.classList||new z(t)}function z(t){this._node=t,this._names=B(t.getAttribute("class")||"")}function R(t,n){for(var e=X(t),r=-1,i=n.length;++r<i;)e.add(n[r])}function I(t,n){for(var e=X(t),r=-1,i=n.length;++r<i;)e.remove(n[r])}function $(t){return function(){R(this,t)}}function V(t){return function(){I(this,t)}}function Y(t,n){return function(){(n.apply(this,arguments)?R:I)(this,t)}}function F(){this.textContent=""}function U(t){return function(){this.textContent=t}}function Z(t){return function(){var n=t.apply(this,arguments);this.textContent=null==n?"":n}}function G(){this.innerHTML=""}function K(t){return function(){this.innerHTML=t}}function W(t){return function(){var n=t.apply(this,arguments);this.innerHTML=null==n?"":n}}function J(){this.nextSibling&&this.parentNode.appendChild(this)}function Q(){this.previousSibling&&this.parentNode.insertBefore(this,this.parentNode.firstChild)}function tt(t){return function(){var n=this.ownerDocument,e=this.namespaceURI;return e===b&&n.documentElement.namespaceURI===b?n.createElement(t):n.createElementNS(e,t)}}function nt(t){return function(){return this.ownerDocument.createElementNS(t.space,t.local)}}function et(t){var n=M(t);return(n.local?nt:tt)(n)}function rt(){return null}function it(){var t=this.parentNode;t&&t.removeChild(this)}function ot(){var Line truncated
+2
View File
@@ -0,0 +1,2 @@
// https://d3js.org v7.8.5 Copyright 2010-2023 Mike Bostock
!function(t,n){"object"==typeof exports&&"undefined"!=typeof module?n(exports):"function"==typeof define&&define.amd?define(["exports"],n):n((t="undefined"!=typeof globalThis?globalThis:t||self).d3=t.d3||{})}(this,(function(t){"use strict";function n(t,n){return null==t||null==n?NaN:t<n?-1:t>n?1:t>=n?0:NaN}function e(t,n){return null==t||null==n?NaN:n<t?-1:n>t?1:n>=t?0:NaN}function r(t){let r,o,a;function u(t,n,e=0,i=t.length){if(e<i){if(0!==r(n,n))return i;do{const r=e+i>>>1;o(t[r],n)<0?e=r+1:i=r}while(e<i)}return e}return 2!==t.length?(r=n,o=(e,r)=>n(t(e),r),a=(n,e)=>t(n)-e):(r=t===n||t===e?t:i,o=t,a=t),{left:u,center:function(t,n,e=0,r=t.length){const i=u(t,n,e,r-1);return i>e&&a(t[i-1],n)>-a(t[i],n)?i-1:i},right:function(t,n,e=0,i=t.length){if(e<i){if(0!==r(n,n))return i;do{const r=e+i>>>1;o(t[r],n)<=0?e=r+1:i=r}while(e<i)}return e}}}function i(){return 0}function o(t){return null===t?NaN:+t}const a=r(n),u=a.right,c=a.left,f=r(o).center;var s=u;const l=d(y),h=d((function(t){const n=y(t);return(t,e,r,i,o)=>{n(t,e,(r<<=2)+0,(i<<=2)+0,o<<=2),n(t,e,r+1,i+1,o),n(t,e,r+2,i+2,o),n(t,e,r+3,i+3,o)}}));function d(t){return function(n,e,r=e){if(!((e=+e)>=0))throw new RangeError("invalid rx");if(!((r=+r)>=0))throw new RangeError("invalid ry");let{data:i,width:o,height:a}=n;if(!((o=Math.floor(o))>=0))throw new RangeError("invalid width");if(!((a=Math.floor(void 0!==a?a:i.length/o))>=0))throw new RangeError("invalid height");if(!o||!a||!e&&!r)return n;const u=e&&t(e),c=r&&t(r),f=i.slice();return u&&c?(p(u,f,i,o,a),p(u,i,f,o,a),p(u,f,i,o,a),g(c,i,f,o,a),g(c,f,i,o,a),g(c,i,f,o,a)):u?(p(u,i,f,o,a),p(u,f,i,o,a),p(u,i,f,o,a)):c&&(g(c,i,f,o,a),g(c,f,i,o,a),g(c,i,f,o,a)),n}}function p(t,n,e,r,i){for(let o=0,a=r*i;o<a;)t(n,e,o,o+=r,1)}function g(t,n,e,r,i){for(let o=0,a=r*i;o<r;++o)t(n,e,o,o+a,r)}function y(t){const n=Math.floor(t);if(n===t)return function(t){const n=2*t+1;return(e,r,i,o,a)=>{if(!((o-=a)>=i))return;let u=t*r[i];const c=a*t;for(let t=i,n=i+c;t<n;t+=a)u+=r[Math.min(o,t)];for(let t=i,f=o;t<=f;t+=a)u+=r[Math.min(o,t+c)],e[t]=u/n,u-=r[Math.max(i,t-c)]}}(t);const e=t-n,r=2*t+1;return(t,i,o,a,u)=>{if(!((a-=u)>=o))return;let c=n*i[o];const f=u*n,s=f+u;for(let t=o,n=o+f;t<n;t+=u)c+=i[Math.min(a,t)];for(let n=o,l=a;n<=l;n+=u)c+=i[Math.min(a,n+f)],t[n]=(c+e*(i[Math.max(o,n-s)]+i[Math.min(a,n+s)]))/r,c-=i[Math.max(o,n-f)]}}function v(t,n){let e=0;if(void 0===n)for(let n of t)null!=n&&(n=+n)>=n&&++e;else{let r=-1;for(let i of t)null!=(i=n(i,++r,t))&&(i=+i)>=i&&++e}return e}function _(t){return 0|t.length}function b(t){return!(t>0)}function m(t){return"object"!=typeof t||"length"in t?t:Array.from(t)}function x(t,n){let e,r=0,i=0,o=0;if(void 0===n)for(let n of t)null!=n&&(n=+n)>=n&&(e=n-i,i+=e/++r,o+=e*(n-i));else{let a=-1;for(let u of t)null!=(u=n(u,++a,t))&&(u=+u)>=u&&(e=u-i,i+=e/++r,o+=e*(u-i))}if(r>1)return o/(r-1)}function w(t,n){const e=x(t,n);return e?Math.sqrt(e):e}function M(t,n){let e,r;if(void 0===n)for(const n of t)null!=n&&(void 0===e?n>=n&&(e=r=n):(e>n&&(e=n),r<n&&(r=n)));else{let i=-1;for(let o of t)null!=(o=n(o,++i,t))&&(void 0===e?o>=o&&(e=r=o):(e>o&&(e=o),r<o&&(r=o)))}return[e,r]}class T{constructor(){this._partials=new Float64Array(32),this._n=0}add(t){const n=this._partials;let e=0;for(let r=0;r<this._n&&r<32;r++){const i=n[r],o=t+i,a=Math.abs(t)<Math.abs(i)?t-(o-i):i-(o-t);a&&(n[e++]=a),t=o}return n[e]=t,this._n=e+1,this}valueOf(){const t=this._partials;let n,e,r,i=this._n,o=0;if(i>0){for(o=t[--i];i>0&&(n=o,e=t[--i],o=n+e,r=e-(o-n),!r););i>0&&(r<0&&t[i-1]<0||r>0&&t[i-1]>0)&&(e=2*r,n=o+e,e==n-o&&(o=n))}return o}}class InternMap extends Map{constructor(t,n=N){if(super(),Object.defineProperties(this,{_intern:{value:new Map},_key:{value:n}}),null!=t)for(const[n,e]of t)this.set(n,e)}get(t){return super.get(A(this,t))}has(t){return super.has(A(this,t))}set(t,n){return super.set(S(this,t),n)}delete(t){return super.delete(E(this,t))}}class InternSet extends Set{constructor(t,n=N){if(super(),Object.defineProperties(this,{_intern:{value:new Map},_key:{value:n}}),null!=t)for(const n of t)this.add(n)}has(t){return super.has(A(this,t))}add(t){return super.add(S(this,t))}delete(t){return super.delete(E(this,t))}}function A({_intern:t,_key:n},e){const r=n(e);return t.has(r)?t.get(r):e}function S({_intern:t,_key:n},e){const r=n(e);return t.has(r)?t.get(r):(t.set(r,e),e)}function E({_intern:t,_key:n},e){const r=n(e);return t.has(r)&&(e=t.get(r),t.delete(r)),e}function N(t){return null!==t&&"object"==typeof t?t.valueOf():t}function k(t){return t}function C(t,...n){return F(t,k,k,n)}function P(t,...n){return F(t,Array.from,k,n)}function z(t,n){for(let e=1,r=n.length;e<r;++e)t=t.flatMap((t=>t.pop().map((([n,e])=>[...t,n,e]))));return t}function $(t,n,...e){return F(t,k,n,e)}function D(t,n,...e){return F(t,Array.from,n,e)}function R(t){if(1!==t.length)throw new Error("duplicate key");return t[0]}function F(t,n,e,r){return function t(i,o){if(o>=r.length)return e(i);const a=new InternMap,u=r[o++];let c=-1;for(const t of i){Line truncated
+151
View File
@@ -0,0 +1,151 @@
"""Thin Python wrapper around C binary writer for profiling data."""
import time
import _remote_debugging
from .collector import Collector
# Compression type constants (must match binary_io.h)
COMPRESSION_NONE = 0
COMPRESSION_ZSTD = 1
CAPTURE_FEATURES = {
"all_threads": 1 << 0,
"native": 1 << 1,
"gc": 1 << 2,
"opcodes": 1 << 3,
"blocking": 1 << 4,
}
def encode_capture_config(capture_config):
if capture_config is None:
return -1
return sum(
bit for name, bit in CAPTURE_FEATURES.items()
if capture_config.get(name, False)
)
def _resolve_compression(compression):
"""Resolve compression type from string or int.
Args:
compression: 'auto', 'zstd', 'none', or int (0/1)
Returns:
int: Compression type constant
"""
if isinstance(compression, int):
return compression
compression = compression.lower()
if compression == 'none':
return COMPRESSION_NONE
elif compression == 'zstd':
return COMPRESSION_ZSTD
elif compression == 'auto':
# Auto: use zstd if available, otherwise none
if _remote_debugging.zstd_available():
return COMPRESSION_ZSTD
return COMPRESSION_NONE
else:
raise ValueError(f"Unknown compression type: {compression}")
class BinaryCollector(Collector):
"""High-performance binary collector using C implementation.
This collector writes profiling data directly to a binary file format
with optional zstd compression. All I/O is performed in C for maximum
throughput.
The binary format uses string/frame deduplication and varint encoding
for efficient storage.
"""
def __init__(self, filename, sample_interval_usec, *, skip_idle=False,
compression='auto', mode=None, capture_config=None):
"""Create a new binary collector.
Args:
filename: Path to output binary file
sample_interval_usec: Sampling interval in microseconds
skip_idle: If True, skip idle threads (not used in binary format)
compression: 'auto', 'zstd', 'none', or int (0=none, 1=zstd)
mode: Profiling mode, or None if unknown
capture_config: Mapping of capture feature names to booleans, or
None if the capture configuration is unknown
"""
self.filename = filename
self.sample_interval_usec = sample_interval_usec
self.skip_idle = skip_idle
compression_type = _resolve_compression(compression)
start_time_us = int(time.monotonic() * 1_000_000)
self._writer = _remote_debugging.BinaryWriter(
filename, sample_interval_usec, start_time_us,
compression=compression_type,
mode=-1 if mode is None else mode,
capture_features=encode_capture_config(capture_config),
)
def collect(self, stack_frames, timestamp_us=None):
"""Collect profiling data from stack frames.
This passes stack_frames directly to the C writer which handles
all encoding and buffering.
Args:
stack_frames: List of InterpreterInfo objects from _remote_debugging
timestamp_us: Optional timestamp in microseconds. If not provided,
uses time.monotonic() to generate one.
"""
if timestamp_us is None:
timestamp_us = int(time.monotonic() * 1_000_000)
self._writer.write_sample(stack_frames, timestamp_us)
def collect_failed_sample(self):
"""Record a failed sample attempt (no-op for binary format)."""
pass
def set_stats(self, sample_interval_usec, duration_sec, sample_rate,
error_rate=None, missed_samples=None, **kwargs):
"""Persist measured statistics for later replay."""
self._writer.set_stats(
duration_sec, sample_rate, error_rate, missed_samples
)
def export(self, filename=None):
"""Finalize and close the binary file.
Args:
filename: Ignored (binary files are written incrementally)
"""
self._writer.finalize()
return True
@property
def total_samples(self):
return self._writer.total_samples
def get_stats(self):
"""Get encoding statistics.
Returns:
Dict with encoding statistics including repeat/full/suffix/pop-push
record counts, frames written/saved, and compression ratio.
"""
return self._writer.get_stats()
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
"""Context manager exit - finalize unless there was an error."""
if exc_type is None:
self._writer.finalize()
else:
self._writer.close()
return False
+148
View File
@@ -0,0 +1,148 @@
"""Thin Python wrapper around C binary reader for profiling data."""
import _remote_debugging
from .gecko_collector import GeckoCollector
from .stack_collector import FlamegraphCollector, CollapsedStackCollector
from .jsonl_collector import JsonlCollector
from .pstats_collector import PstatsCollector
from .binary_collector import CAPTURE_FEATURES
class BinaryReader:
"""High-performance binary reader using C implementation.
This reader uses memory-mapped I/O (on Unix) for fast replay of
profiling data from binary files.
Use as a context manager:
with BinaryReader('profile.bin') as reader:
info = reader.get_info()
reader.replay_samples(collector, progress_callback)
"""
def __init__(self, filename):
"""Create a new binary reader.
Args:
filename: Path to input binary file
"""
self.filename = filename
self._reader = None
def __enter__(self):
self._reader = _remote_debugging.BinaryReader(self.filename)
return self
def __exit__(self, exc_type, exc_val, exc_tb):
if self._reader is not None:
self._reader.close()
self._reader = None
return False
def get_info(self):
"""Get metadata about the binary file.
Returns:
dict: File metadata including:
- sample_count: Number of samples in the file
- sample_interval_us: Sampling interval in microseconds
- start_time_us: Start timestamp in microseconds
- string_count: Number of unique strings
- frame_count: Number of unique frames
- compression: Compression type used
- mode: Profiling mode, or None if not recorded
- capture_config: Capture feature mapping, or None if not
recorded
"""
if self._reader is None:
raise RuntimeError("Reader not open. Use as context manager.")
info = self._reader.get_info()
capture_features = info.pop("capture_features")
info["capture_config"] = (
None if capture_features is None else {
name: bool(capture_features & bit)
for name, bit in CAPTURE_FEATURES.items()
}
)
return info
def replay_samples(self, collector, progress_callback=None):
"""Replay samples from binary file through a collector.
This allows converting binary profiling data to other formats
(e.g., flamegraph, pstats) by replaying through the appropriate
collector.
Args:
collector: A Collector instance with a collect() method
progress_callback: Optional callable(current, total) for progress
Returns:
int: Number of samples replayed
"""
if self._reader is None:
raise RuntimeError("Reader not open. Use as context manager.")
return self._reader.replay(collector, progress_callback)
@property
def sample_count(self):
if self._reader is None:
raise RuntimeError("Reader not open. Use as context manager.")
return self._reader.get_info()['sample_count']
def get_stats(self):
"""Get reconstruction statistics from replay.
Returns:
dict: Statistics about record types decoded and samples
reconstructed during replay.
"""
if self._reader is None:
raise RuntimeError("Reader not open. Use as context manager.")
return self._reader.get_stats()
def convert_binary_to_format(input_file, output_file, output_format,
sample_interval_usec=None, progress_callback=None):
"""Convert a binary profiling file to another format.
Args:
input_file: Path to input binary file
output_file: Path to output file
output_format: Target format ('flamegraph', 'collapsed', 'pstats', etc.)
sample_interval_usec: Override sample interval (uses file's if None)
progress_callback: Optional callable(current, total) for progress
Returns:
int: Number of samples converted
"""
with BinaryReader(input_file) as reader:
info = reader.get_info()
interval = sample_interval_usec or info['sample_interval_us']
# Create appropriate collector based on format
if output_format == 'flamegraph':
collector = FlamegraphCollector(interval)
elif output_format == 'collapsed':
collector = CollapsedStackCollector(interval)
elif output_format == 'pstats':
collector = PstatsCollector(interval)
elif output_format == 'gecko':
collector = GeckoCollector(interval)
elif output_format == "jsonl":
collector = JsonlCollector(interval, mode=info.get("mode"))
else:
raise ValueError(f"Unknown output format: {output_format}")
# Replay samples through collector
count = reader.replay_samples(collector, progress_callback)
if hasattr(collector, "set_replay_stats"):
collector.set_replay_stats(info)
if hasattr(collector, "set_mode"):
collector.set_mode(info.get("mode"))
# Export to target format
collector.export(output_file)
return count
+292
View File
@@ -0,0 +1,292 @@
from abc import ABC, abstractmethod
from .constants import (
DEFAULT_LOCATION,
THREAD_STATUS_HAS_GIL,
THREAD_STATUS_ON_CPU,
THREAD_STATUS_GIL_REQUESTED,
THREAD_STATUS_UNKNOWN,
THREAD_STATUS_HAS_EXCEPTION,
_INTERNAL_FRAME_SUFFIXES,
)
try:
from _remote_debugging import FrameInfo
except ImportError:
# Fallback definition if _remote_debugging is not available
FrameInfo = None
def normalize_location(location):
"""Normalize location to a 4-tuple format.
Args:
location: tuple (lineno, end_lineno, col_offset, end_col_offset),
an integer line number, or None
Returns:
tuple: (lineno, end_lineno, col_offset, end_col_offset)
"""
if location is None:
return DEFAULT_LOCATION
if isinstance(location, int):
return (location, location, -1, -1)
return location
def extract_lineno(location):
"""Extract lineno from location.
Args:
location: tuple (lineno, end_lineno, col_offset, end_col_offset),
an integer line number, or None
Returns:
int: The line number (0 for synthetic frames)
"""
if location is None:
return 0
if isinstance(location, int):
return location
return location[0]
def _is_internal_frame(frame):
if isinstance(frame, tuple):
filename = frame[0] if frame else ""
else:
filename = getattr(frame, "filename", "")
if not filename:
return False
return filename.endswith(_INTERNAL_FRAME_SUFFIXES)
def filter_internal_frames(frames):
if not frames:
return frames
return [f for f in frames if not _is_internal_frame(f)]
def _build_task_graph(awaited_info_list):
task_map = {}
child_to_parent = {} # child_id -> (selected_parent_id, parent_count)
all_task_ids = set()
all_parent_ids = set()
for awaited_info in awaited_info_list:
thread_id = awaited_info.thread_id
for task_info in awaited_info.awaited_by:
task_id = task_info.task_id
task_map[task_id] = (task_info, thread_id)
all_task_ids.add(task_id)
if task_info.awaited_by:
parent_ids = [p.task_name for p in task_info.awaited_by]
parent_count = len(parent_ids)
all_parent_ids.update(parent_ids)
selected_parent = min(parent_ids) if parent_count > 1 else parent_ids[0]
child_to_parent[task_id] = (selected_parent, parent_count)
return task_map, child_to_parent, all_task_ids, all_parent_ids
def _find_leaf_tasks(all_task_ids, all_parent_ids):
return all_task_ids - all_parent_ids
def _build_linear_stacks(leaf_task_ids, task_map, child_to_parent):
for leaf_id in leaf_task_ids:
frames = []
visited = set()
current_id = leaf_id
thread_id = None
while current_id is not None:
if current_id in visited:
break
visited.add(current_id)
if current_id not in task_map:
break
task_info, tid = task_map[current_id]
if thread_id is None:
thread_id = tid
if task_info.coroutine_stack:
for coro_info in task_info.coroutine_stack:
for frame in coro_info.call_stack:
frames.append(frame)
parent_info = child_to_parent.get(current_id)
task_name = task_info.task_name or "Task-" + str(task_info.task_id)
if parent_info:
selected_parent, parent_count = parent_info
if parent_count > 1:
task_name = f"{task_name} ({parent_count} parents)"
frames.append(FrameInfo(("<task>", None, task_name, None)))
current_id = selected_parent
else:
frames.append(FrameInfo(("<task>", None, task_name, None)))
current_id = None
if frames and thread_id is not None:
yield frames, thread_id, leaf_id
def iter_async_frames(awaited_info_list):
task_map, child_to_parent, all_task_ids, all_parent_ids = _build_task_graph(awaited_info_list)
leaf_task_ids = _find_leaf_tasks(all_task_ids, all_parent_ids)
yield from _build_linear_stacks(leaf_task_ids, task_map, child_to_parent)
class Collector(ABC):
aggregating = False
@abstractmethod
def collect(self, stack_frames, timestamps_us=None):
"""Collect profiling data from stack frames.
Args:
stack_frames: List of InterpreterInfo objects
timestamps_us: Optional list of timestamps in microseconds. If provided
(from binary replay with RLE batching), use these instead of current
time. If None, collectors should use time.monotonic() or similar.
The list may contain multiple timestamps when samples are batched
together (same stack, different times).
"""
def collect_failed_sample(self):
"""Collect data about a failed sample attempt."""
@abstractmethod
def export(self, filename):
"""Export collected data.
Returns:
bool: True if output was generated, False if there was no data to export.
"""
@staticmethod
def _filter_internal_frames(frames):
return filter_internal_frames(frames)
def _iter_all_frames(self, stack_frames, skip_idle=False):
for interpreter_info in stack_frames:
for thread_info in interpreter_info.threads:
# skip_idle now means: skip if thread is not actively running
# A thread is "active" if it has the GIL OR is on CPU
if skip_idle:
status_flags = thread_info.status
has_gil = bool(status_flags & THREAD_STATUS_HAS_GIL)
on_cpu = bool(status_flags & THREAD_STATUS_ON_CPU)
if not (has_gil or on_cpu):
continue
frames = thread_info.frame_info
if frames:
# Filter out internal profiler frames from the bottom of the stack
frames = self._filter_internal_frames(frames)
if frames:
yield frames, thread_info.thread_id
def _iter_async_frames(self, awaited_info_list):
yield from iter_async_frames(awaited_info_list)
def _iter_stacks(self, stack_frames, skip_idle=False):
"""Yield (frames, thread_id) for all stacks, handling both sync and async modes."""
if stack_frames and hasattr(stack_frames[0], "awaited_by"):
for frames, thread_id, _ in iter_async_frames(stack_frames):
if frames:
yield frames, thread_id
else:
for frames, thread_id in self._iter_all_frames(stack_frames, skip_idle=skip_idle):
if frames:
yield frames, thread_id
def _is_gc_frame(self, frame):
if isinstance(frame, tuple):
funcname = frame[2] if len(frame) >= 3 else ""
else:
funcname = getattr(frame, "funcname", "")
return "<GC>" in funcname or "gc_collect" in funcname
def _collect_thread_status_stats(self, stack_frames):
"""Collect aggregate and per-thread status statistics from a sample.
Returns:
tuple: (aggregate_status_counts, has_gc_frame, per_thread_stats)
- aggregate_status_counts: dict with has_gil, on_cpu, has_exception, etc.
- has_gc_frame: bool indicating if any thread has GC frames
- per_thread_stats: dict mapping thread_id to per-thread counts
"""
status_counts = {
"has_gil": 0,
"on_cpu": 0,
"gil_requested": 0,
"unknown": 0,
"has_exception": 0,
"total": 0,
}
has_gc_frame = False
per_thread_stats = {}
for interpreter_info in stack_frames:
threads = getattr(interpreter_info, "threads", [])
for thread_info in threads:
status_counts["total"] += 1
# Track thread status using bit flags
status_flags = getattr(thread_info, "status", 0)
if status_flags & THREAD_STATUS_HAS_GIL:
status_counts["has_gil"] += 1
if status_flags & THREAD_STATUS_ON_CPU:
status_counts["on_cpu"] += 1
if status_flags & THREAD_STATUS_GIL_REQUESTED:
status_counts["gil_requested"] += 1
if status_flags & THREAD_STATUS_UNKNOWN:
status_counts["unknown"] += 1
if status_flags & THREAD_STATUS_HAS_EXCEPTION:
status_counts["has_exception"] += 1
# Track per-thread statistics
thread_id = getattr(thread_info, "thread_id", None)
if thread_id is not None:
if thread_id not in per_thread_stats:
per_thread_stats[thread_id] = {
"has_gil": 0,
"on_cpu": 0,
"gil_requested": 0,
"unknown": 0,
"has_exception": 0,
"total": 0,
"gc_samples": 0,
}
thread_stats = per_thread_stats[thread_id]
thread_stats["total"] += 1
if status_flags & THREAD_STATUS_HAS_GIL:
thread_stats["has_gil"] += 1
if status_flags & THREAD_STATUS_ON_CPU:
thread_stats["on_cpu"] += 1
if status_flags & THREAD_STATUS_GIL_REQUESTED:
thread_stats["gil_requested"] += 1
if status_flags & THREAD_STATUS_UNKNOWN:
thread_stats["unknown"] += 1
if status_flags & THREAD_STATUS_HAS_EXCEPTION:
thread_stats["has_exception"] += 1
# Check for GC frames in this thread
frames = getattr(thread_info, "frame_info", None)
if frames:
for frame in frames:
if self._is_gc_frame(frame):
thread_stats["gc_samples"] += 1
has_gc_frame = True
break
return status_counts, has_gc_frame, per_thread_stats
+57
View File
@@ -0,0 +1,57 @@
"""Constants for the sampling profiler."""
# Time unit conversion constants
MICROSECONDS_PER_SECOND = 1_000_000
MILLISECONDS_PER_SECOND = 1_000
# Profiling mode constants
PROFILING_MODE_WALL = 0
PROFILING_MODE_CPU = 1
PROFILING_MODE_GIL = 2
PROFILING_MODE_ALL = 3 # Combines GIL + CPU checks
PROFILING_MODE_EXCEPTION = 4 # Only samples when thread has an active exception
PROFILING_MODE_NAMES = {
PROFILING_MODE_WALL: "wall",
PROFILING_MODE_CPU: "cpu",
PROFILING_MODE_GIL: "gil",
PROFILING_MODE_ALL: "all",
PROFILING_MODE_EXCEPTION: "exception",
}
# Sort mode constants
SORT_MODE_NSAMPLES = 0
SORT_MODE_TOTTIME = 1
SORT_MODE_CUMTIME = 2
SORT_MODE_SAMPLE_PCT = 3
SORT_MODE_CUMUL_PCT = 4
SORT_MODE_NSAMPLES_CUMUL = 5
# Default location for synthetic frames (native, GC) that have no source location
# Format: (lineno, end_lineno, col_offset, end_col_offset)
DEFAULT_LOCATION = (0, 0, -1, -1)
# Internal frame path suffixes to filter from profiling output
# These are internal profiler modules that should not appear in user-facing output
_INTERNAL_FRAME_SUFFIXES = (
"_sync_coordinator.py",
)
# Thread status flags
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,
)
except ImportError:
# Fallback for tests or when module is not available
THREAD_STATUS_HAS_GIL = (1 << 0)
THREAD_STATUS_ON_CPU = (1 << 1)
THREAD_STATUS_UNKNOWN = (1 << 2)
THREAD_STATUS_GIL_REQUESTED = (1 << 3)
THREAD_STATUS_HAS_EXCEPTION = (1 << 4)
THREAD_STATUS_MAIN_THREAD = (1 << 5)
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+268
View File
@@ -0,0 +1,268 @@
"""JSON Lines (JSONL) collector for the sampling profiler.
Emits a normalized newline-delimited JSON record stream suitable for
programmatic consumption by external tools, scripts, and agents. Each line
is one JSON object; consumers can parse the file incrementally line by
line, but the producer writes the whole file at the end of the run (it is
not a live/streaming producer).
Record schema
=============
Every record is a JSON object with at least ``"type"``, ``"v"`` (record
schema version), and ``"run_id"`` (UUID4 hex tagging the run; allows
demultiplexing concatenated streams). Records appear in this fixed order:
1. ``meta`` (exactly one, first line)::
{"type":"meta","v":0,"run_id":"<hex>",
"sample_interval_usec":<int>,"mode":"wall|cpu|gil|all|exception"}
``mode`` is omitted when not provided.
2. ``string_table`` (zero or more)::
{"type":"string_table","v":0,"run_id":"<hex>",
"strings":[{"str_id":<int>,"value":"<str>"}, ...]}
Strings (filenames, function names) are interned to keep repeated values
compact. IDs are zero-based. Each chunk holds up to ``_CHUNK_SIZE``
entries, and each entry carries its explicit ``str_id`` so consumers do
not need to infer offsets across chunks.
3. ``frame_table`` (zero or more)::
{"type":"frame_table","v":0,"run_id":"<hex>",
"frames":[{"frame_id":<int>,"path_str_id":<int>,"func_str_id":<int>,
"line":<int>,"end_line":<int>,"col":<int>,
"end_col":<int>}, ...]}
``end_line``/``col``/``end_col`` are *omitted* when source location data
is unavailable (a missing key means "not available", not zero or null).
``line`` is ``0`` for synthetic frames (for example, internal marker
frames whose source location is None). Frame IDs are zero-based.
4. ``agg`` (zero or more)::
{"type":"agg","v":0,"run_id":"<hex>","kind":"frame","scope":"final",
"samples_total":<int>,
"entries":[{"frame_id":<int>,"self":<int>,"cumulative":<int>}, ...]}
``self`` counts samples where the frame was the leaf (currently
executing); ``cumulative`` counts samples where the frame appeared
anywhere in the stack (deduped per sample so recursion does not
double-count). ``samples_total`` is the run-wide total, repeated on
each chunk so a streaming consumer always knows the denominator.
5. ``end`` (exactly one, last line)::
{"type":"end","v":0,"run_id":"<hex>","samples_total":<int>}
Presence of ``end`` is the consumer's signal that the file is complete.
Forward compatibility
=====================
Consumers MUST ignore unknown record ``"type"`` values and unknown object
fields. New fields will be added by adding optional keys; an incompatible
schema change will bump the per-record ``"v"``.
"""
from collections import Counter
import json
import uuid
from itertools import batched
from .constants import PROFILING_MODE_NAMES
from .collector import normalize_location
from .stack_collector import StackTraceCollector
_CHUNK_SIZE = 256
_SCHEMA_VERSION = 0
class JsonlCollector(StackTraceCollector):
"""Collector that exports finalized profiling data as JSONL.
See the module docstring for the full record schema. The collector
accumulates samples in memory and writes the complete file at
``export()`` time.
"""
def __init__(self, sample_interval_usec, *, skip_idle=False, mode=None):
super().__init__(sample_interval_usec, skip_idle=skip_idle)
self.run_id = uuid.uuid4().hex
self._string_to_id = {}
self._strings = []
self._frame_to_id = {}
self._frames = []
self._frame_self = Counter()
self._frame_cumulative = Counter()
self._samples_total = 0
self._seen_frame_ids = set()
self._mode = mode
def process_frames(self, frames, _thread_id, weight=1):
self._samples_total += weight
self._seen_frame_ids.clear()
for i, (filename, location, funcname, _opcode) in enumerate(frames):
frame_id = self._get_or_create_frame_id(
filename, location, funcname
)
is_leaf = i == 0
count_cumulative = frame_id not in self._seen_frame_ids
if count_cumulative:
self._seen_frame_ids.add(frame_id)
if is_leaf:
self._frame_self[frame_id] += weight
if count_cumulative:
self._frame_cumulative[frame_id] += weight
def export(self, filename):
with open(filename, "w", encoding="utf-8") as output:
self._write_message(output, self._build_meta_record())
self._write_chunked_records(
output,
{
"type": "string_table",
"v": _SCHEMA_VERSION,
"run_id": self.run_id,
},
"strings",
self._strings,
)
self._write_chunked_records(
output,
{
"type": "frame_table",
"v": _SCHEMA_VERSION,
"run_id": self.run_id,
},
"frames",
self._frames,
)
self._write_chunked_records(
output,
{
"type": "agg",
"v": _SCHEMA_VERSION,
"run_id": self.run_id,
"kind": "frame",
"scope": "final",
"samples_total": self._samples_total,
},
"entries",
self._iter_final_agg_entries(),
)
self._write_message(output, self._build_end_record())
print(f"JSONL profile written to {filename}")
return True
def _build_meta_record(self):
record = {
"type": "meta",
"v": _SCHEMA_VERSION,
"run_id": self.run_id,
"sample_interval_usec": self.sample_interval_usec,
}
if self._mode is not None:
record["mode"] = PROFILING_MODE_NAMES.get(
self._mode, str(self._mode)
)
return record
def _build_end_record(self):
record = {
"type": "end",
"v": _SCHEMA_VERSION,
"run_id": self.run_id,
"samples_total": self._samples_total,
}
return record
def _iter_final_agg_entries(self):
for frame_record in self._frames:
frame_id = frame_record["frame_id"]
yield {
"frame_id": frame_id,
"self": self._frame_self[frame_id],
"cumulative": self._frame_cumulative[frame_id],
}
def _get_or_create_frame_id(self, filename, location, funcname):
location_fields = self._location_to_export_fields(location)
func_str_id = self._intern_string(funcname)
path_str_id = self._intern_string(filename)
frame_key = (
path_str_id,
func_str_id,
location_fields["line"],
location_fields.get("end_line"),
location_fields.get("col"),
location_fields.get("end_col"),
)
if (frame_id := self._frame_to_id.get(frame_key)) is not None:
return frame_id
frame_id = len(self._frames)
frame_record = {
"frame_id": frame_id,
"path_str_id": path_str_id,
"func_str_id": func_str_id,
**location_fields,
}
self._frame_to_id[frame_key] = frame_id
self._frames.append(frame_record)
return frame_id
def _intern_string(self, value):
value = str(value)
if (string_id := self._string_to_id.get(value)) is not None:
return string_id
string_id = len(self._strings)
self._string_to_id[value] = string_id
self._strings.append({"str_id": string_id, "value": value})
return string_id
@staticmethod
def _location_to_export_fields(location):
lineno, end_lineno, col_offset, end_col_offset = normalize_location(
location
)
fields = {"line": lineno}
if end_lineno > 0:
fields["end_line"] = end_lineno
if col_offset >= 0:
fields["col"] = col_offset
if end_col_offset >= 0:
fields["end_col"] = end_col_offset
return fields
def _write_chunked_records(
self, output, base_record, chunk_field, entries
):
for chunk in batched(entries, _CHUNK_SIZE):
self._write_message(output, {**base_record, chunk_field: chunk})
@staticmethod
def _write_message(output, record):
output.write(json.dumps(record, separators=(",", ":")))
output.write("\n")
+102
View File
@@ -0,0 +1,102 @@
"""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 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 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 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 _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, 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__':
parts = parts[:-1]
return '.'.join(parts) if parts else path.stem
+94
View File
@@ -0,0 +1,94 @@
"""Opcode utilities for bytecode-level profiler visualization.
This module provides utilities to get opcode names and detect specialization
status using the opcode module's metadata. Used by heatmap and flamegraph
collectors to display which bytecode instructions are executing at each
source line, including Python's adaptive specialization optimizations.
"""
import opcode
# Build opcode name mapping: opcode number -> opcode name
# This includes both standard opcodes and specialized variants (Python 3.11+)
_OPCODE_NAMES = dict(enumerate(opcode.opname))
if hasattr(opcode, "_specialized_opmap"):
for name, op in opcode._specialized_opmap.items():
_OPCODE_NAMES[op] = name
# Build deopt mapping: specialized opcode number -> base opcode number
# Python 3.11+ uses adaptive specialization where generic opcodes like
# LOAD_ATTR can be replaced at runtime with specialized variants like
# LOAD_ATTR_INSTANCE_VALUE. This mapping lets us show both forms.
_DEOPT_MAP = {}
if hasattr(opcode, "_specializations") and hasattr(
opcode, "_specialized_opmap"
):
for base_name, variant_names in opcode._specializations.items():
base_opcode = opcode.opmap.get(base_name)
if base_opcode is not None:
for variant_name in variant_names:
variant_opcode = opcode._specialized_opmap.get(variant_name)
if variant_opcode is not None:
_DEOPT_MAP[variant_opcode] = base_opcode
def get_opcode_info(opcode_num):
"""Get opcode name and specialization info from an opcode number.
Args:
opcode_num: The opcode number (0-255 or higher for specialized)
Returns:
A dict with keys:
- 'opname': The opcode name (e.g., 'LOAD_ATTR_INSTANCE_VALUE')
- 'base_opname': The base opcode name (e.g., 'LOAD_ATTR')
- 'is_specialized': True if this is a specialized instruction
"""
opname = _OPCODE_NAMES.get(opcode_num)
if opname is None:
return {
"opname": f"<{opcode_num}>",
"base_opname": f"<{opcode_num}>",
"is_specialized": False,
}
base_opcode = _DEOPT_MAP.get(opcode_num)
if base_opcode is not None:
base_opname = _OPCODE_NAMES.get(base_opcode, f"<{base_opcode}>")
return {
"opname": opname,
"base_opname": base_opname,
"is_specialized": True,
}
return {
"opname": opname,
"base_opname": opname,
"is_specialized": False,
}
def format_opcode(opcode_num):
"""Format an opcode for display, showing base opcode for specialized ones.
Args:
opcode_num: The opcode number (0-255 or higher for specialized)
Returns:
A formatted string like 'LOAD_ATTR' or 'LOAD_ATTR_INSTANCE_VALUE (LOAD_ATTR)'
"""
info = get_opcode_info(opcode_num)
if info["is_specialized"]:
return f"{info['opname']} ({info['base_opname']})"
return info["opname"]
def get_opcode_mapping():
"""Get opcode name and deopt mappings for JavaScript consumption.
Returns:
A dict with keys:
- 'names': Dict mapping opcode numbers to opcode names
- 'deopt': Dict mapping specialized opcode numbers to base opcode numbers
"""
return {"names": _OPCODE_NAMES, "deopt": _DEOPT_MAP}
+429
View File
@@ -0,0 +1,429 @@
import collections
import marshal
import pstats
lazy from _colorize import get_colors
from .collector import Collector, extract_lineno
from .constants import MICROSECONDS_PER_SECOND, PROFILING_MODE_CPU
class PstatsCollector(Collector):
aggregating = True
def __init__(self, sample_interval_usec, *, skip_idle=False):
self.result = collections.defaultdict(
lambda: dict(total_rec_calls=0, direct_calls=0, cumulative_calls=0)
)
self.stats = {}
self.sample_interval_usec = sample_interval_usec
self.callers = collections.defaultdict(
lambda: collections.defaultdict(int)
)
self.skip_idle = skip_idle
self._seen_locations = set()
def _process_frames(self, frames, weight=1):
"""Process a single thread's frame stack."""
if not frames:
return
self._seen_locations.clear()
# Process each frame in the stack to track cumulative calls
# frame.location is int, tuple (lineno, end_lineno, col_offset, end_col_offset), or None
for frame in frames:
lineno = extract_lineno(frame.location)
location = (frame.filename, lineno, frame.funcname)
if location not in self._seen_locations:
self._seen_locations.add(location)
self.result[location]["cumulative_calls"] += weight
# The top frame gets counted as an inline call (directly executing)
top_lineno = extract_lineno(frames[0].location)
top_location = (frames[0].filename, top_lineno, frames[0].funcname)
self.result[top_location]["direct_calls"] += weight
# Track caller-callee relationships for call graph
for i in range(1, len(frames)):
callee_frame = frames[i - 1]
caller_frame = frames[i]
callee_lineno = extract_lineno(callee_frame.location)
caller_lineno = extract_lineno(caller_frame.location)
callee = (callee_frame.filename, callee_lineno, callee_frame.funcname)
caller = (caller_frame.filename, caller_lineno, caller_frame.funcname)
self.callers[callee][caller] += weight
def collect(self, stack_frames, timestamps_us=None):
weight = len(timestamps_us) if timestamps_us else 1
for frames, _ in self._iter_stacks(stack_frames, skip_idle=self.skip_idle):
self._process_frames(frames, weight=weight)
def export(self, filename):
self.create_stats()
self._dump_stats(filename)
return True
def _dump_stats(self, file):
stats_with_marker = dict(self.stats)
stats_with_marker[("__sampled__",)] = True
with open(file, "wb") as f:
marshal.dump(stats_with_marker, f)
# Needed for compatibility with pstats.Stats
def create_stats(self):
sample_interval_sec = self.sample_interval_usec / MICROSECONDS_PER_SECOND
callers = {}
for fname, call_counts in self.result.items():
total = call_counts["direct_calls"] * sample_interval_sec
cumulative_calls = call_counts["cumulative_calls"]
cumulative = cumulative_calls * sample_interval_sec
callers = dict(self.callers.get(fname, {}))
self.stats[fname] = (
call_counts["direct_calls"], # cc = direct calls for sample percentage
cumulative_calls, # nc = cumulative calls for cumulative percentage
total,
cumulative,
callers,
)
def print_stats(self, sort=-1, limit=None, show_summary=True, mode=None):
"""Print formatted statistics to stdout."""
# Create stats object
stats = pstats.SampledStats(self).strip_dirs()
if not stats.stats:
print("No samples were collected.")
if mode == PROFILING_MODE_CPU:
print("This can happen in CPU mode when all threads are idle.")
return
# Get the stats data
stats_list = []
for func, (
direct_calls,
cumulative_calls,
total_time,
cumulative_time,
callers,
) in stats.stats.items():
stats_list.append(
(
func,
direct_calls,
cumulative_calls,
total_time,
cumulative_time,
callers,
)
)
# Calculate total samples for percentage calculations (using direct_calls)
total_samples = sum(
direct_calls for _, direct_calls, _, _, _, _ in stats_list
)
# Sort based on the requested field
sort_field = sort
if sort_field == -1: # stdname
stats_list.sort(key=lambda x: str(x[0]))
elif sort_field == 0: # nsamples (direct samples)
stats_list.sort(key=lambda x: x[1], reverse=True) # direct_calls
elif sort_field == 1: # tottime
stats_list.sort(key=lambda x: x[3], reverse=True) # total_time
elif sort_field == 2: # cumtime
stats_list.sort(key=lambda x: x[4], reverse=True) # cumulative_time
elif sort_field == 3: # sample%
stats_list.sort(
key=lambda x: (x[1] / total_samples * 100)
if total_samples > 0
else 0,
reverse=True, # direct_calls percentage
)
elif sort_field == 4: # cumul%
stats_list.sort(
key=lambda x: (x[2] / total_samples * 100)
if total_samples > 0
else 0,
reverse=True, # cumulative_calls percentage
)
elif sort_field == 5: # nsamples (cumulative samples)
stats_list.sort(key=lambda x: x[2], reverse=True) # cumulative_calls
# Apply limit if specified
if limit is not None:
stats_list = stats_list[:limit]
# Determine the best unit for time columns based on maximum values
max_total_time = max(
(total_time for _, _, _, total_time, _, _ in stats_list), default=0
)
max_cumulative_time = max(
(cumulative_time for _, _, _, _, cumulative_time, _ in stats_list),
default=0,
)
total_time_unit, total_time_scale = self._determine_best_unit(max_total_time)
cumulative_time_unit, cumulative_time_scale = self._determine_best_unit(
max_cumulative_time
)
# Define column widths for consistent alignment
col_widths = {
"nsamples": 15, # "nsamples" column (inline/cumulative format)
"sample_pct": 8, # "sample%" column
"tottime": max(12, len(f"tottime ({total_time_unit})")),
"cum_pct": 8, # "cumul%" column
"cumtime": max(12, len(f"cumtime ({cumulative_time_unit})")),
}
# Print header with colors and proper alignment
ANSIColors = get_colors()
print(f"{ANSIColors.BOLD_BLUE}Profile Stats:{ANSIColors.RESET}")
header_nsamples = f"{ANSIColors.BOLD_BLUE}{'nsamples':>{col_widths['nsamples']}}{ANSIColors.RESET}"
header_sample_pct = f"{ANSIColors.BOLD_BLUE}{'sample%':>{col_widths['sample_pct']}}{ANSIColors.RESET}"
header_tottime = f"{ANSIColors.BOLD_BLUE}{f'tottime ({total_time_unit})':>{col_widths['tottime']}}{ANSIColors.RESET}"
header_cum_pct = f"{ANSIColors.BOLD_BLUE}{'cumul%':>{col_widths['cum_pct']}}{ANSIColors.RESET}"
header_cumtime = f"{ANSIColors.BOLD_BLUE}{f'cumtime ({cumulative_time_unit})':>{col_widths['cumtime']}}{ANSIColors.RESET}"
header_filename = (
f"{ANSIColors.BOLD_BLUE}filename:lineno(function){ANSIColors.RESET}"
)
print(
f"{header_nsamples} {header_sample_pct} {header_tottime} {header_cum_pct} {header_cumtime} {header_filename}"
)
# Print each line with proper alignment
for (
func,
direct_calls,
cumulative_calls,
total_time,
cumulative_time,
callers,
) in stats_list:
# Calculate percentages
sample_pct = (
(direct_calls / total_samples * 100) if total_samples > 0 else 0
)
cum_pct = (
(cumulative_calls / total_samples * 100)
if total_samples > 0
else 0
)
# Format values with proper alignment - always use A/B format
nsamples_str = f"{direct_calls}/{cumulative_calls}"
nsamples_str = f"{nsamples_str:>{col_widths['nsamples']}}"
sample_pct_str = f"{sample_pct:{col_widths['sample_pct']}.1f}"
tottime = f"{total_time * total_time_scale:{col_widths['tottime']}.3f}"
cum_pct_str = f"{cum_pct:{col_widths['cum_pct']}.1f}"
cumtime = f"{cumulative_time * cumulative_time_scale:{col_widths['cumtime']}.3f}"
# Format the function name with colors
func_name = (
f"{ANSIColors.GREEN}{func[0]}{ANSIColors.RESET}:"
f"{ANSIColors.YELLOW}{func[1]}{ANSIColors.RESET}("
f"{ANSIColors.CYAN}{func[2]}{ANSIColors.RESET})"
)
# Print the formatted line with consistent spacing
print(
f"{nsamples_str} {sample_pct_str} {tottime} {cum_pct_str} {cumtime} {func_name}"
)
# Print legend
print(f"\n{ANSIColors.BOLD_BLUE}Legend:{ANSIColors.RESET}")
print(
f" {ANSIColors.YELLOW}nsamples{ANSIColors.RESET}: Direct/Cumulative samples (direct executing / on call stack)"
)
print(
f" {ANSIColors.YELLOW}sample%{ANSIColors.RESET}: Percentage of total samples this function was directly executing"
)
print(
f" {ANSIColors.YELLOW}tottime{ANSIColors.RESET}: Estimated total time spent directly in this function"
)
print(
f" {ANSIColors.YELLOW}cumul%{ANSIColors.RESET}: Percentage of total samples when this function was on the call stack"
)
print(
f" {ANSIColors.YELLOW}cumtime{ANSIColors.RESET}: Estimated cumulative time (including time in called functions)"
)
print(
f" {ANSIColors.YELLOW}filename:lineno(function){ANSIColors.RESET}: Function location and name"
)
# Print summary of interesting functions if enabled
if show_summary and stats_list:
self._print_summary(stats_list, total_samples)
@staticmethod
def _determine_best_unit(max_value):
"""Determine the best unit (s, ms, μs) and scale factor for a maximum value."""
if max_value >= 1.0:
return "s", 1.0
elif max_value >= 0.001:
return "ms", 1000.0
else:
return "μs", float(MICROSECONDS_PER_SECOND)
def _print_summary(self, stats_list, total_samples):
"""Print summary of interesting functions."""
ANSIColors = get_colors()
print(
f"\n{ANSIColors.BOLD_BLUE}Summary of Interesting Functions:{ANSIColors.RESET}"
)
# Aggregate stats by fully qualified function name (ignoring line numbers)
func_aggregated = {}
for (
func,
direct_calls,
cumulative_calls,
total_time,
cumulative_time,
callers,
) in stats_list:
# Use filename:function_name as the key to get fully qualified name
qualified_name = f"{func[0]}:{func[2]}"
if qualified_name not in func_aggregated:
func_aggregated[qualified_name] = [
0,
0,
0,
0,
] # direct_calls, cumulative_calls, total_time, cumulative_time
func_aggregated[qualified_name][0] += direct_calls
func_aggregated[qualified_name][1] += cumulative_calls
func_aggregated[qualified_name][2] += total_time
func_aggregated[qualified_name][3] += cumulative_time
# Convert aggregated data back to list format for processing
aggregated_stats = []
for qualified_name, (
prim_calls,
total_calls,
total_time,
cumulative_time,
) in func_aggregated.items():
# Parse the qualified name back to filename and function name
if ":" in qualified_name:
filename, func_name = qualified_name.rsplit(":", 1)
else:
filename, func_name = "", qualified_name
# Create a dummy func tuple with filename and function name for display
dummy_func = (filename, "", func_name)
aggregated_stats.append(
(
dummy_func,
prim_calls,
total_calls,
total_time,
cumulative_time,
{},
)
)
# Determine best units for summary metrics
max_total_time = max(
(total_time for _, _, _, total_time, _, _ in aggregated_stats),
default=0,
)
max_cumulative_time = max(
(
cumulative_time
for _, _, _, _, cumulative_time, _ in aggregated_stats
),
default=0,
)
total_unit, total_scale = self._determine_best_unit(max_total_time)
cumulative_unit, cumulative_scale = self._determine_best_unit(
max_cumulative_time
)
def _format_func_name(func):
"""Format function name with colors."""
return (
f"{ANSIColors.GREEN}{func[0]}{ANSIColors.RESET}:"
f"{ANSIColors.YELLOW}{func[1]}{ANSIColors.RESET}("
f"{ANSIColors.CYAN}{func[2]}{ANSIColors.RESET})"
)
def _print_top_functions(stats_list, title, key_func, format_line, n=3):
"""Print top N functions sorted by key_func with formatted output."""
print(f"\n{ANSIColors.BOLD_BLUE}{title}:{ANSIColors.RESET}")
sorted_stats = sorted(stats_list, key=key_func, reverse=True)
for stat in sorted_stats[:n]:
if line := format_line(stat):
print(f" {line}")
# Functions with highest direct/cumulative ratio (hot spots)
def format_hotspots(stat):
func, direct_calls, cumulative_calls, total_time, _, _ = stat
if direct_calls > 0 and cumulative_calls > 0:
ratio = direct_calls / cumulative_calls
direct_pct = (
(direct_calls / total_samples * 100)
if total_samples > 0
else 0
)
return (
f"{ratio:.3f} direct/cumulative ratio, "
f"{direct_pct:.1f}% direct samples: {_format_func_name(func)}"
)
return None
_print_top_functions(
aggregated_stats,
"Functions with Highest Direct/Cumulative Ratio (Hot Spots)",
key_func=lambda x: (x[1] / x[2]) if x[2] > 0 else 0,
format_line=format_hotspots,
)
# Functions with highest call frequency (cumulative/direct difference)
def format_call_frequency(stat):
func, direct_calls, cumulative_calls, total_time, _, _ = stat
if cumulative_calls > direct_calls:
call_frequency = cumulative_calls - direct_calls
cum_pct = (
(cumulative_calls / total_samples * 100)
if total_samples > 0
else 0
)
return (
f"{call_frequency:d} indirect calls, "
f"{cum_pct:.1f}% total stack presence: {_format_func_name(func)}"
)
return None
_print_top_functions(
aggregated_stats,
"Functions with Highest Call Frequency (Indirect Calls)",
key_func=lambda x: x[2] - x[1], # Sort by (cumulative - direct)
format_line=format_call_frequency,
)
# Functions with highest cumulative-to-direct multiplier (call magnification)
def format_call_magnification(stat):
func, direct_calls, cumulative_calls, total_time, _, _ = stat
if direct_calls > 0 and cumulative_calls > direct_calls:
multiplier = cumulative_calls / direct_calls
indirect_calls = cumulative_calls - direct_calls
return (
f"{multiplier:.1f}x call magnification, "
f"{indirect_calls:d} indirect calls from {direct_calls:d} direct: {_format_func_name(func)}"
)
return None
_print_top_functions(
aggregated_stats,
"Functions with Highest Call Magnification (Cumulative/Direct)",
key_func=lambda x: (x[2] / x[1])
if x[1] > 0
else 0, # Sort by cumulative/direct ratio
format_line=format_call_magnification,
)
+665
View File
@@ -0,0 +1,665 @@
import _remote_debugging
import contextlib
import os
import statistics
import sys
import sysconfig
import time
from collections import deque
lazy from _colorize import get_colors
from .pstats_collector import PstatsCollector
from .stack_collector import CollapsedStackCollector, FlamegraphCollector
from .heatmap_collector import HeatmapCollector
from .gecko_collector import GeckoCollector
from .binary_collector import BinaryCollector
@contextlib.contextmanager
def _pause_threads(unwinder, blocking):
"""Context manager to pause/resume threads around sampling if blocking is True."""
if blocking:
unwinder.pause_threads()
try:
yield
finally:
unwinder.resume_threads()
else:
yield
from .constants import (
PROFILING_MODE_WALL,
PROFILING_MODE_CPU,
PROFILING_MODE_GIL,
PROFILING_MODE_ALL,
PROFILING_MODE_EXCEPTION,
)
from ._format_utils import fmt
try:
from .live_collector import LiveStatsCollector
except ImportError:
LiveStatsCollector = None
_FREE_THREADED_BUILD = sysconfig.get_config_var("Py_GIL_DISABLED") is not None
# Minimum number of samples required before showing the TUI
# If fewer samples are collected, we skip the TUI and just print a message
MIN_SAMPLES_FOR_TUI = 200
# Maximum number of consecutive identical samples to keep before flushing.
MAX_PENDING_SAMPLES = 8192
def _resolve_python_pid(pid):
"""On Windows, if pid is a venvlauncher process, return the child Python PID.
The venvlauncher (used as python.exe in venvs) spawns the real Python
interpreter as a child process via CreateProcessW. The RemoteUnwinder
needs the child's PID, not the launcher's.
Returns the original pid if not on Windows, not a venv launcher,
or no child process is found.
"""
if os.name != "nt" or sys.prefix == sys.base_prefix:
return pid
try:
children = _remote_debugging.get_child_pids(pid, recursive=False)
python_children = [
child for child in children
if _remote_debugging.is_python_process(child)
]
if len(python_children) == 1:
return python_children[0]
except (OSError, RuntimeError) as err:
raise SystemExit(
f"Failed to initialize profiler from virtualenv: {err}\n"
f"Try running with the base interpreter: {sys._base_executable}"
) from err
return pid
class SampleProfiler:
def __init__(self, pid, sample_interval_usec, all_threads, *, mode=PROFILING_MODE_WALL, native=False, gc=True, opcodes=False, skip_non_matching_threads=True, collect_stats=False, blocking=False):
self.pid = _resolve_python_pid(pid)
self.sample_interval_usec = sample_interval_usec
self.all_threads = all_threads
self.mode = mode # Store mode for later use
self.collect_stats = collect_stats
self.blocking = blocking
try:
self.unwinder = self._new_unwinder(native, gc, opcodes, skip_non_matching_threads)
except RuntimeError as err:
raise SystemExit(err) from err
# Track sample intervals and total sample count
self.sample_intervals = deque(maxlen=100)
self.total_samples = 0
self.realtime_stats = False
def _new_unwinder(self, native, gc, opcodes, skip_non_matching_threads):
kwargs = {}
if _FREE_THREADED_BUILD or self.all_threads:
kwargs['all_threads'] = self.all_threads
else:
kwargs['only_active_thread'] = bool(self.all_threads)
return _remote_debugging.RemoteUnwinder(
self.pid,
mode=self.mode,
native=native,
gc=gc,
opcodes=opcodes,
skip_non_matching_threads=skip_non_matching_threads,
cache_frames=True,
stats=self.collect_stats,
**kwargs
)
def _get_stack_trace(self, async_aware=None):
with _pause_threads(self.unwinder, self.blocking):
if async_aware == "all":
return self.unwinder.get_all_awaited_by()
if async_aware == "running":
return self.unwinder.get_async_stack_trace()
return self.unwinder.get_stack_trace()
def dump_stack(self, *, async_aware=None):
"""Return a single stack snapshot from the target process."""
return self._get_stack_trace(async_aware=async_aware)
def sample(self, collector, duration_sec=None, *, async_aware=False):
sample_interval_sec = self.sample_interval_usec / 1_000_000
num_samples = 0
errors = 0
interrupted = False
running_time_sec = 0
start_time = next_time = time.perf_counter()
last_sample_time = start_time
realtime_update_interval = 1.0 # Update every second
last_realtime_update = start_time
aggregating = getattr(collector, 'aggregating', False) is True
prev_stack = None
pending_count = 0
pending_timestamps = [] if aggregating else None
def flush_pending():
nonlocal pending_count, pending_timestamps
if pending_count == 0:
return
pending_count = 0
ts = pending_timestamps
pending_timestamps = []
collector.collect(prev_stack, timestamps_us=ts)
try:
while duration_sec is None or running_time_sec < duration_sec:
# Check if live collector wants to stop
if hasattr(collector, 'running') and not collector.running:
break
current_time = time.perf_counter()
current_time_us = int(current_time * 1_000_000)
if next_time > current_time:
sleep_time = (next_time - current_time) * 0.9
if sleep_time > 0.0001:
time.sleep(sleep_time)
elif next_time < current_time:
try:
stack_frames = self._get_stack_trace(
async_aware=async_aware
)
if aggregating:
if stack_frames != prev_stack:
flush_pending()
prev_stack = stack_frames
pending_count += 1
pending_timestamps.append(current_time_us)
if pending_count >= MAX_PENDING_SAMPLES:
flush_pending()
else:
collector.collect(stack_frames)
except ProcessLookupError as e:
running_time_sec = current_time - start_time
break
except (RuntimeError, UnicodeDecodeError, MemoryError, OSError):
flush_pending()
collector.collect_failed_sample()
errors += 1
prev_stack = None
except Exception as e:
if not _is_process_running(self.pid):
break
raise e from None
# Track actual sampling intervals for real-time stats
if num_samples > 0:
actual_interval = current_time - last_sample_time
self.sample_intervals.append(
1.0 / actual_interval
) # Convert to Hz
self.total_samples += 1
# Print real-time statistics if enabled
if (
self.realtime_stats
and (current_time - last_realtime_update)
>= realtime_update_interval
):
self._print_realtime_stats()
last_realtime_update = current_time
last_sample_time = current_time
num_samples += 1
next_time += sample_interval_sec
running_time_sec = time.perf_counter() - start_time
except KeyboardInterrupt:
interrupted = True
running_time_sec = time.perf_counter() - start_time
print("Interrupted by user.")
finally:
flush_pending()
# Clear real-time stats line if it was being displayed
if self.realtime_stats and len(self.sample_intervals) > 0:
print() # Add newline after real-time stats
sample_rate = num_samples / running_time_sec if running_time_sec > 0 else 0
error_rate = (errors / num_samples) * 100 if num_samples > 0 else 0
expected_samples = int(running_time_sec / sample_interval_sec)
missed_samples = (expected_samples - num_samples) / expected_samples * 100 if expected_samples > 0 else 0
# Don't print stats for live mode (curses is handling display)
is_live_mode = LiveStatsCollector is not None and isinstance(collector, LiveStatsCollector)
if not is_live_mode:
s = "" if num_samples == 1 else "s"
print(f"Captured {num_samples:n} sample{s} in {fmt(running_time_sec, 2)} seconds")
print(f"Sample rate: {fmt(sample_rate, 2)} samples/sec")
print(f"Error rate: {fmt(error_rate, 2)}")
# Print unwinder stats if stats collection is enabled
if self.collect_stats:
self._print_unwinder_stats()
if isinstance(collector, BinaryCollector):
self._print_binary_stats(collector)
# Pass stats to flamegraph collector if it's the right type
if hasattr(collector, 'set_stats'):
collector.set_stats(self.sample_interval_usec, running_time_sec, sample_rate, error_rate, missed_samples, mode=self.mode)
if num_samples < expected_samples and not is_live_mode and not interrupted:
print(
f"Warning: missed {expected_samples - num_samples} samples "
f"from the expected total of {expected_samples} "
f"({fmt((expected_samples - num_samples) / expected_samples * 100, 2)}%)"
)
def _print_realtime_stats(self):
"""Print real-time sampling statistics."""
if len(self.sample_intervals) < 2:
return
# Calculate statistics on the Hz values (deque automatically maintains rolling window)
hz_values = list(self.sample_intervals)
mean_hz = statistics.mean(hz_values)
min_hz = min(hz_values)
max_hz = max(hz_values)
# Calculate microseconds per sample for all metrics (1/Hz * 1,000,000)
mean_us_per_sample = (1.0 / mean_hz) * 1_000_000 if mean_hz > 0 else 0
min_us_per_sample = (
(1.0 / max_hz) * 1_000_000 if max_hz > 0 else 0
) # Min time = Max Hz
max_us_per_sample = (
(1.0 / min_hz) * 1_000_000 if min_hz > 0 else 0
) # Max time = Min Hz
# Build cache stats string if stats collection is enabled
ANSIColors = get_colors()
cache_stats_str = ""
if self.collect_stats:
try:
stats = self.unwinder.get_stats()
hits = stats.get('frame_cache_hits', 0)
partial = stats.get('frame_cache_partial_hits', 0)
misses = stats.get('frame_cache_misses', 0)
total = hits + partial + misses
if total > 0:
hit_pct = (hits + partial) / total * 100
cache_stats_str = f" {ANSIColors.MAGENTA}Cache: {fmt(hit_pct)}% ({hits}+{partial}/{misses}){ANSIColors.RESET}"
except RuntimeError:
pass
# Clear line and print stats
print(
f"\r\033[K{ANSIColors.BOLD_BLUE}Stats:{ANSIColors.RESET} "
f"{ANSIColors.YELLOW}{fmt(mean_hz)}Hz ({fmt(mean_us_per_sample)}µs){ANSIColors.RESET} "
f"{ANSIColors.GREEN}Min: {fmt(min_hz)}Hz{ANSIColors.RESET} "
f"{ANSIColors.RED}Max: {fmt(max_hz)}Hz{ANSIColors.RESET} "
f"{ANSIColors.CYAN}N={self.total_samples}{ANSIColors.RESET}"
f"{cache_stats_str}",
end="",
flush=True,
)
def _print_unwinder_stats(self):
"""Print unwinder statistics including cache performance."""
try:
stats = self.unwinder.get_stats()
except RuntimeError:
return # Stats not enabled
ANSIColors = get_colors()
print(f"\n{ANSIColors.BOLD_BLUE}{'='*50}{ANSIColors.RESET}")
print(f"{ANSIColors.BOLD_BLUE}Unwinder Statistics:{ANSIColors.RESET}")
# Frame cache stats
total_samples = stats.get('total_samples', 0)
frame_cache_hits = stats.get('frame_cache_hits', 0)
frame_cache_partial_hits = stats.get('frame_cache_partial_hits', 0)
frame_cache_misses = stats.get('frame_cache_misses', 0)
total_lookups = frame_cache_hits + frame_cache_partial_hits + frame_cache_misses
# Calculate percentages
hits_pct = (frame_cache_hits / total_lookups * 100) if total_lookups > 0 else 0
partial_pct = (frame_cache_partial_hits / total_lookups * 100) if total_lookups > 0 else 0
misses_pct = (frame_cache_misses / total_lookups * 100) if total_lookups > 0 else 0
print(f" {ANSIColors.CYAN}Frame Cache:{ANSIColors.RESET}")
print(f" Total samples: {total_samples:n}")
print(f" Full hits: {frame_cache_hits:n} ({ANSIColors.GREEN}{fmt(hits_pct)}%{ANSIColors.RESET})")
print(f" Partial hits: {frame_cache_partial_hits:n} ({ANSIColors.YELLOW}{fmt(partial_pct)}%{ANSIColors.RESET})")
print(f" Misses: {frame_cache_misses:n} ({ANSIColors.RED}{fmt(misses_pct)}%{ANSIColors.RESET})")
# Frame read stats
frames_from_cache = stats.get('frames_read_from_cache', 0)
frames_from_memory = stats.get('frames_read_from_memory', 0)
total_frames = frames_from_cache + frames_from_memory
cache_frame_pct = (frames_from_cache / total_frames * 100) if total_frames > 0 else 0
memory_frame_pct = (frames_from_memory / total_frames * 100) if total_frames > 0 else 0
print(f" {ANSIColors.CYAN}Frame Reads:{ANSIColors.RESET}")
print(f" From cache: {frames_from_cache:n} ({ANSIColors.GREEN}{fmt(cache_frame_pct)}%{ANSIColors.RESET})")
print(f" From memory: {frames_from_memory:n} ({ANSIColors.RED}{fmt(memory_frame_pct)}%{ANSIColors.RESET})")
# Code object cache stats
code_hits = stats.get('code_object_cache_hits', 0)
code_misses = stats.get('code_object_cache_misses', 0)
total_code = code_hits + code_misses
code_hits_pct = (code_hits / total_code * 100) if total_code > 0 else 0
code_misses_pct = (code_misses / total_code * 100) if total_code > 0 else 0
print(f" {ANSIColors.CYAN}Code Object Cache:{ANSIColors.RESET}")
print(f" Hits: {code_hits:n} ({ANSIColors.GREEN}{fmt(code_hits_pct)}%{ANSIColors.RESET})")
print(f" Misses: {code_misses:n} ({ANSIColors.RED}{fmt(code_misses_pct)}%{ANSIColors.RESET})")
batched_attempts = stats.get('batched_read_attempts', 0)
batched_successes = stats.get('batched_read_successes', 0)
batched_misses = stats.get('batched_read_misses', 0)
segments_requested = stats.get('batched_read_segments_requested', 0)
segments_completed = stats.get('batched_read_segments_completed', 0)
if batched_attempts > 0:
batched_success_rate = stats.get('batched_read_success_rate', 0.0)
batched_miss_rate = 100.0 - batched_success_rate
segment_completion_rate = stats.get(
'batched_read_segment_completion_rate', 0.0
)
print(f" {ANSIColors.CYAN}Batched Reads:{ANSIColors.RESET}")
print(f" Attempts: {batched_attempts:n}")
print(
f" Successes: {batched_successes:n} "
f"({ANSIColors.GREEN}{fmt(batched_success_rate)}%{ANSIColors.RESET})"
)
print(
f" Misses: {batched_misses:n} "
f"({ANSIColors.RED}{fmt(batched_miss_rate)}%{ANSIColors.RESET})"
)
print(
f" Segments read: {segments_completed:n}/{segments_requested:n} "
f"({ANSIColors.GREEN}{fmt(segment_completion_rate)}%{ANSIColors.RESET})"
)
# Memory operations
memory_reads = stats.get('memory_reads', 0)
memory_bytes = stats.get('memory_bytes_read', 0)
if memory_bytes >= 1024 * 1024:
memory_str = f"{fmt(memory_bytes / (1024 * 1024))} MB"
elif memory_bytes >= 1024:
memory_str = f"{fmt(memory_bytes / 1024)} KB"
else:
memory_str = f"{memory_bytes} B"
print(f" {ANSIColors.CYAN}Memory:{ANSIColors.RESET}")
print(f" Read operations: {memory_reads:n} ({memory_str})")
# Stale invalidations
stale_invalidations = stats.get('stale_cache_invalidations', 0)
if stale_invalidations > 0:
print(f" {ANSIColors.YELLOW}Stale cache invalidations: {stale_invalidations}{ANSIColors.RESET}")
def _print_binary_stats(self, collector):
"""Print binary I/O encoding statistics."""
try:
stats = collector.get_stats()
except (ValueError, RuntimeError):
return # Collector closed or stats unavailable
ANSIColors = get_colors()
print(f" {ANSIColors.CYAN}Binary Encoding:{ANSIColors.RESET}")
repeat_records = stats.get('repeat_records', 0)
repeat_samples = stats.get('repeat_samples', 0)
full_records = stats.get('full_records', 0)
suffix_records = stats.get('suffix_records', 0)
pop_push_records = stats.get('pop_push_records', 0)
total_records = stats.get('total_records', 0)
if total_records > 0:
repeat_pct = repeat_records / total_records * 100
full_pct = full_records / total_records * 100
suffix_pct = suffix_records / total_records * 100
pop_push_pct = pop_push_records / total_records * 100
else:
repeat_pct = full_pct = suffix_pct = pop_push_pct = 0
print(f" Records: {total_records:,}")
print(f" RLE repeat: {repeat_records:,} ({ANSIColors.GREEN}{repeat_pct:.1f}%{ANSIColors.RESET}) [{repeat_samples:,} samples]")
print(f" Full stack: {full_records:,} ({full_pct:.1f}%)")
print(f" Suffix match: {suffix_records:,} ({suffix_pct:.1f}%)")
print(f" Pop-push: {pop_push_records:,} ({pop_push_pct:.1f}%)")
frames_written = stats.get('total_frames_written', 0)
frames_saved = stats.get('frames_saved', 0)
compression_pct = stats.get('frame_compression_pct', 0)
print(f" {ANSIColors.CYAN}Frame Efficiency:{ANSIColors.RESET}")
print(f" Frames written: {frames_written:,}")
print(f" Frames saved: {frames_saved:,} ({ANSIColors.GREEN}{compression_pct:.1f}%{ANSIColors.RESET})")
bytes_written = stats.get('bytes_written', 0)
if bytes_written >= 1024 * 1024:
bytes_str = f"{bytes_written / (1024 * 1024):.1f} MB"
elif bytes_written >= 1024:
bytes_str = f"{bytes_written / 1024:.1f} KB"
else:
bytes_str = f"{bytes_written} B"
print(f" Bytes (pre-zstd): {bytes_str}")
def _is_process_running(pid):
if pid <= 0:
return False
if os.name == "posix":
try:
os.kill(pid, 0)
return True
except ProcessLookupError:
return False
except PermissionError:
# EPERM means process exists but we can't signal it
return True
elif sys.platform == "win32":
try:
_remote_debugging.RemoteUnwinder(pid)
except Exception:
return False
return True
else:
raise ValueError(f"Unsupported platform: {sys.platform}")
def sample(
pid,
collector,
*,
duration_sec=None,
all_threads=False,
realtime_stats=False,
mode=PROFILING_MODE_WALL,
async_aware=None,
native=False,
gc=True,
opcodes=False,
blocking=False,
):
"""Sample a process using the provided collector.
Args:
pid: Process ID to sample
collector: Collector instance to use for gathering samples
duration_sec: How long to sample for (seconds), or None to run until
the process exits or interrupted
all_threads: Whether to sample all threads
realtime_stats: Whether to print real-time sampling statistics
mode: Profiling mode - WALL (all samples), CPU (only when on CPU),
GIL (only when holding GIL), ALL (includes GIL and CPU status),
EXCEPTION (only when thread has an active exception)
native: Whether to include native frames
gc: Whether to include GC frames
opcodes: Whether to include opcode information
blocking: Whether to stop all threads before sampling for consistent snapshots
Returns:
The collector with collected samples
"""
# Get sample interval from collector
sample_interval_usec = collector.sample_interval_usec
# PROFILING_MODE_ALL implies no skipping at all
if mode == PROFILING_MODE_ALL:
skip_non_matching_threads = False
else:
# For most modes, skip non-matching threads
# Gecko collector overrides this by setting skip_idle=False
skip_non_matching_threads = True
profiler = SampleProfiler(
pid,
sample_interval_usec,
all_threads=all_threads,
mode=mode,
native=native,
gc=gc,
opcodes=opcodes,
skip_non_matching_threads=skip_non_matching_threads,
collect_stats=realtime_stats,
blocking=blocking,
)
profiler.realtime_stats = realtime_stats
# Run the sampling
profiler.sample(collector, duration_sec, async_aware=async_aware)
return collector
def dump_stack(
pid,
*,
all_threads=False,
mode=PROFILING_MODE_ALL,
async_aware=None,
native=False,
gc=True,
opcodes=False,
blocking=False,
):
"""Return a single stack snapshot from a process."""
if mode == PROFILING_MODE_ALL:
skip_non_matching_threads = False
else:
skip_non_matching_threads = True
profiler = SampleProfiler(
pid,
sample_interval_usec=1,
all_threads=all_threads,
mode=mode,
native=native,
gc=gc,
opcodes=opcodes,
skip_non_matching_threads=skip_non_matching_threads,
blocking=blocking,
)
return profiler.dump_stack(async_aware=async_aware)
def sample_live(
pid,
collector,
*,
duration_sec=None,
all_threads=False,
realtime_stats=False,
mode=PROFILING_MODE_WALL,
async_aware=None,
native=False,
gc=True,
opcodes=False,
blocking=False,
):
"""Sample a process in live/interactive mode with curses TUI.
Args:
pid: Process ID to sample
collector: LiveStatsCollector instance
duration_sec: How long to sample for (seconds)
all_threads: Whether to sample all threads
realtime_stats: Whether to print real-time sampling statistics
mode: Profiling mode - WALL (all samples), CPU (only when on CPU),
GIL (only when holding GIL), ALL (includes GIL and CPU status),
EXCEPTION (only when thread has an active exception)
native: Whether to include native frames
gc: Whether to include GC frames
opcodes: Whether to include opcode information
blocking: Whether to stop all threads before sampling for consistent snapshots
Returns:
The collector with collected samples
"""
import curses
# Check if process is alive before doing any heavy initialization
if not _is_process_running(pid):
print(f"No samples collected - process {pid} exited before profiling could begin.", file=sys.stderr)
return collector
# Get sample interval from collector
sample_interval_usec = collector.sample_interval_usec
# PROFILING_MODE_ALL implies no skipping at all
if mode == PROFILING_MODE_ALL:
skip_non_matching_threads = False
else:
skip_non_matching_threads = True
profiler = SampleProfiler(
pid,
sample_interval_usec,
all_threads=all_threads,
mode=mode,
native=native,
gc=gc,
opcodes=opcodes,
skip_non_matching_threads=skip_non_matching_threads,
collect_stats=realtime_stats,
blocking=blocking,
)
profiler.realtime_stats = realtime_stats
def curses_wrapper_func(stdscr):
collector.init_curses(stdscr)
try:
profiler.sample(collector, duration_sec, async_aware=async_aware)
# If too few samples were collected, exit cleanly without showing TUI
if collector.successful_samples < MIN_SAMPLES_FOR_TUI:
# Clear screen before exiting to avoid visual artifacts
stdscr.clear()
stdscr.refresh()
return
# Mark as finished and keep the TUI running until user presses 'q'
collector.mark_finished()
# Keep processing input until user quits
while collector.running:
collector._handle_input()
time.sleep(0.05) # Small sleep to avoid busy waiting
finally:
collector.cleanup_curses()
try:
curses.wrapper(curses_wrapper_func)
except KeyboardInterrupt:
pass
# If too few samples were collected, print a message
if collector.successful_samples < MIN_SAMPLES_FOR_TUI:
if collector.successful_samples == 0:
print(f"No samples collected - process {pid} exited before profiling could begin.", file=sys.stderr)
else:
print(f"Only {collector.successful_samples} sample(s) collected (minimum {MIN_SAMPLES_FOR_TUI} required for TUI) - process {pid} exited too quickly.", file=sys.stderr)
return collector
+907
View File
@@ -0,0 +1,907 @@
import base64
import collections
import functools
import importlib.resources
import json
import linecache
import os
import sys
import sysconfig
from ._css_utils import get_combined_css
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):
aggregating = True
def __init__(self, sample_interval_usec, *, skip_idle=False):
self.sample_interval_usec = sample_interval_usec
self.skip_idle = skip_idle
def collect(self, stack_frames, timestamps_us=None):
weight = len(timestamps_us) if timestamps_us else 1
for frames, thread_id in self._iter_stacks(stack_frames, skip_idle=self.skip_idle):
self.process_frames(frames, thread_id, weight=weight)
def process_frames(self, frames, thread_id, weight=1):
pass
class CollapsedStackCollector(StackTraceCollector):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.stack_counter = collections.Counter()
def process_frames(self, frames, thread_id, weight=1):
# Extract only (filename, lineno, funcname) - opcode not needed for collapsed stacks
# frame is (filename, location, funcname, opcode)
call_tree = tuple(
(f[0], extract_lineno(f[1]), f[2]) for f in reversed(frames)
)
self.stack_counter[(call_tree, thread_id)] += weight
def export(self, filename):
lines = []
for (call_tree, thread_id), count in self.stack_counter.items():
parts = [f"tid:{thread_id}"]
for file, line, func in call_tree:
# This is what pstats does for "special" frames:
if file == "~" and line == 0:
part = func
else:
part = f"{os.path.basename(file)}:{func}:{line}"
parts.append(part)
stack_str = ";".join(parts)
lines.append((stack_str, count))
lines.sort(key=lambda x: (-x[1], x[0]))
with open(filename, "w") as f:
for stack, count in lines:
f.write(f"{stack} {count}\n")
print(f"Collapsed stack output written to {filename}")
return True
class FlamegraphCollector(StackTraceCollector):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.stats = {"sample_interval_usec": self.sample_interval_usec}
self._root = {
"samples": 0,
"children": {},
"threads": set(),
"thread_samples": collections.Counter(),
"thread_self": collections.Counter(),
}
self._total_samples = 0
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)
self.thread_status_counts = {
"has_gil": 0,
"on_cpu": 0,
"gil_requested": 0,
"unknown": 0,
"has_exception": 0,
"total": 0,
}
self.samples_with_gc_frames = 0
# Per-thread statistics
self.per_thread_stats = {} # {thread_id: {has_gil, on_cpu, gil_requested, unknown, has_exception, total, gc_samples}}
def collect(self, stack_frames, timestamps_us=None):
"""Override to track thread status statistics before processing frames."""
# Weight is number of timestamps (samples with identical stack)
weight = len(timestamps_us) if timestamps_us else 1
# Increment sample count by weight
self._sample_count += weight
# Collect both aggregate and per-thread statistics using base method
status_counts, has_gc_frame, per_thread_stats = self._collect_thread_status_stats(stack_frames)
# Merge aggregate status counts (multiply by weight)
for key in status_counts:
self.thread_status_counts[key] += status_counts[key] * weight
# Update aggregate GC frame count
if has_gc_frame:
self.samples_with_gc_frames += weight
# Merge per-thread statistics (multiply by weight)
for thread_id, stats in per_thread_stats.items():
if thread_id not in self.per_thread_stats:
self.per_thread_stats[thread_id] = {
"has_gil": 0,
"on_cpu": 0,
"gil_requested": 0,
"unknown": 0,
"has_exception": 0,
"total": 0,
"gc_samples": 0,
}
for key, value in stats.items():
self.per_thread_stats[thread_id][key] += value * weight
# Call parent collect to process frames
super().collect(stack_frames, timestamps_us)
def set_stats(self, sample_interval_usec, duration_sec, sample_rate,
error_rate=None, missed_samples=None, mode=None):
"""Set profiling statistics to include in flamegraph data."""
self.stats = {
"sample_interval_usec": sample_interval_usec,
"duration_sec": duration_sec,
"sample_rate": sample_rate,
"error_rate": error_rate,
"missed_samples": missed_samples,
"mode": mode
}
def set_replay_stats(self, info):
"""Restore measured statistics stored in a binary profile."""
duration_sec = info.get("duration_sec")
sample_rate = info.get("sample_rate")
if duration_sec is None or sample_rate is None:
return
self.set_stats(
self.sample_interval_usec,
duration_sec,
sample_rate,
error_rate=info.get("error_rate"),
missed_samples=info.get("missed_samples"),
mode=self.stats.get("mode"),
)
def set_mode(self, mode):
self.stats["mode"] = mode
def export(self, filename):
flamegraph_data = self._convert_to_flamegraph_format()
# Debug output with string table statistics
num_functions = len(flamegraph_data.get("children", []))
total_time = flamegraph_data.get("value", 0)
string_count = len(self._string_table)
s1 = "" if num_functions == 1 else "s"
s2 = "" if total_time == 1 else "s"
s3 = "" if string_count == 1 else "s"
print(
f"Flamegraph data: {num_functions} root function{s1}, "
f"{total_time} total sample{s2}, "
f"{string_count} unique string{s3}"
)
if num_functions == 0:
print(
"Warning: No functions found in profiling data. Check if sampling captured any data."
)
return False
html_content = self._create_flamegraph_html(flamegraph_data)
with open(filename, "w", encoding="utf-8") as f:
f.write(html_content)
print(f"Flamegraph saved to: {filename}")
return True
@staticmethod
@functools.lru_cache(maxsize=None)
def _format_function_name(func):
filename, lineno, funcname = func
# Special frames like <GC> and <native> 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 <GC> and <native> should not show file:line
if filename == "~" and lineno == 0:
return funcname
return f"{funcname} ({module_name}:{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, *, min_samples=None):
if self._total_samples == 0:
return {
"name": self._string_table.intern("No Data"),
"value": 0,
"children": [],
"threads": [],
"strings": self._string_table.get_strings()
}
def convert_children(children, min_samples, path_info):
out = []
for func, node in children.items():
samples = node["samples"]
significant_for_thread = any(
thread_samples >= max(
1,
int(
self._root["thread_samples"][thread_id]
* 0.001
),
)
for thread_id, thread_samples
in node["thread_samples"].items()
)
if samples < min_samples and not significant_for_thread:
continue
# Intern all string components for maximum efficiency
filename_idx = self._string_table.intern(func[0])
funcname_idx = self._string_table.intern(func[2])
module_name = self._get_module_name(func[0], path_info)
module_idx = self._string_table.intern(module_name)
name_idx = self._string_table.intern(self._format_function_name(func))
label_idx = self._string_table.intern(self._format_module_name(func, module_name))
child_entry = {
"name": name_idx,
"label": label_idx,
"value": samples,
"self": node.get("self", 0),
"children": [],
"filename": filename_idx,
"module": module_idx,
"lineno": func[1],
"funcname": funcname_idx,
"threads": sorted(list(node.get("threads", set()))),
"thread_values": {
thread_id: [
samples,
node["thread_self"].get(thread_id, 0),
]
for thread_id, samples in sorted(
node["thread_samples"].items()
)
},
}
source = self._get_source_lines(func)
if source:
# Intern source lines for memory efficiency
source_indices = [self._string_table.intern(line) for line in source]
child_entry["source"] = source_indices
# Include opcode data if available
opcodes = node.get("opcodes", {})
if opcodes:
child_entry["opcodes"] = dict(opcodes)
thread_opcodes = node.get("thread_opcodes")
if thread_opcodes:
child_entry["thread_opcodes"] = {
thread_id: dict(counts)
for thread_id, counts in sorted(
thread_opcodes.items()
)
}
# Recurse
child_entry["children"] = convert_children(
node["children"], min_samples, path_info
)
out.append(child_entry)
# Sort by value (descending) then by name index for consistent ordering
out.sort(key=lambda x: (-x["value"], x["name"]))
return out
# Filter out very small functions (less than 0.1% of total samples)
total_samples = self._total_samples
if min_samples is None:
min_samples = max(1, int(total_samples * 0.001))
path_info = get_python_path_info()
root_children = convert_children(self._root["children"], min_samples, path_info)
if not root_children:
return {
"name": self._string_table.intern("No significant data"),
"value": 0,
"children": [],
"strings": self._string_table.get_strings()
}
# Calculate thread status percentages for display
is_free_threaded = bool(sysconfig.get_config_var("Py_GIL_DISABLED"))
total_threads = max(1, self.thread_status_counts["total"])
thread_stats = {
"has_gil_pct": (self.thread_status_counts["has_gil"] / total_threads) * 100,
"on_cpu_pct": (self.thread_status_counts["on_cpu"] / total_threads) * 100,
"gil_requested_pct": (self.thread_status_counts["gil_requested"] / total_threads) * 100,
"has_exception_pct": (self.thread_status_counts["has_exception"] / total_threads) * 100,
"gc_pct": (self.samples_with_gc_frames / max(1, self._sample_count)) * 100,
"free_threaded": is_free_threaded,
**self.thread_status_counts
}
# Calculate per-thread statistics with percentages
per_thread_stats_with_pct = {}
total_samples_denominator = max(1, self._sample_count)
for thread_id, stats in self.per_thread_stats.items():
total = max(1, stats["total"])
per_thread_stats_with_pct[thread_id] = {
"has_gil_pct": (stats["has_gil"] / total) * 100,
"on_cpu_pct": (stats["on_cpu"] / total) * 100,
"gil_requested_pct": (stats["gil_requested"] / total) * 100,
"has_exception_pct": (stats["has_exception"] / total) * 100,
"gc_pct": (stats["gc_samples"] / total_samples_denominator) * 100,
**stats
}
# Build opcode mapping for JS
opcode_mapping = get_opcode_mapping()
# If we only have one root child, make it the root to avoid redundant level
root_thread_values = {
thread_id: [samples, 0]
for thread_id, samples in sorted(
self._root["thread_samples"].items()
)
}
sole_root_covers_profile = (
len(root_children) == 1
and root_children[0]["value"] == total_samples
and {
thread_id: values[0]
for thread_id, values
in root_children[0]["thread_values"].items()
} == {
thread_id: values[0]
for thread_id, values in root_thread_values.items()
}
)
if sole_root_covers_profile:
main_child = root_children[0]
# Update name and label to indicate it's the program root
old_name = self._string_table.get_string(main_child["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,
"per_thread_stats": per_thread_stats_with_pct
}
main_child["threads"] = sorted(list(self._all_threads))
main_child["strings"] = self._string_table.get_strings()
main_child["opcode_mapping"] = opcode_mapping
return main_child
program_root_idx = self._string_table.intern("Program Root")
return {
"name": program_root_idx,
"label": program_root_idx,
"value": total_samples,
"children": root_children,
"stats": {
**self.stats,
"thread_stats": thread_stats,
"per_thread_stats": per_thread_stats_with_pct
},
"threads": sorted(list(self._all_threads)),
"thread_values": root_thread_values,
"strings": self._string_table.get_strings(),
"opcode_mapping": opcode_mapping
}
def process_frames(self, frames, thread_id, weight=1):
"""Process stack frames into flamegraph tree structure.
Args:
frames: List of (filename, location, funcname, opcode) tuples in
leaf-to-root order. location is (lineno, end_lineno, col_offset, end_col_offset).
opcode is None if not gathered.
thread_id: Thread ID for this stack trace
weight: Number of samples this stack represents (for batched RLE)
"""
# Reverse to root->leaf order for tree building
self._root["samples"] += weight
self._root["thread_samples"][thread_id] += weight
self._total_samples += weight
self._root["threads"].add(thread_id)
self._all_threads.add(thread_id)
current = self._root
for filename, location, funcname, opcode in reversed(frames):
lineno = extract_lineno(location)
func = (filename, lineno, funcname)
func = self._func_intern.setdefault(func, func)
node = current["children"].get(func)
if node is None:
node = {
"samples": 0,
"children": {},
"threads": set(),
"thread_samples": collections.Counter(),
"thread_self": collections.Counter(),
"opcodes": collections.Counter(),
"self": 0,
}
current["children"][func] = node
node["samples"] += weight
node["thread_samples"][thread_id] += weight
node["threads"].add(thread_id)
if opcode is not None:
node["opcodes"][opcode] += weight
thread_opcodes = node.setdefault("thread_opcodes", {})
thread_opcodes.setdefault(
thread_id, collections.Counter()
)[opcode] += weight
current = node
if current is not self._root:
current["self"] += weight
current["thread_self"][thread_id] += weight
def _get_source_lines(self, func):
filename, lineno, _ = func
try:
lines = []
start_line = max(1, lineno - 2)
end_line = lineno + 3
for line_num in range(start_line, end_line):
line = linecache.getline(filename, line_num)
if line.strip():
marker = "→ " if line_num == lineno else " "
lines.append(f"{marker}{line_num}: {line.rstrip()}")
return lines if lines else None
except Exception:
return None
def _create_flamegraph_html(self, data):
data_json = json.dumps(data)
template_dir = importlib.resources.files(__package__)
vendor_dir = template_dir / "_vendor"
assets_dir = template_dir / "_assets"
d3_path = vendor_dir / "d3" / "7.8.5" / "d3.min.js"
d3_flame_graph_dir = vendor_dir / "d3-flame-graph" / "4.1.3"
fg_css_path = d3_flame_graph_dir / "d3-flamegraph.css"
fg_js_path = d3_flame_graph_dir / "d3-flamegraph.min.js"
fg_tooltip_js_path = d3_flame_graph_dir / "d3-flamegraph-tooltip.min.js"
html_template = (template_dir / "_flamegraph_assets" / "flamegraph_template.html").read_text(encoding="utf-8")
css_content = get_combined_css("flamegraph")
base_js = (template_dir / "_shared_assets" / "base.js").read_text(encoding="utf-8")
component_js = (template_dir / "_flamegraph_assets" / "flamegraph.js").read_text(encoding="utf-8")
js_content = f"{base_js}\n{component_js}"
# Set title and subtitle based on whether this is a differential flamegraph
is_differential = data.get("stats", {}).get("is_differential", False)
if is_differential:
title = "Tachyon Profiler - Differential Flamegraph Report"
subtitle = "Differential Flamegraph Report"
else:
title = "Tachyon Profiler - Flamegraph Report"
subtitle = "Flamegraph Report"
html_template = html_template.replace("{{TITLE}}", title)
html_template = html_template.replace("{{SUBTITLE}}", subtitle)
# Inline first-party CSS/JS
html_template = html_template.replace(
"<!-- INLINE_CSS -->", f"<style>\n{css_content}\n</style>"
)
html_template = html_template.replace(
"<!-- INLINE_JS -->", f"<script>\n{js_content}\n</script>"
)
png_path = assets_dir / "tachyon-logo.png"
b64_logo = base64.b64encode(png_path.read_bytes()).decode("ascii")
# Let CSS control size; keep markup simple
logo_html = f'<img src="data:image/png;base64,{b64_logo}" alt="Tachyon logo"/>'
html_template = html_template.replace("<!-- INLINE_LOGO -->", logo_html)
html_template = html_template.replace(
"<!-- PYTHON_VERSION -->", f"{sys.version_info.major}.{sys.version_info.minor}"
)
d3_js = d3_path.read_text(encoding="utf-8")
fg_css = fg_css_path.read_text(encoding="utf-8")
fg_js = fg_js_path.read_text(encoding="utf-8")
fg_tooltip_js = fg_tooltip_js_path.read_text(encoding="utf-8")
html_template = html_template.replace(
"<!-- INLINE_VENDOR_D3_JS -->",
f"<script>\n{d3_js}\n</script>",
)
html_template = html_template.replace(
"<!-- INLINE_VENDOR_FLAMEGRAPH_CSS -->",
f"<style>\n{fg_css}\n</style>",
)
html_template = html_template.replace(
"<!-- INLINE_VENDOR_FLAMEGRAPH_JS -->",
f"<script>\n{fg_js}\n</script>",
)
html_template = html_template.replace(
"<!-- INLINE_VENDOR_FLAMEGRAPH_TOOLTIP_JS -->",
f"<script>\n{fg_tooltip_js}\n</script>",
)
# Replace the placeholder with actual data
html_content = html_template.replace(
"{{FLAMEGRAPH_DATA}}", data_json
)
return html_content
class DiffFlamegraphCollector(FlamegraphCollector):
"""Differential flamegraph collector that compares against a baseline binary profile."""
def __init__(self, sample_interval_usec, *, baseline_binary_path,
skip_idle=False, mode=None, capture_config=None):
super().__init__(sample_interval_usec, skip_idle=skip_idle)
if not os.path.exists(baseline_binary_path):
raise ValueError(f"Baseline file not found: {baseline_binary_path}")
self.baseline_binary_path = baseline_binary_path
self._baseline_collector = None
self._elided_paths = set()
self.mode = mode
self.capture_config = capture_config
def _load_baseline(self):
"""Load baseline profile from binary file."""
from .binary_reader import BinaryReader
with BinaryReader(self.baseline_binary_path) as reader:
info = reader.get_info()
baseline_mode = info.get("mode")
if (
baseline_mode is not None
and self.mode is not None
and baseline_mode != self.mode
):
raise ValueError(
"Baseline profiling mode does not match current mode"
)
baseline_config = info.get("capture_config")
if baseline_config is not None and self.capture_config is not None:
names = baseline_config.keys() | self.capture_config.keys()
mismatches = [
name for name in names
if baseline_config.get(name, False)
!= self.capture_config.get(name, False)
]
else:
mismatches = []
if mismatches:
raise ValueError(
"Baseline capture configuration does not match current "
f"configuration: {', '.join(sorted(mismatches))}"
)
baseline_collector = FlamegraphCollector(
sample_interval_usec=info['sample_interval_us'],
skip_idle=self.skip_idle
)
reader.replay_samples(baseline_collector)
self._baseline_collector = baseline_collector
def _aggregate_path_samples(self, root_node, path=None):
"""Aggregate samples by stack path, excluding line numbers for cross-profile matching."""
if path is None:
path = ()
stats = {}
for func, node in root_node["children"].items():
filename, _lineno, funcname = func
func_key = (filename, funcname)
path_key = path + (func_key,)
total_samples = node.get("samples", 0)
self_samples = node.get("self", 0)
if path_key in stats:
stats[path_key]["total"] += total_samples
stats[path_key]["self"] += self_samples
else:
stats[path_key] = {
"total": total_samples,
"self": self_samples
}
child_stats = self._aggregate_path_samples(node, path_key)
for key, data in child_stats.items():
if key in stats:
stats[key]["total"] += data["total"]
stats[key]["self"] += data["self"]
else:
stats[key] = data
return stats
def _convert_to_flamegraph_format(self):
"""Convert to flamegraph format with differential annotations."""
if self._baseline_collector is None:
self._load_baseline()
current_flamegraph = super()._convert_to_flamegraph_format()
current_stats = self._aggregate_path_samples(self._root)
baseline_stats = self._aggregate_path_samples(self._baseline_collector._root)
# Scale baseline values to make them comparable, accounting for both
# sample count differences and sample interval differences.
baseline_total = self._baseline_collector._total_samples
if baseline_total > 0 and self._total_samples > 0:
current_time = self._total_samples * self.sample_interval_usec
baseline_time = baseline_total * self._baseline_collector.sample_interval_usec
scale = current_time / baseline_time
elif baseline_total > 0:
# Current profile is empty - use interval-based scale for elided display
scale = self.sample_interval_usec / self._baseline_collector.sample_interval_usec
else:
scale = 1.0
self._annotate_nodes_with_diff(current_flamegraph, current_stats, baseline_stats, scale)
self._add_elided_flamegraph(current_flamegraph, current_stats, baseline_stats, scale)
return current_flamegraph
def _annotate_nodes_with_diff(self, current_flamegraph, current_stats, baseline_stats, scale):
"""Annotate each node in the tree with diff metadata."""
if "stats" not in current_flamegraph:
current_flamegraph["stats"] = {}
current_flamegraph["stats"]["baseline_samples"] = self._baseline_collector._total_samples
current_flamegraph["stats"]["current_samples"] = self._total_samples
current_flamegraph["stats"]["baseline_scale"] = scale
current_flamegraph["stats"]["is_differential"] = True
if self._is_promoted_root(current_flamegraph):
self._add_diff_data_to_node(current_flamegraph, (), current_stats, baseline_stats, scale)
else:
for child in current_flamegraph["children"]:
self._add_diff_data_to_node(child, (), current_stats, baseline_stats, scale)
def _add_diff_data_to_node(self, node, path, current_stats, baseline_stats, scale):
"""Recursively add diff metadata to nodes."""
func_key = self._extract_func_key(node, self._string_table)
path_key = path + (func_key,) if func_key else path
current_data = current_stats.get(path_key, {"total": 0, "self": 0})
baseline_data = baseline_stats.get(path_key, {"total": 0, "self": 0})
current_self = node.get("self", 0)
current_total = node.get("value", 0)
current_nonself = current_total - current_self
aggregate_nonself = current_data["total"] - current_data["self"]
# Allocate self and descendant samples separately. Line-number
# changes can split one function path into several rendered nodes,
# and using independent weights for self and inclusive totals could
# otherwise assign a node more self samples than total samples.
self_weight = self._sample_weight(
current_self,
current_data["self"],
current_total,
current_data["total"],
)
nonself_weight = self._sample_weight(
current_nonself,
aggregate_nonself,
current_total,
current_data["total"],
)
baseline_self = baseline_data["self"] * scale * self_weight
baseline_nonself = (
baseline_data["total"] - baseline_data["self"]
) * scale * nonself_weight
baseline_total = baseline_self + baseline_nonself
diff = current_self - baseline_self
if baseline_self > 0:
diff_pct = (diff / baseline_self) * 100.0
elif current_self > 0:
diff_pct = 100.0
else:
diff_pct = 0.0
node["baseline"] = baseline_self
node["baseline_total"] = baseline_total
node["self_time"] = current_self
node["diff"] = diff
node["diff_pct"] = diff_pct
if "children" in node and node["children"]:
for child in node["children"]:
self._add_diff_data_to_node(child, path_key, current_stats, baseline_stats, scale)
@staticmethod
def _sample_weight(value, aggregate, fallback_value, fallback_aggregate):
if aggregate > 0:
return value / aggregate
if fallback_aggregate > 0:
return fallback_value / fallback_aggregate
return 0
def _is_promoted_root(self, data):
"""Check if the data represents a promoted root node."""
return "filename" in data and "funcname" in data
def _add_elided_flamegraph(self, current_flamegraph, current_stats, baseline_stats, scale):
"""Calculate elided paths and add elided flamegraph to stats."""
self._elided_paths = baseline_stats.keys() - current_stats.keys()
# A sampled stack can end at an internal path that also has elided
# descendants. Count every disappeared path with self samples, not
# just the leaves of the elided path tree.
elided_stacks = {
path
for path in self._elided_paths
if baseline_stats[path]["self"] > 0
}
current_flamegraph["stats"]["elided_count"] = len(elided_stacks)
if self._elided_paths:
elided_flamegraph = self._build_elided_flamegraph(baseline_stats, scale)
if elided_flamegraph:
current_flamegraph["stats"]["elided_flamegraph"] = elided_flamegraph
def _build_elided_flamegraph(self, baseline_stats, scale):
"""Build flamegraph containing only elided paths from baseline.
This re-runs the base conversion pipeline on the baseline collector
to produce a complete formatted flamegraph, then prunes it to keep
only elided paths.
"""
if not self._baseline_collector or not self._elided_paths:
return None
# Suppress source line collection for elided nodes - these functions
# no longer exist in the current profile, so source lines from the
# current machine's filesystem would be misleading or unavailable.
orig_get_source = self._baseline_collector._get_source_lines
self._baseline_collector._get_source_lines = lambda func: None
try:
baseline_data = self._baseline_collector._convert_to_flamegraph_format(
min_samples=1
)
finally:
self._baseline_collector._get_source_lines = orig_get_source
# Remove non-elided nodes and recalculate values
if not self._extract_elided_nodes(baseline_data, path=()):
return None
self._add_elided_metadata(baseline_data, baseline_stats, scale, path=())
# Merge only profiling metadata, not thread-level stats
for key in ("sample_interval_usec", "duration_sec", "sample_rate",
"error_rate", "missed_samples", "mode"):
if key in self.stats:
baseline_data["stats"][key] = self.stats[key]
baseline_data["stats"]["is_differential"] = True
baseline_data["stats"]["baseline_samples"] = self._baseline_collector._total_samples
baseline_data["stats"]["current_samples"] = self._total_samples
return baseline_data
def _extract_elided_nodes(self, node, path):
"""Remove non-elided nodes and recalculate values bottom-up."""
if not node:
return False
func_key = self._extract_func_key(node, self._baseline_collector._string_table)
current_path = path + (func_key,) if func_key else path
is_elided = current_path in self._elided_paths if func_key else False
if "children" in node:
# Filter children, keeping only those with elided descendants
elided_children = []
total_value = 0
for child in node["children"]:
if self._extract_elided_nodes(child, current_path):
elided_children.append(child)
total_value += child.get("value", 0)
node["children"] = elided_children
# Recalculate value for structural (non-elided) ancestor nodes;
# elided nodes keep their original value to preserve self-samples
if elided_children and not is_elided:
node["value"] = total_value
node["self"] = 0
node.pop("opcodes", None)
node.pop("thread_opcodes", None)
# Keep this node if it's elided or has elided descendants
return is_elided or bool(node.get("children"))
def _add_elided_metadata(self, node, baseline_stats, scale, path):
"""Add differential metadata showing this path disappeared."""
if not node:
return
func_key = self._extract_func_key(node, self._baseline_collector._string_table)
current_path = path + (func_key,) if func_key else path
baseline_self = 0
baseline_total = 0
if func_key and current_path in baseline_stats:
baseline_total = node.get("value", 0) * scale
# Matched nodes are retained only as structural ancestors. Their
# own samples are still present in the current profile and must
# not be reported as disappeared.
if current_path in self._elided_paths:
baseline_self = node.get("self", 0) * scale
node["baseline"] = baseline_self
node["baseline_total"] = baseline_total
node["diff"] = -baseline_self
else:
node["baseline"] = 0
node["baseline_total"] = 0
node["diff"] = 0
node["self_time"] = 0
# Elided paths have zero current self-time, so the change is always
# -100% when there was actual baseline self-time to lose.
# For internal nodes with no baseline self-time, use 0% to avoid
# misleading tooltips.
if baseline_self > 0:
node["diff_pct"] = -100.0
else:
node["diff_pct"] = 0.0
if "children" in node and node["children"]:
for child in node["children"]:
self._add_elided_metadata(child, baseline_stats, scale, current_path)
def _extract_func_key(self, node, string_table):
"""Extract (filename, funcname) key from node, excluding line numbers.
Line numbers are excluded to match functions even if they moved.
Returns None for root nodes that don't have function information.
"""
if "filename" not in node or "funcname" not in node:
return None
filename = string_table.get_string(node["filename"])
funcname = string_table.get_string(node["funcname"])
return (filename, funcname)
+53
View File
@@ -0,0 +1,53 @@
"""String table implementation for memory-efficient string storage in profiling data."""
class StringTable:
"""A string table for interning strings and reducing memory usage."""
def __init__(self):
self._strings = []
self._string_to_index = {}
def intern(self, string):
"""Intern a string and return its index.
Args:
string: The string to intern
Returns:
int: The index of the string in the table
"""
if not isinstance(string, str):
string = str(string)
if string in self._string_to_index:
return self._string_to_index[string]
index = len(self._strings)
self._strings.append(string)
self._string_to_index[string] = index
return index
def get_string(self, index):
"""Get a string by its index.
Args:
index: The index of the string
Returns:
str: The string at the given index, or empty string if invalid
"""
if 0 <= index < len(self._strings):
return self._strings[index]
return ""
def get_strings(self):
"""Get the list of all strings in the table.
Returns:
list: A copy of the strings list
"""
return self._strings.copy()
def __len__(self):
"""Return the number of strings in the table."""
return len(self._strings)
+220
View File
@@ -0,0 +1,220 @@
"""Tracing profiler for Python.
This module provides deterministic profiling of Python programs by tracing
every function call and return.
"""
__all__ = ("run", "runctx", "Profile")
import _lsprof
import importlib.machinery
import importlib.util
import io
from profiling.tracing._utils import _Utils
# ____________________________________________________________
# Simple interface
def run(statement, filename=None, sort=-1):
"""Run statement under profiler optionally saving results in filename
This function takes a single argument that can be passed to the
"exec" statement, and an optional file name. In all cases this
routine attempts to "exec" its first argument and gather profiling
statistics from the execution. If no file name is present, then this
function automatically prints a simple profiling report, sorted by the
standard name string (file/line/function-name) that is presented in
each line.
"""
return _Utils(Profile).run(statement, filename, sort)
def runctx(statement, globals, locals, filename=None, sort=-1):
"""Run statement under profiler, supplying your own globals and locals,
optionally saving results in filename.
statement and filename have the same semantics as profile.run
"""
return _Utils(Profile).runctx(statement, globals, locals,
filename, sort)
# ____________________________________________________________
class Profile(_lsprof.Profiler):
"""Profile(timer=None, timeunit=None, subcalls=True, builtins=True)
Builds a profiler object using the specified timer function.
The default timer is a fast built-in one based on real time.
For custom timer functions returning integers, timeunit can
be a float specifying a scale (i.e. how long each integer unit
is, in seconds).
"""
# Most of the functionality is in the base class.
# This subclass only adds convenient and backward-compatible methods.
def print_stats(self, sort=-1):
import pstats
if not isinstance(sort, tuple):
sort = (sort,)
pstats.Stats(self).strip_dirs().sort_stats(*sort).print_stats()
def dump_stats(self, file):
import marshal
with open(file, 'wb') as f:
self.create_stats()
marshal.dump(self.stats, f)
def create_stats(self):
self.disable()
self.snapshot_stats()
def snapshot_stats(self):
entries = self.getstats()
self.stats = {}
callersdicts = {}
# call information
for entry in entries:
func = label(entry.code)
nc = entry.callcount # ncalls column of pstats (before '/')
cc = nc - entry.reccallcount # ncalls column of pstats (after '/')
tt = entry.inlinetime # tottime column of pstats
ct = entry.totaltime # cumtime column of pstats
callers = {}
callersdicts[id(entry.code)] = callers
self.stats[func] = cc, nc, tt, ct, callers
# subcall information
for entry in entries:
if entry.calls:
func = label(entry.code)
for subentry in entry.calls:
try:
callers = callersdicts[id(subentry.code)]
except KeyError:
continue
nc = subentry.callcount
cc = nc - subentry.reccallcount
tt = subentry.inlinetime
ct = subentry.totaltime
if func in callers:
prev = callers[func]
nc += prev[0]
cc += prev[1]
tt += prev[2]
ct += prev[3]
callers[func] = nc, cc, tt, ct
# The following two methods can be called by clients to use
# a profiler to profile a statement, given as a string.
def run(self, cmd):
import __main__
dict = __main__.__dict__
return self.runctx(cmd, dict, dict)
def runctx(self, cmd, globals, locals):
self.enable()
try:
exec(cmd, globals, locals)
finally:
self.disable()
return self
# This method is more useful to profile a single function call.
def runcall(self, func, /, *args, **kw):
self.enable()
try:
return func(*args, **kw)
finally:
self.disable()
def __enter__(self):
self.enable()
return self
def __exit__(self, *exc_info):
self.disable()
# ____________________________________________________________
def label(code):
if isinstance(code, str):
return ('~', 0, code) # built-in functions ('~' sorts at the end)
else:
return (code.co_filename, code.co_firstlineno, code.co_name)
# ____________________________________________________________
def main():
import os
import sys
import runpy
import pstats
from optparse import OptionParser
usage = "cProfile.py [-o output_file_path] [-s sort] [-m module | scriptfile] [arg] ..."
parser = OptionParser(usage=usage)
parser.allow_interspersed_args = False
parser.add_option('-o', '--outfile', dest="outfile",
help="Save stats to <outfile>", default=None)
parser.add_option('-s', '--sort', dest="sort",
help="Sort order when printing to stdout, based on pstats.Stats class",
default=2,
choices=sorted(pstats.Stats.sort_arg_dict_default))
parser.add_option('-m', dest="module", action="store_true",
help="Profile a library module", default=False)
if not sys.argv[1:]:
parser.print_usage()
sys.exit(2)
(options, args) = parser.parse_args()
sys.argv[:] = args
# The script that we're profiling may chdir, so capture the absolute path
# to the output file at startup.
if options.outfile is not None:
options.outfile = os.path.abspath(options.outfile)
if len(args) > 0:
if options.module:
code = "run_module(modname, run_name='__main__')"
globs = {
'run_module': runpy.run_module,
'modname': args[0]
}
else:
progname = args[0]
sys.path.insert(0, os.path.dirname(progname))
with io.open_code(progname) as fp:
code = compile(fp.read(), progname, 'exec', module='__main__')
spec = importlib.machinery.ModuleSpec(name='__main__', loader=None,
origin=progname)
module = importlib.util.module_from_spec(spec)
# Set __main__ so that importing __main__ in the profiled code will
# return the same namespace that the code is executing under.
sys.modules['__main__'] = module
# Ensure that we're using the same __dict__ instance as the module
# for the global variables so that updates to globals are reflected
# in the module's namespace.
globs = module.__dict__
globs.update({
# Set __spec__ to None so the profiled program behaves like a
# script run directly (gh-140729).
'__spec__': None,
'__file__': spec.origin,
'__name__': spec.name,
'__package__': None,
})
try:
runctx(code, globs, None, options.outfile, options.sort)
except BrokenPipeError as exc:
# Prevent "Exception ignored" during interpreter shutdown.
sys.stdout = None
sys.exit(exc.errno)
else:
parser.print_usage()
return parser
# When invoked as main program, invoke the profiler on a script
if __name__ == '__main__':
main()
+6
View File
@@ -0,0 +1,6 @@
"""Run the tracing profiler from the command line."""
from profiling.tracing import main
if __name__ == '__main__':
main()
+32
View File
@@ -0,0 +1,32 @@
class _Utils:
"""Support class for utility functions which are shared by
profile.py and cProfile.py modules.
Not supposed to be used directly.
"""
def __init__(self, profiler):
self.profiler = profiler
def run(self, statement, filename, sort):
prof = self.profiler()
try:
prof.run(statement)
except SystemExit:
pass
finally:
self._show(prof, filename, sort)
def runctx(self, statement, globals, locals, filename, sort):
prof = self.profiler()
try:
prof.runctx(statement, globals, locals)
except SystemExit:
pass
finally:
self._show(prof, filename, sort)
def _show(self, prof, filename, sort):
if filename is not None:
prof.dump_stats(filename)
else:
prof.print_stats(sort)
+66 -3
View File
@@ -139,7 +139,11 @@ class Stats:
return return
elif isinstance(arg, str): elif isinstance(arg, str):
with open(arg, 'rb') as f: with open(arg, 'rb') as f:
self.stats = marshal.load(f) stats = marshal.load(f)
if (('__sampled__',)) in stats:
stats.pop((('__sampled__',)))
self.__class__ = SampledStats
self.stats = stats
try: try:
file_stats = os.stat(arg) file_stats = os.stat(arg)
arg = time.ctime(file_stats.st_mtime) + " " + arg arg = time.ctime(file_stats.st_mtime) + " " + arg
@@ -150,6 +154,7 @@ class Stats:
arg.create_stats() arg.create_stats()
self.stats = arg.stats self.stats = arg.stats
arg.stats = {} arg.stats = {}
return
if not self.stats: if not self.stats:
raise TypeError("Cannot create or construct a %r object from %r" raise TypeError("Cannot create or construct a %r object from %r"
% (self.__class__, arg)) % (self.__class__, arg))
@@ -467,7 +472,10 @@ class Stats:
subheader = isinstance(value, tuple) subheader = isinstance(value, tuple)
break break
if subheader: if subheader:
print(" "*name_size + " ncalls tottime cumtime", file=self.stream) self.print_call_subheading(name_size)
def print_call_subheading(self, name_size):
print(" "*name_size + " ncalls tottime cumtime", file=self.stream)
def print_call_line(self, name_size, source, call_dict, arrow="->"): def print_call_line(self, name_size, source, call_dict, arrow="->"):
print(func_std_string(source).ljust(name_size) + arrow, end=' ', file=self.stream) print(func_std_string(source).ljust(name_size) + arrow, end=' ', file=self.stream)
@@ -516,6 +524,35 @@ class Stats:
print(f8(ct/cc), end=' ', file=self.stream) print(f8(ct/cc), end=' ', file=self.stream)
print(func_std_string(func), file=self.stream) print(func_std_string(func), file=self.stream)
class SampledStats(Stats):
def __init__(self, *args, stream=None):
super().__init__(*args, stream=stream)
self.sort_arg_dict = {
"samples" : (((1,-1), ), "sample count"),
"nsamples" : (((1,-1), ), "sample count"),
"cumtime" : (((3,-1), ), "cumulative time"),
"cumulative": (((3,-1), ), "cumulative time"),
"filename" : (((4, 1), ), "file name"),
"line" : (((5, 1), ), "line number"),
"module" : (((4, 1), ), "file name"),
"name" : (((6, 1), ), "function name"),
"nfl" : (((6, 1),(4, 1),(5, 1),), "name/file/line"),
"psamples" : (((0,-1), ), "primitive call count"),
"stdname" : (((7, 1), ), "standard name"),
"time" : (((2,-1), ), "internal time"),
"tottime" : (((2,-1), ), "internal time"),
}
def print_call_subheading(self, name_size):
print(" "*name_size + " nsamples tottime cumtime", file=self.stream)
def print_title(self):
print(' nsamples tottime persample cumtime persample', end=' ', file=self.stream)
print('filename:lineno(function)', file=self.stream)
class TupleComp: class TupleComp:
"""This class provides a generic function for comparing any two tuples. """This class provides a generic function for comparing any two tuples.
Each instance records a list of tuple-indices (from most significant Each instance records a list of tuple-indices (from most significant
@@ -607,6 +644,24 @@ def f8(x):
# Statistics browser added by ESR, April 2001 # Statistics browser added by ESR, April 2001
#************************************************************************** #**************************************************************************
class StatsLoaderShim:
"""Compatibility shim implementing 'create_stats' needed by Stats classes
to handle already unmarshalled data."""
def __init__(self, raw_stats):
self.stats = raw_stats
def create_stats(self):
pass
def stats_factory(raw_stats):
"""Return a Stats or SampledStats instance based on the marker in raw_stats."""
if (('__sampled__',)) in raw_stats:
raw_stats = dict(raw_stats) # avoid mutating caller's dict
raw_stats.pop((('__sampled__',)))
return SampledStats(StatsLoaderShim(raw_stats))
else:
return Stats(StatsLoaderShim(raw_stats))
if __name__ == '__main__': if __name__ == '__main__':
import cmd import cmd
try: try:
@@ -693,7 +748,15 @@ if __name__ == '__main__':
def do_read(self, line): def do_read(self, line):
if line: if line:
try: try:
self.stats = Stats(line) with open(line, 'rb') as f:
raw_stats = marshal.load(f)
self.stats = stats_factory(raw_stats)
try:
file_stats = os.stat(line)
arg = time.ctime(file_stats.st_mtime) + " " + line
except Exception:
arg = line
self.stats.files = [arg]
except OSError as err: except OSError as err:
print(err.args[1], file=self.stream) print(err.args[1], file=self.stream)
return return
+163 -148
View File
@@ -2,12 +2,27 @@ import unittest
from asyncio import tools from asyncio import tools
from collections import namedtuple import _remote_debugging
FrameInfo = namedtuple('FrameInfo', ['funcname', 'filename', 'lineno'])
CoroInfo = namedtuple('CoroInfo', ['call_stack', 'task_name']) def LocationInfo(lineno, end_lineno=None, col_offset=None, end_col_offset=None):
TaskInfo = namedtuple('TaskInfo', ['task_id', 'task_name', 'coroutine_stack', 'awaited_by']) return _remote_debugging.LocationInfo((lineno, end_lineno, col_offset, end_col_offset))
AwaitedInfo = namedtuple('AwaitedInfo', ['thread_id', 'awaited_by'])
def FrameInfo(funcname, filename, location, opcode=None):
return _remote_debugging.FrameInfo((filename, location, funcname, opcode))
def CoroInfo(call_stack, task_name):
return _remote_debugging.CoroInfo((call_stack, task_name))
def TaskInfo(task_id, task_name, coroutine_stack, awaited_by):
return _remote_debugging.TaskInfo((task_id, task_name, coroutine_stack, awaited_by))
def AwaitedInfo(thread_id, awaited_by):
return _remote_debugging.AwaitedInfo((thread_id, awaited_by))
# mock output of get_all_awaited_by function. # mock output of get_all_awaited_by function.
@@ -33,33 +48,33 @@ TEST_INPUTS_TREE = [
awaited_by=[ awaited_by=[
CoroInfo( CoroInfo(
call_stack=[ call_stack=[
FrameInfo("awaiter3", "/path/to/app.py", 130), FrameInfo("awaiter3", "/path/to/app.py", LocationInfo(130)),
FrameInfo("awaiter2", "/path/to/app.py", 120), FrameInfo("awaiter2", "/path/to/app.py", LocationInfo(120)),
FrameInfo("awaiter", "/path/to/app.py", 110) FrameInfo("awaiter", "/path/to/app.py", LocationInfo(110))
], ],
task_name=4 task_name=4
), ),
CoroInfo( CoroInfo(
call_stack=[ call_stack=[
FrameInfo("awaiterB3", "/path/to/app.py", 190), FrameInfo("awaiterB3", "/path/to/app.py", LocationInfo(190)),
FrameInfo("awaiterB2", "/path/to/app.py", 180), FrameInfo("awaiterB2", "/path/to/app.py", LocationInfo(180)),
FrameInfo("awaiterB", "/path/to/app.py", 170) FrameInfo("awaiterB", "/path/to/app.py", LocationInfo(170))
], ],
task_name=5 task_name=5
), ),
CoroInfo( CoroInfo(
call_stack=[ call_stack=[
FrameInfo("awaiterB3", "/path/to/app.py", 190), FrameInfo("awaiterB3", "/path/to/app.py", LocationInfo(190)),
FrameInfo("awaiterB2", "/path/to/app.py", 180), FrameInfo("awaiterB2", "/path/to/app.py", LocationInfo(180)),
FrameInfo("awaiterB", "/path/to/app.py", 170) FrameInfo("awaiterB", "/path/to/app.py", LocationInfo(170))
], ],
task_name=6 task_name=6
), ),
CoroInfo( CoroInfo(
call_stack=[ call_stack=[
FrameInfo("awaiter3", "/path/to/app.py", 130), FrameInfo("awaiter3", "/path/to/app.py", LocationInfo(130)),
FrameInfo("awaiter2", "/path/to/app.py", 120), FrameInfo("awaiter2", "/path/to/app.py", LocationInfo(120)),
FrameInfo("awaiter", "/path/to/app.py", 110) FrameInfo("awaiter", "/path/to/app.py", LocationInfo(110))
], ],
task_name=7 task_name=7
) )
@@ -72,9 +87,9 @@ TEST_INPUTS_TREE = [
awaited_by=[ awaited_by=[
CoroInfo( CoroInfo(
call_stack=[ call_stack=[
FrameInfo("_aexit", "", 0), FrameInfo("_aexit", "", LocationInfo(0)),
FrameInfo("__aexit__", "", 0), FrameInfo("__aexit__", "", LocationInfo(0)),
FrameInfo("main", "", 0) FrameInfo("main", "", LocationInfo(0))
], ],
task_name=2 task_name=2
) )
@@ -87,9 +102,9 @@ TEST_INPUTS_TREE = [
awaited_by=[ awaited_by=[
CoroInfo( CoroInfo(
call_stack=[ call_stack=[
FrameInfo("_aexit", "", 0), FrameInfo("_aexit", "", LocationInfo(0)),
FrameInfo("__aexit__", "", 0), FrameInfo("__aexit__", "", LocationInfo(0)),
FrameInfo("main", "", 0) FrameInfo("main", "", LocationInfo(0))
], ],
task_name=2 task_name=2
) )
@@ -102,10 +117,10 @@ TEST_INPUTS_TREE = [
awaited_by=[ awaited_by=[
CoroInfo( CoroInfo(
call_stack=[ call_stack=[
FrameInfo("_aexit", "", 0), FrameInfo("_aexit", "", LocationInfo(0)),
FrameInfo("__aexit__", "", 0), FrameInfo("__aexit__", "", LocationInfo(0)),
FrameInfo("blocho_caller", "", 0), FrameInfo("blocho_caller", "", LocationInfo(0)),
FrameInfo("bloch", "", 0) FrameInfo("bloch", "", LocationInfo(0))
], ],
task_name=8 task_name=8
) )
@@ -118,10 +133,10 @@ TEST_INPUTS_TREE = [
awaited_by=[ awaited_by=[
CoroInfo( CoroInfo(
call_stack=[ call_stack=[
FrameInfo("_aexit", "", 0), FrameInfo("_aexit", "", LocationInfo(0)),
FrameInfo("__aexit__", "", 0), FrameInfo("__aexit__", "", LocationInfo(0)),
FrameInfo("blocho_caller", "", 0), FrameInfo("blocho_caller", "", LocationInfo(0)),
FrameInfo("bloch", "", 0) FrameInfo("bloch", "", LocationInfo(0))
], ],
task_name=8 task_name=8
) )
@@ -134,10 +149,10 @@ TEST_INPUTS_TREE = [
awaited_by=[ awaited_by=[
CoroInfo( CoroInfo(
call_stack=[ call_stack=[
FrameInfo("_aexit", "", 0), FrameInfo("_aexit", "", LocationInfo(0)),
FrameInfo("__aexit__", "", 0), FrameInfo("__aexit__", "", LocationInfo(0)),
FrameInfo("blocho_caller", "", 0), FrameInfo("blocho_caller", "", LocationInfo(0)),
FrameInfo("bloch", "", 0) FrameInfo("bloch", "", LocationInfo(0))
], ],
task_name=9 task_name=9
) )
@@ -150,10 +165,10 @@ TEST_INPUTS_TREE = [
awaited_by=[ awaited_by=[
CoroInfo( CoroInfo(
call_stack=[ call_stack=[
FrameInfo("_aexit", "", 0), FrameInfo("_aexit", "", LocationInfo(0)),
FrameInfo("__aexit__", "", 0), FrameInfo("__aexit__", "", LocationInfo(0)),
FrameInfo("blocho_caller", "", 0), FrameInfo("blocho_caller", "", LocationInfo(0)),
FrameInfo("bloch", "", 0) FrameInfo("bloch", "", LocationInfo(0))
], ],
task_name=9 task_name=9
) )
@@ -222,7 +237,7 @@ TEST_INPUTS_TREE = [
coroutine_stack=[], coroutine_stack=[],
awaited_by=[ awaited_by=[
CoroInfo( CoroInfo(
call_stack=[FrameInfo("main2", "", 0)], call_stack=[FrameInfo("main2", "", LocationInfo(0))],
task_name=5 task_name=5
) )
] ]
@@ -233,7 +248,7 @@ TEST_INPUTS_TREE = [
coroutine_stack=[], coroutine_stack=[],
awaited_by=[ awaited_by=[
CoroInfo( CoroInfo(
call_stack=[FrameInfo("main2", "", 0)], call_stack=[FrameInfo("main2", "", LocationInfo(0))],
task_name=5 task_name=5
) )
] ]
@@ -244,7 +259,7 @@ TEST_INPUTS_TREE = [
coroutine_stack=[], coroutine_stack=[],
awaited_by=[ awaited_by=[
CoroInfo( CoroInfo(
call_stack=[FrameInfo("main2", "", 0)], call_stack=[FrameInfo("main2", "", LocationInfo(0))],
task_name=5 task_name=5
) )
] ]
@@ -266,7 +281,7 @@ TEST_INPUTS_TREE = [
coroutine_stack=[], coroutine_stack=[],
awaited_by=[ awaited_by=[
CoroInfo( CoroInfo(
call_stack=[FrameInfo("main", "", 0)], call_stack=[FrameInfo("main", "", LocationInfo(0))],
task_name=1 task_name=1
) )
] ]
@@ -277,7 +292,7 @@ TEST_INPUTS_TREE = [
coroutine_stack=[], coroutine_stack=[],
awaited_by=[ awaited_by=[
CoroInfo( CoroInfo(
call_stack=[FrameInfo("main", "", 0)], call_stack=[FrameInfo("main", "", LocationInfo(0))],
task_name=1 task_name=1
) )
] ]
@@ -288,7 +303,7 @@ TEST_INPUTS_TREE = [
coroutine_stack=[], coroutine_stack=[],
awaited_by=[ awaited_by=[
CoroInfo( CoroInfo(
call_stack=[FrameInfo("main", "", 0)], call_stack=[FrameInfo("main", "", LocationInfo(0))],
task_name=1 task_name=1
) )
] ]
@@ -347,7 +362,7 @@ TEST_INPUTS_TREE = [
coroutine_stack=[], coroutine_stack=[],
awaited_by=[ awaited_by=[
CoroInfo( CoroInfo(
call_stack=[FrameInfo("main", "", 0)], call_stack=[FrameInfo("main", "", LocationInfo(0))],
task_name=4 task_name=4
) )
] ]
@@ -358,7 +373,7 @@ TEST_INPUTS_TREE = [
coroutine_stack=[], coroutine_stack=[],
awaited_by=[ awaited_by=[
CoroInfo( CoroInfo(
call_stack=[FrameInfo("main", "", 0)], call_stack=[FrameInfo("main", "", LocationInfo(0))],
task_name=4 task_name=4
) )
] ]
@@ -369,7 +384,7 @@ TEST_INPUTS_TREE = [
coroutine_stack=[], coroutine_stack=[],
awaited_by=[ awaited_by=[
CoroInfo( CoroInfo(
call_stack=[FrameInfo("main", "", 0)], call_stack=[FrameInfo("main", "", LocationInfo(0))],
task_name=4 task_name=4
) )
] ]
@@ -415,11 +430,11 @@ TEST_INPUTS_CYCLES_TREE = [
coroutine_stack=[], coroutine_stack=[],
awaited_by=[ awaited_by=[
CoroInfo( CoroInfo(
call_stack=[FrameInfo("awaiter2", "", 0)], call_stack=[FrameInfo("awaiter2", "", LocationInfo(0))],
task_name=4 task_name=4
), ),
CoroInfo( CoroInfo(
call_stack=[FrameInfo("main", "", 0)], call_stack=[FrameInfo("main", "", LocationInfo(0))],
task_name=2 task_name=2
) )
] ]
@@ -430,7 +445,7 @@ TEST_INPUTS_CYCLES_TREE = [
coroutine_stack=[], coroutine_stack=[],
awaited_by=[ awaited_by=[
CoroInfo( CoroInfo(
call_stack=[FrameInfo("awaiter", "", 0)], call_stack=[FrameInfo("awaiter", "", LocationInfo(0))],
task_name=3 task_name=3
) )
] ]
@@ -462,9 +477,9 @@ TEST_INPUTS_CYCLES_TREE = [
awaited_by=[ awaited_by=[
CoroInfo( CoroInfo(
call_stack=[ call_stack=[
FrameInfo("nested", "", 0), FrameInfo("nested", "", LocationInfo(0)),
FrameInfo("nested", "", 0), FrameInfo("nested", "", LocationInfo(0)),
FrameInfo("task_b", "", 0) FrameInfo("task_b", "", LocationInfo(0))
], ],
task_name=4 task_name=4
) )
@@ -477,17 +492,17 @@ TEST_INPUTS_CYCLES_TREE = [
awaited_by=[ awaited_by=[
CoroInfo( CoroInfo(
call_stack=[ call_stack=[
FrameInfo("nested", "", 0), FrameInfo("nested", "", LocationInfo(0)),
FrameInfo("nested", "", 0), FrameInfo("nested", "", LocationInfo(0)),
FrameInfo("task_c", "", 0) FrameInfo("task_c", "", LocationInfo(0))
], ],
task_name=5 task_name=5
), ),
CoroInfo( CoroInfo(
call_stack=[ call_stack=[
FrameInfo("nested", "", 0), FrameInfo("nested", "", LocationInfo(0)),
FrameInfo("nested", "", 0), FrameInfo("nested", "", LocationInfo(0)),
FrameInfo("task_a", "", 0) FrameInfo("task_a", "", LocationInfo(0))
], ],
task_name=3 task_name=3
) )
@@ -500,8 +515,8 @@ TEST_INPUTS_CYCLES_TREE = [
awaited_by=[ awaited_by=[
CoroInfo( CoroInfo(
call_stack=[ call_stack=[
FrameInfo("nested", "", 0), FrameInfo("nested", "", LocationInfo(0)),
FrameInfo("nested", "", 0) FrameInfo("nested", "", LocationInfo(0))
], ],
task_name=6 task_name=6
) )
@@ -514,9 +529,9 @@ TEST_INPUTS_CYCLES_TREE = [
awaited_by=[ awaited_by=[
CoroInfo( CoroInfo(
call_stack=[ call_stack=[
FrameInfo("nested", "", 0), FrameInfo("nested", "", LocationInfo(0)),
FrameInfo("nested", "", 0), FrameInfo("nested", "", LocationInfo(0)),
FrameInfo("task_b", "", 0) FrameInfo("task_b", "", LocationInfo(0))
], ],
task_name=4 task_name=4
) )
@@ -553,33 +568,33 @@ TEST_INPUTS_TABLE = [
awaited_by=[ awaited_by=[
CoroInfo( CoroInfo(
call_stack=[ call_stack=[
FrameInfo("awaiter3", "", 0), FrameInfo("awaiter3", "", LocationInfo(0)),
FrameInfo("awaiter2", "", 0), FrameInfo("awaiter2", "", LocationInfo(0)),
FrameInfo("awaiter", "", 0) FrameInfo("awaiter", "", LocationInfo(0))
], ],
task_name=4 task_name=4
), ),
CoroInfo( CoroInfo(
call_stack=[ call_stack=[
FrameInfo("awaiter1_3", "", 0), FrameInfo("awaiter1_3", "", LocationInfo(0)),
FrameInfo("awaiter1_2", "", 0), FrameInfo("awaiter1_2", "", LocationInfo(0)),
FrameInfo("awaiter1", "", 0) FrameInfo("awaiter1", "", LocationInfo(0))
], ],
task_name=5 task_name=5
), ),
CoroInfo( CoroInfo(
call_stack=[ call_stack=[
FrameInfo("awaiter1_3", "", 0), FrameInfo("awaiter1_3", "", LocationInfo(0)),
FrameInfo("awaiter1_2", "", 0), FrameInfo("awaiter1_2", "", LocationInfo(0)),
FrameInfo("awaiter1", "", 0) FrameInfo("awaiter1", "", LocationInfo(0))
], ],
task_name=6 task_name=6
), ),
CoroInfo( CoroInfo(
call_stack=[ call_stack=[
FrameInfo("awaiter3", "", 0), FrameInfo("awaiter3", "", LocationInfo(0)),
FrameInfo("awaiter2", "", 0), FrameInfo("awaiter2", "", LocationInfo(0)),
FrameInfo("awaiter", "", 0) FrameInfo("awaiter", "", LocationInfo(0))
], ],
task_name=7 task_name=7
) )
@@ -592,9 +607,9 @@ TEST_INPUTS_TABLE = [
awaited_by=[ awaited_by=[
CoroInfo( CoroInfo(
call_stack=[ call_stack=[
FrameInfo("_aexit", "", 0), FrameInfo("_aexit", "", LocationInfo(0)),
FrameInfo("__aexit__", "", 0), FrameInfo("__aexit__", "", LocationInfo(0)),
FrameInfo("main", "", 0) FrameInfo("main", "", LocationInfo(0))
], ],
task_name=2 task_name=2
) )
@@ -607,9 +622,9 @@ TEST_INPUTS_TABLE = [
awaited_by=[ awaited_by=[
CoroInfo( CoroInfo(
call_stack=[ call_stack=[
FrameInfo("_aexit", "", 0), FrameInfo("_aexit", "", LocationInfo(0)),
FrameInfo("__aexit__", "", 0), FrameInfo("__aexit__", "", LocationInfo(0)),
FrameInfo("main", "", 0) FrameInfo("main", "", LocationInfo(0))
], ],
task_name=2 task_name=2
) )
@@ -622,10 +637,10 @@ TEST_INPUTS_TABLE = [
awaited_by=[ awaited_by=[
CoroInfo( CoroInfo(
call_stack=[ call_stack=[
FrameInfo("_aexit", "", 0), FrameInfo("_aexit", "", LocationInfo(0)),
FrameInfo("__aexit__", "", 0), FrameInfo("__aexit__", "", LocationInfo(0)),
FrameInfo("blocho_caller", "", 0), FrameInfo("blocho_caller", "", LocationInfo(0)),
FrameInfo("bloch", "", 0) FrameInfo("bloch", "", LocationInfo(0))
], ],
task_name=8 task_name=8
) )
@@ -638,10 +653,10 @@ TEST_INPUTS_TABLE = [
awaited_by=[ awaited_by=[
CoroInfo( CoroInfo(
call_stack=[ call_stack=[
FrameInfo("_aexit", "", 0), FrameInfo("_aexit", "", LocationInfo(0)),
FrameInfo("__aexit__", "", 0), FrameInfo("__aexit__", "", LocationInfo(0)),
FrameInfo("blocho_caller", "", 0), FrameInfo("blocho_caller", "", LocationInfo(0)),
FrameInfo("bloch", "", 0) FrameInfo("bloch", "", LocationInfo(0))
], ],
task_name=8 task_name=8
) )
@@ -654,10 +669,10 @@ TEST_INPUTS_TABLE = [
awaited_by=[ awaited_by=[
CoroInfo( CoroInfo(
call_stack=[ call_stack=[
FrameInfo("_aexit", "", 0), FrameInfo("_aexit", "", LocationInfo(0)),
FrameInfo("__aexit__", "", 0), FrameInfo("__aexit__", "", LocationInfo(0)),
FrameInfo("blocho_caller", "", 0), FrameInfo("blocho_caller", "", LocationInfo(0)),
FrameInfo("bloch", "", 0) FrameInfo("bloch", "", LocationInfo(0))
], ],
task_name=9 task_name=9
) )
@@ -670,10 +685,10 @@ TEST_INPUTS_TABLE = [
awaited_by=[ awaited_by=[
CoroInfo( CoroInfo(
call_stack=[ call_stack=[
FrameInfo("_aexit", "", 0), FrameInfo("_aexit", "", LocationInfo(0)),
FrameInfo("__aexit__", "", 0), FrameInfo("__aexit__", "", LocationInfo(0)),
FrameInfo("blocho_caller", "", 0), FrameInfo("blocho_caller", "", LocationInfo(0)),
FrameInfo("bloch", "", 0) FrameInfo("bloch", "", LocationInfo(0))
], ],
task_name=9 task_name=9
) )
@@ -797,7 +812,7 @@ TEST_INPUTS_TABLE = [
coroutine_stack=[], coroutine_stack=[],
awaited_by=[ awaited_by=[
CoroInfo( CoroInfo(
call_stack=[FrameInfo("main2", "", 0)], call_stack=[FrameInfo("main2", "", LocationInfo(0))],
task_name=5 task_name=5
) )
] ]
@@ -808,7 +823,7 @@ TEST_INPUTS_TABLE = [
coroutine_stack=[], coroutine_stack=[],
awaited_by=[ awaited_by=[
CoroInfo( CoroInfo(
call_stack=[FrameInfo("main2", "", 0)], call_stack=[FrameInfo("main2", "", LocationInfo(0))],
task_name=5 task_name=5
) )
] ]
@@ -819,7 +834,7 @@ TEST_INPUTS_TABLE = [
coroutine_stack=[], coroutine_stack=[],
awaited_by=[ awaited_by=[
CoroInfo( CoroInfo(
call_stack=[FrameInfo("main2", "", 0)], call_stack=[FrameInfo("main2", "", LocationInfo(0))],
task_name=5 task_name=5
) )
] ]
@@ -841,7 +856,7 @@ TEST_INPUTS_TABLE = [
coroutine_stack=[], coroutine_stack=[],
awaited_by=[ awaited_by=[
CoroInfo( CoroInfo(
call_stack=[FrameInfo("main", "", 0)], call_stack=[FrameInfo("main", "", LocationInfo(0))],
task_name=1 task_name=1
) )
] ]
@@ -852,7 +867,7 @@ TEST_INPUTS_TABLE = [
coroutine_stack=[], coroutine_stack=[],
awaited_by=[ awaited_by=[
CoroInfo( CoroInfo(
call_stack=[FrameInfo("main", "", 0)], call_stack=[FrameInfo("main", "", LocationInfo(0))],
task_name=1 task_name=1
) )
] ]
@@ -863,7 +878,7 @@ TEST_INPUTS_TABLE = [
coroutine_stack=[], coroutine_stack=[],
awaited_by=[ awaited_by=[
CoroInfo( CoroInfo(
call_stack=[FrameInfo("main", "", 0)], call_stack=[FrameInfo("main", "", LocationInfo(0))],
task_name=1 task_name=1
) )
] ]
@@ -916,7 +931,7 @@ TEST_INPUTS_TABLE = [
coroutine_stack=[], coroutine_stack=[],
awaited_by=[ awaited_by=[
CoroInfo( CoroInfo(
call_stack=[FrameInfo("main", "", 0)], call_stack=[FrameInfo("main", "", LocationInfo(0))],
task_name=4 task_name=4
) )
] ]
@@ -927,7 +942,7 @@ TEST_INPUTS_TABLE = [
coroutine_stack=[], coroutine_stack=[],
awaited_by=[ awaited_by=[
CoroInfo( CoroInfo(
call_stack=[FrameInfo("main", "", 0)], call_stack=[FrameInfo("main", "", LocationInfo(0))],
task_name=4 task_name=4
) )
] ]
@@ -938,7 +953,7 @@ TEST_INPUTS_TABLE = [
coroutine_stack=[], coroutine_stack=[],
awaited_by=[ awaited_by=[
CoroInfo( CoroInfo(
call_stack=[FrameInfo("main", "", 0)], call_stack=[FrameInfo("main", "", LocationInfo(0))],
task_name=4 task_name=4
) )
] ]
@@ -979,11 +994,11 @@ TEST_INPUTS_TABLE = [
coroutine_stack=[], coroutine_stack=[],
awaited_by=[ awaited_by=[
CoroInfo( CoroInfo(
call_stack=[FrameInfo("awaiter2", "", 0)], call_stack=[FrameInfo("awaiter2", "", LocationInfo(0))],
task_name=4 task_name=4
), ),
CoroInfo( CoroInfo(
call_stack=[FrameInfo("main", "", 0)], call_stack=[FrameInfo("main", "", LocationInfo(0))],
task_name=2 task_name=2
) )
] ]
@@ -994,7 +1009,7 @@ TEST_INPUTS_TABLE = [
coroutine_stack=[], coroutine_stack=[],
awaited_by=[ awaited_by=[
CoroInfo( CoroInfo(
call_stack=[FrameInfo("awaiter", "", 0)], call_stack=[FrameInfo("awaiter", "", LocationInfo(0))],
task_name=3 task_name=3
) )
] ]
@@ -1033,9 +1048,9 @@ TEST_INPUTS_TABLE = [
awaited_by=[ awaited_by=[
CoroInfo( CoroInfo(
call_stack=[ call_stack=[
FrameInfo("nested", "", 0), FrameInfo("nested", "", LocationInfo(0)),
FrameInfo("nested", "", 0), FrameInfo("nested", "", LocationInfo(0)),
FrameInfo("task_b", "", 0) FrameInfo("task_b", "", LocationInfo(0))
], ],
task_name=4 task_name=4
) )
@@ -1048,17 +1063,17 @@ TEST_INPUTS_TABLE = [
awaited_by=[ awaited_by=[
CoroInfo( CoroInfo(
call_stack=[ call_stack=[
FrameInfo("nested", "", 0), FrameInfo("nested", "", LocationInfo(0)),
FrameInfo("nested", "", 0), FrameInfo("nested", "", LocationInfo(0)),
FrameInfo("task_c", "", 0) FrameInfo("task_c", "", LocationInfo(0))
], ],
task_name=5 task_name=5
), ),
CoroInfo( CoroInfo(
call_stack=[ call_stack=[
FrameInfo("nested", "", 0), FrameInfo("nested", "", LocationInfo(0)),
FrameInfo("nested", "", 0), FrameInfo("nested", "", LocationInfo(0)),
FrameInfo("task_a", "", 0) FrameInfo("task_a", "", LocationInfo(0))
], ],
task_name=3 task_name=3
) )
@@ -1071,8 +1086,8 @@ TEST_INPUTS_TABLE = [
awaited_by=[ awaited_by=[
CoroInfo( CoroInfo(
call_stack=[ call_stack=[
FrameInfo("nested", "", 0), FrameInfo("nested", "", LocationInfo(0)),
FrameInfo("nested", "", 0) FrameInfo("nested", "", LocationInfo(0))
], ],
task_name=6 task_name=6
) )
@@ -1085,9 +1100,9 @@ TEST_INPUTS_TABLE = [
awaited_by=[ awaited_by=[
CoroInfo( CoroInfo(
call_stack=[ call_stack=[
FrameInfo("nested", "", 0), FrameInfo("nested", "", LocationInfo(0)),
FrameInfo("nested", "", 0), FrameInfo("nested", "", LocationInfo(0)),
FrameInfo("task_b", "", 0) FrameInfo("task_b", "", LocationInfo(0))
], ],
task_name=4 task_name=4
) )
@@ -1290,7 +1305,7 @@ class TestAsyncioToolsBasic(unittest.TestCase):
coroutine_stack=[], coroutine_stack=[],
awaited_by=[ awaited_by=[
CoroInfo( CoroInfo(
call_stack=[FrameInfo("main", "", 0)], call_stack=[FrameInfo("main", "", LocationInfo(0))],
task_name=3 task_name=3
) )
] ]
@@ -1301,7 +1316,7 @@ class TestAsyncioToolsBasic(unittest.TestCase):
coroutine_stack=[], coroutine_stack=[],
awaited_by=[ awaited_by=[
CoroInfo( CoroInfo(
call_stack=[FrameInfo("main", "", 0)], call_stack=[FrameInfo("main", "", LocationInfo(0))],
task_name=2 task_name=2
) )
] ]
@@ -1331,7 +1346,7 @@ class TestAsyncioToolsBasic(unittest.TestCase):
coroutine_stack=[], coroutine_stack=[],
awaited_by=[ awaited_by=[
CoroInfo( CoroInfo(
call_stack=[FrameInfo("main", "", 0)], call_stack=[FrameInfo("main", "", LocationInfo(0))],
task_name=2 task_name=2
) )
] ]
@@ -1342,7 +1357,7 @@ class TestAsyncioToolsBasic(unittest.TestCase):
coroutine_stack=[], coroutine_stack=[],
awaited_by=[ awaited_by=[
CoroInfo( CoroInfo(
call_stack=[FrameInfo("main", "", 0)], call_stack=[FrameInfo("main", "", LocationInfo(0))],
task_name=3 task_name=3
) )
] ]
@@ -1379,7 +1394,7 @@ class TestAsyncioToolsBasic(unittest.TestCase):
coroutine_stack=[], coroutine_stack=[],
awaited_by=[ awaited_by=[
CoroInfo( CoroInfo(
call_stack=[FrameInfo("main", "", 0)], call_stack=[FrameInfo("main", "", LocationInfo(0))],
task_name=2 task_name=2
) )
] ]
@@ -1390,7 +1405,7 @@ class TestAsyncioToolsBasic(unittest.TestCase):
coroutine_stack=[], coroutine_stack=[],
awaited_by=[ awaited_by=[
CoroInfo( CoroInfo(
call_stack=[FrameInfo("main", "", 0)], call_stack=[FrameInfo("main", "", LocationInfo(0))],
task_name=3 task_name=3
) )
] ]
@@ -1417,11 +1432,11 @@ class TestAsyncioToolsBasic(unittest.TestCase):
awaited_by=[ awaited_by=[
CoroInfo( CoroInfo(
call_stack=[ call_stack=[
FrameInfo("c1", "", 0), FrameInfo("c1", "", LocationInfo(0)),
FrameInfo("c2", "", 0), FrameInfo("c2", "", LocationInfo(0)),
FrameInfo("c3", "", 0), FrameInfo("c3", "", LocationInfo(0)),
FrameInfo("c4", "", 0), FrameInfo("c4", "", LocationInfo(0)),
FrameInfo("c5", "", 0) FrameInfo("c5", "", LocationInfo(0))
], ],
task_name=11 task_name=11
) )
@@ -1461,7 +1476,7 @@ class TestAsyncioToolsBasic(unittest.TestCase):
coroutine_stack=[], coroutine_stack=[],
awaited_by=[ awaited_by=[
CoroInfo( CoroInfo(
call_stack=[FrameInfo("call1", "", 0)], call_stack=[FrameInfo("call1", "", LocationInfo(0))],
task_name=2 task_name=2
) )
] ]
@@ -1472,7 +1487,7 @@ class TestAsyncioToolsBasic(unittest.TestCase):
coroutine_stack=[], coroutine_stack=[],
awaited_by=[ awaited_by=[
CoroInfo( CoroInfo(
call_stack=[FrameInfo("call2", "", 0)], call_stack=[FrameInfo("call2", "", LocationInfo(0))],
task_name=3 task_name=3
) )
] ]
@@ -1483,11 +1498,11 @@ class TestAsyncioToolsBasic(unittest.TestCase):
coroutine_stack=[], coroutine_stack=[],
awaited_by=[ awaited_by=[
CoroInfo( CoroInfo(
call_stack=[FrameInfo("call3", "", 0)], call_stack=[FrameInfo("call3", "", LocationInfo(0))],
task_name=1 task_name=1
), ),
CoroInfo( CoroInfo(
call_stack=[FrameInfo("call4", "", 0)], call_stack=[FrameInfo("call4", "", LocationInfo(0))],
task_name=2 task_name=2
) )
] ]
@@ -1511,7 +1526,7 @@ class TestAsyncioToolsBasic(unittest.TestCase):
coroutine_stack=[], coroutine_stack=[],
awaited_by=[ awaited_by=[
CoroInfo( CoroInfo(
call_stack=[FrameInfo("foo", "", 0)], call_stack=[FrameInfo("foo", "", LocationInfo(0))],
task_name=2 task_name=2
) )
] ]
@@ -1555,7 +1570,7 @@ class TestAsyncioToolsEdgeCases(unittest.TestCase):
coroutine_stack=[], coroutine_stack=[],
awaited_by=[ awaited_by=[
CoroInfo( CoroInfo(
call_stack=[FrameInfo("loopback", "", 0)], call_stack=[FrameInfo("loopback", "", LocationInfo(0))],
task_name=1 task_name=1
) )
] ]
@@ -1579,7 +1594,7 @@ class TestAsyncioToolsEdgeCases(unittest.TestCase):
coroutine_stack=[], coroutine_stack=[],
awaited_by=[ awaited_by=[
CoroInfo( CoroInfo(
call_stack=[FrameInfo("coro", "", 0)], call_stack=[FrameInfo("coro", "", LocationInfo(0))],
task_name=999 task_name=999
) )
] ]
@@ -1603,11 +1618,11 @@ class TestAsyncioToolsEdgeCases(unittest.TestCase):
coroutine_stack=[], coroutine_stack=[],
awaited_by=[ awaited_by=[
CoroInfo( CoroInfo(
call_stack=[FrameInfo("frameA", "", 0)], call_stack=[FrameInfo("frameA", "", LocationInfo(0))],
task_name=2 task_name=2
), ),
CoroInfo( CoroInfo(
call_stack=[FrameInfo("frameA", "", 0)], call_stack=[FrameInfo("frameA", "", LocationInfo(0))],
task_name=3 task_name=3
) )
] ]
@@ -1651,7 +1666,7 @@ class TestAsyncioToolsEdgeCases(unittest.TestCase):
coroutine_stack=[], coroutine_stack=[],
awaited_by=[ awaited_by=[
CoroInfo( CoroInfo(
call_stack=[FrameInfo("f1", "", 0)], call_stack=[FrameInfo("f1", "", LocationInfo(0))],
task_name=2 task_name=2
) )
] ]
@@ -1682,8 +1697,8 @@ class TestAsyncioToolsEdgeCases(unittest.TestCase):
awaited_by=[ awaited_by=[
CoroInfo( CoroInfo(
call_stack=[ call_stack=[
FrameInfo("f1", "", 0), FrameInfo("f1", "", LocationInfo(0)),
FrameInfo("f2", "", 0) FrameInfo("f2", "", LocationInfo(0))
], ],
task_name=2 task_name=2
) )
+5 -1
View File
@@ -4,12 +4,16 @@ import sys
import pstats import pstats
import unittest import unittest
import os import os
import warnings
from difflib import unified_diff from difflib import unified_diff
from io import StringIO from io import StringIO
from test.support.os_helper import TESTFN, unlink, temp_dir, change_cwd from test.support.os_helper import TESTFN, unlink, temp_dir, change_cwd
from contextlib import contextmanager, redirect_stdout from contextlib import contextmanager, redirect_stdout
import profile # Suppress deprecation warning for profile module (PEP 799)
with warnings.catch_warnings():
warnings.simplefilter("ignore", DeprecationWarning)
import profile
from test.profilee import testfunc, timer from test.profilee import testfunc, timer
from test.support.script_helper import assert_python_failure, assert_python_ok from test.support.script_helper import assert_python_failure, assert_python_ok
+5
View File
@@ -0,0 +1,5 @@
import os
from test.support import load_package_tests
def load_tests(*args):
return load_package_tests(os.path.dirname(__file__), *args)
+4
View File
@@ -0,0 +1,4 @@
from . import load_tests
import unittest
unittest.main()
+862
View File
@@ -0,0 +1,862 @@
"""Tests for the heatmap collector (profiling.sampling)."""
import os
import shutil
import tempfile
import unittest
from collections import namedtuple
from pathlib import Path
# Matches the C structseq LocationInfo from _remote_debugging
LocationInfo = namedtuple('LocationInfo', ['lineno', 'end_lineno', 'col_offset', 'end_col_offset'])
from profiling.sampling.heatmap_collector import (
HeatmapCollector,
get_python_path_info,
extract_module_name,
)
from test.support import captured_stdout, captured_stderr
# =============================================================================
# Unit Tests for Public Helper Functions
# =============================================================================
class TestPathInfoFunctions(unittest.TestCase):
"""Test public helper functions for path information."""
def test_get_python_path_info_returns_dict(self):
"""Test that get_python_path_info returns a dictionary with expected keys."""
path_info = get_python_path_info()
self.assertIsInstance(path_info, dict)
self.assertIn('stdlib', path_info)
self.assertIn('site_packages', path_info)
self.assertIn('sys_path', path_info)
def test_get_python_path_info_stdlib_is_path_or_none(self):
"""Test that stdlib is either a Path object or None."""
path_info = get_python_path_info()
if path_info['stdlib'] is not None:
self.assertIsInstance(path_info['stdlib'], Path)
def test_get_python_path_info_site_packages_is_list(self):
"""Test that site_packages is a list."""
path_info = get_python_path_info()
self.assertIsInstance(path_info['site_packages'], list)
for item in path_info['site_packages']:
self.assertIsInstance(item, Path)
def test_get_python_path_info_sys_path_is_list(self):
"""Test that sys_path is a list of Path objects."""
path_info = get_python_path_info()
self.assertIsInstance(path_info['sys_path'], list)
for item in path_info['sys_path']:
self.assertIsInstance(item, Path)
def test_extract_module_name_with_none(self):
"""Test extract_module_name with None filename."""
path_info = get_python_path_info()
module_name, module_type = extract_module_name(None, path_info)
self.assertEqual(module_name, 'unknown')
self.assertEqual(module_type, 'other')
def test_extract_module_name_with_empty_string(self):
"""Test extract_module_name with empty filename."""
path_info = get_python_path_info()
module_name, module_type = extract_module_name('', path_info)
self.assertEqual(module_name, 'unknown')
self.assertEqual(module_type, 'other')
def test_extract_module_name_with_stdlib_file(self):
"""Test extract_module_name with a standard library file."""
path_info = get_python_path_info()
# Use os module as a known stdlib file
if path_info['stdlib']:
stdlib_file = str(path_info['stdlib'] / 'os.py')
module_name, module_type = extract_module_name(stdlib_file, path_info)
self.assertEqual(module_type, 'stdlib')
self.assertIn('os', module_name)
def test_extract_module_name_with_project_file(self):
"""Test extract_module_name with a project file."""
path_info = get_python_path_info()
# Create a mock project file path
if path_info['sys_path']:
# Use current directory as project path
project_file = '/some/project/path/mymodule.py'
module_name, module_type = extract_module_name(project_file, path_info)
# Should classify as 'other' if not in sys.path
self.assertIn(module_type, ['project', 'other'])
def test_extract_module_name_removes_py_extension(self):
"""Test that .py extension is removed from module names."""
path_info = get_python_path_info()
# Test with a simple .py file
module_name, module_type = extract_module_name('/path/to/test.py', path_info)
# Module name should not contain .py
self.assertNotIn('.py', module_name)
def test_extract_module_name_with_special_files(self):
"""Test extract_module_name with special filenames like <string>."""
path_info = get_python_path_info()
special_files = ['<string>', '<stdin>', '[eval]']
for special_file in special_files:
module_name, module_type = extract_module_name(special_file, path_info)
self.assertEqual(module_type, 'other')
# =============================================================================
# Unit Tests for HeatmapCollector Public API
# =============================================================================
class TestHeatmapCollectorInit(unittest.TestCase):
"""Test HeatmapCollector initialization."""
def test_init_creates_empty_data_structures(self):
"""Test that __init__ creates empty data structures."""
collector = HeatmapCollector(sample_interval_usec=100)
# Check that data structures are initialized
self.assertIsInstance(collector.line_samples, dict)
self.assertIsInstance(collector.file_samples, dict)
self.assertIsInstance(collector.line_self_samples, dict)
self.assertIsInstance(collector.file_self_samples, dict)
self.assertIsInstance(collector.call_graph, dict)
self.assertIsInstance(collector.callers_graph, dict)
self.assertIsInstance(collector.function_definitions, dict)
self.assertIsInstance(collector.edge_samples, dict)
# Check that they're empty
self.assertEqual(len(collector.line_samples), 0)
self.assertEqual(len(collector.file_samples), 0)
self.assertEqual(len(collector.line_self_samples), 0)
self.assertEqual(len(collector.file_self_samples), 0)
def test_init_sets_total_samples_to_zero(self):
"""Test that total samples starts at zero."""
collector = HeatmapCollector(sample_interval_usec=100)
self.assertEqual(collector._total_samples, 0)
def test_init_gets_path_info(self):
"""Test that path info is retrieved during init."""
collector = HeatmapCollector(sample_interval_usec=100)
self.assertIsNotNone(collector._path_info)
self.assertIn('stdlib', collector._path_info)
class TestHeatmapCollectorSetStats(unittest.TestCase):
"""Test HeatmapCollector.set_stats() method."""
def test_set_stats_stores_all_parameters(self):
"""Test that set_stats stores all provided parameters."""
collector = HeatmapCollector(sample_interval_usec=100)
collector.set_stats(
sample_interval_usec=500,
duration_sec=10.5,
sample_rate=99.5,
error_rate=0.5
)
self.assertEqual(collector.stats['sample_interval_usec'], 500)
self.assertEqual(collector.stats['duration_sec'], 10.5)
self.assertEqual(collector.stats['sample_rate'], 99.5)
self.assertEqual(collector.stats['error_rate'], 0.5)
def test_set_stats_includes_system_info(self):
"""Test that set_stats includes Python and platform info."""
collector = HeatmapCollector(sample_interval_usec=100)
collector.set_stats(sample_interval_usec=100, duration_sec=1.0, sample_rate=100.0)
self.assertIn('python_version', collector.stats)
self.assertIn('python_implementation', collector.stats)
self.assertIn('platform', collector.stats)
def test_set_stats_accepts_kwargs(self):
"""Test that set_stats accepts additional kwargs."""
collector = HeatmapCollector(sample_interval_usec=100)
collector.set_stats(
sample_interval_usec=100,
duration_sec=1.0,
sample_rate=100.0,
custom_key='custom_value',
another_key=42
)
self.assertEqual(collector.stats['custom_key'], 'custom_value')
self.assertEqual(collector.stats['another_key'], 42)
def test_set_stats_with_none_error_rate(self):
"""Test set_stats with error_rate=None."""
collector = HeatmapCollector(sample_interval_usec=100)
collector.set_stats(sample_interval_usec=100, duration_sec=1.0, sample_rate=100.0)
self.assertIn('error_rate', collector.stats)
self.assertIsNone(collector.stats['error_rate'])
class TestHeatmapCollectorProcessFrames(unittest.TestCase):
"""Test HeatmapCollector.process_frames() method."""
def test_process_frames_increments_total_samples(self):
"""Test that process_frames increments total samples count."""
collector = HeatmapCollector(sample_interval_usec=100)
initial_count = collector._total_samples
frames = [('file.py', (10, 10, -1, -1), 'func', None)]
collector.process_frames(frames, thread_id=1)
self.assertEqual(collector._total_samples, initial_count + 1)
def test_process_frames_records_line_samples(self):
"""Test that process_frames records line samples."""
collector = HeatmapCollector(sample_interval_usec=100)
frames = [('test.py', (5, 5, -1, -1), 'test_func', None)]
collector.process_frames(frames, thread_id=1)
# Check that line was recorded
self.assertIn(('test.py', 5), collector.line_samples)
self.assertEqual(collector.line_samples[('test.py', 5)], 1)
def test_process_frames_records_multiple_lines_in_stack(self):
"""Test that process_frames records all lines in a stack."""
collector = HeatmapCollector(sample_interval_usec=100)
frames = [
('file1.py', (10, 10, -1, -1), 'func1', None),
('file2.py', (20, 20, -1, -1), 'func2', None),
('file3.py', (30, 30, -1, -1), 'func3', None)
]
collector.process_frames(frames, thread_id=1)
# All frames should be recorded
self.assertIn(('file1.py', 10), collector.line_samples)
self.assertIn(('file2.py', 20), collector.line_samples)
self.assertIn(('file3.py', 30), collector.line_samples)
def test_process_frames_distinguishes_self_samples(self):
"""Test that process_frames distinguishes self (leaf) samples."""
collector = HeatmapCollector(sample_interval_usec=100)
frames = [
('leaf.py', (5, 5, -1, -1), 'leaf_func', None), # This is the leaf (top of stack)
('caller.py', (10, 10, -1, -1), 'caller_func', None)
]
collector.process_frames(frames, thread_id=1)
# Leaf should have self sample
self.assertIn(('leaf.py', 5), collector.line_self_samples)
self.assertEqual(collector.line_self_samples[('leaf.py', 5)], 1)
# Caller should NOT have self sample
self.assertNotIn(('caller.py', 10), collector.line_self_samples)
def test_process_frames_accumulates_samples(self):
"""Test that multiple calls accumulate samples."""
collector = HeatmapCollector(sample_interval_usec=100)
frames = [('file.py', (10, 10, -1, -1), 'func', None)]
collector.process_frames(frames, thread_id=1)
collector.process_frames(frames, thread_id=1)
collector.process_frames(frames, thread_id=1)
self.assertEqual(collector.line_samples[('file.py', 10)], 3)
self.assertEqual(collector._total_samples, 3)
def test_process_frames_ignores_invalid_frames(self):
"""Test that process_frames ignores invalid frames."""
collector = HeatmapCollector(sample_interval_usec=100)
# These should be ignored
invalid_frames = [
('<string>', (1, 1, -1, -1), 'test', None),
('[eval]', (1, 1, -1, -1), 'test', None),
('', (1, 1, -1, -1), 'test', None),
(None, (1, 1, -1, -1), 'test', None),
('__init__', (0, 0, -1, -1), 'test', None), # Special invalid frame
]
for frame in invalid_frames:
collector.process_frames([frame], thread_id=1)
# Should not record these invalid frames
for frame in invalid_frames:
if frame[0]:
self.assertNotIn((frame[0], frame[1][0]), collector.line_samples)
def test_process_frames_builds_call_graph(self):
"""Test that process_frames builds call graph relationships."""
collector = HeatmapCollector(sample_interval_usec=100)
frames = [
('callee.py', (5, 5, -1, -1), 'callee_func', None),
('caller.py', (10, 10, -1, -1), 'caller_func', None)
]
collector.process_frames(frames, thread_id=1)
# Check that call relationship was recorded
caller_key = ('caller.py', 10)
self.assertIn(caller_key, collector.call_graph)
# Check callers graph
callee_key = ('callee.py', 5)
self.assertIn(callee_key, collector.callers_graph)
def test_process_frames_records_function_definitions(self):
"""Test that process_frames records function definition locations."""
collector = HeatmapCollector(sample_interval_usec=100)
frames = [('module.py', (42, 42, -1, -1), 'my_function', None)]
collector.process_frames(frames, thread_id=1)
self.assertIn(('module.py', 'my_function'), collector.function_definitions)
self.assertEqual(collector.function_definitions[('module.py', 'my_function')], 42)
def test_process_frames_tracks_edge_samples(self):
"""Test that process_frames tracks edge sample counts."""
collector = HeatmapCollector(sample_interval_usec=100)
frames = [
('callee.py', (5, 5, -1, -1), 'callee', None),
('caller.py', (10, 10, -1, -1), 'caller', None)
]
# Process same call stack multiple times
collector.process_frames(frames, thread_id=1)
collector.process_frames(frames, thread_id=1)
# Check that edge count is tracked
self.assertGreater(len(collector.edge_samples), 0)
def test_process_frames_weight_applies_to_identical_samples(self):
collector = HeatmapCollector(sample_interval_usec=100)
frames = [
('callee.py', (5, 5, -1, -1), 'callee', None),
('caller.py', (10, 10, -1, -1), 'caller', None),
]
collector.process_frames(frames, thread_id=1, weight=5)
edge_key = (('caller.py', 10), ('callee.py', 5))
self.assertEqual(collector.edge_samples[edge_key], 5)
self.assertEqual(collector.line_samples[('callee.py', 5)], 5)
self.assertEqual(collector.line_samples[('caller.py', 10)], 5)
def test_process_frames_handles_empty_frames(self):
"""Test that process_frames handles empty frame list."""
collector = HeatmapCollector(sample_interval_usec=100)
initial_count = collector._total_samples
collector.process_frames([], thread_id=1)
# Should still increment total samples
self.assertEqual(collector._total_samples, initial_count + 1)
def test_process_frames_with_file_samples_dict(self):
"""Test that file_samples dict is properly populated."""
collector = HeatmapCollector(sample_interval_usec=100)
frames = [('test.py', (10, 10, -1, -1), 'func', None)]
collector.process_frames(frames, thread_id=1)
self.assertIn('test.py', collector.file_samples)
self.assertIn(10, collector.file_samples['test.py'])
self.assertEqual(collector.file_samples['test.py'][10], 1)
def frame(filename, line, func):
"""Create a frame tuple: (filename, location, funcname, opcode)."""
return (filename, (line, line, -1, -1), func, None)
class TestHeatmapCollectorNavigationButtons(unittest.TestCase):
"""Test navigation button behavior for caller/callee relationships.
For every call stack:
- Root frames (entry points): only DOWN arrow (callees)
- Middle frames: both UP and DOWN arrows
- Leaf frames: only UP arrow (callers)
"""
def collect(self, *stacks):
"""Create collector and process frame stacks."""
collector = HeatmapCollector(sample_interval_usec=100)
for stack in stacks:
collector.process_frames(stack, thread_id=1)
return collector
def test_deep_call_stack_relationships(self):
"""Test root/middle/leaf navigation in a 5-level call stack."""
# Stack: root -> A -> B -> C -> leaf
stack = [
frame('leaf.py', 5, 'leaf'),
frame('c.py', 10, 'func_c'),
frame('b.py', 15, 'func_b'),
frame('a.py', 20, 'func_a'),
frame('root.py', 25, 'root'),
]
c = self.collect(stack)
# Root: only callees (no one calls it)
self.assertIn(('root.py', 25), c.call_graph)
self.assertNotIn(('root.py', 25), c.callers_graph)
# Middle frames: both callers and callees
for key in [('a.py', 20), ('b.py', 15), ('c.py', 10)]:
self.assertIn(key, c.call_graph)
self.assertIn(key, c.callers_graph)
# Leaf: only callers (doesn't call anyone)
self.assertNotIn(('leaf.py', 5), c.call_graph)
self.assertIn(('leaf.py', 5), c.callers_graph)
def test_all_lines_in_function_see_callers(self):
"""Test that interior lines map to their function for caller lookup."""
# Same function sampled at different lines (12, 15, 10)
c = self.collect(
[frame('mod.py', 12, 'my_func'), frame('caller.py', 100, 'caller')],
[frame('mod.py', 15, 'my_func'), frame('caller.py', 100, 'caller')],
[frame('mod.py', 10, 'my_func'), frame('caller.py', 100, 'caller')],
)
# All lines should map to same function
for line in [10, 12, 15]:
self.assertEqual(c.line_to_function[('mod.py', line)], 'my_func')
# Function definition line should have callers
func_def = c.function_definitions[('mod.py', 'my_func')]
self.assertIn(('mod.py', func_def), c.callers_graph)
def test_multiple_callers_and_callees(self):
"""Test multiple callers/callees are recorded correctly."""
# Two callers -> target, and caller -> two callees
c = self.collect(
[frame('target.py', 10, 'target'), frame('caller1.py', 20, 'c1')],
[frame('target.py', 10, 'target'), frame('caller2.py', 30, 'c2')],
[frame('callee1.py', 5, 'f1'), frame('dispatcher.py', 40, 'dispatch')],
[frame('callee2.py', 6, 'f2'), frame('dispatcher.py', 40, 'dispatch')],
)
# Target has 2 callers
callers = c.callers_graph[('target.py', 10)]
self.assertEqual({x[0] for x in callers}, {'caller1.py', 'caller2.py'})
# Dispatcher has 2 callees
callees = c.call_graph[('dispatcher.py', 40)]
self.assertEqual({x[0] for x in callees}, {'callee1.py', 'callee2.py'})
def test_edge_samples_counted(self):
"""Test that repeated calls accumulate edge counts."""
stack = [frame('callee.py', 10, 'callee'), frame('caller.py', 20, 'caller')]
c = self.collect(stack, stack, stack)
edge_key = (('caller.py', 20), ('callee.py', 10))
self.assertEqual(c.edge_samples[edge_key], 3)
class TestHeatmapCollectorExport(unittest.TestCase):
"""Test HeatmapCollector.export() method."""
def setUp(self):
"""Set up test directory."""
self.test_dir = tempfile.mkdtemp()
self.addCleanup(shutil.rmtree, self.test_dir)
def test_export_creates_output_directory(self):
"""Test that export creates the output directory."""
collector = HeatmapCollector(sample_interval_usec=100)
# Add some data
frames = [('test.py', (10, 10, -1, -1), 'func', None)]
collector.process_frames(frames, thread_id=1)
output_path = os.path.join(self.test_dir, 'heatmap_output')
with captured_stdout(), captured_stderr():
collector.export(output_path)
self.assertTrue(os.path.exists(output_path))
self.assertTrue(os.path.isdir(output_path))
def test_export_creates_index_html(self):
"""Test that export creates index.html."""
collector = HeatmapCollector(sample_interval_usec=100)
frames = [('test.py', (10, 10, -1, -1), 'func', None)]
collector.process_frames(frames, thread_id=1)
output_path = os.path.join(self.test_dir, 'heatmap_output')
with captured_stdout(), captured_stderr():
collector.export(output_path)
index_path = os.path.join(output_path, 'index.html')
self.assertTrue(os.path.exists(index_path))
def test_export_creates_file_htmls(self):
"""Test that export creates individual file HTMLs."""
collector = HeatmapCollector(sample_interval_usec=100)
frames = [('test.py', (10, 10, -1, -1), 'func', None)]
collector.process_frames(frames, thread_id=1)
output_path = os.path.join(self.test_dir, 'heatmap_output')
with captured_stdout(), captured_stderr():
collector.export(output_path)
# Check for file_XXXX.html files
html_files = [f for f in os.listdir(output_path)
if f.startswith('file_') and f.endswith('.html')]
self.assertGreater(len(html_files), 0)
def test_export_with_empty_data(self):
"""Test export with no data collected."""
collector = HeatmapCollector(sample_interval_usec=100)
output_path = os.path.join(self.test_dir, 'empty_output')
# Should handle empty data gracefully
with captured_stdout(), captured_stderr():
collector.export(output_path)
def test_export_handles_html_suffix(self):
"""Test that export handles .html suffix in output path."""
collector = HeatmapCollector(sample_interval_usec=100)
frames = [('test.py', (10, 10, -1, -1), 'func', None)]
collector.process_frames(frames, thread_id=1)
# Path with .html suffix should be stripped
output_path = os.path.join(self.test_dir, 'output.html')
with captured_stdout(), captured_stderr():
collector.export(output_path)
# Should create directory without .html
expected_dir = os.path.join(self.test_dir, 'output')
self.assertTrue(os.path.exists(expected_dir))
def test_export_with_multiple_files(self):
"""Test export with multiple files."""
collector = HeatmapCollector(sample_interval_usec=100)
# Add samples for multiple files
collector.process_frames([('file1.py', (10, 10, -1, -1), 'func1', None)], thread_id=1)
collector.process_frames([('file2.py', (20, 20, -1, -1), 'func2', None)], thread_id=1)
collector.process_frames([('file3.py', (30, 30, -1, -1), 'func3', None)], thread_id=1)
output_path = os.path.join(self.test_dir, 'multi_file')
with captured_stdout(), captured_stderr():
collector.export(output_path)
# Should create HTML for each file
html_files = [f for f in os.listdir(output_path)
if f.startswith('file_') and f.endswith('.html')]
self.assertGreaterEqual(len(html_files), 3)
def test_export_index_contains_file_references(self):
"""Test that index.html contains references to profiled files."""
collector = HeatmapCollector(sample_interval_usec=100)
collector.set_stats(sample_interval_usec=100, duration_sec=1.0, sample_rate=100.0)
frames = [('mytest.py', (10, 10, -1, -1), 'my_func', None)]
collector.process_frames(frames, thread_id=1)
output_path = os.path.join(self.test_dir, 'test_output')
with captured_stdout(), captured_stderr():
collector.export(output_path)
index_path = os.path.join(output_path, 'index.html')
with open(index_path, 'r', encoding='utf-8') as f:
content = f.read()
# Should contain reference to the file
self.assertIn('mytest', content)
def test_export_file_html_has_line_numbers(self):
"""Test that exported file HTML contains line numbers."""
collector = HeatmapCollector(sample_interval_usec=100)
# Create a temporary Python file
temp_file = os.path.join(self.test_dir, 'temp_source.py')
with open(temp_file, 'w') as f:
f.write('def test():\n pass\n')
frames = [(temp_file, (1, 1, -1, -1), 'test', None)]
collector.process_frames(frames, thread_id=1)
output_path = os.path.join(self.test_dir, 'line_test')
with captured_stdout(), captured_stderr():
collector.export(output_path)
# Find the generated file HTML
html_files = [f for f in os.listdir(output_path)
if f.startswith('file_') and f.endswith('.html')]
if html_files:
with open(os.path.join(output_path, html_files[0]), 'r', encoding='utf-8') as f:
content = f.read()
# Should have line-related content
self.assertIn('line-', content)
class MockFrameInfo:
"""Mock FrameInfo for testing.
Frame format: (filename, location, funcname, opcode) where:
- location is a tuple (lineno, end_lineno, col_offset, end_col_offset)
- opcode is an int or None
"""
def __init__(self, filename, lineno, funcname, opcode=None):
self.filename = filename
self.funcname = funcname
self.opcode = opcode
self.location = (lineno, lineno, -1, -1)
def __iter__(self):
return iter((self.filename, self.location, self.funcname, self.opcode))
def __getitem__(self, index):
return (self.filename, self.location, self.funcname, self.opcode)[index]
def __len__(self):
return 4
def __repr__(self):
return f"MockFrameInfo('{self.filename}', {self.location}, '{self.funcname}', {self.opcode})"
class MockThreadInfo:
"""Mock ThreadInfo for testing since the real one isn't accessible."""
def __init__(self, thread_id, frame_info, status=0):
self.thread_id = thread_id
self.frame_info = frame_info
self.status = status # Thread status flags
def __repr__(self):
return f"MockThreadInfo(thread_id={self.thread_id}, frame_info={self.frame_info})"
class MockInterpreterInfo:
"""Mock InterpreterInfo for testing since the real one isn't accessible."""
def __init__(self, interpreter_id, threads):
self.interpreter_id = interpreter_id
self.threads = threads
def __repr__(self):
return f"MockInterpreterInfo(interpreter_id={self.interpreter_id}, threads={self.threads})"
class TestHeatmapCollector(unittest.TestCase):
"""Tests for HeatmapCollector functionality."""
def test_heatmap_collector_basic(self):
"""Test basic HeatmapCollector functionality."""
collector = HeatmapCollector(sample_interval_usec=100)
# Test empty state
self.assertEqual(len(collector.file_samples), 0)
self.assertEqual(len(collector.line_samples), 0)
# Test collecting sample data - frames are 4-tuples: (filename, location, funcname, opcode)
test_frames = [
MockInterpreterInfo(
0,
[MockThreadInfo(
1,
[MockFrameInfo("file.py", 10, "func1"), MockFrameInfo("file.py", 20, "func2")],
)]
)
]
collector.collect(test_frames)
# Should have recorded samples for the file
self.assertGreater(len(collector.line_samples), 0)
self.assertIn("file.py", collector.file_samples)
# Check that line samples were recorded
file_data = collector.file_samples["file.py"]
self.assertGreater(len(file_data), 0)
def test_heatmap_collector_export(self):
"""Test heatmap HTML export functionality."""
heatmap_dir = tempfile.mkdtemp()
self.addCleanup(shutil.rmtree, heatmap_dir)
collector = HeatmapCollector(sample_interval_usec=100)
# Create test data with multiple files using MockFrameInfo
test_frames1 = [
MockInterpreterInfo(
0,
[MockThreadInfo(1, [MockFrameInfo("file.py", 10, "func1"), MockFrameInfo("file.py", 20, "func2")])],
)
]
test_frames2 = [
MockInterpreterInfo(
0,
[MockThreadInfo(1, [MockFrameInfo("file.py", 10, "func1"), MockFrameInfo("file.py", 20, "func2")])],
)
] # Same stack
test_frames3 = [
MockInterpreterInfo(0, [MockThreadInfo(1, [MockFrameInfo("other.py", 5, "other_func")])])
]
collector.collect(test_frames1)
collector.collect(test_frames2)
collector.collect(test_frames3)
# Export heatmap
with (captured_stdout(), captured_stderr()):
collector.export(heatmap_dir)
# Verify index.html was created
index_path = os.path.join(heatmap_dir, "index.html")
self.assertTrue(os.path.exists(index_path))
self.assertGreater(os.path.getsize(index_path), 0)
# Check index contains HTML content
with open(index_path, "r", encoding="utf-8") as f:
content = f.read()
# Should be valid HTML
self.assertIn("<!doctype html>", content.lower())
self.assertIn("<html", content)
self.assertIn("Tachyon Profiler", content)
# Should contain file references
self.assertIn("file.py", content)
self.assertIn("other.py", content)
# Verify individual file HTMLs were created
file_htmls = [f for f in os.listdir(heatmap_dir) if f.startswith("file_") and f.endswith(".html")]
self.assertGreater(len(file_htmls), 0)
# Check one of the file HTMLs
file_html_path = os.path.join(heatmap_dir, file_htmls[0])
with open(file_html_path, "r", encoding="utf-8") as f:
file_content = f.read()
# Should contain heatmap styling and JavaScript
self.assertIn("line-sample", file_content)
self.assertIn("nav-btn", file_content)
class TestHeatmapCollectorLocation(unittest.TestCase):
"""Tests for HeatmapCollector location handling."""
def test_heatmap_with_full_location_info(self):
"""Test HeatmapCollector uses full location tuple."""
collector = HeatmapCollector(sample_interval_usec=1000)
# Frame with full location: (lineno, end_lineno, col_offset, end_col_offset)
frame = MockFrameInfo("test.py", 10, "func")
# Override with full location info
frame.location = LocationInfo(10, 15, 4, 20)
frames = [
MockInterpreterInfo(
0,
[MockThreadInfo(1, [frame])]
)
]
collector.collect(frames)
# Verify data was collected with location info
# HeatmapCollector uses file_samples dict with filename -> Counter of linenos
self.assertIn("test.py", collector.file_samples)
# Line 10 should have samples
self.assertIn(10, collector.file_samples["test.py"])
def test_heatmap_with_none_location(self):
"""Test HeatmapCollector handles None location gracefully."""
collector = HeatmapCollector(sample_interval_usec=1000)
# Synthetic frame with None location
frame = MockFrameInfo("~", 0, "<native>")
frame.location = None
frames = [
MockInterpreterInfo(
0,
[MockThreadInfo(1, [frame])]
)
]
# Should not raise
collector.collect(frames)
def test_heatmap_export_with_location_data(self):
"""Test HeatmapCollector export includes location info."""
tmp_dir = tempfile.mkdtemp()
self.addCleanup(shutil.rmtree, tmp_dir)
collector = HeatmapCollector(sample_interval_usec=1000)
frame = MockFrameInfo("test.py", 10, "process")
frame.location = LocationInfo(10, 12, 0, 30)
frames = [
MockInterpreterInfo(
0,
[MockThreadInfo(1, [frame])]
)
]
collector.collect(frames)
# Export should work
with (captured_stdout(), captured_stderr()):
collector.export(tmp_dir)
self.assertTrue(os.path.exists(os.path.join(tmp_dir, "index.html")))
def test_heatmap_collector_frame_format(self):
"""Test HeatmapCollector with 4-element frame format."""
collector = HeatmapCollector(sample_interval_usec=1000)
frames = [
MockInterpreterInfo(
0,
[
MockThreadInfo(
1,
[
MockFrameInfo("app.py", 100, "main", opcode=90),
MockFrameInfo("utils.py", 50, "helper", opcode=100),
MockFrameInfo("lib.py", 25, "process", opcode=None),
],
)
],
)
]
collector.collect(frames)
# Should have recorded data for the files
self.assertIn("app.py", collector.file_samples)
self.assertIn("utils.py", collector.file_samples)
self.assertIn("lib.py", collector.file_samples)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,9 @@
"""Tests for the sampling profiler (profiling.sampling)."""
import os
from test.support import load_package_tests
def load_tests(*args):
"""Load all tests from this subpackage."""
return load_package_tests(os.path.dirname(__file__), *args)
@@ -0,0 +1,202 @@
"""Helper utilities for sampling profiler tests."""
import contextlib
import socket
import subprocess
import sys
import unittest
from collections import namedtuple
from test.support import SHORT_TIMEOUT
from test.support.socket_helper import find_unused_port
from test.support.os_helper import unlink
PROCESS_VM_READV_SUPPORTED = False
try:
from _remote_debugging import PROCESS_VM_READV_SUPPORTED # noqa: F401
import _remote_debugging # noqa: F401
except ImportError:
raise unittest.SkipTest(
"Test only runs when _remote_debugging is available"
)
else:
import profiling.sampling # noqa: F401
from profiling.sampling.sample import SampleProfiler # noqa: F401
skip_if_not_supported = unittest.skipIf(
(
sys.platform != "darwin"
and sys.platform != "linux"
and sys.platform != "win32"
),
"Test only runs on Linux, Windows and MacOS",
)
SubprocessInfo = namedtuple("SubprocessInfo", ["process", "socket"])
def _wait_for_signal(sock, expected_signals, timeout=SHORT_TIMEOUT):
"""
Wait for expected signal(s) from a socket with proper timeout and EOF handling.
Args:
sock: Connected socket to read from
expected_signals: Single bytes object or list of bytes objects to wait for
timeout: Socket timeout in seconds
Returns:
bytes: Complete accumulated response buffer
Raises:
RuntimeError: If connection closed before signal received or timeout
"""
if isinstance(expected_signals, bytes):
expected_signals = [expected_signals]
sock.settimeout(timeout)
buffer = b""
while True:
# Check if all expected signals are in buffer
if all(sig in buffer for sig in expected_signals):
return buffer
try:
chunk = sock.recv(4096)
if not chunk:
raise RuntimeError(
f"Connection closed before receiving expected signals. "
f"Expected: {expected_signals}, Got: {buffer[-200:]!r}"
)
buffer += chunk
except socket.timeout:
raise RuntimeError(
f"Timeout waiting for signals. "
f"Expected: {expected_signals}, Got: {buffer[-200:]!r}"
) from None
except OSError as e:
raise RuntimeError(
f"Socket error while waiting for signals: {e}. "
f"Expected: {expected_signals}, Got: {buffer[-200:]!r}"
) from None
def _cleanup_sockets(*sockets):
"""Safely close multiple sockets, ignoring errors."""
for sock in sockets:
if sock is not None:
try:
sock.close()
except OSError:
pass
def _cleanup_process(proc, timeout=SHORT_TIMEOUT):
"""Terminate a process gracefully, escalating to kill if needed."""
if proc.poll() is not None:
return
proc.terminate()
try:
proc.wait(timeout=timeout)
return
except subprocess.TimeoutExpired:
pass
proc.kill()
try:
proc.wait(timeout=timeout)
except subprocess.TimeoutExpired:
pass # Process refuses to die, nothing more we can do
@contextlib.contextmanager
def test_subprocess(script, wait_for_working=False):
"""Context manager to create a test subprocess with socket synchronization.
Args:
script: Python code to execute in the subprocess. If wait_for_working
is True, script should send b"working" after starting work.
wait_for_working: If True, wait for both "ready" and "working" signals.
Default False for backward compatibility.
Yields:
SubprocessInfo: Named tuple with process and socket objects
"""
# Find an unused port for socket communication
port = find_unused_port()
# Inject socket connection code at the beginning of the script
socket_code = f"""
import socket
_test_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
_test_sock.connect(('localhost', {port}))
_test_sock.sendall(b"ready")
"""
# Combine socket code with user script
full_script = socket_code + script
# Create server socket to wait for process to be ready
server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server_socket.bind(("localhost", port))
server_socket.settimeout(SHORT_TIMEOUT)
server_socket.listen(1)
proc = subprocess.Popen(
[sys.executable, "-c", full_script],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
client_socket = None
try:
# Wait for process to connect and send ready signal
client_socket, _ = server_socket.accept()
server_socket.close()
server_socket = None
# Wait for ready signal, and optionally working signal
if wait_for_working:
_wait_for_signal(client_socket, [b"ready", b"working"])
else:
_wait_for_signal(client_socket, b"ready")
yield SubprocessInfo(proc, client_socket)
finally:
_cleanup_sockets(client_socket, server_socket)
_cleanup_process(proc)
def close_and_unlink(file):
"""Close a file and unlink it from the filesystem."""
file.close()
unlink(file.name)
def jsonl_tables(records):
"""Extract the canonical sections of a parsed JSONL profile.
Returns ``(meta, str_defs, frame_defs, agg, end)`` where ``str_defs`` is a
``{str_id: value}`` dict, ``frame_defs`` is a flat list of all frame
definitions across chunks, and ``agg`` is the first agg record (sufficient
for tests that only emit one chunk).
"""
meta = next(record for record in records if record["type"] == "meta")
end = next(record for record in records if record["type"] == "end")
agg = next(record for record in records if record["type"] == "agg")
str_defs = {
item["str_id"]: item["value"]
for record in records
if record["type"] == "string_table"
for item in record["strings"]
}
frame_defs = [
item
for record in records
if record["type"] == "frame_table"
for item in record["frames"]
]
return meta, str_defs, frame_defs, agg, end
+112
View File
@@ -0,0 +1,112 @@
"""Mock classes for sampling profiler tests."""
from collections import namedtuple
# Matches the C structseq LocationInfo from _remote_debugging
LocationInfo = namedtuple('LocationInfo', ['lineno', 'end_lineno', 'col_offset', 'end_col_offset'])
class MockFrameInfo:
"""Mock FrameInfo for testing.
Frame format: (filename, location, funcname, opcode) where:
- location is a tuple (lineno, end_lineno, col_offset, end_col_offset)
- opcode is an int or None
"""
def __init__(self, filename, lineno, funcname, opcode=None):
self.filename = filename
self.funcname = funcname
self.opcode = opcode
self.location = LocationInfo(lineno, lineno, -1, -1)
def __iter__(self):
return iter((self.filename, self.location, self.funcname, self.opcode))
def __getitem__(self, index):
return (self.filename, self.location, self.funcname, self.opcode)[index]
def __len__(self):
return 4
def __repr__(self):
return f"MockFrameInfo('{self.filename}', {self.location}, '{self.funcname}', {self.opcode})"
class MockThreadInfo:
"""Mock ThreadInfo for testing since the real one isn't accessible."""
def __init__(
self, thread_id, frame_info, status=0
): # Default to THREAD_STATE_RUNNING (0)
self.thread_id = thread_id
self.frame_info = frame_info
self.status = status
def __repr__(self):
return f"MockThreadInfo(thread_id={self.thread_id}, frame_info={self.frame_info}, status={self.status})"
class MockInterpreterInfo:
"""Mock InterpreterInfo for testing since the real one isn't accessible."""
def __init__(self, interpreter_id, threads):
self.interpreter_id = interpreter_id
self.threads = threads
def __repr__(self):
return f"MockInterpreterInfo(interpreter_id={self.interpreter_id}, threads={self.threads})"
class MockCoroInfo:
"""Mock CoroInfo for testing async tasks."""
def __init__(self, task_name, call_stack):
self.task_name = task_name # In reality, this is the parent task ID
self.call_stack = call_stack
def __repr__(self):
return f"MockCoroInfo(task_name={self.task_name}, call_stack={self.call_stack})"
class MockTaskInfo:
"""Mock TaskInfo for testing async tasks."""
def __init__(self, task_id, task_name, coroutine_stack, awaited_by=None):
self.task_id = task_id
self.task_name = task_name
self.coroutine_stack = coroutine_stack # List of CoroInfo objects
self.awaited_by = awaited_by or [] # List of CoroInfo objects (parents)
def __repr__(self):
return f"MockTaskInfo(task_id={self.task_id}, task_name={self.task_name})"
class MockAwaitedInfo:
"""Mock AwaitedInfo for testing async tasks."""
def __init__(self, thread_id, awaited_by):
self.thread_id = thread_id
self.awaited_by = awaited_by # List of TaskInfo objects
def __repr__(self):
return f"MockAwaitedInfo(thread_id={self.thread_id}, awaited_by={len(self.awaited_by)} tasks)"
def make_diff_collector_with_mock_baseline(baseline_samples):
"""Create a DiffFlamegraphCollector with baseline injected directly,
skipping the binary round-trip that _load_baseline normally does."""
from profiling.sampling.stack_collector import (
DiffFlamegraphCollector,
FlamegraphCollector,
)
baseline = FlamegraphCollector(1000)
for sample in baseline_samples:
baseline.collect(sample)
# Path is unused since we inject _baseline_collector directly;
# use __file__ as a dummy path that passes the existence check.
diff = DiffFlamegraphCollector(1000, baseline_binary_path=__file__)
diff._baseline_collector = baseline
return diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+308
View File
@@ -0,0 +1,308 @@
"""Test suite for the cProfile module."""
import multiprocessing
import sys
import unittest
# rip off all interesting stuff from test_profile
import profiling.tracing as cProfile
import tempfile
import textwrap
from test.test_profile import ProfileTest, regenerate_expected_output
from test.support.script_helper import assert_python_failure, assert_python_ok
from test import support
class CProfileTest(ProfileTest):
profilerclass = cProfile.Profile
profilermodule = cProfile
expected_max_output = "{built-in method builtins.max}"
def get_expected_output(self):
return _ProfileOutput
def test_bad_counter_during_dealloc(self):
# bpo-3895
import _lsprof
with support.catch_unraisable_exception() as cm:
obj = _lsprof.Profiler(lambda: int)
obj.enable()
obj.disable()
obj.clear()
self.assertEqual(cm.unraisable.exc_type, TypeError)
def test_crash_with_not_enough_args(self):
# gh-126220
import _lsprof
for profile in [_lsprof.Profiler(), cProfile.Profile()]:
for method in [
"_pystart_callback",
"_pyreturn_callback",
"_ccall_callback",
"_creturn_callback",
]:
with self.subTest(profile=profile, method=method):
method_obj = getattr(profile, method)
with self.assertRaises(TypeError):
method_obj() # should not crash
def test_evil_external_timer(self):
# gh-120289
# Disabling profiler in external timer should not crash
import _lsprof
class EvilTimer():
def __init__(self, disable_count):
self.count = 0
self.disable_count = disable_count
def __call__(self):
self.count += 1
if self.count == self.disable_count:
profiler_with_evil_timer.disable()
return self.count
# this will trigger external timer to disable profiler at
# call event - in initContext in _lsprof.c
with support.catch_unraisable_exception() as cm:
profiler_with_evil_timer = _lsprof.Profiler(EvilTimer(1))
profiler_with_evil_timer.enable()
# Make a call to trigger timer
(lambda: None)()
profiler_with_evil_timer.disable()
profiler_with_evil_timer.clear()
self.assertEqual(cm.unraisable.exc_type, RuntimeError)
# this will trigger external timer to disable profiler at
# return event - in Stop in _lsprof.c
with support.catch_unraisable_exception() as cm:
profiler_with_evil_timer = _lsprof.Profiler(EvilTimer(2))
profiler_with_evil_timer.enable()
# Make a call to trigger timer
(lambda: None)()
profiler_with_evil_timer.disable()
profiler_with_evil_timer.clear()
self.assertEqual(cm.unraisable.exc_type, RuntimeError)
def test_enable_in_external_timer(self):
# gh-157639: Enabling the profiler from an external timer should not crash
import _lsprof
# the timer re-arms monitoring from inside disable(), so the tool
# id stays claimed once the profiler is torn down
self.addCleanup(sys.monitoring.free_tool_id, sys.monitoring.PROFILER_ID)
def timer():
try:
profiler.enable()
except Exception:
pass
return 0
profiler = _lsprof.Profiler(timer=timer)
profiler.enable()
(lambda: None)()
profiler.disable()
profiler.clear()
def test_profile_enable_disable(self):
prof = self.profilerclass()
# Make sure we clean ourselves up if the test fails for some reason.
self.addCleanup(prof.disable)
prof.enable()
self.assertEqual(
sys.monitoring.get_tool(sys.monitoring.PROFILER_ID), "cProfile")
prof.disable()
self.assertIs(sys.monitoring.get_tool(sys.monitoring.PROFILER_ID), None)
def test_profile_as_context_manager(self):
prof = self.profilerclass()
# Make sure we clean ourselves up if the test fails for some reason.
self.addCleanup(prof.disable)
with prof as __enter__return_value:
# profile.__enter__ should return itself.
self.assertIs(prof, __enter__return_value)
# profile should be set as the global profiler inside the
# with-block
self.assertEqual(
sys.monitoring.get_tool(sys.monitoring.PROFILER_ID), "cProfile")
# profile shouldn't be set once we leave the with-block.
self.assertIs(sys.monitoring.get_tool(sys.monitoring.PROFILER_ID), None)
def test_second_profiler(self):
pr = self.profilerclass()
pr2 = self.profilerclass()
pr.enable()
self.addCleanup(pr.disable)
msg = f"tool {sys.monitoring.PROFILER_ID} is already in use"
self.assertRaisesRegex(ValueError, msg, pr2.enable)
def test_throw(self):
"""
gh-106152
generator.throw() should trigger a call in cProfile
"""
def gen():
yield
pr = self.profilerclass()
pr.enable()
g = gen()
try:
g.throw(SyntaxError)
except SyntaxError:
pass
pr.disable()
pr.create_stats()
self.assertTrue(any("throw" in func[2] for func in pr.stats.keys())),
def test_clear_with_nested_calls(self):
# Calling clear() during nested profiled calls should not leak
# ProfilerContexts. clearEntries() must walk the entire linked list,
# not just free the top context.
import _lsprof
def level3(profiler):
profiler.clear()
def level2(profiler):
level3(profiler)
def level1(profiler):
level2(profiler)
profiler = _lsprof.Profiler()
profiler.enable()
for _ in range(100):
level1(profiler)
profiler.disable()
profiler.clear()
def test_bad_descriptor(self):
# gh-132250
# cProfile should not crash when the profiler callback fails to locate
# the actual function of a method.
with self.profilerclass() as prof:
with self.assertRaises(TypeError):
bytes.find(str())
class TestCommandLine(unittest.TestCase):
def test_sort(self):
rc, out, err = assert_python_failure('-m', 'cProfile', '-s', 'demo')
self.assertGreater(rc, 0)
self.assertIn(b"option -s: invalid choice: 'demo'", err)
def test_profile_script_importing_main(self):
"""Check that scripts that reference __main__ see their own namespace
when being profiled."""
with tempfile.NamedTemporaryFile("w+", delete_on_close=False) as f:
f.write(textwrap.dedent("""\
class Foo:
pass
import __main__
assert Foo == __main__.Foo
"""))
f.close()
assert_python_ok('-m', "cProfile", f.name)
def _test_process_run_pickle(self, start_method):
val = 10
with tempfile.NamedTemporaryFile("w+", delete_on_close=False) as f:
f.write(textwrap.dedent(
f'''\
import multiprocessing
def worker(x):
print(__name__)
exit(x ** 2)
if __name__ == "__main__":
multiprocessing.set_start_method('{start_method}')
p = multiprocessing.Process(target=worker, args=({val},))
p.start()
p.join()
print("p.exitcode =", p.exitcode)
'''))
f.close()
_, out, err = assert_python_ok('-m', "cProfile", f.name)
self.assertIn(b"__mp_main__", out)
self.assertIn(bytes(f"exitcode = {val**2}", encoding='utf8'), out)
self.assertNotIn(b"Can't pickle", err)
def test_process_spawn_pickle(self):
# gh-140729: test use Process in cProfile.
self._test_process_run_pickle('spawn')
@unittest.skipUnless("forkserver" in multiprocessing.get_all_start_methods(),
"forkserver start method is not available")
def test_process_forkserver_pickle(self):
# gh-140729: test use Process in cProfile.
self._test_process_run_pickle('forkserver')
def main():
if '-r' not in sys.argv:
unittest.main()
else:
regenerate_expected_output(__file__, CProfileTest)
# Don't remove this comment. Everything below it is auto-generated.
#--cut--------------------------------------------------------------------------
_ProfileOutput = {}
_ProfileOutput['print_stats'] = """\
28 0.028 0.001 0.028 0.001 profilee.py:110(__getattr__)
1 0.270 0.270 1.000 1.000 profilee.py:25(testfunc)
23/3 0.150 0.007 0.170 0.057 profilee.py:35(factorial)
20 0.020 0.001 0.020 0.001 profilee.py:48(mul)
2 0.040 0.020 0.600 0.300 profilee.py:55(helper)
4 0.116 0.029 0.120 0.030 profilee.py:73(helper1)
2 0.000 0.000 0.140 0.070 profilee.py:84(helper2_indirect)
8 0.312 0.039 0.400 0.050 profilee.py:88(helper2)
8 0.064 0.008 0.080 0.010 profilee.py:98(subhelper)"""
_ProfileOutput['print_callers'] = """\
profilee.py:110(__getattr__) <- 16 0.016 0.016 profilee.py:98(subhelper)
profilee.py:25(testfunc) <- 1 0.270 1.000 <string>:1(<module>)
profilee.py:35(factorial) <- 1 0.014 0.130 profilee.py:25(testfunc)
20/3 0.130 0.147 profilee.py:35(factorial)
2 0.006 0.040 profilee.py:84(helper2_indirect)
profilee.py:48(mul) <- 20 0.020 0.020 profilee.py:35(factorial)
profilee.py:55(helper) <- 2 0.040 0.600 profilee.py:25(testfunc)
profilee.py:73(helper1) <- 4 0.116 0.120 profilee.py:55(helper)
profilee.py:84(helper2_indirect) <- 2 0.000 0.140 profilee.py:55(helper)
profilee.py:88(helper2) <- 6 0.234 0.300 profilee.py:55(helper)
2 0.078 0.100 profilee.py:84(helper2_indirect)
profilee.py:98(subhelper) <- 8 0.064 0.080 profilee.py:88(helper2)
{built-in method builtins.hasattr} <- 4 0.000 0.004 profilee.py:73(helper1)
8 0.000 0.008 profilee.py:88(helper2)
{built-in method sys.exception} <- 4 0.000 0.000 profilee.py:73(helper1)
{method 'append' of 'list' objects} <- 4 0.000 0.000 profilee.py:73(helper1)"""
_ProfileOutput['print_callees'] = """\
<string>:1(<module>) -> 1 0.270 1.000 profilee.py:25(testfunc)
profilee.py:110(__getattr__) ->
profilee.py:25(testfunc) -> 1 0.014 0.130 profilee.py:35(factorial)
2 0.040 0.600 profilee.py:55(helper)
profilee.py:35(factorial) -> 20/3 0.130 0.147 profilee.py:35(factorial)
20 0.020 0.020 profilee.py:48(mul)
profilee.py:48(mul) ->
profilee.py:55(helper) -> 4 0.116 0.120 profilee.py:73(helper1)
2 0.000 0.140 profilee.py:84(helper2_indirect)
6 0.234 0.300 profilee.py:88(helper2)
profilee.py:73(helper1) -> 4 0.000 0.004 {built-in method builtins.hasattr}
profilee.py:84(helper2_indirect) -> 2 0.006 0.040 profilee.py:35(factorial)
2 0.078 0.100 profilee.py:88(helper2)
profilee.py:88(helper2) -> 8 0.064 0.080 profilee.py:98(subhelper)
profilee.py:98(subhelper) -> 16 0.016 0.016 profilee.py:110(__getattr__)
{built-in method builtins.hasattr} -> 12 0.012 0.012 profilee.py:110(__getattr__)"""
if __name__ == "__main__":
main()
+4
View File
@@ -11,6 +11,10 @@ pub fn open(path: impl AsRef<Path>) -> io::Result<File> {
File::open(path) File::open(path)
} }
pub fn create(path: impl AsRef<Path>) -> io::Result<File> {
File::create(path)
}
pub fn read(path: impl AsRef<Path>) -> io::Result<Vec<u8>> { pub fn read(path: impl AsRef<Path>) -> io::Result<Vec<u8>> {
fs::read(path) fs::read(path)
} }
+3 -2
View File
@@ -2,7 +2,7 @@
name = "rustpython-pylib" name = "rustpython-pylib"
description = "A subset of the Python standard library for use with RustPython" description = "A subset of the Python standard library for use with RustPython"
license-file = "Lib/PSF-LICENSE" license-file = "Lib/PSF-LICENSE"
include = ["Cargo.toml", "build.rs", "src/**/*.rs", "Lib/", "!Lib/**/test/", "!Lib/**/*.pyc"] include = ["Cargo.toml", "build.rs", "src/**/*.rs", "LICENSE-profiling", "Lib/", "!Lib/**/test/", "!Lib/**/*.pyc"]
authors = ["CPython Developers"] authors = ["CPython Developers"]
version.workspace = true version.workspace = true
edition.workspace = true edition.workspace = true
@@ -10,7 +10,7 @@ rust-version.workspace = true
repository.workspace = true repository.workspace = true
[features] [features]
freeze-stdlib = ["dep:rustpython-compiler-core", "dep:rustpython-derive"] freeze-stdlib = ["dep:rustpython-compiler-core", "dep:rustpython-derive", "dep:sha2"]
[dependencies] [dependencies]
rustpython-compiler-core = { workspace = true, optional = true } rustpython-compiler-core = { workspace = true, optional = true }
@@ -18,6 +18,7 @@ rustpython-derive = { workspace = true, optional = true }
[build-dependencies] [build-dependencies]
glob = { workspace = true } glob = { workspace = true }
sha2 = { workspace = true, optional = true }
[lints] [lints]
workspace = true workspace = true
+225
View File
@@ -0,0 +1,225 @@
Vendored profiling output assets
===============================
License notices and terms copied from CPython commit
d220409a13b77de7a6c30abe16ea90db01528edc, with the section reference adapted
for this standalone notice.
Source notices: Doc/license.rst, "Profiling module" and the Apache License
text referenced there (reproduced below from the "OpenSSL" section).
https://github.com/python/cpython/blob/d220409a13b77de7a6c30abe16ea90db01528edc/Doc/license.rst
Asset mapping under Lib/profiling/sampling/_vendor/:
- d3/7.8.5/d3.min.js: d3.js notice below.
- d3-flame-graph/4.1.3/d3-flamegraph.css: Apache License 2.0.
- d3-flame-graph/4.1.3/d3-flamegraph.min.js: Apache License 2.0.
- d3-flame-graph/4.1.3/d3-flamegraph-tooltip.min.js: Apache License 2.0.
Canonical profiling notices
---------------------------
The :mod:`!profiling` module includes vendored third-party libraries in
:file:`Lib/profiling/sampling/_vendor/` with the following licenses:
**d3-flamegraph**
The d3-flamegraph library is distributed under the Apache License, Version 2.0.
See "Referenced Apache License text" below for the full text of the Apache License Version 2.0.
**d3.js**
The d3.js library contains the following notice::
Copyright 2010-2021 Mike Bostock
Permission to use, copy, modify, and/or distribute this software for any purpose
with or without fee is hereby granted, provided that the above copyright notice
and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS
OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER
TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF
THIS SOFTWARE.
Referenced Apache License text
------------------------------
Apache License
Version 2.0, January 2004
https://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
+131
View File
@@ -1,6 +1,8 @@
const CRATE_ROOT: &str = "../.."; const CRATE_ROOT: &str = "../..";
fn main() { fn main() {
#[cfg(feature = "freeze-stdlib")]
embed_profiling_resources();
process_python_libs(format!("{CRATE_ROOT}/vm/Lib/python_builtins/*").as_str()); process_python_libs(format!("{CRATE_ROOT}/vm/Lib/python_builtins/*").as_str());
process_python_libs(format!("{CRATE_ROOT}/vm/Lib/core_modules/*").as_str()); process_python_libs(format!("{CRATE_ROOT}/vm/Lib/core_modules/*").as_str());
@@ -50,3 +52,132 @@ fn process_python_libs(pattern: &str) {
println!("cargo:rerun-if-changed={display}"); println!("cargo:rerun-if-changed={display}");
} }
} }
// spell-checker: ignore pyfile
// Only these canonical CPython 3.15.0 assets are bundled. This is deliberately
// not a recursive embedding of Lib data or tests. Keep the notices in
// crates/pylib/LICENSE-profiling with every distribution of these bytes.
#[cfg(feature = "freeze-stdlib")]
const PROFILING_RESOURCES: &[(&str, &str)] = &[
(
"profiling/sampling/_assets/tachyon-logo.png",
"b72b0e28079ee7c033458f279e1c721d4d689db43f6e616848504d09ebe9c85d",
),
(
"profiling/sampling/_flamegraph_assets/flamegraph.css",
"a57f0512fe7071ca7915ac063e45df2173d7cea3b1fdded36fbb481cc14dafbb",
),
(
"profiling/sampling/_flamegraph_assets/flamegraph.js",
"9b39e61bfeadb6e4f9132c6dedb3b6f62f421821dc98261079ca6942f7649995",
),
(
"profiling/sampling/_flamegraph_assets/flamegraph_template.html",
"6469b5f46e27eaccf9b212e2d30d30807bca15873ea106a66726cbafc0babf39",
),
(
"profiling/sampling/_heatmap_assets/heatmap.css",
"17dd5a24ea930b2c8e3e51cd16dda20b68434273b9400ef3f965159e31213608",
),
(
"profiling/sampling/_heatmap_assets/heatmap.js",
"335f02d7b8baa94f0294afc0a71a3a4cbc5799187524524ce2a62ee18d16023f",
),
(
"profiling/sampling/_heatmap_assets/heatmap_index.js",
"d75ff0fe6781bba58b3a2ce3af63a329da39a7002f3eabf1adcc3cdc14c4d3fd",
),
(
"profiling/sampling/_heatmap_assets/heatmap_index_template.html",
"152b8b80c6cd318403c8e4a7a6d3b9256ae2dc3459698c606398bb58e199cffe",
),
(
"profiling/sampling/_heatmap_assets/heatmap_pyfile_template.html",
"ed68e3905f19b4535d561628359464ef7d575332a126a827fdc7b70ca4c17099",
),
(
"profiling/sampling/_heatmap_assets/heatmap_shared.js",
"15e4ba8b7830fb5e33f9aec95c42acf62b950df11210884aee62eddd580a68ab",
),
(
"profiling/sampling/_shared_assets/base.css",
"f09f2a549ce69a31e6a3fc29979a32204781320b377bd6488f00f71c3811af23",
),
(
"profiling/sampling/_shared_assets/base.js",
"b6f0d0a0720a7f3b20b14be710b7db257a89098178f3ab785d77da76eb019b18",
),
(
"profiling/sampling/_vendor/d3/7.8.5/d3.min.js",
"d6b03aefc9f6c44c7bc78713679c78c295028fa914319119e5cc4b4954855b1c",
),
(
"profiling/sampling/_vendor/d3-flame-graph/4.1.3/d3-flamegraph-tooltip.min.js",
"105738fc5fe7ca67327cf9a91a63fbd54e5fd3d2becd0e07650ca919b5678ac9",
),
(
"profiling/sampling/_vendor/d3-flame-graph/4.1.3/d3-flamegraph.css",
"7d75d68cb662819a37401248863100dd9db351f4bebf3310845d9196fbab7e2a",
),
(
"profiling/sampling/_vendor/d3-flame-graph/4.1.3/d3-flamegraph.min.js",
"1846f1e636ed1bd2c32fc87d1e24d63aff7e4f18629b4bbd819351f09ea22353",
),
];
#[cfg(feature = "freeze-stdlib")]
fn embed_profiling_resources() {
use core::fmt::Write;
use sha2::{Digest, Sha256};
use std::path::PathBuf;
// Match LIB_PATH's Windows checkout-link handling without relying on Git's
// ability to create a symlink. Canonicalize the root before containment checks.
let lib = if cfg!(windows) {
std::fs::read_to_string("Lib")
.map_or_else(|_| PathBuf::from("Lib"), |link| PathBuf::from(link.trim()))
} else {
PathBuf::from("Lib")
};
let lib = lib
.canonicalize()
.expect("resolve canonical Lib resource root");
let out = PathBuf::from(std::env::var_os("OUT_DIR").expect("Cargo OUT_DIR"));
let mut source = String::from(
"pub const FROZEN_STDLIB_RESOURCES: &[FrozenResourceBundle] = &[(\"profiling\", &[\n",
);
println!("cargo:rerun-if-changed=Lib");
for (index, &(relative, expected)) in PROFILING_RESOURCES.iter().enumerate() {
let path = lib.join(relative);
println!("cargo:rerun-if-changed={}", path.display());
let canonical = path.canonicalize().expect("resolve profiling resource");
assert!(
canonical.starts_with(&lib),
"profiling resource escapes Lib: {relative}"
);
assert!(
canonical.is_file(),
"profiling resource is not a file: {relative}"
);
let bytes = std::fs::read(&canonical).expect("read profiling resource");
let mut actual = String::with_capacity(64);
for byte in Sha256::digest(&bytes) {
write!(actual, "{byte:02x}").expect("write digest hex");
}
assert_eq!(
actual, expected,
"profiling resource differs from pinned CPython: {relative}"
);
// Snapshot exactly the bytes we hashed, rather than asking include_bytes!
// to reread a mutable source file after this build script has returned.
let snapshot = out.join(format!("profiling-resource-{index}.bin"));
std::fs::write(&snapshot, bytes).expect("write owned resource snapshot");
let logical = relative
.strip_prefix("profiling/")
.expect("profiling resource path");
writeln!(source, "({logical:?}, include_bytes!({snapshot:?})),")
.expect("write resource source");
}
source.push_str("])];\n");
std::fs::write(out.join("frozen_resources.rs"), source).expect("write resource sidecar");
}
+9
View File
@@ -14,3 +14,12 @@ pub const LIB_PATH: &str = match option_env!("win_lib_path") {
#[cfg(feature = "freeze-stdlib")] #[cfg(feature = "freeze-stdlib")]
pub const FROZEN_STDLIB: &rustpython_compiler_core::frozen::FrozenLib = pub const FROZEN_STDLIB: &rustpython_compiler_core::frozen::FrozenLib =
rustpython_derive::py_freeze!(dir = "../Lib", crate_name = "rustpython_compiler_core"); rustpython_derive::py_freeze!(dir = "../Lib", crate_name = "rustpython_compiler_core");
#[cfg(feature = "freeze-stdlib")]
type FrozenResource = (&'static str, &'static [u8]);
#[cfg(feature = "freeze-stdlib")]
type FrozenResourceBundle = (&'static str, &'static [FrozenResource]);
// Resource bytes are registered alongside the exact frozen module batch.
#[cfg(feature = "freeze-stdlib")]
include!(concat!(env!("OUT_DIR"), "/frozen_resources.rs"));
+704 -7
View File
@@ -1,19 +1,46 @@
mod binary;
pub(crate) use _remote_debugging::module_def; pub(crate) use _remote_debugging::module_def;
#[pymodule] #[pymodule]
mod _remote_debugging { mod _remote_debugging {
use super::binary;
use crate::vm::{ use crate::vm::{
Py, PyObjectRef, PyResult, VirtualMachine, AsObject, Py, PyObject, PyObjectRef, PyRef, PyResult, VirtualMachine,
builtins::PyType, builtins::{PyBaseExceptionRef, PyDictRef, PyList, PyTuple, PyType},
function::FuncArgs, common::{lock::PyMutex, rc::PyRc},
types::{Constructor, PyStructSequence}, convert::{IntoPyException, ToPyObject},
function::{FsPath, FuncArgs},
types::{Constructor, DefaultConstructor, Initializer, PyStructSequence},
}; };
use alloc::sync::Arc;
use core::sync::atomic::{AtomicBool, Ordering};
#[pystruct_sequence_data]
struct LocationInfoData {
lineno: PyObjectRef,
end_lineno: PyObjectRef,
col_offset: PyObjectRef,
end_col_offset: PyObjectRef,
}
#[pyattr]
#[pystruct_sequence(
name = "LocationInfo",
module = "_remote_debugging",
data = "LocationInfoData"
)]
struct LocationInfo;
#[pyclass(with(PyStructSequence))]
impl LocationInfo {}
#[pystruct_sequence_data] #[pystruct_sequence_data]
struct FrameInfoData { struct FrameInfoData {
filename: String, filename: PyObjectRef,
lineno: i64, location: PyObjectRef,
funcname: String, funcname: PyObjectRef,
opcode: PyObjectRef,
} }
#[pyattr] #[pyattr]
@@ -58,6 +85,7 @@ mod _remote_debugging {
#[pystruct_sequence_data] #[pystruct_sequence_data]
struct ThreadInfoData { struct ThreadInfoData {
thread_id: PyObjectRef, thread_id: PyObjectRef,
status: PyObjectRef,
frame_info: PyObjectRef, frame_info: PyObjectRef,
} }
@@ -72,6 +100,41 @@ mod _remote_debugging {
#[pyclass(with(PyStructSequence))] #[pyclass(with(PyStructSequence))]
impl ThreadInfo {} impl ThreadInfo {}
#[pystruct_sequence_data]
struct InterpreterInfoData {
interpreter_id: PyObjectRef,
threads: PyObjectRef,
}
#[pyattr]
#[pystruct_sequence(
name = "InterpreterInfo",
module = "_remote_debugging",
data = "InterpreterInfoData"
)]
struct InterpreterInfo;
#[pyclass(with(PyStructSequence))]
impl InterpreterInfo {}
#[pyattr]
const THREAD_STATUS_HAS_GIL: u8 = 1;
#[pyattr]
const THREAD_STATUS_ON_CPU: u8 = 2;
#[pyattr]
const THREAD_STATUS_UNKNOWN: u8 = 4;
#[pyattr]
const THREAD_STATUS_GIL_REQUESTED: u8 = 8;
#[pyattr]
const THREAD_STATUS_HAS_EXCEPTION: u8 = 16;
#[pyattr]
const THREAD_STATUS_MAIN_THREAD: u8 = 32;
#[pyfunction]
fn zstd_available() -> bool {
false
}
#[pystruct_sequence_data] #[pystruct_sequence_data]
struct AwaitedInfoData { struct AwaitedInfoData {
thread_id: PyObjectRef, thread_id: PyObjectRef,
@@ -89,6 +152,640 @@ mod _remote_debugging {
#[pyclass(with(PyStructSequence))] #[pyclass(with(PyStructSequence))]
impl AwaitedInfo {} impl AwaitedInfo {}
fn codec_error(error: binary::Error, vm: &VirtualMachine) -> PyBaseExceptionRef {
match error {
binary::Error::Io(error) => error.into_pyexception(vm),
binary::Error::Value(message) => vm.new_value_error(message),
binary::Error::Overflow(message) => vm.new_overflow_error(message),
binary::Error::Memory => vm.new_memory_error("unable to allocate profiling data"),
binary::Error::Runtime(message) => vm.new_runtime_error(message),
}
}
fn codec_path_error(
error: binary::Error,
path: PyObjectRef,
vm: &VirtualMachine,
) -> PyBaseExceptionRef {
let is_io = matches!(error, binary::Error::Io(_));
let exception = codec_error(error, vm);
if is_io {
let _ = exception.as_object().set_attr("filename", path, vm);
}
exception
}
fn list_items(value: &PyObject, name: &str, vm: &VirtualMachine) -> PyResult<Vec<PyObjectRef>> {
value
.downcast_ref::<PyList>()
.map(|list| list.borrow_vec().to_vec())
.ok_or_else(|| vm.new_type_error(format!("{name} must be a list")))
}
fn record_fields<'a>(
value: &'a PyObject,
count: usize,
name: &str,
vm: &VirtualMachine,
) -> PyResult<&'a [PyObjectRef]> {
value
.downcast_ref::<PyTuple>()
.map(|tuple| tuple.as_slice())
.filter(|fields| fields.len() >= count)
.ok_or_else(|| {
vm.new_type_error(format!(
"{name} must be a tuple with at least {count} fields"
))
})
}
fn normalize_frame(value: &PyObject, vm: &VirtualMachine) -> PyResult<binary::Frame> {
let fields = record_fields(value, 4, "frame", vm)?;
let mut location = [-1; 4];
if !vm.is_none(&fields[1]) {
let values = record_fields(&fields[1], 4, "location", vm)?;
for (target, value) in location.iter_mut().zip(values) {
// Match CPython: C long is 32-bit on Windows, 64-bit on LP64.
#[allow(clippy::unnecessary_cast)]
let normalized = value.try_to_value::<core::ffi::c_long>(vm).unwrap_or(-1) as i32;
*target = normalized;
}
}
let opcode = fields[3]
.try_to_value::<core::ffi::c_long>(vm)
.ok()
.filter(|value| (0..=254).contains(value))
.map_or(255, |value| value as u8);
Ok(binary::Frame {
filename: fields[0].try_to_value::<String>(vm)?,
funcname: fields[2].try_to_value::<String>(vm)?,
location,
opcode,
})
}
#[derive(FromArgs)]
struct BinaryWriterArgs {
#[pyarg(any)]
filename: PyObjectRef,
#[pyarg(any)]
sample_interval_us: u64,
#[pyarg(any)]
start_time_us: u64,
#[pyarg(named, default = 0)]
compression: i32,
#[pyarg(named, default = -1)]
mode: i32,
#[pyarg(named, default = -1)]
capture_features: i32,
}
#[derive(Debug, Default)]
struct WriterState {
writer: Option<binary::Writer>,
cached_total_samples: u64,
}
#[pyattr]
#[pyclass(name = "BinaryWriter", module = "_remote_debugging")]
#[derive(Debug, PyPayload, Default)]
struct BinaryWriter {
state: PyMutex<WriterState>,
}
impl DefaultConstructor for BinaryWriter {}
impl Initializer for BinaryWriter {
type Args = BinaryWriterArgs;
fn init(zelf: &Py<Self>, args: Self::Args, vm: &VirtualMachine) -> PyResult<()> {
if !(-1..=4).contains(&args.mode) {
return Err(vm.new_value_error("invalid profiling mode"));
}
if !(-1..=31).contains(&args.capture_features) {
return Err(vm.new_value_error("invalid capture features"));
}
let path =
FsPath::try_from_path_like(args.filename.clone(), true, vm)?.to_path_buf(vm)?;
let old = {
let mut state = zelf.state.lock();
state.cached_total_samples = 0;
state.writer.take()
};
drop(old);
let writer = binary::Writer::create(
&path,
args.sample_interval_us,
args.start_time_us,
args.compression,
args.mode,
args.capture_features,
[
crate::vm::version::MAJOR as u8,
crate::vm::version::MINOR as u8,
crate::vm::version::MICRO as u8,
],
)
.map_err(|error| codec_path_error(error, args.filename, vm))?;
zelf.state.lock().writer = Some(writer);
Ok(())
}
}
#[derive(FromArgs)]
struct BinaryExitArgs {
#[pyarg(positional, optional)]
exc_type: Option<PyObjectRef>,
#[pyarg(positional, optional)]
_exc_value: Option<PyObjectRef>,
#[pyarg(positional, optional)]
_exc_tb: Option<PyObjectRef>,
}
#[derive(FromArgs)]
struct SetStatsArgs {
#[pyarg(any)]
duration_sec: f64,
#[pyarg(any)]
sample_rate: f64,
#[pyarg(any, optional)]
error_rate: Option<PyObjectRef>,
#[pyarg(any, optional)]
missed_samples: Option<PyObjectRef>,
}
fn optional_float(value: Option<PyObjectRef>, vm: &VirtualMachine) -> PyResult<Option<f64>> {
value
.filter(|value| !vm.is_none(value))
.map(|value| value.try_float(vm).map(|value| value.to_f64()))
.transpose()
}
#[pyclass(with(Constructor, Initializer))]
impl BinaryWriter {
#[pymethod]
fn write_sample(
zelf: &Py<Self>,
stack_frames: PyObjectRef,
timestamp_us: u64,
vm: &VirtualMachine,
) -> PyResult<()> {
if zelf.state.lock().writer.is_none() {
return Err(vm.new_value_error("Writer is closed"));
}
for interpreter in list_items(&stack_frames, "stack_frames", vm)? {
let fields = record_fields(&interpreter, 2, "interpreter", vm)?;
let interpreter_id = fields[0].try_to_value::<u32>(vm)?;
for thread in list_items(&fields[1], "threads", vm)? {
let fields = record_fields(&thread, 3, "thread", vm)?;
let thread_id = fields[0].try_to_value::<u64>(vm)?;
let status = fields[1].try_to_value::<core::ffi::c_long>(vm)? as u8;
let frame_objects = {
let list = fields[2]
.downcast_ref::<PyList>()
.ok_or_else(|| vm.new_type_error("frame_info must be a list"))?;
list.borrow_vec()
.iter()
.take(256)
.cloned()
.collect::<Vec<_>>()
};
let frames = frame_objects
.into_iter()
.map(|frame| normalize_frame(&frame, vm))
.collect::<PyResult<Vec<_>>>()?;
let sample = binary::Sample {
interpreter_id,
thread_id,
status,
frames,
timestamp_us,
};
// All Python conversions above have finished before borrowing file state.
let result = {
let mut state = zelf.state.lock();
state
.writer
.as_mut()
.map(|writer| writer.write_sample(&sample))
};
result
.ok_or_else(|| vm.new_value_error("Writer is closed"))?
.map_err(|error| codec_error(error, vm))?;
}
}
Ok(())
}
#[pymethod]
fn set_stats(zelf: &Py<Self>, args: SetStatsArgs, vm: &VirtualMachine) -> PyResult<()> {
let is_open = zelf.state.lock().writer.is_some();
if !is_open {
return Err(vm.new_value_error("Writer is closed"));
}
let profile = binary::ProfileStats {
duration_sec: args.duration_sec,
sample_rate: args.sample_rate,
error_rate: optional_float(args.error_rate, vm)?,
missed_samples: optional_float(args.missed_samples, vm)?,
};
let result = {
let mut state = zelf.state.lock();
state
.writer
.as_mut()
.map(|writer| writer.set_stats(profile))
};
result
.ok_or_else(|| vm.new_value_error("Writer is closed"))?
.map_err(|error| codec_error(error, vm))
}
#[pymethod]
fn finalize(zelf: &Py<Self>, vm: &VirtualMachine) -> PyResult<()> {
let result = {
let mut state = zelf.state.lock();
if let Some(writer) = state.writer.as_mut() {
match writer.finalize() {
Ok(()) => {
state.cached_total_samples = writer.total_samples();
Ok(state.writer.take())
}
Err(error) => Err(Some(error)),
}
} else {
Err(None)
}
};
match result {
Ok(writer) => {
drop(writer);
Ok(())
}
Err(Some(error)) => Err(codec_error(error, vm)),
Err(None) => Err(vm.new_value_error("Writer is already closed")),
}
}
#[pymethod]
fn close(zelf: &Py<Self>) {
let writer = zelf.state.lock().writer.take();
drop(writer);
}
#[pygetset]
fn total_samples(zelf: &Py<Self>) -> u64 {
let state = zelf.state.lock();
state
.writer
.as_ref()
.map_or(state.cached_total_samples, binary::Writer::total_samples)
}
#[pymethod]
fn get_stats(zelf: &Py<Self>, vm: &VirtualMachine) -> PyResult<PyDictRef> {
let stats = zelf
.state
.lock()
.writer
.as_ref()
.map(|writer| *writer.stats());
let stats = stats.ok_or_else(|| vm.new_value_error("Writer is closed"))?;
let dict = vm.ctx.new_dict();
for (key, value) in [
("repeat_records", stats.repeat_records),
("repeat_samples", stats.repeat_samples),
("full_records", stats.full_records),
("suffix_records", stats.suffix_records),
("pop_push_records", stats.pop_push_records),
("total_records", stats.total_records()),
("total_samples", stats.total_samples),
("total_frames_written", stats.total_frames_written),
("frames_saved", stats.frames_saved),
("bytes_written", stats.bytes_written),
] {
dict.set_item(key, value.to_pyobject(vm), vm)?;
}
dict.set_item(
"frame_compression_pct",
stats.frame_compression_pct().to_pyobject(vm),
vm,
)?;
Ok(dict)
}
#[pymethod]
fn __enter__(zelf: PyRef<Self>) -> PyRef<Self> {
zelf
}
#[pymethod]
fn __exit__(zelf: &Py<Self>, args: BinaryExitArgs, vm: &VirtualMachine) -> PyResult<bool> {
if args.exc_type.as_ref().is_none_or(|value| vm.is_none(value)) {
let is_open = zelf.state.lock().writer.is_some();
if is_open && let Err(error) = Self::finalize(zelf, vm) {
Self::close(zelf);
return Err(error);
}
} else {
Self::close(zelf);
}
Ok(false)
}
}
#[derive(Debug)]
struct ReaderOpen {
reader: Arc<binary::Reader>,
stats: PyMutex<binary::ReaderStats>,
replaying: AtomicBool,
}
struct ReplayLease(PyRc<ReaderOpen>);
impl Drop for ReplayLease {
fn drop(&mut self) {
self.0.replaying.store(false, Ordering::Release);
}
}
#[pyattr]
#[pyclass(name = "BinaryReader", module = "_remote_debugging")]
#[derive(Debug, PyPayload, Default)]
struct BinaryReader {
state: PyMutex<Option<PyRc<ReaderOpen>>>,
}
impl DefaultConstructor for BinaryReader {}
#[derive(FromArgs)]
struct BinaryReaderArgs {
#[pyarg(any)]
filename: PyObjectRef,
}
impl Initializer for BinaryReader {
type Args = BinaryReaderArgs;
fn init(zelf: &Py<Self>, args: Self::Args, vm: &VirtualMachine) -> PyResult<()> {
let path =
FsPath::try_from_path_like(args.filename.clone(), true, vm)?.to_path_buf(vm)?;
let old = zelf.state.lock().take();
drop(old);
let reader = binary::Reader::open(&path)
.map_err(|error| codec_path_error(error, args.filename, vm))?;
*zelf.state.lock() = Some(PyRc::new(ReaderOpen {
reader: Arc::new(reader),
stats: PyMutex::new(binary::ReaderStats::default()),
replaying: AtomicBool::new(false),
}));
Ok(())
}
}
fn replay_records(sample: &binary::Sample, vm: &VirtualMachine) -> PyObjectRef {
let frames = sample
.frames
.iter()
.map(|frame| {
let location = if frame.location[0] == -1 {
vm.ctx.none()
} else {
vm.ctx
.new_tuple(
frame
.location
.iter()
.map(|value| value.to_pyobject(vm))
.collect(),
)
.into()
};
FrameInfo::from_data(
FrameInfoData {
filename: frame.filename.clone().to_pyobject(vm),
location,
funcname: frame.funcname.clone().to_pyobject(vm),
opcode: if frame.opcode == 255 {
vm.ctx.none()
} else {
frame.opcode.to_pyobject(vm)
},
},
vm,
)
.into()
})
.collect();
let thread = ThreadInfo::from_data(
ThreadInfoData {
thread_id: sample.thread_id.to_pyobject(vm),
status: sample.status.to_pyobject(vm),
frame_info: vm.ctx.new_list(frames).into(),
},
vm,
);
let interpreter = InterpreterInfo::from_data(
InterpreterInfoData {
interpreter_id: sample.interpreter_id.to_pyobject(vm),
threads: vm.ctx.new_list(vec![thread.into()]).into(),
},
vm,
);
vm.ctx.new_list(vec![interpreter.into()]).into()
}
#[derive(FromArgs)]
struct ReplayArgs {
#[pyarg(any)]
collector: PyObjectRef,
#[pyarg(any, optional)]
progress_callback: Option<PyObjectRef>,
}
#[pyclass(with(Constructor, Initializer))]
impl BinaryReader {
fn opened(zelf: &Py<Self>, vm: &VirtualMachine) -> PyResult<PyRc<ReaderOpen>> {
let open = zelf.state.lock().clone();
open.ok_or_else(|| vm.new_value_error("Reader is closed"))
}
#[pymethod]
fn close(zelf: &Py<Self>) {
let old = zelf.state.lock().take();
drop(old);
}
#[pygetset]
fn sample_count(zelf: &Py<Self>) -> u64 {
zelf.state
.lock()
.as_ref()
.map_or(0, |open| open.reader.metadata().sample_count)
}
#[pygetset]
fn sample_interval_us(zelf: &Py<Self>) -> u64 {
zelf.state
.lock()
.as_ref()
.map_or(0, |open| open.reader.metadata().sample_interval_us)
}
#[pymethod]
fn get_info(zelf: &Py<Self>, vm: &VirtualMachine) -> PyResult<PyDictRef> {
let open = Self::opened(zelf, vm)?;
let info = open.reader.metadata();
let dict = vm.ctx.new_dict();
dict.set_item("version", info.version.to_pyobject(vm), vm)?;
dict.set_item(
"python_version",
vm.ctx
.new_tuple(
info.python_version
.iter()
.map(|value| value.to_pyobject(vm))
.collect(),
)
.into(),
vm,
)?;
for (key, value) in [
("start_time_us", info.start_time_us),
("sample_interval_us", info.sample_interval_us),
("sample_count", info.sample_count),
("thread_count", u64::from(info.thread_count)),
("string_count", u64::from(info.string_count)),
("frame_count", u64::from(info.frame_count)),
] {
dict.set_item(key, value.to_pyobject(vm), vm)?;
}
dict.set_item(
"compression_type",
(info.compression_type as i32).to_pyobject(vm),
vm,
)?;
dict.set_item("mode", info.mode.to_pyobject(vm), vm)?;
dict.set_item(
"capture_features",
info.capture_features.to_pyobject(vm),
vm,
)?;
let profile = info.profile_stats.as_ref();
for (key, value) in [
("duration_sec", profile.map(|value| value.duration_sec)),
("sample_rate", profile.map(|value| value.sample_rate)),
("error_rate", profile.and_then(|value| value.error_rate)),
(
"missed_samples",
profile.and_then(|value| value.missed_samples),
),
] {
dict.set_item(key, value.to_pyobject(vm), vm)?;
}
Ok(dict)
}
#[pymethod]
fn get_stats(zelf: &Py<Self>, vm: &VirtualMachine) -> PyResult<PyDictRef> {
let open = Self::opened(zelf, vm)?;
let stats = *open.stats.lock();
let dict = vm.ctx.new_dict();
for (key, value) in [
("repeat_records", stats.repeat_records),
("repeat_samples", stats.repeat_samples),
("full_records", stats.full_records),
("suffix_records", stats.suffix_records),
("pop_push_records", stats.pop_push_records),
("total_records", stats.total_records()),
("total_samples", stats.total_samples),
("stack_reconstructions", stats.stack_reconstructions),
] {
dict.set_item(key, value.to_pyobject(vm), vm)?;
}
Ok(dict)
}
#[pymethod]
fn replay(zelf: &Py<Self>, args: ReplayArgs, vm: &VirtualMachine) -> PyResult<u64> {
let open = Self::opened(zelf, vm)?;
if open
.replaying
.compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed)
.is_err()
{
return Err(vm.new_runtime_error("A replay is already active on this reader"));
}
let _lease = ReplayLease(open.clone());
// The per-open snapshot survives close/reinitialization, but cannot
// publish counters into a replacement reader. No lock crosses Python.
let initial_collect = args.collector.get_attr("collect", vm).map_err(|error| {
if error.fast_isinstance(vm.ctx.exceptions.attribute_error) {
vm.new_type_error("Collector must have a collect() method")
} else {
error
}
})?;
drop(initial_collect);
let progress = args.progress_callback.filter(|value| !vm.is_none(value));
let total = open.reader.metadata().sample_count;
if let Some(callback) = &progress {
callback.call((0_u64, total), vm)?;
}
let mut replay = open.reader.replay();
let mut committed_samples = 0;
loop {
let batch = replay.next_batch();
let mut stats = *replay.stats();
stats.total_samples = committed_samples;
*open.stats.lock() = stats;
let Some(batch) = batch.map_err(|error| codec_error(error, vm))? else {
break;
};
let records = replay_records(&batch.sample, vm);
let timestamps: PyObjectRef = vm
.ctx
.new_list(
batch
.timestamps
.iter()
.map(|value| value.to_pyobject(vm))
.collect(),
)
.into();
args.collector
.get_attr("collect", vm)?
.call((records, timestamps), vm)?;
if batch.record_complete {
committed_samples = replay.replayed();
*open.stats.lock() = *replay.stats();
if let Some(callback) = &progress {
if batch.is_repeat
&& committed_samples % 1000 < u64::from(batch.record_samples)
{
callback.call((committed_samples, total), vm)?;
}
if committed_samples % 1000 == 0 {
callback.call((committed_samples, total), vm)?;
}
}
}
}
if let Some(callback) = &progress {
callback.call((replay.replayed(), total), vm)?;
}
Ok(replay.replayed())
}
#[pymethod]
fn __enter__(zelf: PyRef<Self>) -> PyRef<Self> {
zelf
}
#[pymethod]
fn __exit__(zelf: &Py<Self>, _args: BinaryExitArgs) -> bool {
Self::close(zelf);
false
}
}
#[pyattr] #[pyattr]
#[pyclass(name = "RemoteUnwinder", module = "_remote_debugging")] #[pyclass(name = "RemoteUnwinder", module = "_remote_debugging")]
#[derive(Debug, PyPayload)] #[derive(Debug, PyPayload)]
File diff suppressed because it is too large. Load diff
+30 -1
View File
@@ -15,6 +15,7 @@ use crate::{
convert::ToPyObject, convert::ToPyObject,
frozen, frozen,
function::OptionalArg, function::OptionalArg,
object::{Traverse, TraverseFn},
types::{Comparable, Constructor, Hashable, Representable}, types::{Comparable, Constructor, Hashable, Representable},
}; };
use alloc::fmt; use alloc::fmt;
@@ -440,7 +441,7 @@ struct CoCached {
freevars: OnceCell<PyTupleRef>, freevars: OnceCell<PyTupleRef>,
} }
#[pyclass(module = false, name = "code")] #[pyclass(module = false, name = "code", traverse = "manual", clear = false)]
pub struct PyCode { pub struct PyCode {
#[pymember(name = "co_argcount", path = "arg_count")] #[pymember(name = "co_argcount", path = "arg_count")]
#[pymember(name = "co_posonlyargcount", path = "posonlyarg_count")] #[pymember(name = "co_posonlyargcount", path = "posonlyarg_count")]
@@ -482,6 +483,34 @@ pub struct PyCode {
pub has_exc_handling: bool, pub has_exc_handling: bool,
} }
// SAFETY: Visit each owned reference once, including initialized cache cells.
// Constant elements belong to the constants tuple and are not direct edges.
// Interned names and source paths are borrowed static references.
unsafe impl Traverse for PyCode {
fn traverse(&self, tracer: &mut TraverseFn<'_>) {
self.co_consts().traverse(tracer);
self.co_name.traverse(tracer);
self.qualified_name.traverse(tracer);
self.co_names.traverse(tracer);
self.co_localsplusnames.traverse(tracer);
self.co_localspluskinds.traverse(tracer);
self.co_linetable.traverse(tracer);
self.co_exceptiontable.traverse(tracer);
if let Some(value) = self.co_cached.code.get() {
value.traverse(tracer);
}
if let Some(value) = self.co_cached.varnames.get() {
value.traverse(tracer);
}
if let Some(value) = self.co_cached.cellvars.get() {
value.traverse(tracer);
}
if let Some(value) = self.co_cached.freevars.get() {
value.traverse(tracer);
}
}
}
impl Deref for PyCode { impl Deref for PyCode {
type Target = CodeObject; type Target = CodeObject;
fn deref(&self) -> &Self::Target { fn deref(&self) -> &Self::Target {
+6 -2
View File
@@ -305,8 +305,12 @@ impl Py<PyFloat> {
}) })
} }
#[pystaticmethod] #[pyclassmethod]
fn __getformat__(typestr: PyUtf8StrRef, vm: &VirtualMachine) -> PyResult<String> { fn __getformat__(
_cls: PyTypeRef,
typestr: PyUtf8StrRef,
vm: &VirtualMachine,
) -> PyResult<String> {
if !matches!(typestr.as_str(), "double" | "float") { if !matches!(typestr.as_str(), "double" | "float") {
return Err( return Err(
vm.new_value_error("__getformat__() argument 1 must be 'double' or 'float'") vm.new_value_error("__getformat__() argument 1 must be 'double' or 'float'")
+719
View File
@@ -0,0 +1,719 @@
//! Resource indexes bound to a particular registration of frozen modules.
use crate::frozen::FrozenModule;
use alloc::{
collections::{BTreeMap, BTreeSet},
sync::Arc,
};
use core::fmt;
use std::collections::HashMap;
/// A package-relative logical POSIX path and its embedded contents.
pub type FrozenResource = (&'static str, &'static [u8]);
/// A frozen package name and the resources registered with its code.
pub type FrozenResourceBundle = (&'static str, &'static [FrozenResource]);
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum ResourceError {
InvalidPath(String),
DuplicatePath(String),
FileDirectoryCollision(String),
DuplicateModule(String),
DuplicateOwner(String),
MissingOwner(String),
OwnerNotPackage(String),
NotFound(String),
IsDirectory(String),
NotDirectory(String),
}
impl fmt::Display for ResourceError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let (description, path) = match self {
Self::InvalidPath(path) => ("invalid relative resource path", path),
Self::DuplicatePath(path) => ("duplicate resource path", path),
Self::FileDirectoryCollision(path) => ("resource is both a file and directory", path),
Self::DuplicateModule(path) => {
("duplicate frozen module in resource registration", path)
}
Self::DuplicateOwner(path) => ("duplicate frozen resource owner", path),
Self::MissingOwner(path) => ("resource owner is absent from this registration", path),
Self::OwnerNotPackage(path) => ("resource owner is not a package", path),
Self::NotFound(path) => ("resource does not exist", path),
Self::IsDirectory(path) => ("resource is a directory", path),
Self::NotDirectory(path) => ("resource is not a directory", path),
};
write!(f, "{description}: {path:?}")
}
}
impl core::error::Error for ResourceError {}
fn normalize_relative(path: &str) -> Result<String, ResourceError> {
if path.starts_with('/') || path.contains(['\\', '\0', ':']) {
return Err(ResourceError::InvalidPath(path.to_owned()));
}
let mut parts = Vec::new();
for part in path.split('/') {
match part {
"" | "." => {}
".." => return Err(ResourceError::InvalidPath(path.to_owned())),
_ => parts.push(part),
}
}
Ok(parts.join("/"))
}
/// An immutable tree of embedded files, with explicit inferred directories.
#[derive(Debug)]
pub struct ResourceIndex {
root_name: &'static str,
files: BTreeMap<String, &'static [u8]>,
directories: BTreeSet<String>,
}
impl ResourceIndex {
fn add_directory(&mut self, path: &str) -> Result<(), ResourceError> {
let normalized = normalize_relative(path)?;
if normalized != path {
return Err(ResourceError::InvalidPath(path.to_owned()));
}
let mut directory = path;
loop {
if self.files.contains_key(directory) {
return Err(ResourceError::FileDirectoryCollision(directory.to_owned()));
}
self.directories.insert(directory.to_owned());
let Some((parent, _)) = directory.rsplit_once('/') else {
break;
};
directory = parent;
}
Ok(())
}
fn new(owner: &'static str, resources: &[FrozenResource]) -> Result<Self, ResourceError> {
let mut index = Self {
root_name: owner.rsplit('.').next().unwrap_or(owner),
files: BTreeMap::new(),
directories: BTreeSet::from([String::new()]),
};
for &(path, bytes) in resources {
let normalized = normalize_relative(path)?;
// Manifest paths are canonical; query paths may contain harmless dots or slashes.
if normalized.is_empty() || normalized != path {
return Err(ResourceError::InvalidPath(path.to_owned()));
}
if index.files.contains_key(path) {
return Err(ResourceError::DuplicatePath(path.to_owned()));
}
if index.directories.contains(path) {
return Err(ResourceError::FileDirectoryCollision(path.to_owned()));
}
let mut parent = path;
while let Some((prefix, _)) = parent.rsplit_once('/') {
if index.files.contains_key(prefix) {
return Err(ResourceError::FileDirectoryCollision(prefix.to_owned()));
}
index.directories.insert(prefix.to_owned());
parent = prefix;
}
index.files.insert(normalized, bytes);
}
Ok(index)
}
}
/// An immutable snapshot of one directory or file in an embedded resource tree.
#[derive(Clone, Debug)]
pub struct ResourceView {
index: Arc<ResourceIndex>,
prefix: String,
}
impl ResourceView {
#[must_use]
pub fn name(&self) -> &str {
if self.prefix.is_empty() {
self.index.root_name
} else {
self.prefix.rsplit('/').next().unwrap_or(&self.prefix)
}
}
#[must_use]
pub fn is_file(&self) -> bool {
self.index.files.contains_key(&self.prefix)
}
#[must_use]
pub fn is_dir(&self) -> bool {
self.index.directories.contains(&self.prefix)
}
pub fn join(&self, descendant: &str) -> Result<Self, ResourceError> {
let descendant = normalize_relative(descendant)?;
let prefix = match (self.prefix.is_empty(), descendant.is_empty()) {
(_, true) => self.prefix.clone(),
(true, false) => descendant,
(false, false) => format!("{}/{descendant}", self.prefix),
};
Ok(Self {
index: self.index.clone(),
prefix,
})
}
pub fn children(&self) -> Result<Vec<Self>, ResourceError> {
if !self.is_dir() {
return Err(if self.is_file() {
ResourceError::NotDirectory(self.prefix.clone())
} else {
ResourceError::NotFound(self.prefix.clone())
});
}
let prefix = if self.prefix.is_empty() {
String::new()
} else {
format!("{}/", self.prefix)
};
let children: BTreeSet<_> = self
.index
.files
.keys()
.chain(self.index.directories.iter())
.filter(|path| {
path.strip_prefix(prefix.as_str())
.is_some_and(|rest| !rest.is_empty() && !rest.contains('/'))
})
.cloned()
.collect();
Ok(children
.into_iter()
.map(|prefix| Self {
index: self.index.clone(),
prefix,
})
.collect())
}
pub fn bytes(&self) -> Result<&'static [u8], ResourceError> {
self.index.files.get(&self.prefix).copied().ok_or_else(|| {
if self.is_dir() {
ResourceError::IsDirectory(self.prefix.clone())
} else {
ResourceError::NotFound(self.prefix.clone())
}
})
}
}
pub(crate) struct FrozenRegistration {
modules: Vec<(&'static str, FrozenModule)>,
resources: BTreeMap<&'static str, Arc<ResourceIndex>>,
}
impl FrozenRegistration {
pub(crate) fn code_only(
modules: impl IntoIterator<Item = (&'static str, FrozenModule)>,
) -> Self {
Self {
modules: modules.into_iter().collect(),
resources: BTreeMap::new(),
}
}
pub(crate) fn with_resources(
modules: impl IntoIterator<Item = (&'static str, FrozenModule)>,
resources: &[FrozenResourceBundle],
) -> Result<Self, ResourceError> {
let mut registration = Self::code_only(modules);
let mut names = BTreeMap::new();
for &(name, module) in &registration.modules {
if names.insert(name, module.package).is_some() {
return Err(ResourceError::DuplicateModule(name.to_owned()));
}
}
for &(owner, files) in resources {
if owner.is_empty()
|| owner.split('.').any(str::is_empty)
|| owner.contains(['/', '\\', '\0', ':'])
{
return Err(ResourceError::InvalidPath(owner.to_owned()));
}
match names.get(owner) {
None => return Err(ResourceError::MissingOwner(owner.to_owned())),
Some(false) => return Err(ResourceError::OwnerNotPackage(owner.to_owned())),
Some(true) => {}
}
if registration.resources.contains_key(owner) {
return Err(ResourceError::DuplicateOwner(owner.to_owned()));
}
let mut index = ResourceIndex::new(owner, files)?;
for (&name, &package) in &names {
if package
&& let Some(suffix) = name.strip_prefix(owner)
&& let Some(relative) = suffix.strip_prefix('.')
{
index.add_directory(&relative.replace('.', "/"))?;
}
}
registration.resources.insert(owner, Arc::new(index));
}
Ok(registration)
}
}
#[derive(Clone, Copy)]
struct FrozenEntry {
module: FrozenModule,
registration: u64,
}
#[derive(Clone)]
struct ResourceBinding {
owner: &'static str,
owner_registration: u64,
anchor_registration: u64,
view: ResourceView,
}
/// Frozen modules with resource ownership invalidated on every replacement.
///
/// Mutable map access is deliberately not exposed: even identical-byte replacements
/// are distinct registrations and must not inherit another registration's resources.
#[derive(Clone, Default)]
pub struct FrozenModuleRegistry {
modules: HashMap<&'static str, FrozenEntry, rapidhash::quality::RandomState>,
bindings: BTreeMap<&'static str, ResourceBinding>,
next_registration: u64,
}
impl FrozenModuleRegistry {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn get(&self, name: &str) -> Option<&FrozenModule> {
self.modules.get(name).map(|entry| &entry.module)
}
pub fn keys(&self) -> impl Iterator<Item = &&'static str> {
self.modules.keys()
}
#[must_use]
pub fn len(&self) -> usize {
self.modules.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.modules.is_empty()
}
#[must_use]
pub fn contains_key(&self, name: &str) -> bool {
self.modules.contains_key(name)
}
fn next_token(&mut self) -> u64 {
self.next_registration = self
.next_registration
.checked_add(1)
.expect("frozen module registration counter exhausted");
self.next_registration
}
fn invalidate(&mut self, name: &str) {
self.bindings.retain(|anchor, binding| {
*anchor != name
&& binding.owner != name
&& !anchor
.strip_prefix(name)
.is_some_and(|suffix| suffix.starts_with('.'))
});
}
pub fn insert(&mut self, name: &'static str, module: FrozenModule) -> Option<FrozenModule> {
let registration = self.next_token();
self.invalidate(name);
self.modules
.insert(
name,
FrozenEntry {
module,
registration,
},
)
.map(|entry| entry.module)
}
pub fn remove(&mut self, name: &str) -> Option<FrozenModule> {
self.invalidate(name);
self.modules.remove(name).map(|entry| entry.module)
}
pub fn clear(&mut self) {
self.bindings.clear();
self.modules.clear();
}
pub fn register_with_resources(
&mut self,
modules: impl IntoIterator<Item = (&'static str, FrozenModule)>,
resources: &[FrozenResourceBundle],
) -> Result<(), ResourceError> {
let registration = FrozenRegistration::with_resources(modules, resources)?;
self.apply_registration(registration);
Ok(())
}
pub(crate) fn apply_registration(&mut self, batch: FrozenRegistration) {
let registration = self.next_token();
for &(name, module) in &batch.modules {
self.invalidate(name);
self.modules.insert(
name,
FrozenEntry {
module,
registration,
},
);
}
for &(anchor, module) in &batch.modules {
let package = if module.package {
anchor
} else if let Some((package, _)) = anchor.rsplit_once('.') {
package
} else {
continue;
};
let owner = batch
.resources
.keys()
.copied()
.filter(|owner| {
package == *owner
|| package
.strip_prefix(*owner)
.is_some_and(|suffix| suffix.starts_with('.'))
})
.max_by_key(|owner| owner.len());
let Some(owner) = owner else {
continue;
};
// Every containing package down to the owner must belong to this batch.
let mut current = package;
let eligible =
loop {
if !self.modules.get(current).is_some_and(|entry| {
entry.registration == registration && entry.module.package
}) {
break false;
}
if current == owner {
break true;
}
let Some((parent, _)) = current.rsplit_once('.') else {
break false;
};
current = parent;
};
if !eligible {
continue;
}
let prefix = package
.strip_prefix(owner)
.unwrap_or_default()
.trim_start_matches('.')
.replace('.', "/");
let view = ResourceView {
index: batch.resources[owner].clone(),
prefix,
};
self.bindings.insert(
anchor,
ResourceBinding {
owner,
owner_registration: registration,
anchor_registration: registration,
view,
},
);
}
}
/// Acquire a snapshot only if both the exact anchor and owner are still current.
#[must_use]
pub fn resource_view(&self, fullname: &str) -> Option<ResourceView> {
let binding = self.bindings.get(fullname)?;
let anchor = self.modules.get(fullname)?;
let owner = self.modules.get(binding.owner)?;
(anchor.registration == binding.anchor_registration
&& owner.registration == binding.owner_registration
&& owner.module.package)
.then(|| binding.view.clone())
}
}
impl Extend<(&'static str, FrozenModule)> for FrozenModuleRegistry {
fn extend<T: IntoIterator<Item = (&'static str, FrozenModule)>>(&mut self, iter: T) {
self.apply_registration(FrozenRegistration::code_only(iter));
}
}
impl FromIterator<(&'static str, FrozenModule)> for FrozenModuleRegistry {
fn from_iter<T: IntoIterator<Item = (&'static str, FrozenModule)>>(iter: T) -> Self {
let mut registry = Self::new();
registry.extend(iter);
registry
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::frozen::FrozenCodeObject;
const ASSETS: &[FrozenResource] =
&[("data.txt", b"hello"), ("nested/4.1.3/data.bin", b"\0\xff")];
const BUNDLES: &[FrozenResourceBundle] = &[("sample", ASSETS)];
fn module(package: bool) -> FrozenModule {
FrozenModule {
code: FrozenCodeObject {
bytes: b"same-code",
},
package,
}
}
fn registered() -> FrozenModuleRegistry {
let mut registry = FrozenModuleRegistry::new();
registry
.register_with_resources(
[("sample", module(true)), ("sample.worker", module(false))],
BUNDLES,
)
.unwrap();
registry
}
#[test]
fn replacement_never_inherits_identical_code_resources() {
let mut registry = registered();
let snapshot = registry.resource_view("sample").unwrap();
registry.insert("sample", module(true));
assert!(registry.resource_view("sample").is_none());
assert!(registry.resource_view("sample.worker").is_none());
assert_eq!(
snapshot.join("data.txt").unwrap().bytes().unwrap(),
b"hello"
);
registry
.register_with_resources([("sample", module(true))], BUNDLES)
.unwrap();
assert!(registry.resource_view("sample").is_some());
assert!(registry.resource_view("sample.worker").is_none());
}
#[test]
fn anchor_replacement_and_unrelated_registration() {
let mut registry = registered();
registry.extend([("other", module(true)), ("sample.worker", module(false))]);
assert!(registry.resource_view("sample").is_some());
assert!(registry.resource_view("sample.worker").is_none());
assert!(registry.resource_view("sample.unknown").is_none());
assert!(registry.resource_view("samplex").is_none());
}
#[test]
fn clones_have_independent_ownership() {
let parent = registered();
let mut child = parent.clone();
child.remove("sample");
assert!(parent.resource_view("sample").is_some());
assert!(child.resource_view("sample").is_none());
child.insert("sample", module(true));
assert!(child.resource_view("sample").is_none());
}
#[test]
fn resource_replacement_and_clear_do_not_merge_old_assets() {
const REPLACEMENT: &[FrozenResourceBundle] = &[("sample", &[("new.txt", b"new")])];
let mut registry = registered();
registry
.register_with_resources([("sample", module(true))], REPLACEMENT)
.unwrap();
let view = registry.resource_view("sample").unwrap();
assert!(!view.join("data.txt").unwrap().is_file());
assert_eq!(view.join("new.txt").unwrap().bytes().unwrap(), b"new");
registry.clear();
registry.extend([("sample", module(true))]);
assert!(registry.resource_view("sample").is_none());
}
#[test]
fn invalid_batch_is_atomic() {
let mut registry = registered();
assert_eq!(
registry.register_with_resources([("other", module(true))], BUNDLES),
Err(ResourceError::MissingOwner("sample".to_owned()))
);
assert!(!registry.contains_key("other"));
assert!(registry.resource_view("sample").is_some());
assert_eq!(
registry.register_with_resources([("sample", module(false))], BUNDLES),
Err(ResourceError::OwnerNotPackage("sample".to_owned()))
);
assert_eq!(
registry.register_with_resources(
[("sample", module(true)), ("sample", module(true))],
BUNDLES
),
Err(ResourceError::DuplicateModule("sample".to_owned()))
);
}
#[test]
fn tree_queries_are_component_based() {
let registry = registered();
let root = registry.resource_view("sample").unwrap();
assert_eq!(root.name(), "sample");
assert!(root.is_dir());
assert_eq!(
root.children()
.unwrap()
.iter()
.map(ResourceView::name)
.collect::<Vec<_>>(),
["data.txt", "nested"]
);
assert_eq!(
root.join("./nested//4.1.3/data.bin")
.unwrap()
.bytes()
.unwrap(),
b"\0\xff"
);
assert!(!root.join("nest").unwrap().is_dir());
assert!(matches!(root.bytes(), Err(ResourceError::IsDirectory(_))));
assert!(matches!(
root.join("data.txt").unwrap().children(),
Err(ResourceError::NotDirectory(_))
));
let missing = root.join("missing").unwrap();
assert!(!missing.is_file() && !missing.is_dir());
assert!(matches!(missing.bytes(), Err(ResourceError::NotFound(_))));
assert!(matches!(
missing.children(),
Err(ResourceError::NotFound(_))
));
}
#[test]
fn unsafe_paths_are_rejected() {
let root = registered().resource_view("sample").unwrap();
for path in [
"../data.txt",
"nested/../data.txt",
"/data.txt",
"C:data.txt",
"C:/data.txt",
"\\\\host\\file",
"nested\\data.txt",
"nul\0name",
] {
assert!(matches!(
root.join(path),
Err(ResourceError::InvalidPath(_))
));
}
assert_eq!(root.join("").unwrap().name(), "sample");
}
#[test]
fn manifests_reject_collisions_in_either_order() {
for entries in [
vec![("a", b"x".as_slice()), ("a/b", b"x".as_slice())],
vec![("a/b", b"x".as_slice()), ("a", b"x".as_slice())],
] {
assert!(matches!(
ResourceIndex::new("sample", &entries),
Err(ResourceError::FileDirectoryCollision(_))
));
}
assert!(matches!(
ResourceIndex::new("sample", &[("a", b"x"), ("a", b"y")]),
Err(ResourceError::DuplicatePath(_))
));
assert!(matches!(
ResourceIndex::new("sample", &[("./a", b"x")]),
Err(ResourceError::InvalidPath(_))
));
}
#[test]
fn nested_package_anchors_require_same_registration() {
let mut registry = FrozenModuleRegistry::new();
registry.insert("sample.nested", module(true));
registry
.register_with_resources(
[
("sample", module(true)),
("sample.nested.worker", module(false)),
],
BUNDLES,
)
.unwrap();
assert!(registry.resource_view("sample.nested.worker").is_none());
registry
.register_with_resources(
[
("sample", module(true)),
("sample.nested", module(true)),
("sample.nested.worker", module(false)),
],
BUNDLES,
)
.unwrap();
let view = registry.resource_view("sample.nested.worker").unwrap();
assert_eq!(view.name(), "nested");
assert!(view.is_dir());
registry.insert("sample.nested", module(true));
assert!(registry.resource_view("sample.nested.worker").is_none());
}
#[test]
fn package_without_files_has_an_empty_directory_view() {
let mut registry = FrozenModuleRegistry::new();
registry
.register_with_resources(
[("sample", module(true)), ("sample.empty", module(true))],
BUNDLES,
)
.unwrap();
let root = registry.resource_view("sample.empty").unwrap();
assert!(root.is_dir());
assert!(!root.is_file());
assert!(root.children().unwrap().is_empty());
assert!(matches!(root.bytes(), Err(ResourceError::IsDirectory(_))));
}
#[test]
fn package_directories_cannot_collide_with_embedded_files() {
const FILE: &[FrozenResourceBundle] = &[("sample", &[("nested", b"file")])];
let mut registry = FrozenModuleRegistry::new();
assert_eq!(
registry.register_with_resources(
[("sample", module(true)), ("sample.nested", module(true))],
FILE,
),
Err(ResourceError::FileDirectoryCollision("nested".to_owned()))
);
assert!(registry.is_empty());
}
}
+15 -1
View File
@@ -7,6 +7,7 @@ use crate::{
}, },
class::PyClassDef, class::PyClassDef,
function::{FuncArgs, IntoPyNativeFn, PyNativeFn, SigArg}, function::{FuncArgs, IntoPyNativeFn, PyNativeFn, SigArg},
types::PyTypeFlags,
}; };
bitflags::bitflags! { bitflags::bitflags! {
@@ -294,8 +295,21 @@ impl PyMethodDef {
class: &'static Py<PyType>, class: &'static Py<PyType>,
) -> PyRef<PyNativeFunction> { ) -> PyRef<PyNativeFunction> {
debug_assert!(self.flags.contains(PyMethodFlags::STATIC)); debug_assert!(self.flags.contains(PyMethodFlags::STATIC));
// Match PyCFunction_NewEx(meth, type, type_module(type)): snapshot
// module metadata here so later mutation/deletion remains observable.
let module = if class.slots.flags.has_feature(PyTypeFlags::HEAPTYPE) {
class.attributes.get(ctx.intern_str("__module__"))
} else {
let name = class.slot_name();
let module = name
.rsplit_once('.')
.map_or("builtins", |(module, _)| module);
Some(ctx.intern_str(module).to_owned().into())
};
// m_self is the type; STATIC skips prepending it on call. // m_self is the type; STATIC skips prepending it on call.
self.build_bound_function(ctx, class.to_owned().into()) let function = self.build_bound_function(ctx, class.to_owned().into());
drop(function.module.store(module));
function
} }
/// Concatenate method groups. A pending body is copied from `docs`, then cleared. /// Concatenate method groups. A pending body is copied from `docs`, then cleared.
+6 -1
View File
@@ -133,7 +133,12 @@ pub(crate) fn init_importlib_base(vm: &mut VirtualMachine) -> PyResult<PyObjectR
let bootstrap = import_frozen(vm, "_frozen_importlib")?; let bootstrap = import_frozen(vm, "_frozen_importlib")?;
let install = bootstrap.get_attr("_install", vm)?; let install = bootstrap.get_attr("_install", vm)?;
let imp = import_builtin(vm, "_imp")?; let imp = import_builtin(vm, "_imp")?;
install.call((vm.sys_module.clone(), imp), vm)?; install.call((vm.sys_module.clone(), imp.clone()), vm)?;
let reader = imp.get_attr("_get_frozen_resource_reader", vm)?;
let reader = crate::builtins::PyStaticMethod::new(reader).into_ref(&vm.ctx);
bootstrap
.get_attr("FrozenImporter", vm)?
.set_attr("get_resource_reader", reader, vm)?;
Ok(bootstrap) Ok(bootstrap)
})?; })?;
vm.import_func = vm.builtins.get_attr(identifier!(vm, __import__), vm)?; vm.import_func = vm.builtins.get_attr(identifier!(vm, __import__), vm)?;
+1
View File
@@ -68,6 +68,7 @@ mod exception_group;
pub mod exceptions; pub mod exceptions;
pub mod format; pub mod format;
pub mod frame; pub mod frame;
pub mod frozen_resources;
pub mod function; pub mod function;
pub mod getpath; pub mod getpath;
pub mod import; pub mod import;
+3 -1
View File
@@ -7,6 +7,8 @@ pub(crate) use _imp::module_def;
pub(super) use crate::vm::resolve_frozen_alias; pub(super) use crate::vm::resolve_frozen_alias;
mod resources;
#[cfg(feature = "threading")] #[cfg(feature = "threading")]
#[pymodule(sub, name = "_imp")] #[pymodule(sub, name = "_imp")]
mod lock { mod lock {
@@ -194,7 +196,7 @@ fn find_frozen(name: &str, vm: &VirtualMachine) -> Result<FrozenModule, FrozenEr
Ok(frozen) Ok(frozen)
} }
#[pymodule(with(lock))] #[pymodule(with(lock, resources::resources))]
mod _imp { mod _imp {
use crate::{ use crate::{
AsObject, PyObjectRef, PyPayload, PyRef, PyRefExact, PyResult, VirtualMachine, AsObject, PyObjectRef, PyPayload, PyRef, PyRefExact, PyResult, VirtualMachine,
+275
View File
@@ -0,0 +1,275 @@
//! Read-only resource protocols for explicitly registered frozen assets.
#[pymodule(sub, name = "_imp")]
pub(super) mod resources {
use crate::{
AsObject, Py, PyObjectRef, PyPayload, PyRef, PyResult, VirtualMachine,
builtins::{PyBaseExceptionRef, PyStrRef},
frozen_resources::{ResourceError, ResourceView},
function::{FsPath, FuncArgs, OptionalArg, PosArgs},
protocol::PyNumberMethods,
types::AsNumber,
};
fn resource_error(error: ResourceError, vm: &VirtualMachine) -> PyBaseExceptionRef {
let exception = match &error {
ResourceError::InvalidPath(_) => return vm.new_value_error(error.to_string()),
ResourceError::NotFound(_) => vm.ctx.exceptions.file_not_found_error,
ResourceError::IsDirectory(_) => vm.ctx.exceptions.is_a_directory_error,
ResourceError::NotDirectory(_) => vm.ctx.exceptions.not_a_directory_error,
_ => return vm.new_runtime_error(error.to_string()),
};
vm.new_os_subtype_error(exception.to_owned(), None, error.to_string())
.upcast()
}
fn join_resource(
view: &ResourceView,
path: PyObjectRef,
vm: &VirtualMachine,
) -> PyResult<ResourceView> {
let path = FsPath::try_from_path_like(path, false, vm)?;
let FsPath::Str(path) = path else {
return Err(vm.new_type_error("resource paths must be str or os.PathLike[str]"));
};
let path = path
.to_str()
.ok_or_else(|| vm.new_value_error("resource paths must not contain surrogates"))?;
view.join(path).map_err(|error| resource_error(error, vm))
}
fn open_binary(view: &ResourceView, vm: &VirtualMachine) -> PyResult {
let bytes = view.bytes().map_err(|error| resource_error(error, vm))?;
// Import lazily: installing the importer hook must not bootstrap _io.
let io = vm.import("_io", 0)?;
let buffer = io
.get_attr("BytesIO", vm)?
.call((vm.ctx.new_bytes(bytes.to_vec()),), vm)?;
io.get_attr("BufferedReader", vm)?.call((buffer,), vm)
}
#[derive(FromArgs)]
struct OpenArgs {
#[pyarg(any, optional, py_default = "'r'")]
mode: OptionalArg<PyStrRef>,
#[pyarg(any, optional)]
encoding: Option<PyObjectRef>,
#[pyarg(any, optional)]
errors: Option<PyObjectRef>,
#[pyarg(any, optional)]
newline: Option<PyObjectRef>,
}
#[derive(FromArgs)]
struct ReadTextArgs {
#[pyarg(any, optional)]
encoding: Option<PyObjectRef>,
#[pyarg(any, optional)]
errors: Option<PyObjectRef>,
#[pyarg(named, optional)]
newline: Option<PyObjectRef>,
}
#[pyattr]
#[pyclass(name = "_FrozenResourceReader", module = "_imp")]
#[derive(Debug, PyPayload)]
struct FrozenResourceReader {
view: ResourceView,
}
#[pyclass(flags(DISALLOW_INSTANTIATION))]
impl FrozenResourceReader {
#[pymethod]
fn open_resource(zelf: &Py<Self>, resource: PyObjectRef, vm: &VirtualMachine) -> PyResult {
open_binary(&join_resource(&zelf.view, resource, vm)?, vm)
}
#[pymethod]
fn resource_path(_zelf: &Py<Self>, resource: PyObjectRef, vm: &VirtualMachine) -> PyResult {
// Embedded assets have no filesystem path. importlib.resources.as_file
// performs any requested temporary extraction through Traversable.
Err(vm.new_exception(
vm.ctx.exceptions.file_not_found_error.to_owned(),
vec![resource],
))
}
#[pymethod]
fn is_resource(
zelf: &Py<Self>,
resource: PyObjectRef,
vm: &VirtualMachine,
) -> PyResult<bool> {
Ok(join_resource(&zelf.view, resource, vm)?.is_file())
}
#[pymethod]
fn contents(zelf: &Py<Self>, vm: &VirtualMachine) -> PyResult {
let children = zelf
.view
.children()
.map_err(|error| resource_error(error, vm))?;
let names = children
.iter()
.map(|child| vm.ctx.new_str(child.name()).into())
.collect();
Ok(vm.ctx.new_tuple(names).as_object().get_iter(vm)?.into())
}
#[pymethod]
fn files(zelf: &Py<Self>, vm: &VirtualMachine) -> PyRef<FrozenTraversable> {
FrozenTraversable {
view: zelf.view.clone(),
}
.into_ref(&vm.ctx)
}
}
#[pyattr]
#[pyclass(name = "_FrozenTraversable", module = "_imp")]
#[derive(Debug, PyPayload)]
struct FrozenTraversable {
view: ResourceView,
}
#[pyclass(with(AsNumber), flags(DISALLOW_INSTANTIATION))]
impl FrozenTraversable {
#[pygetset]
fn name(zelf: &Py<Self>) -> String {
zelf.view.name().to_owned()
}
#[pymethod]
fn iterdir(zelf: &Py<Self>, vm: &VirtualMachine) -> PyResult {
let children = zelf
.view
.children()
.map_err(|error| resource_error(error, vm))?;
let children = children
.into_iter()
.map(|view| Self { view }.into_ref(&vm.ctx).into())
.collect();
Ok(vm.ctx.new_tuple(children).as_object().get_iter(vm)?.into())
}
#[pymethod]
fn is_dir(zelf: &Py<Self>) -> bool {
zelf.view.is_dir()
}
#[pymethod]
fn is_file(zelf: &Py<Self>) -> bool {
zelf.view.is_file()
}
#[pymethod]
fn joinpath(
zelf: &Py<Self>,
descendants: PosArgs<PyObjectRef>,
vm: &VirtualMachine,
) -> PyResult<PyRef<Self>> {
let mut view = zelf.view.clone();
for descendant in descendants {
view = join_resource(&view, descendant, vm)?;
}
Ok(Self { view }.into_ref(&vm.ctx))
}
#[pymethod]
fn open(zelf: &Py<Self>, args: OpenArgs, vm: &VirtualMachine) -> PyResult {
let mode = match &args.mode {
OptionalArg::Present(mode) => mode.as_bytes(),
OptionalArg::Missing => b"r",
};
match mode {
b"rb" => {
if args.encoding.is_some() || args.errors.is_some() || args.newline.is_some() {
return Err(vm.new_value_error(
"binary mode does not accept encoding, errors or newline",
));
}
open_binary(&zelf.view, vm)
}
b"r" => {
let buffer = open_binary(&zelf.view, vm)?;
let mut wrapper_args = FuncArgs::from(vec![buffer]);
for (name, value) in [
("encoding", args.encoding),
("errors", args.errors),
("newline", args.newline),
] {
if let Some(value) = value {
wrapper_args.kwargs.insert(name.into(), value);
}
}
vm.import("_io", 0)?
.get_attr("TextIOWrapper", vm)?
.call(wrapper_args, vm)
}
_ => Err(vm.new_value_error("frozen resources support only 'r' and 'rb' modes")),
}
}
#[pymethod]
fn read_bytes(zelf: &Py<Self>, vm: &VirtualMachine) -> PyResult {
let bytes = zelf
.view
.bytes()
.map_err(|error| resource_error(error, vm))?;
Ok(vm.ctx.new_bytes(bytes.to_vec()).into())
}
#[pymethod]
fn read_text(zelf: &Py<Self>, args: ReadTextArgs, vm: &VirtualMachine) -> PyResult {
let stream = Self::open(
zelf,
OpenArgs {
mode: OptionalArg::Missing,
encoding: args.encoding,
errors: args.errors,
newline: args.newline,
},
vm,
)?;
let result = vm.call_method(&stream, "read", ());
let close = vm.call_method(&stream, "close", ());
match (result, close) {
(Err(read_error), Err(close_error)) => {
close_error.set_context(Some(read_error));
Err(close_error)
}
(Err(error), Ok(_)) | (Ok(_), Err(error)) => Err(error),
(Ok(contents), Ok(_)) => Ok(contents),
}
}
}
impl AsNumber for FrozenTraversable {
fn as_number() -> &'static PyNumberMethods {
static AS_NUMBER: PyNumberMethods = PyNumberMethods {
true_divide: Some(|left, right, vm| {
let Some(path) = left.downcast_ref::<FrozenTraversable>() else {
return Ok(vm.ctx.not_implemented());
};
let view = join_resource(&path.view, right.to_owned(), vm)?;
Ok(FrozenTraversable { view }.into_ref(&vm.ctx).into())
}),
..PyNumberMethods::NOT_IMPLEMENTED
};
&AS_NUMBER
}
}
#[pyfunction]
fn _get_frozen_resource_reader(
fullname: PyStrRef,
vm: &VirtualMachine,
) -> Option<PyRef<FrozenResourceReader>> {
let name = fullname.to_str()?;
super::super::find_frozen(name, vm).ok()?;
// Resources belong to an explicit exact-fullname registration. Resolving
// an unbound alias to another name would bypass registration ownership.
let view = vm.state.frozen.resource_view(name)?;
Some(FrozenResourceReader { view }.into_ref(&vm.ctx))
}
}
+800
View File
@@ -0,0 +1,800 @@
//! Deterministic profiling driven by the VM's execution monitoring events.
// cspell:ignore pystart pythrow creturn
pub(crate) use _lsprof::module_def;
#[pymodule]
mod _lsprof {
use crate::{
AsObject, Py, PyObject, PyObjectRef, PyPayload, PyResult, VirtualMachine,
builtins::{
PyFloat, PyModule, PyStr, PyType,
builtin_func::{PyNativeFunction, PyNativeMethod},
descriptor::PyMethodDescriptor,
},
common::{lock::PyMutex, wtf8::Wtf8Buf},
convert::ToPyObject,
function::{FuncArgs, OptionalArg, PyMethodDef},
object::{Traverse, TraverseFn},
stdlib::sys::monitoring::MonitoringEvent,
types::{Constructor, Initializer, PyStructSequence},
vm::thread::{self, critical_section, lock::PyMutex as OperationMutex},
};
use std::collections::HashMap;
const TOOL_ID: i32 = 2;
const CALLBACKS: &[(MonitoringEvent, &str)] = &[
(MonitoringEvent::PyStart, "_pystart_callback"),
(MonitoringEvent::PyResume, "_pystart_callback"),
(MonitoringEvent::PyThrow, "_pythrow_callback"),
(MonitoringEvent::PyReturn, "_pyreturn_callback"),
(MonitoringEvent::PyYield, "_pyreturn_callback"),
(MonitoringEvent::PyUnwind, "_pyreturn_callback"),
(MonitoringEvent::Call, "_ccall_callback"),
(MonitoringEvent::CReturn, "_creturn_callback"),
(MonitoringEvent::CRaise, "_creturn_callback"),
];
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
enum Key {
Python(usize),
Native(usize),
}
#[derive(Clone, Debug, Default)]
struct Counts {
total: i128,
inline: i128,
calls: usize,
recursive: usize,
depth: isize,
}
impl Counts {
fn stop(&mut self, total: i128, inline: i128) {
self.depth -= 1;
if self.depth == 0 {
self.total += total;
} else {
self.recursive += 1;
}
self.inline += inline;
self.calls += 1;
}
}
#[derive(Clone, Debug)]
struct Entry {
code: PyObjectRef,
// Dynamically allocated native method definitions must outlive their keys.
owner: Option<PyObjectRef>,
counts: Counts,
callees: HashMap<Key, Counts>,
}
#[derive(Debug)]
struct Context {
id: u64,
key: Key,
start: i128,
subtime: i128,
}
#[derive(Debug, Default)]
struct State {
entries: HashMap<Key, Entry>,
stack: Vec<Context>,
epoch: u64,
next_context: u64,
missing: Option<PyObjectRef>,
timer: Option<PyObjectRef>,
unit: f64,
enabled: bool,
subcalls: bool,
builtins: bool,
in_timer: bool,
}
#[pyattr]
#[pyclass(name = "Profiler", module = "_lsprof", traverse = "manual")]
#[derive(Debug, PyPayload)]
struct Profiler {
operation: OperationMutex,
interpreter_id: i64,
state: PyMutex<State>,
}
impl Profiler {
// Operation exclusion is separate from payload borrowing. Keep this
// profiler interpreter-bound even though PyMutex itself is not.
fn with_operation<R>(&self, vm: &VirtualMachine, f: impl FnOnce() -> R) -> R {
assert_eq!(self.interpreter_id, vm.state.interpreter_id);
assert_eq!(
thread::try_with_current_vm(|current| current.state.interpreter_id),
Some(self.interpreter_id),
"profiler operation requires the current interpreter"
);
#[cfg(feature = "threading")]
{
let slot = thread::current_thread_slot()
.expect("profiler operation requires a thread slot");
assert_eq!(
slot.state.load(core::sync::atomic::Ordering::Acquire),
thread::ThreadState::Attached as i32,
"profiler operation requires an attached interpreter"
);
}
critical_section::with(vm, &self.operation, f)
}
}
// SAFETY: Each owned Python reference is visited exactly once. Contexts
// contain keys only; they do not own additional references to the entries.
unsafe impl Traverse for Profiler {
fn traverse(&self, tracer: &mut TraverseFn<'_>) {
{
let state = self.state.lock();
state.timer.traverse(tracer);
state.missing.traverse(tracer);
#[expect(
clippy::iter_over_hash_type,
reason = "GC tracing visits every owned reference regardless of entry order"
)]
for entry in state.entries.values() {
entry.code.traverse(tracer);
entry.owner.traverse(tracer);
}
}
}
fn clear(&mut self, out: &mut Vec<PyObjectRef>) {
let state = self.state.get_mut();
out.extend(state.timer.take());
#[expect(
clippy::iter_over_hash_type,
reason = "GC clearing transfers every owned reference; entry order is irrelevant"
)]
for (_, entry) in state.entries.drain() {
out.push(entry.code);
out.extend(entry.owner);
}
out.extend(state.missing.take());
state.stack.clear();
state.enabled = false;
state.in_timer = false;
}
}
#[derive(FromArgs)]
struct InitArgs {
#[pyarg(any, optional)]
timer: OptionalArg<PyObjectRef>,
#[pyarg(any, default = 0.0)]
timeunit: f64,
#[pyarg(any, default = true)]
subcalls: bool,
#[pyarg(any, default = true)]
builtins: bool,
}
#[derive(FromArgs)]
struct EnableArgs {
#[pyarg(any, default = true)]
subcalls: bool,
#[pyarg(any, default = true)]
builtins: bool,
}
impl Constructor for Profiler {
type Args = FuncArgs;
fn py_new(_cls: &Py<PyType>, _args: Self::Args, vm: &VirtualMachine) -> PyResult<Self> {
Ok(Self {
operation: OperationMutex::new(),
interpreter_id: vm.state.interpreter_id,
state: PyMutex::new(State::default()),
})
}
}
impl Initializer for Profiler {
type Args = InitArgs;
fn init(zelf: &Py<Self>, args: InitArgs, vm: &VirtualMachine) -> PyResult<()> {
let old_timer = {
let mut state = zelf.state.lock();
state.unit = args.timeunit;
state.subcalls = args.subcalls;
state.builtins = args.builtins;
core::mem::replace(&mut state.timer, args.timer.into_option())
};
drop(old_timer);
let monitoring = vm.sys_module.get_attr("monitoring", vm)?;
let missing = monitoring.get_attr("MISSING", vm);
let old_missing = {
let mut state = zelf.state.lock();
core::mem::replace(&mut state.missing, missing.as_ref().ok().cloned())
};
drop(old_missing);
drop(monitoring);
missing.map(|_| ())
}
}
// Declared before the callable snapshot so its finalizer still observes
// in_timer during unwinding, as it does on the ordinary call-return path.
struct TimerCallGuard<'a> {
profiler: &'a Profiler,
active: bool,
}
impl Drop for TimerCallGuard<'_> {
fn drop(&mut self) {
if self.active {
self.profiler.state.lock().in_timer = false;
}
}
}
impl Profiler {
fn timer(&self, vm: &VirtualMachine) -> i128 {
let mut reset = TimerCallGuard {
profiler: self,
active: false,
};
let timer = {
let mut state = self.state.lock();
let timer = state.timer.clone();
if timer.is_some() {
state.in_timer = true;
reset.active = true;
}
timer
};
let result = if let Some(timer) = timer {
let value = timer.call((), vm);
// Release our callable root at call return, before clearing the
// flag. A replaced timer's finalizer is still inside the call.
drop(timer);
drop(reset);
// Conversion and result destruction occur with the flag clear.
let unit = self.state.lock().unit;
value.and_then(|value| {
if unit > 0.0 {
value.try_into_value::<i64>(vm).map(i128::from)
} else if let Some(value) = value.downcast_ref::<PyFloat>() {
let nanos = (value.to_f64() * 1_000_000_000.0).floor();
if nanos.is_nan() {
Err(vm.new_value_error("Invalid value NaN (not a number)"))
} else if !(i64::MIN as f64..-(i64::MIN as f64)).contains(&nanos) {
Err(vm
.new_overflow_error("timestamp too large to convert to C PyTime_t"))
} else {
Ok(nanos as i64 as i128)
}
} else {
let seconds = value.try_into_value::<i64>(vm)?;
seconds
.checked_mul(1_000_000_000)
.map(i128::from)
.ok_or_else(|| {
vm.new_overflow_error(
"timestamp too large to convert to C PyTime_t",
)
})
}
})
} else {
super::super::time::profiler_time(vm).map(|nanos| nanos as i128)
};
match result {
Ok(time) => time,
Err(error) => {
let timer = self.state.lock().timer.clone();
let message =
timer
.as_ref()
.and_then(|timer| timer.repr(vm).ok())
.map(|timer| {
format!("Exception ignored while calling _lsprof timer {timer}")
});
drop(timer);
vm.run_unraisable(error, message, vm.ctx.none());
0
}
}
}
fn enter(
&self,
key: Key,
code: PyObjectRef,
owner: Option<PyObjectRef>,
vm: &VirtualMachine,
) {
// Unused input owners are dropped after the data guard, never in entry().
let mut incoming = Some(Entry {
code,
owner,
counts: Counts::default(),
callees: HashMap::new(),
});
let token = {
let mut state = self.state.lock();
if state.in_timer {
return;
}
state
.entries
.entry(key)
.or_insert_with(|| incoming.take().unwrap());
state.entries.get_mut(&key).unwrap().counts.depth += 1;
let caller = state
.subcalls
.then(|| state.stack.last().map(|ctx| ctx.key))
.flatten();
if let Some(caller) = caller {
state
.entries
.get_mut(&caller)
.unwrap()
.callees
.entry(key)
.or_default()
.depth += 1;
}
let id = state.next_context;
state.next_context = id
.checked_add(1)
.expect("profiler context identity exhausted");
state.stack.push(Context {
id,
key,
start: 0,
subtime: 0,
});
(state.epoch, id)
};
drop(incoming);
let start = self.timer(vm);
let mut state = self.state.lock();
if state.epoch == token.0
&& let Some(context) = state.stack.iter_mut().find(|context| context.id == token.1)
{
context.start = start;
}
}
fn leave(&self, key: Key, vm: &VirtualMachine) {
let token = {
let mut state = self.state.lock();
if state.in_timer {
return;
}
let Some(context) = state.stack.last() else {
return;
};
let token = (state.epoch, context.id);
if !state.entries.contains_key(&key) {
state.stack.pop();
return;
}
token
};
let now = self.timer(vm);
let mut state = self.state.lock();
// Clear or a reentrant return can retire the exact saved context.
if state.epoch != token.0 {
return;
}
let Some(index) = state.stack.iter().position(|context| context.id == token.1) else {
return;
};
let context = &state.stack[index];
let total = now - context.start;
let inline = total - context.subtime;
let caller = index.checked_sub(1).map(|index| state.stack[index].key);
if let Some(parent) = index.checked_sub(1).map(|index| &mut state.stack[index]) {
parent.subtime += total;
}
// Stop restores its saved previous context, even after reentrant entry.
// Retire any discarded suffix without fabricating completed calls.
state.stack.truncate(index);
state
.entries
.get_mut(&key)
.unwrap()
.counts
.stop(total, inline);
if state.subcalls
&& let Some(caller) = caller
&& let Some(counts) = state
.entries
.get_mut(&caller)
.and_then(|entry| entry.callees.get_mut(&key))
{
counts.stop(total, inline);
}
}
}
// Builtin keys use the method definition, never the transient bound method
// object or its receiver. This both aggregates calls and avoids retaining
// every receiver that the application has used while profiling.
enum NativeCall<'a> {
Function(&'a PyNativeFunction),
Descriptor(&'a Py<PyMethodDescriptor>, &'a PyObject),
}
impl NativeCall<'_> {
fn key(&self) -> Key {
let method = match self {
Self::Function(function) => function.value,
Self::Descriptor(descriptor, _) => descriptor.method,
};
Key::Native(method as *const PyMethodDef as usize)
}
fn owner(&self) -> Option<PyObjectRef> {
match self {
Self::Function(function) => function._method_def_owner.clone(),
Self::Descriptor(descriptor, _) => descriptor._method_def_owner.clone(),
}
}
fn label(&self, vm: &VirtualMachine) -> PyObjectRef {
let function = match self {
Self::Descriptor(descriptor, receiver) => {
let name = descriptor.method.name;
if let Some(attribute) = receiver.class().get_attr(vm.ctx.intern_str(name))
&& let Ok(label) = attribute.repr(vm)
{
return label.into();
}
return vm.ctx.new_str(format!("<built-in method {name}>")).into();
}
Self::Function(function) => function,
};
let name = function.value.name;
// CPython normalizes the raw m_self, including STATIC's hidden
// type receiver, rather than its call-argument accessor.
let receiver = function
.zelf
.as_deref()
.or(function.module_object.as_deref());
let module = function.module.load_owned();
let label = if let Some(receiver) = receiver {
if let Some(descriptor) = receiver.class().get_attr(vm.ctx.intern_str(name))
&& let Ok(label) = descriptor.repr(vm)
{
return label.into();
}
if let Some(module) = module
.as_ref()
.and_then(|module| module.downcast_ref::<PyStr>())
{
return qualified_label(module, name, true, vm);
}
format!("<built-in method {name}>")
} else {
let module_name = if let Some(module) = module
.as_ref()
.and_then(|module| module.downcast_ref::<PyModule>())
{
module
.dict()
.get_item_opt(identifier!(vm, __name__), vm)
.ok()
.flatten()
} else {
module
};
if let Some(module) = module_name
.as_ref()
.and_then(|module| module.downcast_ref::<PyStr>())
&& module.as_wtf8() != "builtins"
{
return qualified_label(module, name, false, vm);
}
format!("<{name}>")
};
vm.ctx.new_str(label).into()
}
}
fn qualified_label(
module: &PyStr,
name: &str,
builtin: bool,
vm: &VirtualMachine,
) -> PyObjectRef {
let mut label = Wtf8Buf::from(if builtin { "<built-in method " } else { "<" });
label.push_wtf8(module.as_wtf8());
label.push_str(".");
label.push_str(name);
label.push_str(">");
vm.ctx.new_str(label).into()
}
fn native_call<'a>(
callable: &'a PyObject,
arg: &'a PyObject,
missing: Option<&PyObjectRef>,
) -> Option<NativeCall<'a>> {
if let Some(descriptor) = callable.downcast_ref::<PyMethodDescriptor>() {
if missing.is_some_and(|missing| arg.is(missing))
|| !arg.fast_isinstance(descriptor.common.typ)
{
return None;
}
return Some(NativeCall::Descriptor(descriptor, arg));
}
let function = if let Some(function) = callable.downcast_ref::<PyNativeFunction>() {
&**function
} else {
let method = callable.downcast_ref::<PyNativeMethod>()?;
&method.func
};
Some(NativeCall::Function(function))
}
#[pyclass(with(Constructor, Initializer), flags(BASETYPE))]
impl Profiler {
#[pymethod]
fn enable(zelf: &Py<Self>, args: EnableArgs, vm: &VirtualMachine) -> PyResult<()> {
zelf.with_operation(vm, || {
{
let mut state = zelf.state.lock();
state.subcalls = args.subcalls;
state.builtins = args.builtins;
}
let monitoring = vm.sys_module.get_attr("monitoring", vm)?;
vm.call_method(&monitoring, "use_tool_id", (TOOL_ID, "cProfile"))?;
let mut events = 0;
for &(event, method) in CALLBACKS {
let callback = zelf.as_object().get_attr(method, vm)?;
vm.call_method(
&monitoring,
"register_callback",
(TOOL_ID, event.mask(), callback),
)?;
events |= event.mask();
}
vm.call_method(&monitoring, "set_events", (TOOL_ID, events))?;
drop(monitoring);
zelf.state.lock().enabled = true;
Ok(())
})
}
#[pymethod]
fn disable(zelf: &Py<Self>, vm: &VirtualMachine) -> PyResult<()> {
zelf.with_operation(vm, || {
let (in_timer, enabled) = {
let state = zelf.state.lock();
(state.in_timer, state.enabled)
};
if in_timer {
return Err(vm.new_runtime_error("cannot disable profiler in external timer"));
}
if !enabled {
return Ok(());
}
let monitoring = vm.sys_module.get_attr("monitoring", vm)?;
for &(event, _) in CALLBACKS {
vm.call_method(
&monitoring,
"register_callback",
(TOOL_ID, event.mask(), vm.ctx.none()),
)?;
}
vm.call_method(&monitoring, "set_events", (TOOL_ID, 0))?;
vm.call_method(&monitoring, "free_tool_id", (TOOL_ID,))?;
drop(monitoring);
zelf.state.lock().enabled = false;
loop {
let key = zelf.state.lock().stack.last().map(|ctx| ctx.key);
let Some(key) = key else {
break;
};
zelf.leave(key, vm);
}
Ok(())
})
}
#[pymethod]
fn clear(zelf: &Py<Self>, vm: &VirtualMachine) -> PyResult<()> {
zelf.with_operation(vm, || {
if zelf.state.lock().in_timer {
return Err(vm.new_runtime_error("cannot clear profiler in external timer"));
}
let entries = {
let mut state = zelf.state.lock();
state.stack.clear();
state.epoch = state
.epoch
.checked_add(1)
.expect("profiler epoch exhausted");
core::mem::take(&mut state.entries)
};
drop(entries);
Ok(())
})
}
#[pymethod]
fn getstats(zelf: &Py<Self>, vm: &VirtualMachine) -> Vec<PyObjectRef> {
zelf.with_operation(vm, || {
let (entries, factor) = {
let state = zelf.state.lock();
let factor = if state.timer.is_some() && state.unit != 0.0 {
state.unit
} else {
1e-9
};
(state.entries.clone(), factor)
};
entries
.values()
.filter(|entry| entry.counts.calls != 0)
.map(|entry| {
let calls = if entry.callees.is_empty() {
vm.ctx.none()
} else {
vm.ctx
.new_list(
entry
.callees
.iter()
.map(|(key, counts)| {
SubentryData {
code: entries[key].code.clone(),
callcount: counts.calls,
reccallcount: counts.recursive,
totaltime: counts.total as f64 * factor,
inlinetime: counts.inline as f64 * factor,
}
.to_pyobject(vm)
})
.collect(),
)
.into()
};
EntryData {
code: entry.code.clone(),
callcount: entry.counts.calls,
reccallcount: entry.counts.recursive,
totaltime: entry.counts.total as f64 * factor,
inlinetime: entry.counts.inline as f64 * factor,
calls,
}
.to_pyobject(vm)
})
.collect()
})
}
#[pymethod]
fn _pystart_callback(
zelf: &Py<Self>,
code: PyObjectRef,
_offset: PyObjectRef,
vm: &VirtualMachine,
) {
zelf.with_operation(vm, || {
zelf.enter(Key::Python(code.get_id()), code, None, vm);
})
}
#[pymethod]
fn _pythrow_callback(
zelf: &Py<Self>,
code: PyObjectRef,
_offset: PyObjectRef,
_exception: PyObjectRef,
vm: &VirtualMachine,
) {
zelf.with_operation(vm, || {
zelf.enter(Key::Python(code.get_id()), code, None, vm);
})
}
#[pymethod]
fn _pyreturn_callback(
zelf: &Py<Self>,
code: PyObjectRef,
_offset: PyObjectRef,
_retval: PyObjectRef,
vm: &VirtualMachine,
) {
zelf.with_operation(vm, || {
zelf.leave(Key::Python(code.get_id()), vm);
})
}
#[pymethod]
fn _ccall_callback(
zelf: &Py<Self>,
_code: PyObjectRef,
_offset: PyObjectRef,
callable: PyObjectRef,
arg: PyObjectRef,
vm: &VirtualMachine,
) {
zelf.with_operation(vm, || {
let (builtins, missing) = {
let state = zelf.state.lock();
(state.builtins, state.missing.clone())
};
if builtins && let Some(call) = native_call(&callable, &arg, missing.as_ref()) {
if zelf.state.lock().in_timer {
return;
}
let key = call.key();
let existing = zelf
.state
.lock()
.entries
.get(&key)
.map(|entry| entry.code.clone());
let code = existing.unwrap_or_else(|| call.label(vm));
zelf.enter(key, code, call.owner(), vm);
}
})
}
#[pymethod]
fn _creturn_callback(
zelf: &Py<Self>,
_code: PyObjectRef,
_offset: PyObjectRef,
callable: PyObjectRef,
arg: PyObjectRef,
vm: &VirtualMachine,
) {
zelf.with_operation(vm, || {
let (builtins, missing) = {
let state = zelf.state.lock();
(state.builtins, state.missing.clone())
};
if builtins && let Some(call) = native_call(&callable, &arg, missing.as_ref()) {
zelf.leave(call.key(), vm);
}
})
}
}
#[pystruct_sequence_data]
struct EntryData {
code: PyObjectRef,
callcount: usize,
reccallcount: usize,
totaltime: f64,
inlinetime: f64,
calls: PyObjectRef,
}
#[pyattr]
#[pystruct_sequence(name = "profiler_entry", module = "_lsprof", data = "EntryData")]
struct ProfilerEntry;
#[pyclass(with(PyStructSequence))]
impl ProfilerEntry {}
#[pystruct_sequence_data]
struct SubentryData {
code: PyObjectRef,
callcount: usize,
reccallcount: usize,
totaltime: f64,
inlinetime: f64,
}
#[pyattr]
#[pystruct_sequence(name = "profiler_subentry", module = "_lsprof", data = "SubentryData")]
struct ProfilerSubentry;
#[pyclass(with(PyStructSequence))]
impl ProfilerSubentry {}
}
+2
View File
@@ -7,6 +7,7 @@ pub(crate) use _collections::ordered_dict::ordered_dict::PyOrderedDict;
mod _functools; mod _functools;
mod _imp; mod _imp;
pub mod _io; pub mod _io;
mod _lsprof;
mod _operator; mod _operator;
mod _sre; mod _sre;
mod _stat; mod _stat;
@@ -137,6 +138,7 @@ pub fn builtin_module_defs(ctx: &Context) -> Vec<&'static PyModuleDef> {
msvcrt::module_def(ctx), msvcrt::module_def(ctx),
#[cfg(all(feature = "host_env", windows))] #[cfg(all(feature = "host_env", windows))]
nt::module_def(ctx), nt::module_def(ctx),
_lsprof::module_def(ctx),
_operator::module_def(ctx), _operator::module_def(ctx),
#[cfg(all(feature = "host_env", any(unix, target_os = "wasi")))] #[cfg(all(feature = "host_env", any(unix, target_os = "wasi")))]
posix::module_def(ctx), posix::module_def(ctx),
+2 -1
View File
@@ -7,6 +7,7 @@
pub use decl::time; pub use decl::time;
pub(crate) use decl::module_def; pub(crate) use decl::module_def;
pub(crate) use decl::perf_counter_ns as profiler_time;
#[pymodule(name = "time", with(#[cfg(any(unix, windows))] platform))] #[pymodule(name = "time", with(#[cfg(any(unix, windows))] platform))]
mod decl { mod decl {
@@ -262,7 +263,7 @@ mod decl {
} }
#[pyfunction] #[pyfunction]
fn perf_counter_ns(vm: &VirtualMachine) -> PyResult<u128> { pub(crate) fn perf_counter_ns(vm: &VirtualMachine) -> PyResult<u128> {
Ok(get_perf_time(vm)?.as_nanos()) Ok(get_perf_time(vm)?.as_nanos())
} }
+36 -10
View File
@@ -7,7 +7,14 @@ use super::{
thread, thread,
}; };
use crate::{ use crate::{
PyResult, builtins, common::rc::PyRc, frozen::FrozenModule, getpath, py_freeze, stdlib::atexit, PyResult, builtins,
common::rc::PyRc,
frozen::FrozenModule,
frozen_resources::{
FrozenModuleRegistry, FrozenRegistration, FrozenResourceBundle, ResourceError,
},
getpath, py_freeze,
stdlib::atexit,
vm::PyBaseExceptionRef, vm::PyBaseExceptionRef,
}; };
use alloc::collections::BTreeMap; use alloc::collections::BTreeMap;
@@ -37,7 +44,7 @@ pub struct InterpreterBuilder {
settings: Settings, settings: Settings,
pub ctx: PyRc<Context>, pub ctx: PyRc<Context>,
module_defs: Vec<&'static builtins::PyModuleDef>, module_defs: Vec<&'static builtins::PyModuleDef>,
frozen_modules: Vec<(&'static str, FrozenModule)>, frozen_modules: Vec<FrozenRegistration>,
init_hooks: Vec<InitFunc>, init_hooks: Vec<InitFunc>,
} }
@@ -46,7 +53,7 @@ struct InitializeVmOpts<'a> {
settings: Settings, settings: Settings,
ctx: PyRc<Context>, ctx: PyRc<Context>,
module_defs: Vec<&'static builtins::PyModuleDef>, module_defs: Vec<&'static builtins::PyModuleDef>,
frozen_modules: Vec<(&'static str, FrozenModule)>, frozen_modules: Vec<FrozenRegistration>,
init_hooks: Vec<InitFunc>, init_hooks: Vec<InitFunc>,
is_main: bool, is_main: bool,
whence: InterpreterWhence, whence: InterpreterWhence,
@@ -122,12 +129,10 @@ where
other => other, other => other,
} as usize); } as usize);
let mut frozen: std::collections::HashMap< let mut frozen: FrozenModuleRegistry = core_frozen_inits().collect();
&'static str, for registration in frozen_modules {
FrozenModule, frozen.apply_registration(registration);
rapidhash::quality::RandomState, }
> = core_frozen_inits().collect();
frozen.extend(frozen_modules);
(config, all_module_defs, frozen, int_max_str_digits) (config, all_module_defs, frozen, int_max_str_digits)
}; };
@@ -368,6 +373,8 @@ impl InterpreterBuilder {
/// ///
/// Frozen modules are Python modules compiled into the binary. /// Frozen modules are Python modules compiled into the binary.
/// This method accepts any iterator of (name, FrozenModule) pairs. /// This method accepts any iterator of (name, FrozenModule) pairs.
/// It registers code only. To include embedded package data, use
/// [`Self::add_frozen_modules_with_resources`] with the matching resource bundle.
/// ///
/// # Example /// # Example
/// ``` /// ```
@@ -382,10 +389,29 @@ impl InterpreterBuilder {
where where
I: IntoIterator<Item = (&'static str, FrozenModule)>, I: IntoIterator<Item = (&'static str, FrozenModule)>,
{ {
self.frozen_modules.extend(frozen); self.frozen_modules
.push(FrozenRegistration::code_only(frozen));
self self
} }
/// Add frozen code and resources as one ownership-preserving registration.
///
/// Every resource owner must be a package in this registration. Replacing a
/// package or module in a later registration never inherits its old resources,
/// even if the replacement contains identical bytecode.
pub fn add_frozen_modules_with_resources<I>(
mut self,
frozen: I,
resources: &'static [FrozenResourceBundle],
) -> Result<Self, ResourceError>
where
I: IntoIterator<Item = (&'static str, FrozenModule)>,
{
self.frozen_modules
.push(FrozenRegistration::with_resources(frozen, resources)?);
Ok(self)
}
/// Build the interpreter. /// Build the interpreter.
/// ///
/// This consumes the configuration and returns a fully initialized Interpreter. /// This consumes the configuration and returns a fully initialized Interpreter.
+6 -4
View File
@@ -41,7 +41,7 @@ use crate::{
convert::ToPyObject, convert::ToPyObject,
exceptions::types::{PyBaseException, PyMemoryError}, exceptions::types::{PyBaseException, PyMemoryError},
frame::{ExecutionResult, FrameObject, FrameObjectRef}, frame::{ExecutionResult, FrameObject, FrameObjectRef},
frozen::FrozenModule, frozen_resources::FrozenModuleRegistry,
function::{ArgMapping, FuncArgs, PySetterValue}, function::{ArgMapping, FuncArgs, PySetterValue},
import, import,
protocol::{PyIterIter, PyIterReturn}, protocol::{PyIterIter, PyIterReturn},
@@ -59,8 +59,10 @@ use core::{
sync::atomic::{AtomicBool, AtomicI64, AtomicU64, AtomicUsize, Ordering}, sync::atomic::{AtomicBool, AtomicI64, AtomicU64, AtomicUsize, Ordering},
}; };
use crossbeam_utils::atomic::AtomicCell; use crossbeam_utils::atomic::AtomicCell;
#[cfg(feature = "threading")]
use std::collections::HashMap;
use std::{ use std::{
collections::{HashMap, HashSet}, collections::HashSet,
ffi::{OsStr, OsString}, ffi::{OsStr, OsString},
}; };
@@ -870,7 +872,7 @@ pub struct PyGlobalState {
/// `_interactive_src_count`: numbers the code filenames of interactive inputs. /// `_interactive_src_count`: numbers the code filenames of interactive inputs.
pub(crate) interactive_src_count: AtomicUsize, pub(crate) interactive_src_count: AtomicUsize,
pub module_defs: BTreeMap<&'static str, &'static builtins::PyModuleDef>, pub module_defs: BTreeMap<&'static str, &'static builtins::PyModuleDef>,
pub frozen: HashMap<&'static str, FrozenModule, rapidhash::quality::RandomState>, pub frozen: FrozenModuleRegistry,
pub stacksize: AtomicCell<usize>, pub stacksize: AtomicCell<usize>,
pub thread_count: AtomicCell<usize>, pub thread_count: AtomicCell<usize>,
/// Registered `atexit` callbacks, newest first. Shared ownership so /// Registered `atexit` callbacks, newest first. Shared ownership so
@@ -1424,7 +1426,7 @@ impl VirtualMachine {
}); });
let guide_message = if cfg!(feature = "freeze-stdlib") { let guide_message = if cfg!(feature = "freeze-stdlib") {
"`rustpython_pylib` may not be set while using `freeze-stdlib` feature. Try using `rustpython::InterpreterBuilder::init_stdlib` or manually call `builder.add_frozen_modules(rustpython_pylib::FROZEN_STDLIB)` in `rustpython_vm::Interpreter::builder()`." "`rustpython_pylib` may not be set while using `freeze-stdlib` feature. Try using `rustpython::InterpreterBuilder::init_stdlib` or manually call `builder.add_frozen_modules_with_resources(rustpython_pylib::FROZEN_STDLIB, rustpython_pylib::FROZEN_STDLIB_RESOURCES)` in `rustpython_vm::Interpreter::builder()`."
} else if !env_set { } else if !env_set {
"Neither RUSTPYTHONPATH nor PYTHONPATH is set. Try setting one of them to the stdlib directory." "Neither RUSTPYTHONPATH nor PYTHONPATH is set. Try setting one of them to the stdlib directory."
} else if path_contains_env { } else if path_contains_env {
@@ -12,10 +12,6 @@ use super::lock::PyMutex;
use crate::VirtualMachine; use crate::VirtualMachine;
/// `Py_BEGIN_CRITICAL_SECTION_MUTEX(m)` ... `Py_END_CRITICAL_SECTION()` /// `Py_BEGIN_CRITICAL_SECTION_MUTEX(m)` ... `Py_END_CRITICAL_SECTION()`
#[cfg_attr(
not(all(test, feature = "threading")),
expect(dead_code, reason = "Removed when the profiler consumer is wired")
)]
pub(crate) fn with<R>(vm: &VirtualMachine, m: &PyMutex, f: impl FnOnce() -> R) -> R { pub(crate) fn with<R>(vm: &VirtualMachine, m: &PyMutex, f: impl FnOnce() -> R) -> R {
#[cfg(feature = "threading")] #[cfg(feature = "threading")]
{ {
-4
View File
@@ -23,10 +23,6 @@ pub(crate) struct PyMutex {
} }
impl PyMutex { impl PyMutex {
#[cfg_attr(
not(all(test, feature = "threading")),
expect(dead_code, reason = "Removed when the profiler consumer is wired")
)]
pub(crate) const fn new() -> Self { pub(crate) const fn new() -> Self {
Self { Self {
bits: AtomicU8::new(0), bits: AtomicU8::new(0),
+5 -1
View File
@@ -83,7 +83,11 @@ impl StoredVirtualMachine {
let defs = rustpython_stdlib::stdlib_module_defs(&builder.ctx); let defs = rustpython_stdlib::stdlib_module_defs(&builder.ctx);
builder = builder builder = builder
.add_native_modules(&defs) .add_native_modules(&defs)
.add_frozen_modules(rustpython_pylib::FROZEN_STDLIB); .add_frozen_modules_with_resources(
rustpython_pylib::FROZEN_STDLIB,
rustpython_pylib::FROZEN_STDLIB_RESOURCES,
)
.expect("validated embedded standard-library resources");
} }
// Browser rustls `_ssl` overrides the rustls-free stdlib `_ssl`. // Browser rustls `_ssl` overrides the rustls-free stdlib `_ssl`.
+5 -1
View File
@@ -27,7 +27,11 @@ fn interpreter_with_config() {
fn interpreter_with_vm() { fn interpreter_with_vm() {
let interpreter = rustpython_vm::Interpreter::builder(Default::default()) let interpreter = rustpython_vm::Interpreter::builder(Default::default())
.add_frozen_modules(rustpython_pylib::FROZEN_STDLIB) .add_frozen_modules_with_resources(
rustpython_pylib::FROZEN_STDLIB,
rustpython_pylib::FROZEN_STDLIB_RESOURCES,
)
.expect("validated embedded standard-library resources")
.build(); .build();
// Use interpreter.enter to reuse the same interpreter later // Use interpreter.enter to reuse the same interpreter later
interpreter.run(|vm| run("rustpython_vm::Interpreter::builder", vm)); interpreter.run(|vm| run("rustpython_vm::Interpreter::builder", vm));
+5 -2
View File
@@ -5,7 +5,6 @@
use std::env; use std::env;
use rustls::crypto::ring; use rustls::crypto::ring;
use rustpython_pylib::FROZEN_STDLIB;
use rustpython_stdlib::{ssl::providers::CryptoExt, stdlib_module_defs}; use rustpython_stdlib::{ssl::providers::CryptoExt, stdlib_module_defs};
use rustpython_vm::Interpreter; use rustpython_vm::Interpreter;
@@ -56,7 +55,11 @@ fn main() {
let defs = stdlib_module_defs(&builder.ctx); let defs = stdlib_module_defs(&builder.ctx);
let result = builder let result = builder
.add_native_modules(&defs) .add_native_modules(&defs)
.add_frozen_modules(FROZEN_STDLIB) .add_frozen_modules_with_resources(
rustpython_pylib::FROZEN_STDLIB,
rustpython_pylib::FROZEN_STDLIB_RESOURCES,
)
.expect("validated embedded standard-library resources")
.build() .build()
.run(|vm| { .run(|vm| {
let scope = vm.new_scope_with_builtins(); let scope = vm.new_scope_with_builtins();
+5 -1
View File
@@ -20,7 +20,11 @@ impl InterpreterBuilderExt for InterpreterBuilder {
cfg_select! { cfg_select! {
feature = "freeze-stdlib" => builder feature = "freeze-stdlib" => builder
.add_frozen_modules(rustpython_pylib::FROZEN_STDLIB) .add_frozen_modules_with_resources(
rustpython_pylib::FROZEN_STDLIB,
rustpython_pylib::FROZEN_STDLIB_RESOURCES,
)
.expect("validated embedded standard-library resources")
.init_hook(set_frozen_stdlib_dir), .init_hook(set_frozen_stdlib_dir),
_ => builder.init_hook(setup_dynamic_stdlib), _ => builder.init_hook(setup_dynamic_stdlib),
} }