11import dataclasses
22import datetime
33import json
4+ import os
5+ import sys
46from enum import Enum
57from pathlib import Path
68from 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
924class 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
1750class 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
0 commit comments