Skip to content

Commit d2a22f4

Browse files
authored
Merge pull request #265 from PyAutoLabs/claude/test-performance-dashboard-y3fdy7
feat: per-script smoke timings as a standing dataset (#264)
2 parents 0324b92 + 0bc7527 commit d2a22f4

6 files changed

Lines changed: 641 additions & 1 deletion

File tree

autohands/aggregate_results.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,14 @@ def _surface(runs: list, script_count: int) -> dict:
131131

132132
def aggregate(results_dir: Path) -> dict:
133133
"""Read all JSON result files and produce a consolidated report."""
134-
json_files = sorted(results_dir.glob("**/*.json"))
134+
# The timing dataset lives in the same directory but is not a run report —
135+
# it has no ``results`` key and would enter ``runs`` as an empty phantom
136+
# run, so it is excluded by name here rather than by shape.
137+
from result_collector import TIMINGS_FILENAME
138+
139+
json_files = sorted(
140+
p for p in results_dir.glob("**/*.json") if p.name != TIMINGS_FILENAME
141+
)
135142
if not json_files:
136143
print(f"No JSON result files found in {results_dir}", file=sys.stderr)
137144
return {

autohands/build_util.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -490,6 +490,7 @@ def _classify_notebook_run(run_target, recorded, report, env, timeout_secs):
490490
status=Status.TIMEOUT,
491491
duration_seconds=duration,
492492
error_message=message,
493+
cap_seconds=timeout_secs,
493494
))
494495
return "timeout"
495496
logging.exception(e)
@@ -509,6 +510,8 @@ def _classify_notebook_run(run_target, recorded, report, env, timeout_secs):
509510
status=Status.PASSED,
510511
duration_seconds=duration,
511512
error_message="sys.exit(0) skip guard (ignored)",
513+
cap_seconds=timeout_secs,
514+
exit_code=e.returncode,
512515
))
513516
else:
514517
print(f" PASS (skipped via sys.exit(0), {duration:.1f}s)")
@@ -523,6 +526,8 @@ def _classify_notebook_run(run_target, recorded, report, env, timeout_secs):
523526
status=Status.PASSED,
524527
duration_seconds=duration,
525528
error_message="InversionException (ignored)",
529+
cap_seconds=timeout_secs,
530+
exit_code=e.returncode,
526531
))
527532
return "passed"
528533

@@ -537,6 +542,8 @@ def _classify_notebook_run(run_target, recorded, report, env, timeout_secs):
537542
duration_seconds=duration,
538543
error_message=str(e),
539544
traceback=stderr,
545+
cap_seconds=timeout_secs,
546+
exit_code=e.returncode,
540547
))
541548
return "failed"
542549
# stderr is captured now (see the subprocess call above), so echo it
@@ -554,6 +561,8 @@ def _classify_notebook_run(run_target, recorded, report, env, timeout_secs):
554561
file=recorded,
555562
status=Status.PASSED,
556563
duration_seconds=duration,
564+
cap_seconds=timeout_secs,
565+
exit_code=0,
557566
))
558567
return "passed"
559568

@@ -758,6 +767,7 @@ def execute_script(f, report=None, env=None, extra_args=None):
758767
status=Status.TIMEOUT,
759768
duration_seconds=duration,
760769
error_message=message,
770+
cap_seconds=timeout_secs,
761771
))
762772
return
763773
logging.exception(e)
@@ -777,6 +787,8 @@ def execute_script(f, report=None, env=None, extra_args=None):
777787
duration_seconds=duration,
778788
error_message=str(e),
779789
traceback=stderr,
790+
cap_seconds=timeout_secs,
791+
exit_code=e.returncode,
780792
))
781793
return
782794
logging.exception(e)
@@ -790,6 +802,8 @@ def execute_script(f, report=None, env=None, extra_args=None):
790802
file=str(f),
791803
status=Status.PASSED,
792804
duration_seconds=duration,
805+
cap_seconds=timeout_secs,
806+
exit_code=0,
793807
))
794808

795809

