dstrack 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.
dstrack/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ from importlib.metadata import PackageNotFoundError, version
2
+
3
+ from . import _logging as _logging
4
+
5
+ try:
6
+ __version__ = version("dstrack")
7
+ except PackageNotFoundError:
8
+ __version__ = "unknown"
@@ -0,0 +1,54 @@
1
+ """Benchmark for dstrack's snapshot creation pipeline.
2
+
3
+ Generates a synthetic CSV in a temporary directory, then times how long it
4
+ takes to build snapshot metadata and statistics for it, alongside a summary of
5
+ the environment the benchmark ran in and a call tree of dstrack's own methods.
6
+
7
+ Layers
8
+ ------
9
+ [dstrack._benchmark._synthetic][]
10
+ Describes and writes the synthetic dataset.
11
+
12
+ [dstrack._benchmark._runner][]
13
+ Runs the snapshot pipeline against it and times each phase.
14
+
15
+ [dstrack._benchmark._profiling][]
16
+ Reduces the ``cProfile`` run to a call tree of dstrack's own methods.
17
+
18
+ [dstrack._benchmark._environment][]
19
+ Describes the machine the benchmark ran on.
20
+
21
+ [dstrack._benchmark._report][], [dstrack._benchmark._cli][]
22
+ Render runs to a console, and wire the whole thing to ``dstrack-benchmark``.
23
+ """
24
+
25
+ from ._cli import app
26
+ from ._environment import EnvironmentInfo, collect_environment_info
27
+ from ._profiling import CallGraph, CallNode, CallScope
28
+ from ._report import ConsoleReporter
29
+ from ._runner import (
30
+ BenchmarkObserver,
31
+ BenchmarkResult,
32
+ BenchmarkRun,
33
+ BenchmarkRunner,
34
+ SilentObserver,
35
+ )
36
+ from ._synthetic import SyntheticColumn, SyntheticCsvSpec, write_synthetic_csv
37
+
38
+ __all__ = [
39
+ "BenchmarkObserver",
40
+ "BenchmarkResult",
41
+ "BenchmarkRun",
42
+ "BenchmarkRunner",
43
+ "CallGraph",
44
+ "CallNode",
45
+ "CallScope",
46
+ "ConsoleReporter",
47
+ "EnvironmentInfo",
48
+ "SilentObserver",
49
+ "SyntheticColumn",
50
+ "SyntheticCsvSpec",
51
+ "app",
52
+ "collect_environment_info",
53
+ "write_synthetic_csv",
54
+ ]
@@ -0,0 +1,114 @@
1
+ """Command-line entry point for the snapshot benchmark."""
2
+
3
+ import shutil
4
+ import tempfile
5
+ from collections.abc import Iterator
6
+ from contextlib import contextmanager
7
+ from pathlib import Path
8
+ from typing import Annotated
9
+
10
+ import typer
11
+
12
+ from dstrack._benchmark._report import ConsoleReporter
13
+ from dstrack._benchmark._runner import BenchmarkRunner
14
+ from dstrack._benchmark._synthetic import SyntheticCsvSpec
15
+
16
+ app = typer.Typer(
17
+ no_args_is_help=True,
18
+ pretty_exceptions_show_locals=False,
19
+ help="Benchmark dstrack's snapshot creation pipeline against a synthetic CSV.",
20
+ )
21
+
22
+ _DEFAULTS = SyntheticCsvSpec()
23
+
24
+
25
+ @app.callback()
26
+ def _main() -> None:
27
+ """Keep ``run`` a named subcommand so a bare invocation shows help.
28
+
29
+ Typer promotes a lone command to the top level, which quietly discards
30
+ ``no_args_is_help``; an explicit callback keeps the command group intact so
31
+ running ``dstrack-benchmark`` with no arguments prints help instead of
32
+ silently kicking off a full-size benchmark.
33
+ """
34
+
35
+
36
+ @contextmanager
37
+ def _workspace(reporter: ConsoleReporter, *, keep_csv: bool) -> Iterator[Path]:
38
+ """Yield a path for the synthetic CSV, discarding it unless ``keep_csv``."""
39
+ tmp_dir = Path(tempfile.mkdtemp(prefix="dstrack-benchmark-"))
40
+ csv_path = tmp_dir / "synthetic_dataset.csv"
41
+ try:
42
+ yield csv_path
43
+ finally:
44
+ if keep_csv:
45
+ reporter.csv_kept(csv_path)
46
+ else:
47
+ shutil.rmtree(tmp_dir, ignore_errors=True)
48
+
49
+
50
+ @app.command(help="Generate a synthetic CSV and benchmark snapshot creation on it.")
51
+ def run(
52
+ rows: Annotated[
53
+ int, typer.Option(min=0, help="Number of data rows to generate.")
54
+ ] = _DEFAULTS.num_rows,
55
+ numeric_cols: Annotated[
56
+ int, typer.Option(min=0, help="Number of numeric (float) columns.")
57
+ ] = _DEFAULTS.num_numeric_cols,
58
+ string_cols: Annotated[
59
+ int, typer.Option(min=0, help="Number of string columns.")
60
+ ] = _DEFAULTS.num_string_cols,
61
+ datetime_cols: Annotated[
62
+ int, typer.Option(min=0, help="Number of datetime columns.")
63
+ ] = _DEFAULTS.num_datetime_cols,
64
+ bool_cols: Annotated[
65
+ int, typer.Option(min=0, help="Number of boolean columns.")
66
+ ] = _DEFAULTS.num_bool_cols,
67
+ null_rate: Annotated[
68
+ float,
69
+ typer.Option(min=0.0, max=1.0, help="Fraction of non-id cells left null."),
70
+ ] = _DEFAULTS.null_rate,
71
+ seed: Annotated[
72
+ int, typer.Option(help="Random seed for reproducible synthetic data.")
73
+ ] = _DEFAULTS.seed,
74
+ keep_csv: Annotated[
75
+ bool,
76
+ typer.Option(
77
+ help="Keep the generated CSV instead of deleting it after the run."
78
+ ),
79
+ ] = False,
80
+ profile: Annotated[
81
+ bool,
82
+ typer.Option(
83
+ help="Profile dstrack's own methods while building the snapshot. "
84
+ "Adds cProfile overhead to the reported metadata/stats timings."
85
+ ),
86
+ ] = True,
87
+ profile_limit: Annotated[
88
+ int,
89
+ typer.Option(
90
+ min=0,
91
+ help="Max number of child methods shown under each node of the "
92
+ "profile call tree, ranked by cumulative time.",
93
+ ),
94
+ ] = 20,
95
+ ) -> None:
96
+ spec = SyntheticCsvSpec(
97
+ num_rows=rows,
98
+ num_numeric_cols=numeric_cols,
99
+ num_string_cols=string_cols,
100
+ num_datetime_cols=datetime_cols,
101
+ num_bool_cols=bool_cols,
102
+ null_rate=null_rate,
103
+ seed=seed,
104
+ )
105
+ reporter = ConsoleReporter()
106
+ runner = BenchmarkRunner(spec, observer=reporter, profile=profile)
107
+
108
+ with _workspace(reporter, keep_csv=keep_csv) as csv_path:
109
+ benchmark_run = runner.run(csv_path)
110
+ reporter.report(benchmark_run, profile_limit=profile_limit)
111
+
112
+
113
+ if __name__ == "__main__":
114
+ app()
@@ -0,0 +1,144 @@
1
+ """Describes the machine and runtime a benchmark executed under.
2
+
3
+ Hardware facts have no portable API, so each is read by a per-OS probe. Every
4
+ probe is best-effort: it returns ``None`` (or raises, which
5
+ [_attempt()][dstrack._benchmark._environment._attempt] swallows) when the
6
+ platform will not give up the answer, and the caller falls back to something
7
+ always available.
8
+ """
9
+
10
+ import os
11
+ import platform
12
+ import subprocess
13
+ from collections.abc import Callable
14
+ from dataclasses import dataclass
15
+ from pathlib import Path
16
+ from typing import TypeVar
17
+
18
+ from dstrack import __version__
19
+
20
+ T = TypeVar("T")
21
+
22
+ _PROBE_TIMEOUT_SECONDS = 5
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class EnvironmentInfo:
27
+ """Snapshot of the machine and runtime a benchmark executed under."""
28
+
29
+ os_name: str
30
+ os_release: str
31
+ os_version: str
32
+ machine: str
33
+ cpu_model: str
34
+ cpu_count: int | None
35
+ total_memory_gb: float | None
36
+ python_implementation: str
37
+ python_version: str
38
+ dstrack_version: str
39
+
40
+
41
+ def _attempt(probe: Callable[[], T | None]) -> T | None:
42
+ """Run a probe, returning ``None`` if the platform refuses to answer."""
43
+ try:
44
+ return probe()
45
+ except (OSError, subprocess.SubprocessError, ValueError):
46
+ return None
47
+
48
+
49
+ def _command_output(*args: str) -> str:
50
+ """Gets the stdout of a command, raising if it fails or times out."""
51
+ result = subprocess.run(
52
+ args,
53
+ capture_output=True,
54
+ text=True,
55
+ timeout=_PROBE_TIMEOUT_SECONDS,
56
+ check=True,
57
+ )
58
+ return result.stdout.strip()
59
+
60
+
61
+ def _first_matching_line(path: Path, prefix: str) -> str | None:
62
+ """First line of ``path`` starting with ``prefix`` (case-insensitive)."""
63
+ if not path.is_file():
64
+ return None
65
+ for line in path.read_text().splitlines():
66
+ if line.lower().startswith(prefix.lower()):
67
+ return line
68
+ return None
69
+
70
+
71
+ def _linux_cpu_model() -> str | None:
72
+ line = _first_matching_line(Path("/proc/cpuinfo"), "model name")
73
+ return line.split(":", 1)[1].strip() if line else None
74
+
75
+
76
+ def _darwin_cpu_model() -> str | None:
77
+ return _command_output("sysctl", "-n", "machdep.cpu.brand_string")
78
+
79
+
80
+ def _windows_cpu_model() -> str | None:
81
+ lines = [
82
+ line.strip()
83
+ for line in _command_output("wmic", "cpu", "get", "name").splitlines()
84
+ if line.strip()
85
+ ]
86
+ return lines[1] if len(lines) >= 2 else None
87
+
88
+
89
+ def _linux_total_memory() -> int | None:
90
+ line = _first_matching_line(Path("/proc/meminfo"), "MemTotal:")
91
+ return int(line.split()[1]) * 1024 if line else None
92
+
93
+
94
+ def _darwin_total_memory() -> int | None:
95
+ return int(_command_output("sysctl", "-n", "hw.memsize"))
96
+
97
+
98
+ def _sysconf_total_memory() -> int | None:
99
+ """Page size times page count, on any POSIX platform exposing sysconf."""
100
+ if not hasattr(os, "sysconf") or "SC_PAGE_SIZE" not in os.sysconf_names:
101
+ return None
102
+ return os.sysconf("SC_PAGE_SIZE") * os.sysconf("SC_PHYS_PAGES")
103
+
104
+
105
+ _CPU_MODEL_PROBES: dict[str, Callable[[], str | None]] = {
106
+ "Linux": _linux_cpu_model,
107
+ "Darwin": _darwin_cpu_model,
108
+ "Windows": _windows_cpu_model,
109
+ }
110
+
111
+ _TOTAL_MEMORY_PROBES: dict[str, Callable[[], int | None]] = {
112
+ "Linux": _linux_total_memory,
113
+ "Darwin": _darwin_total_memory,
114
+ }
115
+
116
+
117
+ def _cpu_model() -> str:
118
+ """Best-effort human-readable CPU model string for the current machine."""
119
+ probe = _CPU_MODEL_PROBES.get(platform.system())
120
+ model = _attempt(probe) if probe is not None else None
121
+ return model or platform.processor() or platform.machine() or "unknown"
122
+
123
+
124
+ def _total_memory_bytes() -> int | None:
125
+ """Best-effort total physical memory in bytes, or None if it can't be read."""
126
+ probe = _TOTAL_MEMORY_PROBES.get(platform.system(), _sysconf_total_memory)
127
+ return _attempt(probe)
128
+
129
+
130
+ def collect_environment_info() -> EnvironmentInfo:
131
+ """Collect OS, hardware, and runtime details describing the current machine."""
132
+ total_memory = _total_memory_bytes()
133
+ return EnvironmentInfo(
134
+ os_name=platform.system(),
135
+ os_release=platform.release(),
136
+ os_version=platform.version(),
137
+ machine=platform.machine(),
138
+ cpu_model=_cpu_model(),
139
+ cpu_count=os.cpu_count(),
140
+ total_memory_gb=total_memory / (1024**3) if total_memory is not None else None,
141
+ python_implementation=platform.python_implementation(),
142
+ python_version=platform.python_version(),
143
+ dstrack_version=__version__,
144
+ )
@@ -0,0 +1,207 @@
1
+ """Reduces a ``cProfile`` run to a call tree of dstrack's own methods.
2
+
3
+ This module is pure model: it turns raw ``pstats`` frames into
4
+ [CallNode][dstrack._benchmark._profiling.CallNode] trees and knows nothing about
5
+ how they are displayed. Rendering lives in [dstrack._benchmark._report][].
6
+ """
7
+
8
+ import cProfile
9
+ import pstats
10
+ from collections.abc import Iterable
11
+ from dataclasses import dataclass, field
12
+ from pathlib import Path
13
+ from typing import TypeAlias
14
+
15
+ # (filename, lineno, funcname) - the key pstats uses to identify a profiled frame.
16
+ FrameKey: TypeAlias = tuple[str, int, str]
17
+ # (primitive_calls, total_calls, total_time, cumulative_time)
18
+ _FrameTotals: TypeAlias = tuple[int, int, float, float]
19
+ # What pstats.Stats.stats maps each frame to: its totals plus its callers.
20
+ _RawStats: TypeAlias = dict[
21
+ FrameKey, tuple[int, int, float, float, dict[FrameKey, _FrameTotals]]
22
+ ]
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class CallScope:
27
+ """The source files whose profiled frames are worth reporting.
28
+
29
+ A frame is in scope when it lives under ``root`` and under none of the
30
+ ``exclude`` paths, which keeps the tree focused on the code being measured
31
+ instead of the interpreter and the benchmark harness itself.
32
+
33
+ Attributes:
34
+ root: Directory every reported frame must live under.
35
+ exclude: Sub-directories of ``root`` to leave out.
36
+ """
37
+
38
+ root: Path
39
+ exclude: tuple[Path, ...] = ()
40
+
41
+ def contains(self, filename: str) -> bool:
42
+ """True if frames from ``filename`` belong in the call tree."""
43
+ try:
44
+ resolved = Path(filename).resolve()
45
+ except OSError:
46
+ return False
47
+ if not resolved.is_relative_to(self.root):
48
+ return False
49
+ return not any(resolved.is_relative_to(path) for path in self.exclude)
50
+
51
+ def location(self, filename: str, lineno: int) -> str:
52
+ """Render a frame's source location relative to ``root``."""
53
+ return f"{Path(filename).resolve().relative_to(self.root)}:{lineno}"
54
+
55
+
56
+ def dstrack_scope() -> CallScope:
57
+ """Scope covering dstrack's own code, minus this benchmark package."""
58
+ benchmark_package = Path(__file__).resolve().parent
59
+ return CallScope(root=benchmark_package.parent, exclude=(benchmark_package,))
60
+
61
+
62
+ @dataclass(frozen=True)
63
+ class CallNode:
64
+ """One method in the call tree, with the callees it was measured calling.
65
+
66
+ Attributes:
67
+ funcname: Name of the profiled function.
68
+ location: Source location, relative to the scope root.
69
+ num_calls: Total number of calls, including recursive ones.
70
+ total_seconds: Time spent in the function itself.
71
+ cumulative_seconds: Time spent in the function and everything it called.
72
+ children: Callees, ordered by cumulative time, descending.
73
+ hidden_children: Callees omitted to respect the tree's child limit.
74
+ recursive: True if this node already appears higher up the same path,
75
+ in which case its callees are not expanded again.
76
+ """
77
+
78
+ funcname: str
79
+ location: str
80
+ num_calls: int
81
+ total_seconds: float
82
+ cumulative_seconds: float
83
+ children: tuple["CallNode", ...] = ()
84
+ hidden_children: int = 0
85
+ recursive: bool = False
86
+
87
+
88
+ @dataclass
89
+ class CallGraph:
90
+ """Caller/callee edges among the profiled frames that fall within a scope.
91
+
92
+ ``pstats`` records, for every frame, the frames that called it. This
93
+ inverts those edges so a method that calls other in-scope methods (e.g.
94
+ [StatsComputer.compute()][dstrack.snapshot._stats.StatsComputer.compute]
95
+ calling ``_build_dataset_stats``) becomes their parent, then projects the
96
+ result into trees rooted at the methods nothing in scope called.
97
+ """
98
+
99
+ _scope: CallScope
100
+ _totals: dict[FrameKey, _FrameTotals] = field(default_factory=dict)
101
+ _children: dict[FrameKey, list[FrameKey]] = field(default_factory=dict)
102
+ _roots: list[FrameKey] = field(default_factory=list)
103
+
104
+ @classmethod
105
+ def from_profile(cls, profiler: cProfile.Profile, scope: CallScope) -> "CallGraph":
106
+ """Build the in-scope call graph of a profiler that has finished running."""
107
+ raw: _RawStats = pstats.Stats(profiler).stats # type: ignore[attr-defined]
108
+ graph = cls(_scope=scope)
109
+ frames = {key for key in raw if scope.contains(key[0])}
110
+ callees: set[FrameKey] = set()
111
+ entrypoints: set[FrameKey] = set()
112
+
113
+ for key in frames:
114
+ primitive_calls, num_calls, total_time, cumulative_time, callers = raw[key]
115
+ graph._totals[key] = (
116
+ primitive_calls,
117
+ num_calls,
118
+ total_time,
119
+ cumulative_time,
120
+ )
121
+ for caller in callers:
122
+ if caller in frames:
123
+ graph._children.setdefault(caller, []).append(key)
124
+ callees.add(key)
125
+ else:
126
+ entrypoints.add(key)
127
+
128
+ # A frame is a root if nothing in scope called it, or if it was also
129
+ # entered from outside the scope. The latter keeps external entry
130
+ # points into a recursive component (f -> g -> f) as roots, which
131
+ # ``frames - callees`` alone would drop, leaving the tree empty.
132
+ graph._roots = graph._by_cumulative((frames - callees) | entrypoints)
133
+ return graph
134
+
135
+ @property
136
+ def is_empty(self) -> bool:
137
+ """True if the profiler captured no in-scope frames at all."""
138
+ return not self._totals
139
+
140
+ def trees(self, *, max_children: int) -> list[CallNode]:
141
+ """Project the graph into call trees, hottest root first.
142
+
143
+ Args:
144
+ max_children: Maximum callees expanded under each node, ranked by
145
+ cumulative time. The rest are counted in ``hidden_children``.
146
+
147
+ Raises:
148
+ ValueError: If ``max_children`` is negative, which would slice the
149
+ callee list from the end rather than cap it as documented.
150
+ """
151
+ if max_children < 0:
152
+ raise ValueError(f"max_children must be >= 0, got {max_children}")
153
+ return [
154
+ self._expand(root, ancestors=frozenset({root}), max_children=max_children)
155
+ for root in self._roots
156
+ ]
157
+
158
+ def _by_cumulative(self, keys: Iterable[FrameKey]) -> list[FrameKey]:
159
+ return sorted(keys, key=lambda key: self._totals[key][3], reverse=True)
160
+
161
+ def _expand(
162
+ self,
163
+ key: FrameKey,
164
+ *,
165
+ ancestors: frozenset[FrameKey],
166
+ max_children: int,
167
+ ) -> CallNode:
168
+ """Build the node for ``key``, expanding its hottest callees.
169
+
170
+ ``ancestors`` guards against (mutual) recursion: a callee already on the
171
+ current path is emitted as a leaf flagged ``recursive`` rather than
172
+ expanded again, which would otherwise never terminate.
173
+ """
174
+ callees = self._by_cumulative(self._children.get(key, []))
175
+ shown, hidden = callees[:max_children], callees[max_children:]
176
+ children = tuple(
177
+ self._node(child, recursive=True)
178
+ if child in ancestors
179
+ else self._expand(
180
+ child,
181
+ ancestors=ancestors | {child},
182
+ max_children=max_children,
183
+ )
184
+ for child in shown
185
+ )
186
+ return self._node(key, children=children, hidden_children=len(hidden))
187
+
188
+ def _node(
189
+ self,
190
+ key: FrameKey,
191
+ *,
192
+ children: tuple[CallNode, ...] = (),
193
+ hidden_children: int = 0,
194
+ recursive: bool = False,
195
+ ) -> CallNode:
196
+ filename, lineno, funcname = key
197
+ _primitive_calls, num_calls, total_time, cumulative_time = self._totals[key]
198
+ return CallNode(
199
+ funcname=funcname,
200
+ location=self._scope.location(filename, lineno),
201
+ num_calls=num_calls,
202
+ total_seconds=total_time,
203
+ cumulative_seconds=cumulative_time,
204
+ children=children,
205
+ hidden_children=hidden_children,
206
+ recursive=recursive,
207
+ )
@@ -0,0 +1,152 @@
1
+ """Renders benchmark runs to a Rich console.
2
+
3
+ This is the only layer that knows what a benchmark looks like on screen. It
4
+ implements [BenchmarkObserver][dstrack._benchmark._runner.BenchmarkObserver], so
5
+ the same object reports progress during a run and the tables after it.
6
+ """
7
+
8
+ from pathlib import Path
9
+
10
+ from rich.console import Console
11
+ from rich.table import Table
12
+
13
+ from dstrack._benchmark._environment import EnvironmentInfo
14
+ from dstrack._benchmark._profiling import CallGraph, CallNode
15
+ from dstrack._benchmark._runner import BenchmarkResult, BenchmarkRun
16
+ from dstrack.console import console as default_console
17
+
18
+ _LAST_CONNECTOR = "└── "
19
+ _MID_CONNECTOR = "├── "
20
+ _LAST_GUIDE = " "
21
+ _MID_GUIDE = "│ "
22
+
23
+
24
+ class ConsoleReporter:
25
+ """Prints a benchmark's progress and results as Rich tables.
26
+
27
+ Args:
28
+ console: Destination console. Defaults to dstrack's shared console;
29
+ inject another to capture the output in tests.
30
+ """
31
+
32
+ def __init__(self, console: Console | None = None) -> None:
33
+ self._console = console or default_console
34
+
35
+ def generating_csv(self, path: Path, num_rows: int) -> None:
36
+ self._console.print(
37
+ f"Generating synthetic CSV at {path} ({num_rows:,} rows)..."
38
+ )
39
+
40
+ def building_metadata(self) -> None:
41
+ self._console.print("Building snapshot metadata...")
42
+
43
+ def computing_stats(self) -> None:
44
+ self._console.print("Computing snapshot statistics...")
45
+
46
+ def report(self, run: BenchmarkRun, *, profile_limit: int) -> None:
47
+ """Print the environment, the timings, and the profile call tree.
48
+
49
+ Args:
50
+ run: The finished benchmark run.
51
+ profile_limit: Max callees shown under each node of the call tree.
52
+ """
53
+ self._console.print(_environment_table(run.environment))
54
+ self._console.print(_results_table(run.result))
55
+ if run.call_graph is not None:
56
+ self._console.print(_call_tree_table(run.call_graph, profile_limit))
57
+
58
+ def csv_kept(self, path: Path) -> None:
59
+ """Report that the generated CSV was left on disk."""
60
+ self._console.print(f"Synthetic CSV kept at {path}")
61
+
62
+
63
+ def _environment_table(env: EnvironmentInfo) -> Table:
64
+ table = Table(title="Environment")
65
+ table.add_column("Field")
66
+ table.add_column("Value")
67
+ table.add_row("OS", f"{env.os_name} {env.os_release}")
68
+ table.add_row("OS version", env.os_version)
69
+ table.add_row("Machine", env.machine)
70
+ table.add_row("CPU model", env.cpu_model)
71
+ table.add_row("CPU count", str(env.cpu_count) if env.cpu_count else "unknown")
72
+ table.add_row(
73
+ "Total memory",
74
+ f"{env.total_memory_gb:.2f} GB"
75
+ if env.total_memory_gb is not None
76
+ else "unknown",
77
+ )
78
+ table.add_row("Python", f"{env.python_implementation} {env.python_version}")
79
+ table.add_row("dstrack version", env.dstrack_version)
80
+ return table
81
+
82
+
83
+ def _results_table(result: BenchmarkResult) -> Table:
84
+ table = Table(title="Snapshot benchmark")
85
+ table.add_column("Metric")
86
+ table.add_column("Value")
87
+ table.add_row("Rows generated", f"{result.num_rows:,}")
88
+ table.add_row("Columns", str(result.num_columns))
89
+ table.add_row("CSV size", f"{result.csv_size_bytes / (1024**2):.2f} MB")
90
+ table.add_row("CSV generation time", f"{result.generation_seconds:.3f} s")
91
+ table.add_row("Metadata build time", f"{result.metadata_seconds:.3f} s")
92
+ table.add_row("Stats compute time", f"{result.stats_seconds:.3f} s")
93
+ table.add_row("Total snapshot time", f"{result.total_snapshot_seconds:.3f} s")
94
+ table.add_row("Throughput", f"{result.rows_per_second:,.0f} rows/s")
95
+ table.add_row("schema_hash", result.schema_hash)
96
+ return table
97
+
98
+
99
+ def _call_tree_table(graph: CallGraph, profile_limit: int) -> Table:
100
+ """Render dstrack's call tree, hottest root first.
101
+
102
+ Guide characters (``├──``, ``└──``, ``│``) are baked into the "Method" cell
103
+ as plain text so the Calls/Total/Cumulative columns line up as a normal
104
+ table regardless of tree depth.
105
+ """
106
+ table = Table(title="dstrack call tree (by cumulative time)")
107
+ table.add_column("Method", overflow="fold", ratio=1)
108
+ table.add_column("Calls", justify="right", no_wrap=True)
109
+ table.add_column("Total (s)", justify="right", no_wrap=True)
110
+ table.add_column("Cumulative (s)", justify="right", no_wrap=True)
111
+
112
+ if graph.is_empty:
113
+ table.add_row("(no dstrack frames captured)", "-", "-", "-")
114
+ return table
115
+
116
+ roots = graph.trees(max_children=profile_limit)
117
+ for i, root in enumerate(roots):
118
+ _add_node_rows(table, root, prefix="", is_last=i == len(roots) - 1)
119
+ return table
120
+
121
+
122
+ def _add_node_rows(table: Table, node: CallNode, *, prefix: str, is_last: bool) -> None:
123
+ """Add a row for ``node`` and, recursively, for the callees under it."""
124
+ connector = _LAST_CONNECTOR if is_last else _MID_CONNECTOR
125
+ table.add_row(
126
+ f"{prefix}{connector}{_format_node(node)}",
127
+ str(node.num_calls),
128
+ f"{node.total_seconds:.4f}",
129
+ f"{node.cumulative_seconds:.4f}",
130
+ )
131
+
132
+ child_prefix = prefix + (_LAST_GUIDE if is_last else _MID_GUIDE)
133
+ for i, child in enumerate(node.children):
134
+ child_is_last = i == len(node.children) - 1 and not node.hidden_children
135
+ _add_node_rows(table, child, prefix=child_prefix, is_last=child_is_last)
136
+
137
+ if node.hidden_children:
138
+ table.add_row(
139
+ f"{child_prefix}{_LAST_CONNECTOR}[dim]... {node.hidden_children} more "
140
+ "(raise --profile-limit to see them)[/dim]",
141
+ "-",
142
+ "-",
143
+ "-",
144
+ )
145
+
146
+
147
+ def _format_node(node: CallNode) -> str:
148
+ """Render a node's function name and source location."""
149
+ label = f"[bold]{node.funcname}[/bold] [dim]{node.location}[/dim]"
150
+ if node.recursive:
151
+ label += " [dim italic](recursive, not expanded)[/dim italic]"
152
+ return label