Skip to content

Commit 98f0f08

Browse files
committed
Helper script to check if a trace file is balanced
codetracer-python-recorder/codetracer_python_recorder/trace_balance.py: codetracer-python-recorder/scripts/check_trace_balance.py: codetracer-python-recorder/tests/python/test_trace_balance.py: Signed-off-by: Tzanko Matev <[email protected]>
1 parent c6d0106 commit 98f0f08

File tree

3 files changed

+274
-0
lines changed

3 files changed

+274
-0
lines changed
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
"""Utilities for validating the balance of Codetracer JSON trace files.
2+
3+
A trace is considered *balanced* when it contains the same number of
4+
``Call`` and ``Return`` events and the stream of events never dips below
5+
zero (i.e. we never see a return without a preceding call). This helper
6+
module provides small, importable utilities that can be reused by the
7+
CLI helper script as well as unit tests.
8+
"""
9+
10+
from __future__ import annotations
11+
12+
import json
13+
from dataclasses import dataclass
14+
from pathlib import Path
15+
from typing import Any, Iterable, Mapping, Sequence
16+
17+
18+
class TraceBalanceError(RuntimeError):
19+
"""Raised when the trace file is malformed or cannot be processed."""
20+
21+
22+
@dataclass(frozen=True)
23+
class TraceBalanceResult:
24+
"""Summary information about call/return balance within a trace."""
25+
26+
call_count: int
27+
return_count: int
28+
first_negative_index: int | None
29+
30+
@property
31+
def is_balanced(self) -> bool:
32+
"""Return True when the trace has matching call/return counts and never underflows."""
33+
return self.call_count == self.return_count and self.first_negative_index is None
34+
35+
@property
36+
def delta(self) -> int:
37+
"""Number of call events minus return events."""
38+
return self.call_count - self.return_count
39+
40+
41+
def load_trace_events(trace_path: Path) -> Sequence[Mapping[str, Any]]:
42+
"""Load and validate the JSON event stream from ``trace.json``."""
43+
try:
44+
raw_text = trace_path.read_text(encoding="utf-8")
45+
except FileNotFoundError as exc:
46+
raise TraceBalanceError(f"trace file not found: {trace_path}") from exc
47+
except OSError as exc: # pragma: no cover - surfaced in tests via mocked IO
48+
raise TraceBalanceError(f"unable to read trace file: {trace_path}") from exc
49+
50+
try:
51+
data = json.loads(raw_text)
52+
except json.JSONDecodeError as exc:
53+
raise TraceBalanceError(f"invalid JSON in trace file: {trace_path}: {exc}") from exc
54+
55+
if not isinstance(data, list):
56+
raise TraceBalanceError(f"trace root must be a JSON array, got {type(data).__name__}")
57+
58+
for index, event in enumerate(data):
59+
if not isinstance(event, dict):
60+
raise TraceBalanceError(
61+
f"event #{index} is not a JSON object (found {type(event).__name__})"
62+
)
63+
64+
return data
65+
66+
67+
def summarize_trace_balance(events: Iterable[Mapping[str, Any]]) -> TraceBalanceResult:
68+
"""Return a balance summary for the provided event sequence."""
69+
calls = 0
70+
returns = 0
71+
active_calls = 0
72+
first_negative_index: int | None = None
73+
74+
for index, event in enumerate(events):
75+
is_call = "Call" in event
76+
is_return = "Return" in event
77+
78+
if is_call and is_return:
79+
raise TraceBalanceError(
80+
f"event #{index} contains both Call and Return payloads, which is unsupported"
81+
)
82+
83+
if is_call:
84+
calls += 1
85+
active_calls += 1
86+
if is_return:
87+
returns += 1
88+
active_calls -= 1
89+
if active_calls < 0 and first_negative_index is None:
90+
first_negative_index = index
91+
92+
return TraceBalanceResult(
93+
call_count=calls,
94+
return_count=returns,
95+
first_negative_index=first_negative_index,
96+
)
97+
98+
99+
__all__ = [
100+
"TraceBalanceError",
101+
"TraceBalanceResult",
102+
"load_trace_events",
103+
"summarize_trace_balance",
104+
]
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
"""CLI helper to verify that a Codetracer ``trace.json`` stream is balanced."""
2+
3+
from __future__ import annotations
4+
5+
import argparse
6+
import sys
7+
from pathlib import Path
8+
from typing import Sequence
9+
10+
from codetracer_python_recorder.trace_balance import (
11+
TraceBalanceError,
12+
load_trace_events,
13+
summarize_trace_balance,
14+
)
15+
16+
17+
def build_parser() -> argparse.ArgumentParser:
18+
parser = argparse.ArgumentParser(
19+
description="Check whether a Codetracer trace.json has matching call and return events.",
20+
)
21+
parser.add_argument(
22+
"trace",
23+
type=Path,
24+
help="Path to trace.json emitted by Codetracer",
25+
)
26+
return parser
27+
28+
29+
def main(argv: Sequence[str] | None = None) -> int:
30+
parser = build_parser()
31+
args = parser.parse_args(argv)
32+
33+
trace_path = args.trace
34+
35+
try:
36+
events = load_trace_events(trace_path)
37+
result = summarize_trace_balance(events)
38+
except TraceBalanceError as exc:
39+
print(f"error: {exc}", file=sys.stderr)
40+
return 2
41+
42+
if result.is_balanced:
43+
print(
44+
f"Balanced trace: {result.call_count} call events, "
45+
f"{result.return_count} return events."
46+
)
47+
return 0
48+
49+
print("Unbalanced trace detected.")
50+
print(f"Call events : {result.call_count}")
51+
print(f"Return events : {result.return_count}")
52+
53+
delta = result.delta
54+
if delta > 0:
55+
print(f"Missing {delta} return event(s).")
56+
elif delta < 0:
57+
print(f"Unexpected {-delta} extra return event(s).")
58+
59+
if result.first_negative_index is not None:
60+
print(
61+
"The first unmatched return appears at event "
62+
f"#{result.first_negative_index} (0-based index)."
63+
)
64+
65+
return 1
66+
67+
68+
if __name__ == "__main__":
69+
sys.exit(main())
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import json
2+
import importlib.util
3+
from pathlib import Path
4+
from typing import List, Mapping
5+
6+
import pytest
7+
8+
from codetracer_python_recorder.trace_balance import (
9+
TraceBalanceError,
10+
TraceBalanceResult,
11+
load_trace_events,
12+
summarize_trace_balance,
13+
)
14+
15+
16+
def _write_trace(tmp_path: Path, events: List[Mapping[str, object]]) -> Path:
17+
path = tmp_path / "trace.json"
18+
path.write_text(json.dumps(events))
19+
return path
20+
21+
22+
def _load_cli() -> object:
23+
module_path = Path(__file__).parents[2] / "scripts" / "check_trace_balance.py"
24+
spec = importlib.util.spec_from_file_location("check_trace_balance", module_path)
25+
if spec is None or spec.loader is None:
26+
raise RuntimeError("Unable to load check_trace_balance.py module spec")
27+
module = importlib.util.module_from_spec(spec)
28+
spec.loader.exec_module(module)
29+
return module
30+
31+
32+
def test_summarize_trace_balance_identifies_balanced_trace() -> None:
33+
events = [{"Call": {}}, {"Return": {}}]
34+
result = summarize_trace_balance(events)
35+
36+
assert isinstance(result, TraceBalanceResult)
37+
assert result.call_count == 1
38+
assert result.return_count == 1
39+
assert result.is_balanced
40+
assert result.delta == 0
41+
assert result.first_negative_index is None
42+
43+
44+
def test_summarize_trace_balance_detects_missing_return() -> None:
45+
events = [{"Call": {}}, {"Call": {}}, {"Return": {}}]
46+
result = summarize_trace_balance(events)
47+
48+
assert result.call_count == 2
49+
assert result.return_count == 1
50+
assert not result.is_balanced
51+
assert result.delta == 1
52+
assert result.first_negative_index is None
53+
54+
55+
def test_summarize_trace_balance_detects_unmatched_return() -> None:
56+
events = [{"Return": {}}, {"Call": {}}]
57+
result = summarize_trace_balance(events)
58+
59+
assert result.call_count == 1
60+
assert result.return_count == 1
61+
assert not result.is_balanced
62+
assert result.first_negative_index == 0
63+
64+
65+
def test_load_trace_events_validates_structure(tmp_path: Path) -> None:
66+
trace_path = _write_trace(tmp_path, [{"Call": {}}])
67+
68+
events = load_trace_events(trace_path)
69+
assert isinstance(events, list)
70+
assert events[0]["Call"] == {}
71+
72+
73+
def test_load_trace_events_raises_on_non_array(tmp_path: Path) -> None:
74+
trace_path = tmp_path / "trace.json"
75+
trace_path.write_text("{}")
76+
77+
with pytest.raises(TraceBalanceError):
78+
load_trace_events(trace_path)
79+
80+
81+
def test_cli_returns_non_zero_on_unbalanced(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
82+
trace_path = _write_trace(tmp_path, [{"Call": {}}, {"Return": {}}, {"Return": {}}])
83+
cli = _load_cli()
84+
85+
exit_code = cli.main([str(trace_path)])
86+
captured = capsys.readouterr()
87+
88+
assert exit_code == 1
89+
assert "Unbalanced trace detected." in captured.out
90+
assert "Unexpected 1 extra return event(s)." in captured.out
91+
92+
93+
def test_cli_reports_success_for_balanced_trace(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
94+
trace_path = _write_trace(tmp_path, [{"Call": {}}, {"Return": {}}])
95+
cli = _load_cli()
96+
97+
exit_code = cli.main([str(trace_path)])
98+
captured = capsys.readouterr()
99+
100+
assert exit_code == 0
101+
assert "Balanced trace" in captured.out

0 commit comments

Comments
 (0)