autohands/result_collector.py

Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,25 @@
11
import dataclasses
22
import datetime
33
import json
4+
import os
5+
import sys
46
from enum import Enum
57
from pathlib import Path
68
from typing import List, Optional
79

10+
# The consolidated per-entry timing dataset written alongside every report.
11+
#
12+
# Why a second file rather than more keys on the per-run JSON: the per-run
13+
# ``<project>__<dir>__<run_type>.json`` files are read by PyAutoHeart
14+
# (``script_timing`` globs ``*__script.json``, ``test_run`` reads the
15+
# aggregated ``report.json``) and by ``aggregate_results``. Their shape is a
16+
# published interface, so the timing dataset is emitted beside them instead —
17+
# one file per report DIRECTORY, merged across the legs that write into it, so
18+
# a run's timings are a single artifact regardless of how many runner
19+
# invocations produced them.
20+
TIMINGS_FILENAME = "smoke_timings.json"
21+
TIMINGS_SCHEMA = "smoke_timings/1"
22+
823

924
class Status(str, Enum):
1025
PASSED = "passed"
@@ -13,6 +28,24 @@ class Status(str, Enum):
1328
TIMEOUT = "timeout"
1429

1530

31+
def workspace_relative(path: str) -> str:
32+
"""Render a recorded result path relative to the workspace root (cwd).
33+
34+
The runners record absolute paths (``find_scripts_in_folder`` and
35+
``files_from_list`` both build from ``Path.cwd()``). The timing dataset is
36+
compared ACROSS runs and machines, where an absolute path is noise and, on
37+
a GitHub runner, a different string every time. A path outside the
38+
workspace, or an already-relative one, is returned unchanged.
39+
"""
40+
p = Path(path)
41+
if not p.is_absolute():
42+
return str(p)
43+
try:
44+
return str(p.relative_to(Path.cwd()))
45+
except ValueError: # pragma: no cover - a path outside the workspace
46+
return str(p)
47+
48+
1649
@dataclasses.dataclass
1750
class ScriptResult:
1851
file: str
@@ -21,8 +54,55 @@ class ScriptResult:
2154
error_message: Optional[str] = None
2255
traceback: Optional[str] = None
2356
skip_reason: Optional[str] = None
57+
# The wall-clock cap that was in force for this entry, as resolved by
58+
# ``build_util.timeout_for`` at the execution site (a profile's
59+
# ``BUILD_SCRIPT_TIMEOUT`` override, else the ambient global). Only an
60+
# entry that actually entered an execution carries one, so ``None`` is the
61+
# marker for "never ran" — which is what keeps a skipped entry out of the
62+
# timing dataset instead of being recorded as a 0-second run.
63+
cap_seconds: Optional[float] = None
64+
# The child's exit status. ``None`` for a timeout (the process group was
65+
# killed, so there is no exit code the script chose) and for entries that
66+
# never ran.
67+
exit_code: Optional[int] = None
68+
69+
@property
70+
def was_timed(self) -> bool:
71+
"""True when this entry actually ran and its duration is a measurement.
72+
73+
A SKIPPED entry never started, and a listed-but-missing entry fails
74+
before any execution — both carry ``duration_seconds == 0.0`` purely as
75+
the dataclass default. Recording those as "0 seconds" would put
76+
fabricated rows in a dataset whose whole purpose is timing, so they are
77+
emitted with a null duration instead.
78+
"""
79+
if self.status == Status.SKIPPED:
80+
return False
81+
return self.cap_seconds is not None or self.duration_seconds > 0
82+
83+
def to_timings_entry(self) -> dict:
84+
"""One row of the timing dataset.
85+
86+
``seconds`` is the runner's OWN measurement — the same
87+
``time.time()`` delta ``build_util`` prints on the ``PASS`` /
88+
``TIMEOUT`` line — never re-derived from timestamps elsewhere.
89+
"""
90+
path = workspace_relative(self.file)
91+
return {
92+
"entry": path,
93+
"kind": "notebook" if path.endswith(".ipynb") else "script",
94+
"status": self.status.value,
95+
"seconds": round(self.duration_seconds, 2) if self.was_timed else None,
96+
"cap_s": self.cap_seconds,
97+
"exit_code": self.exit_code,
98+
}
2499

