diff --git a/CMakeLists.txt b/CMakeLists.txt index c2e1a70c66..4ad8fbb1bf 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -192,6 +192,9 @@ if(BUILD_TESTING) ${CMAKE_CURRENT_SOURCE_DIR}/tools/tests/test_pr_overlap.py) add_test(NAME pr_labels COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/tools/tests/test_pr_labels.py) + add_test(NAME progress_history_date_window COMMAND ${Python3_EXECUTABLE} + ${CMAKE_CURRENT_SOURCE_DIR}/tools/tests/test_progress_history.py HistoryDateWindowTests) + set_tests_properties(progress_history_date_window PROPERTIES TIMEOUT 20) add_test(NAME progress_history COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/tools/tests/test_progress_history.py ProgressHistoryTests) add_test(NAME progress_history_output COMMAND ${Python3_EXECUTABLE} diff --git a/tools/progress_history.py b/tools/progress_history.py index 67deef9d1b..bdfaba3e43 100644 --- a/tools/progress_history.py +++ b/tools/progress_history.py @@ -113,11 +113,22 @@ def save_cache(path, signature, commits): temporary.unlink(missing_ok=True) -def collect(ref, cache=None): +def utc_day(value): + try: + if date.fromisoformat(value).isoformat() == value: + return value + except ValueError: + pass + raise argparse.ArgumentTypeError("date bounds must be valid YYYY-MM-DD UTC days") + + +def collect(ref, cache=None, since=None, until=None): signature = cache_signature() if cache is not None else None records = load_cache(cache, signature) if cache is not None else {} points, changed = [], False for day, sha in daily_commits(ref): + if (since is not None and day < since) or (until is not None and day > until): + continue if sha not in records: records[sha] = measure(sha) changed = True @@ -228,6 +239,8 @@ def page(points): def main(argv=None): parser = argparse.ArgumentParser() + parser.add_argument("--since", type=utc_day, help="first UTC day to measure, inclusive (YYYY-MM-DD)") + parser.add_argument("--until", type=utc_day, help="last UTC day to measure, inclusive (YYYY-MM-DD)") parser.add_argument("output", type=Path) parser.add_argument("--ref", default="HEAD", help="branch or commit whose first-parent history is measured") parser.add_argument("--cache", type=Path, help="reuse per-commit counts while the scanner, ISA list and Python version match") @@ -242,7 +255,9 @@ def main(argv=None): if any(args.cache.resolve() == path.resolve() or (args.cache.exists() and path.exists() and args.cache.samefile(path)) for path in outputs): raise ValueError("history cache must differ from generated reports") - points = collect(args.ref, args.cache) + if args.since and args.until and args.since > args.until: + raise ValueError("--since must not be later than --until") + points = collect(args.ref, args.cache, args.since, args.until) except (OSError, ValueError) as error: parser.error(str(error)) if not points: diff --git a/tools/tests/test_progress_history.py b/tools/tests/test_progress_history.py index 507be5ff7e..1a61f401c7 100644 --- a/tools/tests/test_progress_history.py +++ b/tools/tests/test_progress_history.py @@ -67,6 +67,34 @@ class HistoryReportOutputTests(unittest.TestCase): self.assertGreater((output / filename).stat().st_size, 0) +class HistoryDateWindowTests(unittest.TestCase): + def test_window_filters_days_before_measurement_and_reuses_cache(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + subprocess.run(["git", "init", "-q", directory], check=True) + for day, time, name in (("2026-01-01", "23:59:59Z", "A"), ("2026-01-02", "00:00:00Z", "B"), ("2026-01-02", "23:59:59Z", "C"), ("2026-01-03", "00:00:00Z", "D")): + commit(root, day, name, f"int APS5_VABI {name}() {{ return 0; }}\n", time) + with patch.multiple(progress, ROOT=root, PRX=root / "core/libs/prx", OPCODES=root / OPCODES): + all_days = progress_history.daily_commits("HEAD") + expected = [all_days[1][1]] + actual_measure = progress_history.measure + with patch.object(progress_history, "measure", wraps=actual_measure) as measure: + selected = progress_history.collect("HEAD", root / "cache", "2026-01-02", "2026-01-02") + self.assertEqual([c.args[0] for c in measure.call_args_list], expected) + self.assertEqual([p["date"] for p in selected], ["2026-01-02"]) + with patch.object(progress_history, "measure", side_effect=AssertionError("cache missed")): + self.assertEqual(progress_history.collect("HEAD", root / "cache", "2026-01-02", "2026-01-02"), selected) + self.assertEqual(len(progress_history.collect("HEAD", root / "cache")), 3) + self.assertEqual(progress_history.collect("HEAD", since="2027-01-01"), []) + + def test_invalid_cli_bounds(self): + for options in (("--since", "20260201"), ("--until", "2026-02-30"), ("--since", "2026-02-02", "--until", "2026-02-01")): + with self.subTest(options=options), contextlib.redirect_stderr(io.StringIO()), self.assertRaises(SystemExit) as error: + progress_history.main(["unused-output", *options]) + self.assertEqual(error.exception.code, 2) + + + class ProgressHistoryTests(unittest.TestCase): @unittest.skipUnless(os.name == "nt", "requires Windows read-only replacement semantics") def test_readonly_cache_failure_cleans_temporary_and_can_retry(self):