mirror of
https://github.com/RustPython/RustPython.git
synced 2026-10-28 10:44:36 +08:00
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:
1 parent
6b3f29f055
commit
f0b1e82652
80 files changed
+23512
-215
No files matched your search
@@ -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
@@ -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
@@ -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]
|
||||||
|
|||||||
Vendored
+29
-18
@@ -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:
|
||||||
|
|||||||
Vendored
+13
@@ -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()
|
||||||
Vendored
+9
-1
@@ -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)
|
||||||
|
|||||||
Vendored
+13
@@ -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")
|
||||||
Vendored
+23
@@ -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",
|
||||||
|
)
|
||||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 92 KiB |
Vendored
+22
@@ -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
+1528
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"
|
||||||
|
>×</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">≥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"><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">≥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"><-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
@@ -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)
|
||||||
+1555
File diff suppressed because it is too large.
Load diff
+738
@@ -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 = '▶'; // Right arrow
|
||||||
|
} else {
|
||||||
|
if (!panel.dataset.populated) {
|
||||||
|
populateBytecodePanel(panel, button);
|
||||||
|
}
|
||||||
|
panel.classList.add('expanded');
|
||||||
|
wrapper.classList.add('expanded');
|
||||||
|
button.classList.add('expanded');
|
||||||
|
button.innerHTML = '▼'; // 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);
|
||||||
@@ -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">📄</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">📊</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">⏱</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">⚡</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">⚠</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">💥</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
@@ -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
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
@@ -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
|
||||||
@@ -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
@@ -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
@@ -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
|
||||||
Vendored
+292
@@ -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
|
||||||
Vendored
+57
@@ -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)
|
||||||
+1006
File diff suppressed because it is too large.
Load diff
+1145
File diff suppressed because it is too large.
Load diff
+268
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||||
|
)
|
||||||
Vendored
+665
@@ -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
@@ -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
@@ -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)
|
||||||
Vendored
+220
@@ -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()
|
||||||
Vendored
+6
@@ -0,0 +1,6 @@
|
|||||||
|
"""Run the tracing profiler from the command line."""
|
||||||
|
|
||||||
|
from profiling.tracing import main
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
Vendored
+32
@@ -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)
|
||||||
Vendored
+66
-3
@@ -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
|
||||||
|
|||||||
Vendored
+163
-148
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
Vendored
+5
-1
@@ -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
|
||||||
|
|
||||||
|
|||||||
Vendored
+5
@@ -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)
|
||||||
Vendored
+4
@@ -0,0 +1,4 @@
|
|||||||
|
from . import load_tests
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
unittest.main()
|
||||||
+862
@@ -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
|
||||||
@@ -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
@@ -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()
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -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"));
|
||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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'")
|
||||||
|
|||||||
@@ -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 ®istration.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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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)?;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
|||||||
@@ -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())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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")]
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|||||||
@@ -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,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
@@ -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),
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user