diff --git a/py/scripts/nox-matrix.py b/py/scripts/nox-matrix.py index abc38192..62a5c5e2 100644 --- a/py/scripts/nox-matrix.py +++ b/py/scripts/nox-matrix.py @@ -12,9 +12,9 @@ import argparse import json import platform -import re import subprocess import sys +import tempfile from pathlib import Path @@ -180,18 +180,18 @@ def main() -> None: if args.output_durations is None: sys.exit(subprocess.run(cmd).returncode) - # Stream output while capturing session durations - durations: dict[str, int] = {} - duration_re = re.compile(r"nox > Session (\S+) was successful in (\d+) seconds\.") - process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1) - assert process.stdout is not None - for line in process.stdout: - sys.stdout.write(line) - sys.stdout.flush() - m = duration_re.search(line) - if m: - durations[m.group(1)] = int(m.group(2)) - process.wait() + # Nox formats longer durations as "a minute", etc. Its JSON report + # always contains seconds, independent of the human-readable log format. + with tempfile.TemporaryDirectory() as tmp: + report_path = Path(tmp) / "nox-report.json" + process = subprocess.run([*cmd, "--report", str(report_path)]) + with open(report_path) as f: + report = json.load(f) + durations = { + session["name"]: round(session["duration"]) + for session in report["sessions"] + if session["result"] == "success" + } args.output_durations.parent.mkdir(parents=True, exist_ok=True) with open(args.output_durations, "w") as f: diff --git a/py/src/braintrust/test_nox_matrix.py b/py/src/braintrust/test_nox_matrix.py new file mode 100644 index 00000000..f64cbb32 --- /dev/null +++ b/py/src/braintrust/test_nox_matrix.py @@ -0,0 +1,66 @@ +"""Regression tests for the CI shard timing collector.""" + +import importlib.util +import io +import json +import subprocess +import sys +from pathlib import Path +from types import SimpleNamespace + +import pytest + + +_SCRIPT = Path(__file__).resolve().parents[2] / "scripts" / "nox-matrix.py" + + +@pytest.mark.parametrize("returncode", [0, 1]) +def test_measured_durations_use_nox_report(tmp_path, monkeypatch, returncode): + if not _SCRIPT.exists(): + pytest.skip("scripts/ is not available (wheel mode)") + spec = importlib.util.spec_from_file_location("nox_matrix", _SCRIPT) + matrix = importlib.util.module_from_spec(spec) + spec.loader.exec_module(matrix) + + sessions = [ + {"name": "test_core", "result": "success", "duration": 71.9}, + {"name": "test_provider(latest)", "result": "success", "duration": 3601.2}, + {"name": "test_short", "result": "success", "duration": 0.3}, + {"name": "test_seconds", "result": "success", "duration": 12.4}, + {"name": "test_skipped", "result": "skipped", "duration": 0}, + ] + if returncode: + sessions.append({"name": "test_failed", "result": "failed", "duration": 3.5}) + output = tmp_path / "durations" / "measured.json" + monkeypatch.setattr(sys, "argv", [str(_SCRIPT), "0", "1", "--output-durations", str(output)]) + monkeypatch.setattr(matrix, "get_nox_sessions", lambda _: [s["name"] for s in sessions]) + + def run(cmd, **kwargs): + if cmd[0] == "git": + return subprocess.CompletedProcess(cmd, 0, stdout=str(_SCRIPT.parents[2])) + report_path = Path(cmd[cmd.index("--report") + 1]) + report_path.write_text(json.dumps({"sessions": sessions})) + return subprocess.CompletedProcess(cmd, returncode) + + monkeypatch.setattr(matrix.subprocess, "run", run) + # The old collector only read this human-readable output and lost test_core. + monkeypatch.setattr( + matrix.subprocess, + "Popen", + lambda *args, **kwargs: SimpleNamespace( + stdout=io.StringIO("nox > Session test_core was successful in a minute.\n"), + wait=lambda: returncode, + returncode=returncode, + ), + ) + + with pytest.raises(SystemExit) as exc: + matrix.main() + + assert exc.value.code == returncode + assert json.loads(output.read_text()) == { + "test_core": 72, + "test_provider(latest)": 3601, + "test_short": 0, + "test_seconds": 12, + }