25100
def to_dict(self):
101+
# ``cap_seconds`` / ``exit_code`` are deliberately NOT emitted here.
102+
# This dict is the per-run JSON that PyAutoHeart's ``script_timing``
103+
# and ``test_run`` checks and ``aggregate_results`` read; it stays
104+
# byte-compatible, and the new fields reach consumers through
105+
# ``to_timings_entry`` instead.
26106
d = {
27107
"file": self.file,
28108
"status": self.status.value,
@@ -144,6 +224,154 @@ def to_markdown(self) -> str:
144224

145225
return "\n".join(lines)
146226

227+
# --- the timing dataset (one file per report dir) -------------------------
228+
229+
def _leg(self) -> dict:
230+
"""This report's identity within a shared report directory.
231+
232+
A report dir can receive several runner invocations — the script leg
233+
and the notebook leg of one smoke gate, or every directory of every
234+
workspace in the ``run_all`` mega-run. Each is a *leg*, and the merged
235+
timing file records them all so the dataset states what produced it.
236+
"""
237+
return {
238+
"project": self.project,
239+
"directory": self.directory,
240+
"run_type": self.run_type,
241+
"env_profile": self.env_profile,
242+
"ts": self.completed_at or self.started_at,
243+
"entries": len(self.results),
244+
}
245+
246+
def to_timings(self) -> dict:
247+
"""The timing dataset for THIS report, before merging."""
248+
return {
249+
"schema": TIMINGS_SCHEMA,
250+
"project": self.project,
251+
"directory": self.directory,
252+
"run_type": self.run_type,
253+
"env_profile": self.env_profile,
254+
"python": f"{sys.version_info.major}.{sys.version_info.minor}",
255+
"ts": self.completed_at or self.started_at,
256+
"entries": [r.to_timings_entry() for r in self.results],
257+
"legs": [self._leg()],
258+
}
259+
260+
def merge_timings(self, existing: Optional[dict]) -> dict:
261+
"""Fold this report's entries into an already-written timing dataset.
262+
263+
The merge key is the workspace-relative entry path: the script leg and
264+
the notebook leg contribute disjoint paths, so both survive, while
265+
re-running the SAME leg replaces its own rows rather than duplicating
266+
them (a runner invoked twice into one report dir must not double-count).
267+
268+
The top-level metadata describes the leg that wrote last; ``legs``
269+
carries every contributing leg, which is what a report dir spanning
270+
more than one project (the ``run_all`` mega-run) needs in order to be
271+
read back honestly.
272+
273+
An unreadable or foreign file is replaced rather than merged — a
274+
corrupt sidecar must not take the run down or silently poison the
275+
dataset.
276+
"""
277+
fresh = self.to_timings()
278+
if not isinstance(existing, dict) or existing.get("schema") != TIMINGS_SCHEMA:
279+
return fresh
280+
281+
mine = {e["entry"] for e in fresh["entries"]}
282+
prior = [
283+
e
284+
for e in existing.get("entries", [])
285+
if isinstance(e, dict) and e.get("entry") not in mine
286+
]
287+
fresh["entries"] = prior + fresh["entries"]
288+
289+
def key(leg):
290+
return (leg.get("project"), leg.get("directory"), leg.get("run_type"))
291+
292+
mine_key = key(self._leg())
293+
prior_legs = [
294+
leg
295+
for leg in existing.get("legs", [])
296+
if isinstance(leg, dict) and key(leg) != mine_key
297+
]
298+
fresh["legs"] = prior_legs + fresh["legs"]
299+
return fresh
300+
301+
def write_timings(self, output_dir: Path) -> Path:
302+
path = output_dir / TIMINGS_FILENAME
303+
existing = None
304+
if path.exists():
305+
try:
306+
existing = json.loads(path.read_text())
307+
except (json.JSONDecodeError, OSError, UnicodeDecodeError):
308+
existing = None
309+
with open(path, "w") as f:
310+
json.dump(self.merge_timings(existing), f, indent=2)
311+
return path
312+
313+
def timings_markdown(self) -> str:
314+
"""A slowest-first timing table for the GitHub Actions step summary.
315+
316+
Only this report's own entries: the step summary is append-only, so a
317+
second leg adds its own table rather than restating the first's.
318+
"""
319+
timed = [r for r in self.results if r.was_timed]
320+
untimed = [r for r in self.results if not r.was_timed]
321+
timed.sort(key=lambda r: r.duration_seconds, reverse=True)
322+
323+
total = round(sum(r.duration_seconds for r in timed), 1)
324+
lines = [
325+
"",
326+
f"### Smoke timings — {self.project} / {self.directory} "
327+
f"({self.run_type}, Python "
328+
f"{sys.version_info.major}.{sys.version_info.minor})",
329+
"",
330+
"| Entry | Status | Seconds | Cap |",
331+
"|---|---|---:|---:|",
332+
]
333+
for r in timed + untimed:
334+
entry = workspace_relative(r.file)
335+
seconds = f"{r.duration_seconds:.1f}" if r.was_timed else "—"
336+
# The cap is only informative where it BOUND the entry: on a
337+
# passing script it is the same number on every row and reads as
338+
# noise, while on a timeout it is the whole story.
339+
cap = (
340+
f"{r.cap_seconds:.0f}s"
341+
if r.status == Status.TIMEOUT and r.cap_seconds is not None
342+
else ""
343+
)
344+
lines.append(
345+
f"| `{entry}` | {r.status.value} | {seconds} | {cap} |"
346+
)
347+
lines.append("")
348+
count = len(self.results)
349+
lines.append(
350+
f"**{count} {'entry' if count == 1 else 'entries'}** | "
351+
f"{len(timed)} timed | {total}s total"
352+
)
353+
lines.append("")
354+
return "\n".join(lines)
355+
356+
def append_step_summary(self) -> bool:
357+
"""Append the timing table to ``$GITHUB_STEP_SUMMARY`` when in Actions.
358+
359+
Returns False (and changes nothing) off CI, so a local run is
360+
byte-identical to before. A write failure is reported and swallowed:
361+
the summary is a convenience, and losing it must not fail a run whose
362+
scripts all passed.
363+
"""
364+
target = os.environ.get("GITHUB_STEP_SUMMARY")
365+
if not target:
366+
return False
367+
try:
368+
with open(target, "a") as f:
369+
f.write(self.timings_markdown())
370+
except OSError as exc:
371+
print(f" [smoke timings] step summary not written: {exc}")
372+
return False
373+
return True
374+
147375
def write(self, output_dir: Path):
148376
self.completed_at = datetime.datetime.now().isoformat()
149377
output_dir.mkdir(parents=True, exist_ok=True)
@@ -158,6 +386,12 @@ def write(self, output_dir: Path):
158386
with open(md_path, "w") as f:
159387
f.write(self.to_markdown())
160388

389+
# Every report contributes to the standing timing dataset, and to the
390+
# Actions step summary when there is one. Both legs (run_python.py and
391+
# run.py) reach this same call, so neither needs its own emission.
392+
self.write_timings(output_dir)
393+
self.append_step_summary()
394+
161395
return json_path
162396

163397

autohands/run.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,13 @@
123123
project=project,
124124
directory=directory,
125125
run_type="notebook",
126+
# Same surface statement the script leg has recorded since
127+
# PyAutoHeart#83 §5.3. It was missing here, so every notebook
128+
# report claimed env_profile "unknown" while running under a
129+
# resolved profile — and the timing dataset (PyAutoHands#264)
130+
# inherits this field, where an unknown surface makes two runs
131+
# incomparable for exactly the reason _surface exists.
132+
env_profile=(env_config_path.name if env_config_path else "none"),
126133
)
127134
# Only when the policy file exists: with an explicit list it may be
128135
# absent, and there are then no skip reasons to parse.

0 commit comments

Comments
 (0)