runmap 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- runmap/__init__.py +1 -0
- runmap/__main__.py +145 -0
- runmap/diff.py +84 -0
- runmap/exporter.py +44 -0
- runmap/models.py +33 -0
- runmap/renderer.py +89 -0
- runmap/sampler.py +90 -0
- runmap/tracer.py +82 -0
- runmap/tree.py +93 -0
- runmap-0.1.0.dist-info/METADATA +68 -0
- runmap-0.1.0.dist-info/RECORD +14 -0
- runmap-0.1.0.dist-info/WHEEL +4 -0
- runmap-0.1.0.dist-info/entry_points.txt +2 -0
- runmap-0.1.0.dist-info/licenses/LICENSE +21 -0
runmap/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
runmap/__main__.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import runpy
|
|
5
|
+
import sys
|
|
6
|
+
import traceback as tb
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from runmap import __version__
|
|
10
|
+
from runmap import exporter, diff as diff_mod
|
|
11
|
+
from runmap.tracer import Tracer
|
|
12
|
+
from runmap.tree import build
|
|
13
|
+
from runmap.renderer import render
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _run_script(script: str, args: list[str]) -> None:
|
|
17
|
+
"""Execute target script in its own namespace, replacing sys.argv"""
|
|
18
|
+
script_path = Path(script).resolve()
|
|
19
|
+
if not script_path.exists():
|
|
20
|
+
sys.exit(f"runmap: file not found: {script}")
|
|
21
|
+
|
|
22
|
+
sys.argv = [str(script_path)] + args
|
|
23
|
+
sys.path.insert(0, str(script_path.parent))
|
|
24
|
+
runpy.run_path(str(script_path), run_name="__main__")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _build_run_parser() -> argparse.ArgumentParser:
|
|
28
|
+
p = argparse.ArgumentParser(
|
|
29
|
+
prog="runmap",
|
|
30
|
+
description="Visualize Python function call trees with timing",
|
|
31
|
+
)
|
|
32
|
+
p.add_argument("--version", action="version", version=f"runmap {__version__}")
|
|
33
|
+
p.add_argument("--no-color", action="store_true", help="Disable rich styling")
|
|
34
|
+
p.add_argument("--depth", type=int, default=None, metavar="INT",
|
|
35
|
+
help="Max tree depth (default: unlimited)")
|
|
36
|
+
p.add_argument("--min-ms", type=float, default=0.0, metavar="MS",
|
|
37
|
+
help="Hide nodes below this threshold in ms (default: 0)")
|
|
38
|
+
p.add_argument("--sample", action="store_true",
|
|
39
|
+
help="Use signal-based sampler instead of sys.settrace (Unix only)")
|
|
40
|
+
p.add_argument("--out", default=None, metavar="FILE",
|
|
41
|
+
help="Save .trace JSON to file")
|
|
42
|
+
p.add_argument("script", help="Path to script to profile")
|
|
43
|
+
p.add_argument("script_args", nargs=argparse.REMAINDER,
|
|
44
|
+
help="Arguments passed to the script")
|
|
45
|
+
return p
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _build_diff_parser() -> argparse.ArgumentParser:
|
|
49
|
+
p = argparse.ArgumentParser(
|
|
50
|
+
prog="runmap diff",
|
|
51
|
+
description="Compare two .trace files",
|
|
52
|
+
)
|
|
53
|
+
p.add_argument("a", metavar="A.trace")
|
|
54
|
+
p.add_argument("b", metavar="B.trace")
|
|
55
|
+
p.add_argument("--min-delta-ms", type=float, default=0.0, metavar="MS",
|
|
56
|
+
help="Hide entries with |delta| below this value (default: 0)")
|
|
57
|
+
p.add_argument("--no-color", action="store_true", help="Disable rich styling")
|
|
58
|
+
return p
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _cmd_run(args: argparse.Namespace) -> None:
|
|
62
|
+
if args.sample:
|
|
63
|
+
if sys.platform == "win32":
|
|
64
|
+
sys.exit(
|
|
65
|
+
"runmap: --sample is not available on Windows. "
|
|
66
|
+
"signal.setitimer is Unix-only. Use the default tracer mode."
|
|
67
|
+
)
|
|
68
|
+
from runmap.sampler import Sampler
|
|
69
|
+
sampler = Sampler()
|
|
70
|
+
sampler.start()
|
|
71
|
+
exc_info = None
|
|
72
|
+
try:
|
|
73
|
+
_run_script(args.script, args.script_args)
|
|
74
|
+
except SystemExit:
|
|
75
|
+
raise
|
|
76
|
+
except Exception:
|
|
77
|
+
exc_info = sys.exc_info()
|
|
78
|
+
finally:
|
|
79
|
+
sampler.stop()
|
|
80
|
+
records = sampler.to_records()
|
|
81
|
+
else:
|
|
82
|
+
tracer = Tracer()
|
|
83
|
+
tracer.start()
|
|
84
|
+
exc_info = None
|
|
85
|
+
try:
|
|
86
|
+
_run_script(args.script, args.script_args)
|
|
87
|
+
except SystemExit:
|
|
88
|
+
raise
|
|
89
|
+
except Exception:
|
|
90
|
+
exc_info = sys.exc_info()
|
|
91
|
+
finally:
|
|
92
|
+
tracer.stop()
|
|
93
|
+
records = tracer.records
|
|
94
|
+
|
|
95
|
+
root = build(records)
|
|
96
|
+
render(
|
|
97
|
+
root,
|
|
98
|
+
depth_limit=args.depth,
|
|
99
|
+
min_ms=args.min_ms,
|
|
100
|
+
no_color=args.no_color,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
if args.out:
|
|
104
|
+
exporter.save(records, args.out)
|
|
105
|
+
|
|
106
|
+
if exc_info is not None:
|
|
107
|
+
# Re-raise with original traceback after showing the partial tree
|
|
108
|
+
raise exc_info[1].with_traceback(exc_info[2])
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _cmd_diff(args: argparse.Namespace) -> None:
|
|
112
|
+
old_records = exporter.load(args.a)
|
|
113
|
+
new_records = exporter.load(args.b)
|
|
114
|
+
results = diff_mod.compare(old_records, new_records)
|
|
115
|
+
diff_mod.render_diff(results, min_delta_ms=args.min_delta_ms, no_color=args.no_color)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def main() -> None:
|
|
119
|
+
# Ensure stdout uses UTF-8 regardless of the system codepage (e.g. CP1251 on Windows)
|
|
120
|
+
if hasattr(sys.stdout, "reconfigure"):
|
|
121
|
+
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
|
122
|
+
|
|
123
|
+
argv = sys.argv[1:]
|
|
124
|
+
|
|
125
|
+
# Detect diff subcommand before handing to argparse so that a script path
|
|
126
|
+
# like "example.py" is never mistaken for an invalid subcommand choice
|
|
127
|
+
if argv and argv[0] == "diff":
|
|
128
|
+
parser = _build_diff_parser()
|
|
129
|
+
args = parser.parse_args(argv[1:])
|
|
130
|
+
_cmd_diff(args)
|
|
131
|
+
return
|
|
132
|
+
|
|
133
|
+
# Handle --version / --help with no script given
|
|
134
|
+
if not argv or argv == ["--version"]:
|
|
135
|
+
parser = _build_run_parser()
|
|
136
|
+
parser.parse_args(argv) # prints version or help and exits
|
|
137
|
+
return
|
|
138
|
+
|
|
139
|
+
parser = _build_run_parser()
|
|
140
|
+
args = parser.parse_args(argv)
|
|
141
|
+
_cmd_run(args)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
if __name__ == "__main__":
|
|
145
|
+
main()
|
runmap/diff.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
|
|
5
|
+
from rich.console import Console
|
|
6
|
+
from rich.table import Table
|
|
7
|
+
|
|
8
|
+
from runmap.models import DiffResult, TraceRecord
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _aggregate(records: list[TraceRecord]) -> dict[str, float]:
|
|
12
|
+
"""Sum total_ms per qualname"""
|
|
13
|
+
totals: dict[str, float] = {}
|
|
14
|
+
for r in records:
|
|
15
|
+
ms = (r.end_time - r.start_time) * 1000
|
|
16
|
+
totals[r.qualname] = totals.get(r.qualname, 0.0) + ms
|
|
17
|
+
return totals
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def compare(old_records: list[TraceRecord], new_records: list[TraceRecord]) -> list[DiffResult]:
|
|
21
|
+
old = _aggregate(old_records)
|
|
22
|
+
new = _aggregate(new_records)
|
|
23
|
+
|
|
24
|
+
all_names = set(old) | set(new)
|
|
25
|
+
results: list[DiffResult] = []
|
|
26
|
+
|
|
27
|
+
for name in all_names:
|
|
28
|
+
old_ms = old.get(name, 0.0)
|
|
29
|
+
new_ms = new.get(name, 0.0)
|
|
30
|
+
delta_ms = new_ms - old_ms
|
|
31
|
+
if old_ms == 0:
|
|
32
|
+
delta_pct = math.inf if new_ms > 0 else 0.0
|
|
33
|
+
else:
|
|
34
|
+
delta_pct = (delta_ms / old_ms) * 100
|
|
35
|
+
|
|
36
|
+
results.append(DiffResult(
|
|
37
|
+
node_name=name,
|
|
38
|
+
old_ms=old_ms,
|
|
39
|
+
new_ms=new_ms,
|
|
40
|
+
delta_ms=delta_ms,
|
|
41
|
+
delta_pct=delta_pct,
|
|
42
|
+
))
|
|
43
|
+
|
|
44
|
+
# Sort by abs delta descending
|
|
45
|
+
results.sort(key=lambda r: abs(r.delta_ms), reverse=True)
|
|
46
|
+
return results
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def render_diff(
|
|
50
|
+
results: list[DiffResult],
|
|
51
|
+
min_delta_ms: float = 0.0,
|
|
52
|
+
no_color: bool = False,
|
|
53
|
+
) -> None:
|
|
54
|
+
console = Console(highlight=False, no_color=no_color, legacy_windows=False)
|
|
55
|
+
|
|
56
|
+
filtered = [r for r in results if abs(r.delta_ms) >= min_delta_ms]
|
|
57
|
+
if not filtered:
|
|
58
|
+
console.print("[dim]No differences above threshold.[/dim]")
|
|
59
|
+
return
|
|
60
|
+
|
|
61
|
+
table = Table(show_header=True, header_style="bold")
|
|
62
|
+
table.add_column("Function", style="", no_wrap=True)
|
|
63
|
+
table.add_column("Old (ms)", justify="right")
|
|
64
|
+
table.add_column("New (ms)", justify="right")
|
|
65
|
+
table.add_column("Delta (ms)", justify="right")
|
|
66
|
+
|
|
67
|
+
for r in filtered:
|
|
68
|
+
pct = r.delta_pct
|
|
69
|
+
old_str = f"{r.old_ms:.1f}" if r.old_ms else "-"
|
|
70
|
+
new_str = f"{r.new_ms:.1f}" if r.new_ms else "-"
|
|
71
|
+
|
|
72
|
+
if math.isinf(pct) or abs(pct) < 5:
|
|
73
|
+
delta_str = f"{r.delta_ms:+.1f}"
|
|
74
|
+
style = "dim"
|
|
75
|
+
elif r.delta_ms < 0:
|
|
76
|
+
delta_str = f"{r.delta_ms:+.1f} ({pct:+.1f}%)"
|
|
77
|
+
style = "green"
|
|
78
|
+
else:
|
|
79
|
+
delta_str = f"{r.delta_ms:+.1f} ({pct:+.1f}%)"
|
|
80
|
+
style = "red"
|
|
81
|
+
|
|
82
|
+
table.add_row(r.node_name, old_str, new_str, f"[{style}]{delta_str}[/{style}]")
|
|
83
|
+
|
|
84
|
+
console.print(table)
|
runmap/exporter.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from runmap.models import TraceRecord
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def save(records: list[TraceRecord], path: str | Path) -> None:
|
|
10
|
+
data = [
|
|
11
|
+
{
|
|
12
|
+
"qualname": r.qualname,
|
|
13
|
+
"filename": r.filename,
|
|
14
|
+
"lineno": r.lineno,
|
|
15
|
+
"start_time": r.start_time,
|
|
16
|
+
"end_time": r.end_time,
|
|
17
|
+
"depth": r.depth,
|
|
18
|
+
"parent_name": r.parent_name,
|
|
19
|
+
}
|
|
20
|
+
for r in records
|
|
21
|
+
]
|
|
22
|
+
Path(path).write_text(json.dumps(data, indent=2), encoding="utf-8")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def load(path: str | Path) -> list[TraceRecord]:
|
|
26
|
+
p = Path(path)
|
|
27
|
+
if not p.exists():
|
|
28
|
+
raise FileNotFoundError(
|
|
29
|
+
f"Trace file not found: {p}\n"
|
|
30
|
+
"Generate one with: python -m runmap script.py --out output.trace"
|
|
31
|
+
)
|
|
32
|
+
data = json.loads(p.read_text(encoding="utf-8"))
|
|
33
|
+
return [
|
|
34
|
+
TraceRecord(
|
|
35
|
+
qualname=d["qualname"],
|
|
36
|
+
filename=d["filename"],
|
|
37
|
+
lineno=d["lineno"],
|
|
38
|
+
start_time=d["start_time"],
|
|
39
|
+
end_time=d["end_time"],
|
|
40
|
+
depth=d["depth"],
|
|
41
|
+
parent_name=d["parent_name"],
|
|
42
|
+
)
|
|
43
|
+
for d in data
|
|
44
|
+
]
|
runmap/models.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass
|
|
7
|
+
class TraceRecord:
|
|
8
|
+
qualname: str
|
|
9
|
+
filename: str
|
|
10
|
+
lineno: int
|
|
11
|
+
start_time: float # perf_counter seconds
|
|
12
|
+
end_time: float # perf_counter seconds
|
|
13
|
+
depth: int
|
|
14
|
+
parent_name: str | None
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class CallNode:
|
|
19
|
+
qualname: str
|
|
20
|
+
total_ms: float
|
|
21
|
+
self_ms: float
|
|
22
|
+
call_count: int
|
|
23
|
+
children: list[CallNode] = field(default_factory=list)
|
|
24
|
+
is_hot: bool = False
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class DiffResult:
|
|
29
|
+
node_name: str
|
|
30
|
+
old_ms: float
|
|
31
|
+
new_ms: float
|
|
32
|
+
delta_ms: float
|
|
33
|
+
delta_pct: float # relative to old_ms; inf if old_ms == 0
|
runmap/renderer.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from rich.console import Console
|
|
4
|
+
from rich.tree import Tree
|
|
5
|
+
|
|
6
|
+
from runmap.models import CallNode
|
|
7
|
+
|
|
8
|
+
_BAR_CHAR = "\u2588" # full block
|
|
9
|
+
_BAR_MAX = 30
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _bar(fraction: float) -> str:
|
|
13
|
+
count = round(fraction * _BAR_MAX)
|
|
14
|
+
return _BAR_CHAR * count
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _format_ms(ms: float) -> str:
|
|
18
|
+
if ms == 0.0:
|
|
19
|
+
return "0ms"
|
|
20
|
+
if ms < 1.0:
|
|
21
|
+
formatted = f"{ms:.2f}"
|
|
22
|
+
if formatted == "0.00":
|
|
23
|
+
return "<0.01ms"
|
|
24
|
+
return f"{formatted}ms"
|
|
25
|
+
return f"{ms:.0f}ms"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _add_nodes(
|
|
29
|
+
rich_node: Tree,
|
|
30
|
+
node: CallNode,
|
|
31
|
+
parent_ms: float,
|
|
32
|
+
total_ms: float,
|
|
33
|
+
depth_limit: int | None,
|
|
34
|
+
min_ms: float,
|
|
35
|
+
current_depth: int,
|
|
36
|
+
no_color: bool,
|
|
37
|
+
) -> None:
|
|
38
|
+
visible = [c for c in node.children if c.total_ms >= min_ms]
|
|
39
|
+
hidden = len(node.children) - len(visible)
|
|
40
|
+
|
|
41
|
+
for child in visible:
|
|
42
|
+
fraction = child.total_ms / parent_ms if parent_ms > 0 else 0
|
|
43
|
+
pct_of_total = child.total_ms / total_ms if total_ms > 0 else 0
|
|
44
|
+
|
|
45
|
+
bar = _bar(fraction)
|
|
46
|
+
hot = " :fire:" if child.is_hot else ""
|
|
47
|
+
label = f"{child.qualname} {_format_ms(child.total_ms)} {bar}{hot}"
|
|
48
|
+
|
|
49
|
+
if not no_color and pct_of_total < 0.02:
|
|
50
|
+
label = f"[dim]{label}[/dim]"
|
|
51
|
+
|
|
52
|
+
child_node = rich_node.add(label)
|
|
53
|
+
|
|
54
|
+
at_limit = depth_limit is not None and current_depth >= depth_limit
|
|
55
|
+
if not at_limit and child.children:
|
|
56
|
+
_add_nodes(
|
|
57
|
+
child_node,
|
|
58
|
+
child,
|
|
59
|
+
child.total_ms,
|
|
60
|
+
total_ms,
|
|
61
|
+
depth_limit,
|
|
62
|
+
min_ms,
|
|
63
|
+
current_depth + 1,
|
|
64
|
+
no_color,
|
|
65
|
+
)
|
|
66
|
+
elif at_limit and child.children:
|
|
67
|
+
child_node.add(f"[dim]... {len(child.children)} more[/dim]")
|
|
68
|
+
|
|
69
|
+
if hidden:
|
|
70
|
+
rich_node.add(f"[dim]... {hidden} more (below --min-ms)[/dim]")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def render(
|
|
74
|
+
root: CallNode,
|
|
75
|
+
depth_limit: int | None = None,
|
|
76
|
+
min_ms: float = 0.0,
|
|
77
|
+
no_color: bool = False,
|
|
78
|
+
) -> None:
|
|
79
|
+
"""Render the call tree to stdout using rich"""
|
|
80
|
+
console = Console(highlight=False, no_color=no_color, legacy_windows=False)
|
|
81
|
+
total_ms = root.total_ms
|
|
82
|
+
|
|
83
|
+
if total_ms == 0:
|
|
84
|
+
console.print("[dim]No calls recorded.[/dim]")
|
|
85
|
+
return
|
|
86
|
+
|
|
87
|
+
tree = Tree(f"[bold]{root.qualname}[/bold] {_format_ms(total_ms)}")
|
|
88
|
+
_add_nodes(tree, root, total_ms, total_ms, depth_limit, min_ms, 1, no_color)
|
|
89
|
+
console.print(tree)
|
runmap/sampler.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
import traceback
|
|
5
|
+
from collections import defaultdict
|
|
6
|
+
from time import perf_counter
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from runmap.models import TraceRecord
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _check_unix() -> None:
|
|
13
|
+
if sys.platform == "win32":
|
|
14
|
+
raise RuntimeError(
|
|
15
|
+
"--sample is not available on Windows. "
|
|
16
|
+
"signal.setitimer is a Unix-only API. "
|
|
17
|
+
"Use the default tracer mode instead."
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Sampler:
|
|
22
|
+
"""Signal-based 100 Hz stack sampler, Unix only"""
|
|
23
|
+
|
|
24
|
+
def __init__(self, interval: float = 0.01) -> None:
|
|
25
|
+
_check_unix()
|
|
26
|
+
import signal # noqa: PLC0415 - intentional lazy import; fails on Windows
|
|
27
|
+
self._signal = signal
|
|
28
|
+
self._interval = interval
|
|
29
|
+
self._counts: dict[tuple[str, ...], int] = defaultdict(int)
|
|
30
|
+
self._start_time: float = 0.0
|
|
31
|
+
self._end_time: float = 0.0
|
|
32
|
+
self._prev_handler: Any = signal.SIG_DFL
|
|
33
|
+
|
|
34
|
+
def _handle(self, signum: int, frame: Any) -> None: # noqa: ARG002
|
|
35
|
+
stack = traceback.extract_stack(frame)
|
|
36
|
+
# Build a tuple of qualnames from bottom (oldest) to top (newest)
|
|
37
|
+
names = tuple(
|
|
38
|
+
f"{s.name}" for s in stack
|
|
39
|
+
if s.filename and "<" not in s.filename # skip builtins / frozen
|
|
40
|
+
)
|
|
41
|
+
if names:
|
|
42
|
+
self._counts[names] += 1
|
|
43
|
+
|
|
44
|
+
def start(self) -> None:
|
|
45
|
+
self._start_time = perf_counter()
|
|
46
|
+
self._prev_handler = self._signal.signal(self._signal.SIGALRM, self._handle)
|
|
47
|
+
self._signal.setitimer(self._signal.ITIMER_REAL, self._interval, self._interval)
|
|
48
|
+
|
|
49
|
+
def stop(self) -> None:
|
|
50
|
+
self._signal.setitimer(self._signal.ITIMER_REAL, 0)
|
|
51
|
+
self._signal.signal(self._signal.SIGALRM, self._prev_handler)
|
|
52
|
+
self._end_time = perf_counter()
|
|
53
|
+
|
|
54
|
+
def to_records(self) -> list[TraceRecord]:
|
|
55
|
+
"""Convert sample counts to a flat list of TraceRecord approximations"""
|
|
56
|
+
total_samples = sum(self._counts.values())
|
|
57
|
+
if total_samples == 0:
|
|
58
|
+
return []
|
|
59
|
+
|
|
60
|
+
elapsed = self._end_time - self._start_time
|
|
61
|
+
# Each sample represents ~interval seconds of wall time
|
|
62
|
+
seconds_per_sample = elapsed / total_samples
|
|
63
|
+
|
|
64
|
+
# Aggregate: for each unique (parent, child) pair count inclusive samples
|
|
65
|
+
inclusive: dict[str, int] = defaultdict(int)
|
|
66
|
+
parent_map: dict[str, str | None] = {}
|
|
67
|
+
depth_map: dict[str, int] = {}
|
|
68
|
+
|
|
69
|
+
for stack, count in self._counts.items():
|
|
70
|
+
for i, name in enumerate(stack):
|
|
71
|
+
inclusive[name] += count
|
|
72
|
+
if name not in parent_map:
|
|
73
|
+
parent_map[name] = stack[i - 1] if i > 0 else None
|
|
74
|
+
depth_map[name] = i
|
|
75
|
+
|
|
76
|
+
records: list[TraceRecord] = []
|
|
77
|
+
for name, count in inclusive.items():
|
|
78
|
+
dur = count * seconds_per_sample
|
|
79
|
+
records.append(
|
|
80
|
+
TraceRecord(
|
|
81
|
+
qualname=name,
|
|
82
|
+
filename="<sampled>",
|
|
83
|
+
lineno=0,
|
|
84
|
+
start_time=0.0,
|
|
85
|
+
end_time=dur,
|
|
86
|
+
depth=depth_map[name],
|
|
87
|
+
parent_name=parent_map[name],
|
|
88
|
+
)
|
|
89
|
+
)
|
|
90
|
+
return records
|
runmap/tracer.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
import threading
|
|
5
|
+
from time import perf_counter
|
|
6
|
+
from types import FrameType
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from runmap.models import TraceRecord
|
|
10
|
+
|
|
11
|
+
# Modules whose frames we never want to record
|
|
12
|
+
_SKIP_PREFIXES = ("runmap.", "importlib.", "_frozen_importlib")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _qualname(frame: FrameType) -> str:
|
|
16
|
+
code = frame.f_code
|
|
17
|
+
# f_qualname is available from Python 3.3 on the code object as co_qualname (3.11+)
|
|
18
|
+
# fall back to co_name for older 3.10
|
|
19
|
+
name = getattr(code, "co_qualname", None) or code.co_name
|
|
20
|
+
mod = frame.f_globals.get("__name__") or ""
|
|
21
|
+
return f"{mod}.{name}" if mod else name
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _should_skip(frame: FrameType) -> bool:
|
|
25
|
+
mod = frame.f_globals.get("__name__") or ""
|
|
26
|
+
return any(mod.startswith(p) for p in _SKIP_PREFIXES)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Tracer:
|
|
30
|
+
"""sys.settrace-based call interceptor, thread-safe via threading.local"""
|
|
31
|
+
|
|
32
|
+
def __init__(self) -> None:
|
|
33
|
+
self._local = threading.local()
|
|
34
|
+
self._records: list[TraceRecord] = []
|
|
35
|
+
self._lock = threading.Lock()
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def _stack(self) -> list[tuple[str, str, int, float]]:
|
|
39
|
+
if not hasattr(self._local, "stack"):
|
|
40
|
+
self._local.stack = []
|
|
41
|
+
return self._local.stack
|
|
42
|
+
|
|
43
|
+
def _hook(self, frame: FrameType, event: str, arg: Any) -> Any:
|
|
44
|
+
if _should_skip(frame):
|
|
45
|
+
return self._hook
|
|
46
|
+
|
|
47
|
+
if event == "call":
|
|
48
|
+
qn = _qualname(frame)
|
|
49
|
+
self._stack.append((qn, frame.f_code.co_filename, frame.f_lineno, perf_counter()))
|
|
50
|
+
return self._hook
|
|
51
|
+
|
|
52
|
+
if event == "return" and self._stack:
|
|
53
|
+
qn, filename, lineno, t0 = self._stack.pop()
|
|
54
|
+
depth = len(self._stack)
|
|
55
|
+
parent_name = self._stack[-1][0] if self._stack else None
|
|
56
|
+
record = TraceRecord(
|
|
57
|
+
qualname=qn,
|
|
58
|
+
filename=filename,
|
|
59
|
+
lineno=lineno,
|
|
60
|
+
start_time=t0,
|
|
61
|
+
end_time=perf_counter(),
|
|
62
|
+
depth=depth,
|
|
63
|
+
parent_name=parent_name,
|
|
64
|
+
)
|
|
65
|
+
with self._lock:
|
|
66
|
+
self._records.append(record)
|
|
67
|
+
|
|
68
|
+
return self._hook
|
|
69
|
+
|
|
70
|
+
def start(self) -> None:
|
|
71
|
+
self._local.stack = []
|
|
72
|
+
sys.settrace(self._hook)
|
|
73
|
+
threading.settrace(self._hook)
|
|
74
|
+
|
|
75
|
+
def stop(self) -> None:
|
|
76
|
+
sys.settrace(None)
|
|
77
|
+
threading.settrace(None)
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def records(self) -> list[TraceRecord]:
|
|
81
|
+
with self._lock:
|
|
82
|
+
return list(self._records)
|
runmap/tree.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections import defaultdict
|
|
4
|
+
|
|
5
|
+
from runmap.models import CallNode, TraceRecord
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def build(records: list[TraceRecord]) -> CallNode:
|
|
9
|
+
"""
|
|
10
|
+
Convert a flat list of TraceRecord into a CallNode tree
|
|
11
|
+
|
|
12
|
+
Returns a synthetic <root> node whose children are the top-level calls
|
|
13
|
+
"""
|
|
14
|
+
if not records:
|
|
15
|
+
return CallNode(qualname="<root>", total_ms=0.0, self_ms=0.0, call_count=0)
|
|
16
|
+
|
|
17
|
+
# Group records by qualname to detect recursion and merge call counts
|
|
18
|
+
# We'll reconstruct the tree by walking records in emission order (deepest first,
|
|
19
|
+
# since "return" events fire innermost first)
|
|
20
|
+
|
|
21
|
+
# Build a working set of nodes keyed by qualname
|
|
22
|
+
node_map: dict[str, CallNode] = {}
|
|
23
|
+
children_of: dict[str | None, list[str]] = defaultdict(list)
|
|
24
|
+
|
|
25
|
+
# Track which qualnames appear as ancestors of a given call (for recursion detection)
|
|
26
|
+
# We process records sorted by depth descending so children are handled before parents
|
|
27
|
+
sorted_records = sorted(records, key=lambda r: r.depth, reverse=True)
|
|
28
|
+
|
|
29
|
+
for rec in sorted_records:
|
|
30
|
+
duration_ms = (rec.end_time - rec.start_time) * 1000
|
|
31
|
+
|
|
32
|
+
if rec.qualname not in node_map:
|
|
33
|
+
node_map[rec.qualname] = CallNode(
|
|
34
|
+
qualname=rec.qualname,
|
|
35
|
+
total_ms=0.0,
|
|
36
|
+
self_ms=0.0,
|
|
37
|
+
call_count=0,
|
|
38
|
+
)
|
|
39
|
+
node = node_map[rec.qualname]
|
|
40
|
+
node.total_ms += duration_ms
|
|
41
|
+
node.call_count += 1
|
|
42
|
+
|
|
43
|
+
# Skip recursive frames: if this qualname already appears as its own ancestor
|
|
44
|
+
# in the same call chain, don't register a child edge again
|
|
45
|
+
if rec.parent_name != rec.qualname:
|
|
46
|
+
key = (rec.parent_name, rec.qualname)
|
|
47
|
+
if rec.qualname not in children_of[rec.parent_name]:
|
|
48
|
+
children_of[rec.parent_name].append(rec.qualname)
|
|
49
|
+
|
|
50
|
+
# Only count edges where the parent is a real node (not None)
|
|
51
|
+
all_children: set[str] = set()
|
|
52
|
+
for parent_name, child_names in children_of.items():
|
|
53
|
+
if parent_name is not None:
|
|
54
|
+
all_children.update(child_names)
|
|
55
|
+
|
|
56
|
+
# Compute self_ms for every node: total_ms minus the sum of direct children
|
|
57
|
+
# This covers both interior nodes (whose children appear in children_of)
|
|
58
|
+
# and leaf nodes (which never appear as a parent key)
|
|
59
|
+
for qn, node in node_map.items():
|
|
60
|
+
child_names = children_of.get(qn, [])
|
|
61
|
+
children_total = sum(node_map[c].total_ms for c in child_names if c in node_map)
|
|
62
|
+
node.self_ms = max(0.0, node.total_ms - children_total)
|
|
63
|
+
|
|
64
|
+
# Wire up children lists and sort by total_ms descending
|
|
65
|
+
for qn, node in node_map.items():
|
|
66
|
+
child_names = children_of.get(qn, [])
|
|
67
|
+
node.children = sorted(
|
|
68
|
+
[node_map[c] for c in child_names if c in node_map],
|
|
69
|
+
key=lambda n: n.total_ms,
|
|
70
|
+
reverse=True,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
root_children = sorted(
|
|
74
|
+
[node_map[qn] for qn in node_map if qn not in all_children],
|
|
75
|
+
key=lambda n: n.total_ms,
|
|
76
|
+
reverse=True,
|
|
77
|
+
)
|
|
78
|
+
root_total = sum(n.total_ms for n in root_children)
|
|
79
|
+
|
|
80
|
+
# Mark hot nodes: self_ms > 20% of wall-clock time (root total)
|
|
81
|
+
hot_threshold = root_total * 0.20
|
|
82
|
+
for node in node_map.values():
|
|
83
|
+
node.is_hot = node.self_ms > hot_threshold
|
|
84
|
+
|
|
85
|
+
# Build root
|
|
86
|
+
root = CallNode(
|
|
87
|
+
qualname="<root>",
|
|
88
|
+
total_ms=root_total,
|
|
89
|
+
self_ms=0.0,
|
|
90
|
+
call_count=1,
|
|
91
|
+
children=root_children,
|
|
92
|
+
)
|
|
93
|
+
return root
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: runmap
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python call tree profiler for the terminal
|
|
5
|
+
Project-URL: Homepage, https://github.com/nazarhktwitch/runmap
|
|
6
|
+
Project-URL: Repository, https://github.com/nazarhktwitch/runmap
|
|
7
|
+
Project-URL: Issues, https://github.com/nazarhktwitch/runmap/issues
|
|
8
|
+
Author: Nazar Burlai
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: call-tree,cli,performance,profiler,tracing
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Topic :: Software Development :: Debuggers
|
|
22
|
+
Classifier: Topic :: Software Development :: Testing
|
|
23
|
+
Classifier: Typing :: Typed
|
|
24
|
+
Requires-Python: >=3.10
|
|
25
|
+
Requires-Dist: rich>=13.0
|
|
26
|
+
Provides-Extra: dev
|
|
27
|
+
Requires-Dist: pytest; extra == 'dev'
|
|
28
|
+
Requires-Dist: pytest-cov; extra == 'dev'
|
|
29
|
+
Description-Content-Type: text/markdown
|
|
30
|
+
|
|
31
|
+
# runmap
|
|
32
|
+
|
|
33
|
+
Visualize Python function call trees with timing in the terminal
|
|
34
|
+
|
|
35
|
+
Run any script through runmap and see a tree of every function call, how long
|
|
36
|
+
each took, and where the hot spots are
|
|
37
|
+
|
|
38
|
+
## Getting Started
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pip install runmap
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
python -m runmap script.py
|
|
46
|
+
python -m runmap script.py --min-ms 5 --depth 3
|
|
47
|
+
python -m runmap script.py --out run.trace
|
|
48
|
+
python -m runmap diff before.trace after.trace
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Features
|
|
52
|
+
|
|
53
|
+
- Call tree view: shows the full function call hierarchy with per-node timing.
|
|
54
|
+
- Time bars: proportional block bars next to each node for quick visual scanning.
|
|
55
|
+
- Hot markers: flags functions whose self time exceeds 20% of total runtime.
|
|
56
|
+
- Depth and threshold filters: `--depth` and `--min-ms` trim noise from large trees.
|
|
57
|
+
- Trace files: `--out` saves a `.trace` JSON file for later comparison.
|
|
58
|
+
- Diff mode: `runmap diff A.trace B.trace` shows a delta table (faster/slower/new).
|
|
59
|
+
- Sampling mode: `--sample` uses signal-based 100 Hz sampling instead of `sys.settrace` (Unix only).
|
|
60
|
+
|
|
61
|
+
## Requirements
|
|
62
|
+
|
|
63
|
+
- Python 3.10+
|
|
64
|
+
- [rich](https://github.com/Textualize/rich) >= 13.0
|
|
65
|
+
|
|
66
|
+
## License
|
|
67
|
+
|
|
68
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
runmap/__init__.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
|
|
2
|
+
runmap/__main__.py,sha256=rocVo9JTvM0B749nCk0fzjO9gmiU_Q_T-wTJKMWqzcc,4841
|
|
3
|
+
runmap/diff.py,sha256=rW70H-p6UQCQZkRumu8-RxFRRQCIAKU5bsIXjsQs0oE,2529
|
|
4
|
+
runmap/exporter.py,sha256=lN30osAzf1KW-0I0d_VXDKBuwd8QvNelXc64QXlAVYs,1201
|
|
5
|
+
runmap/models.py,sha256=6L_m1CfDEethflyFVdoPSlHEI_syhA3gyXU48eGPXKY,655
|
|
6
|
+
runmap/renderer.py,sha256=s4s9wKc_1dEvK5Xy0g6e55bZdMv0NLJFgxD2BjjBja0,2456
|
|
7
|
+
runmap/sampler.py,sha256=eUDuLvoJYAmx8jJqMq-0WhbYlyBN63LKpa60QLsA5FA,3210
|
|
8
|
+
runmap/tracer.py,sha256=cJleQs3vF13e9RLYdGf4NjouTTUfCXAtZwfno4BbMA4,2499
|
|
9
|
+
runmap/tree.py,sha256=DYTwRI27d3uX4gnkFpVpDHmF3QmdQrLuVmLkJ0djKBM,3474
|
|
10
|
+
runmap-0.1.0.dist-info/METADATA,sha256=heM9637Koe1HGgeuMGMXLz0OD0uhc6MFdZs8cKv_qIc,2301
|
|
11
|
+
runmap-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
12
|
+
runmap-0.1.0.dist-info/entry_points.txt,sha256=6tpDJla0y9OkDIoBPVYTeV6HWNeIVnu_O3fh6-OI0n8,48
|
|
13
|
+
runmap-0.1.0.dist-info/licenses/LICENSE,sha256=pxXvJ1lzC3MwZwvnczIgMQ9vr9Z0U39h4D9piEJp8VY,1064
|
|
14
|
+
runmap-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 NazarHK
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|