From 573b966d1c0a7039d0aee73be323335665556271 Mon Sep 17 00:00:00 2001 From: Vincent <911vincentlee@gmail.com> Date: Wed, 22 Apr 2026 17:10:18 -0500 Subject: [PATCH 1/4] feature/repeat-flag added repeat flag feature --- oracletrace/cli.py | 74 ++++++++++++++++++++++++++++++++++--------- oracletrace/tracer.py | 51 +++++++++++++++++++++++------ 2 files changed, 100 insertions(+), 25 deletions(-) diff --git a/oracletrace/cli.py b/oracletrace/cli.py index 946f9be..5c9bfa8 100644 --- a/oracletrace/cli.py +++ b/oracletrace/cli.py @@ -4,7 +4,9 @@ import json import runpy import csv -from .tracer import Tracer, TracerData +from collections import defaultdict + +from .tracer import Tracer, TracerData, TracerMetadata, AggFunctionData from .compare import compare_traces, ComparisonData from typing import List, Dict, Any, Optional from re import Pattern @@ -48,6 +50,14 @@ def main() -> int: action="store_true", help="Hide functions which didn't run slower than baseline. Use with --compare" ) + + parser.add_argument( + "--repeat", + metavar="NUMBER", + help="Number of times to run the trace against the previous trace JSON", + default=1 + ) + args: Namespace = parser.parse_args() target: str = args.target @@ -71,26 +81,60 @@ def main() -> int: print(f"Regex error: {pattern} -> {e}") return 1 - # Start tracing, run the script, then stop - tracer: Tracer = Tracer(root, ignore_patterns=ignore_patterns) - tracer.start() - try: - runpy.run_path(target, run_name="__main__") - finally: - tracer.stop() + def run_trace(): + _tracer: Tracer = Tracer(root, ignore_patterns=ignore_patterns) + + _tracer.start() + try: + runpy.run_path(target, run_name="__main__") + finally: + _tracer.stop() + + _data: TracerData = _tracer.get_trace_data() - data: TracerData = tracer.get_trace_data() + return _tracer, _data + + tracer, data = run_trace() + + runs = int(args.repeat) + if runs > 1: + total_time = 0 + tracer_function_aggs = defaultdict(AggFunctionData) + for _ in range(runs): + # Start tracing, run the script, then stop + tracer, data = run_trace() + + for function_data in data.functions: + tracer_function_aggs[function_data.name].add(function_data) + total_time += function_data.total_time + + tracer_agg = TracerData( + metadata=TracerMetadata( + root_path=data.metadata.root_path, + total_functions=len(tracer_function_aggs), + total_execution_time=total_time + ), + functions=list(tracer_function_aggs.values()) + ) + + data = tracer_agg + + def set_default(obj): + if isinstance(obj, set): + return list(obj) + raise TypeError # Save json if args.json: with open(args.json, "w", encoding="utf-8") as f: - json.dump(asdict(data), f, indent=4) + json.dump(asdict(data), f, indent=4, default=set_default) # Display the analysis - if args.top: - tracer.show_results(int(args.top[0])) - else: - tracer.show_results(None) + if runs <= 1: + if args.top: + tracer.show_results(int(args.top[0])) + else: + tracer.show_results(None) # Export as csv if args.csv: @@ -123,7 +167,7 @@ def main() -> int: f"Build failed: performance regression above {args.threshold:.2f}% detected." ) return 2 - + return 0 diff --git a/oracletrace/tracer.py b/oracletrace/tracer.py index 31fcbe1..67fae23 100644 --- a/oracletrace/tracer.py +++ b/oracletrace/tracer.py @@ -9,7 +9,8 @@ from re import Pattern from pathlib import Path from types import FrameType -from dataclasses import dataclass +from dataclasses import dataclass, field + @dataclass class TracerMetadata: @@ -18,17 +19,48 @@ class TracerMetadata: total_execution_time: float @dataclass -class FunctionData: - name: str - total_time: float - call_count: int - avg_time: float - callees: List[str] +class FunctionDataBase: + name: str | None = None + total_time: float | None = None + call_count: int | None = None + avg_time: float | None = None + callees: set[str] = field(default_factory=set) + + +@dataclass +class FunctionData(FunctionDataBase): + pass + +@dataclass +class AggFunctionData(FunctionDataBase): + def add(self, trace: FunctionData) -> None: + if self.name is None: + self.name = trace.name + elif trace.name != self.name: + return + + self.callees.update(trace.callees) + + if self.total_time is None: + self.total_time = trace.total_time + else: + self.total_time = (self.total_time + trace.total_time) / 2 + + if self.call_count is None: + self.call_count = trace.call_count + else: + self.call_count = (self.call_count + trace.call_count) // 2 + + if self.avg_time is None: + self.avg_time = trace.avg_time + else: + self.avg_time = (self.avg_time + trace.avg_time) / 2 + @dataclass class TracerData: metadata: TracerMetadata - functions: List[FunctionData] + functions: List[FunctionDataBase] @classmethod def from_dict(cls, data: Dict[str, Any]) -> "TracerData": @@ -207,14 +239,13 @@ def get_trace_data(self) -> TracerData: for key, total_time in self._func_time.items(): calls = self._func_calls[key] avg_time = total_time / calls if calls else 0 - functions.append( FunctionData( name = key, total_time = total_time, call_count = calls, avg_time = avg_time, - callees = list(self._call_map.get(key, {}).keys()), + callees = {k for k in self._call_map.get(key, {}).keys()}, ) ) From 52a545cdeb2014c3b5706579e52b5c88a9bee6a8 Mon Sep 17 00:00:00 2001 From: Vincent <911vincentlee@gmail.com> Date: Wed, 22 Apr 2026 17:32:06 -0500 Subject: [PATCH 2/4] feature/repeat-flag typing fixes --- oracletrace/cli.py | 11 +++++------ oracletrace/tracer.py | 17 ++++++++--------- 2 files changed, 13 insertions(+), 15 deletions(-) diff --git a/oracletrace/cli.py b/oracletrace/cli.py index 5c9bfa8..802f01b 100644 --- a/oracletrace/cli.py +++ b/oracletrace/cli.py @@ -8,10 +8,9 @@ from .tracer import Tracer, TracerData, TracerMetadata, AggFunctionData from .compare import compare_traces, ComparisonData -from typing import List, Dict, Any, Optional +from typing import List, Dict, Optional from re import Pattern from argparse import ArgumentParser, Namespace -from pathlib import Path from dataclasses import asdict @@ -96,10 +95,10 @@ def run_trace(): tracer, data = run_trace() - runs = int(args.repeat) + runs: int = int(args.repeat) if runs > 1: - total_time = 0 - tracer_function_aggs = defaultdict(AggFunctionData) + total_time: float = 0 + tracer_function_aggs: Dict[str, AggFunctionData] = defaultdict(AggFunctionData) for _ in range(runs): # Start tracing, run the script, then stop tracer, data = run_trace() @@ -108,7 +107,7 @@ def run_trace(): tracer_function_aggs[function_data.name].add(function_data) total_time += function_data.total_time - tracer_agg = TracerData( + tracer_agg: TracerData = TracerData( metadata=TracerMetadata( root_path=data.metadata.root_path, total_functions=len(tracer_function_aggs), diff --git a/oracletrace/tracer.py b/oracletrace/tracer.py index 67fae23..0e8f45b 100644 --- a/oracletrace/tracer.py +++ b/oracletrace/tracer.py @@ -7,7 +7,6 @@ from rich import print from typing import List, Optional, Callable, DefaultDict, Any, Tuple, Dict from re import Pattern -from pathlib import Path from types import FrameType from dataclasses import dataclass, field @@ -20,10 +19,10 @@ class TracerMetadata: @dataclass class FunctionDataBase: - name: str | None = None - total_time: float | None = None - call_count: int | None = None - avg_time: float | None = None + name: str = None + total_time: float = None + call_count: int = None + avg_time: float = None callees: set[str] = field(default_factory=set) @@ -33,7 +32,7 @@ class FunctionData(FunctionDataBase): @dataclass class AggFunctionData(FunctionDataBase): - def add(self, trace: FunctionData) -> None: + def add(self, trace: FunctionDataBase) -> None: if self.name is None: self.name = trace.name elif trace.name != self.name: @@ -66,7 +65,7 @@ class TracerData: def from_dict(cls, data: Dict[str, Any]) -> "TracerData": return cls( metadata=TracerMetadata(**data["metadata"]), - functions=[FunctionData(**f) for f in data["functions"]], + functions=[FunctionDataBase(**f) for f in data["functions"]], ) class Tracer: @@ -220,13 +219,13 @@ def add_nodes(parent_node: Tree, parent_key: str, current_path: set[str]) -> Non ) for child_key, count in sorted_children: - total_time = self._func_time[child_key] + _total_time = self._func_time[child_key] # Detect recursion to prevent infinite loops in the tree if child_key in current_path: parent_node.add(f"[red]↻ {child_key}[/] ({count}x)") continue - node_text = f"{child_key} [dim]({count}x, {total_time:.4f}s)[/]" + node_text = f"{child_key} [dim]({count}x, {_total_time:.4f}s)[/]" child_node = parent_node.add(node_text) add_nodes(child_node, child_key, current_path | {child_key}) From 9ac4b1ade084af8797e3c3c76ac64f49f0edac5b Mon Sep 17 00:00:00 2001 From: Vincent <911vincentlee@gmail.com> Date: Wed, 22 Apr 2026 17:43:13 -0500 Subject: [PATCH 3/4] feature/repeat-flag test fixes --- tests/test_cli.py | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/tests/test_cli.py b/tests/test_cli.py index 6f2532c..fc5cd32 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -2,7 +2,7 @@ import importlib import sys from pathlib import Path -from oracletrace.tracer import TracerData, FunctionData, TracerMetadata +from oracletrace.tracer import TracerData, FunctionData, TracerMetadata, FunctionDataBase from oracletrace.compare import ComparisonData from dataclasses import asdict import pytest @@ -16,6 +16,11 @@ assert str(REPO_ROOT / "oracletrace") in str(Path(cli.__file__).resolve()) +def set_default(obj): + if isinstance(obj, set): + return list(obj) + raise TypeError + @pytest.fixture def trace_data() -> TracerData: return TracerData( @@ -25,14 +30,14 @@ def trace_data() -> TracerData: root_path = str(REPO_ROOT) ), functions = [ - FunctionData( + FunctionDataBase( name = "foo", total_time = 3.033, call_count = 3, avg_time = 1.011, callees=[] ), - FunctionData( + FunctionDataBase( name = "bar", total_time = 2.0, call_count = 2, @@ -56,14 +61,14 @@ def baseline_trace_data() -> TracerData: total_time = 1.5, call_count = 3, avg_time = 0.5, - callees=[] + callees=set() ), FunctionData( name = "bar", total_time = 2.0, call_count = 2, avg_time = 1.0, - callees=[] + callees=set() ) ] ) @@ -340,7 +345,7 @@ def test_main_fails_with_exit_2_on_regression(monkeypatch, tmp_path, empty_trace target = tmp_path / "target.py" target.write_text("print('hello')\n", encoding="utf-8") compare_file = tmp_path / "baseline.json" - compare_file.write_text(json.dumps(asdict(empty_trace_data)), encoding="utf-8") + compare_file.write_text(json.dumps(asdict(empty_trace_data), default=set_default), encoding="utf-8") monkeypatch.setattr(cli, "Tracer", lambda root, ignore_patterns: FakeTracer(root, ignore_patterns, empty_trace_data)) monkeypatch.setattr(cli.runpy, "run_path", lambda *args, **kwargs: None) @@ -376,7 +381,7 @@ def test_main_returns_0_when_no_regression(monkeypatch, tmp_path, trace_data): target = tmp_path / "target.py" target.write_text("print('hello')\n", encoding="utf-8") compare_file = tmp_path / "baseline.json" - compare_file.write_text(json.dumps(asdict(trace_data)), encoding="utf-8") + compare_file.write_text(json.dumps(asdict(trace_data), default=set_default), encoding="utf-8") monkeypatch.setattr(cli, "Tracer", lambda root, ignore_patterns: FakeTracer(root, ignore_patterns, trace_data)) monkeypatch.setattr(cli.runpy, "run_path", lambda *args, **kwargs: None) @@ -406,7 +411,7 @@ def test_main_shows_only_regressions(monkeypatch, tmp_path, trace_data, baseline target = tmp_path / "target.py" target.write_text("print('hello')\n", encoding="utf-8") compare_file = tmp_path / "baseline.json" - compare_file.write_text(json.dumps(asdict(baseline_trace_data)), encoding="utf-8") + compare_file.write_text(json.dumps(asdict(baseline_trace_data), default=set_default), encoding="utf-8") monkeypatch.setattr(cli, "Tracer", lambda root, ignore_patterns: FakeTracer(root, ignore_patterns, trace_data)) monkeypatch.setattr(cli.runpy, "run_path", lambda *args, **kwargs: None) From 34fad93ec346da6f0eb4b8f202036ca69e79405b Mon Sep 17 00:00:00 2001 From: Vincent <911vincentlee@gmail.com> Date: Wed, 22 Apr 2026 17:58:46 -0500 Subject: [PATCH 4/4] feature/repeat-flag functiondata refactor --- oracletrace/cli.py | 9 +++++--- oracletrace/tracer.py | 48 ++++++++++++------------------------------- tests/test_cli.py | 6 +++--- 3 files changed, 22 insertions(+), 41 deletions(-) diff --git a/oracletrace/cli.py b/oracletrace/cli.py index 09de841..cb21611 100644 --- a/oracletrace/cli.py +++ b/oracletrace/cli.py @@ -6,7 +6,7 @@ import csv from collections import defaultdict -from .tracer import Tracer, TracerData, TracerMetadata, AggFunctionData +from .tracer import Tracer, TracerData, TracerMetadata, FunctionData from .compare import compare_traces, ComparisonData from typing import List, Dict, Optional from re import Pattern @@ -109,13 +109,16 @@ def run_trace(): runs: int = int(args.repeat) if runs > 1: total_time: float = 0 - tracer_function_aggs: Dict[str, AggFunctionData] = defaultdict(AggFunctionData) + tracer_function_aggs: Dict[str, FunctionData] = {} for _ in range(runs): # Start tracing, run the script, then stop tracer, data = run_trace() for function_data in data.functions: - tracer_function_aggs[function_data.name].add(function_data) + if tracer_function_aggs.get(function_data.name) is None: + tracer_function_aggs[function_data.name] = function_data + else: + tracer_function_aggs[function_data.name].add(function_data) total_time += function_data.total_time tracer_agg: TracerData = TracerData( diff --git a/oracletrace/tracer.py b/oracletrace/tracer.py index 0e8f45b..f44f621 100644 --- a/oracletrace/tracer.py +++ b/oracletrace/tracer.py @@ -5,7 +5,7 @@ from rich.tree import Tree from rich.table import Table from rich import print -from typing import List, Optional, Callable, DefaultDict, Any, Tuple, Dict +from typing import List, Optional, Callable, DefaultDict, Any, Tuple, Dict, Self from re import Pattern from types import FrameType from dataclasses import dataclass, field @@ -18,54 +18,32 @@ class TracerMetadata: total_execution_time: float @dataclass -class FunctionDataBase: - name: str = None - total_time: float = None - call_count: int = None - avg_time: float = None +class FunctionData: + name: str + total_time: float + call_count: int + avg_time: float callees: set[str] = field(default_factory=set) - -@dataclass -class FunctionData(FunctionDataBase): - pass - -@dataclass -class AggFunctionData(FunctionDataBase): - def add(self, trace: FunctionDataBase) -> None: - if self.name is None: - self.name = trace.name - elif trace.name != self.name: + def add(self, trace: type[Self]) -> None: + if trace.name != self.name: return self.callees.update(trace.callees) - - if self.total_time is None: - self.total_time = trace.total_time - else: - self.total_time = (self.total_time + trace.total_time) / 2 - - if self.call_count is None: - self.call_count = trace.call_count - else: - self.call_count = (self.call_count + trace.call_count) // 2 - - if self.avg_time is None: - self.avg_time = trace.avg_time - else: - self.avg_time = (self.avg_time + trace.avg_time) / 2 - + self.total_time = (self.total_time + trace.total_time) / 2 + self.call_count = (self.call_count + trace.call_count) // 2 + self.avg_time = (self.avg_time + trace.avg_time) / 2 @dataclass class TracerData: metadata: TracerMetadata - functions: List[FunctionDataBase] + functions: List[FunctionData] @classmethod def from_dict(cls, data: Dict[str, Any]) -> "TracerData": return cls( metadata=TracerMetadata(**data["metadata"]), - functions=[FunctionDataBase(**f) for f in data["functions"]], + functions=[FunctionData(**f) for f in data["functions"]], ) class Tracer: diff --git a/tests/test_cli.py b/tests/test_cli.py index fc5cd32..47e9a30 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -2,7 +2,7 @@ import importlib import sys from pathlib import Path -from oracletrace.tracer import TracerData, FunctionData, TracerMetadata, FunctionDataBase +from oracletrace.tracer import TracerData, FunctionData, TracerMetadata, FunctionData from oracletrace.compare import ComparisonData from dataclasses import asdict import pytest @@ -30,14 +30,14 @@ def trace_data() -> TracerData: root_path = str(REPO_ROOT) ), functions = [ - FunctionDataBase( + FunctionData( name = "foo", total_time = 3.033, call_count = 3, avg_time = 1.011, callees=[] ), - FunctionDataBase( + FunctionData( name = "bar", total_time = 2.0, call_count = 2,