blob: e8ff28c9acaac09f8202adc6376e70b06d31d1f3 [file]
#!/usr/bin/env python3
#
# Copyright (c) 2026 Project CHIP Authors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
"""Estimate "REPL Tests - Linux (RUN)" job durations for a list of test filters.
Uses timings collected by `fetch_repl_test_timings.py` and the python tests
present in the local checkout. Filters use the same semantics as the `--regex`
argument of `src/python_testing/execute_python_tests.py` (regex search on the
script path, a leading "!" negates). The "" filter is skipped: that job runs
individual test steps instead of a filter.
When no --filter is given, the filter list is read from the
`repl_tests_linux_run` matrix in `.github/workflows/tests.yaml`.
Also reports tests matched by no filter or by more than one filter. Exits
with a non-zero code in that case.
Example:
scripts/tools/estimate_repl_test_split.py --timings /tmp/timings.json \\
--filter 'TC_[A-B]' --filter 'TC_C[A-K]' --filter '!TC_[A-C]'
"""
import glob
import json
import logging
import os
import re
import statistics
import sys
from pathlib import Path
import click
import yaml
log = logging.getLogger(__name__)
CHIP_ROOT = Path(__file__).resolve().parents[2]
SEARCH_DIRECTORY = "src/python_testing"
WORKFLOW_JOB = "repl_tests_linux_run"
AGGREGATES = {
"median": statistics.median,
"mean": statistics.mean,
"max": max,
}
def load_durations(timing_files: tuple[Path, ...], aggregate: str) -> dict[str, float]:
"""Return test script name -> aggregated duration in seconds."""
samples: dict[str, list[float]] = {}
for path in timing_files:
for run in json.loads(path.read_text())["runs"]:
for job in run["jobs"]:
for t in job["tests"]:
samples.setdefault(t["name"], []).append(t["duration_seconds"])
return {name: AGGREGATES[aggregate](values) for name, values in samples.items()}
def load_workflow_filters(workflow: Path) -> list[str]:
data = yaml.safe_load(workflow.read_text())
return list(data["jobs"][WORKFLOW_JOB]["strategy"]["matrix"]["filter"])
def list_ci_test_scripts() -> list[str]:
"""List test script paths the same way execute_python_tests.py does (non-nightly run).
Paths are relative to CHIP_ROOT (e.g. "src/python_testing/TC_ACE_1_2.py"), matching
what the CI regex filters are applied to.
"""
with open(CHIP_ROOT / SEARCH_DIRECTORY / "test_metadata.yaml") as f:
metadata = yaml.safe_load(f)
excluded = {item["name"] for item in metadata["not_automated"]} | {item["name"] for item in metadata["nightly"]}
scripts = sorted(glob.glob(os.path.join(SEARCH_DIRECTORY, "*.py"), root_dir=CHIP_ROOT))
return [s for s in scripts if os.path.basename(s) not in excluded]
def filter_matches(pattern: str, path: str) -> bool:
"""Match semantics of `execute_python_tests.py run --regex`."""
if pattern.startswith("!"):
return re.search(pattern[1:], path) is None
return re.search(pattern, path) is not None
def format_minutes(value: float) -> str:
return f"{value:.1f}"
@click.command(help=__doc__, context_settings={"max_content_width": 120})
@click.option("--timings", "timing_files", type=click.Path(exists=True, dir_okay=False, path_type=Path), multiple=True, required=True,
help="JSON file written by fetch_repl_test_timings.py. May be repeated.")
@click.option("--filter", "filters", multiple=True, help="Test filter (regex). May be repeated. Default: read from --workflow.")
@click.option("--workflow", type=click.Path(exists=True, dir_okay=False, path_type=Path),
default=CHIP_ROOT / ".github/workflows/tests.yaml", show_default=True, help="Workflow to read default filters from.")
@click.option("--aggregate", type=click.Choice(list(AGGREGATES)), default="median", show_default=True,
help="How to combine durations of a test seen in several runs.")
@click.option("--overhead-minutes", type=float, default=4.5, show_default=True,
help="Per-job setup time (checkout, bootstrap, artifact unpacking) added to the estimate.")
@click.option("--log-level", default="INFO", type=click.Choice(list(logging.getLevelNamesMapping()), case_sensitive=False))
def main(timing_files: tuple[Path, ...], filters: tuple[str, ...], workflow: Path, aggregate: str, overhead_minutes: float,
log_level: str) -> None:
logging.basicConfig(level=log_level.upper(), format="%(levelname)s: %(message)s")
filter_list = list(filters) if filters else load_workflow_filters(workflow)
filter_list = [f for f in filter_list if f != ""]
durations = load_durations(timing_files, aggregate)
scripts = list_ci_test_scripts()
matched_by: dict[str, list[str]] = {s: [] for s in scripts}
print(f"{'filter':<16} {'tests':>6} {'test min':>9} {'est. job min':>13} {'no timing':>10}")
for f in filter_list:
selected = [s for s in scripts if filter_matches(f, s)]
known = [s for s in selected if os.path.basename(s) in durations]
missing = [s for s in selected if os.path.basename(s) not in durations]
for s in selected:
matched_by[s].append(f)
test_minutes = sum(durations[os.path.basename(s)] for s in known) / 60
print(f"{f:<16} {len(selected):>6} {format_minutes(test_minutes):>9} "
f"{format_minutes(test_minutes + overhead_minutes):>13} {len(missing):>10}")
for s in missing:
log.debug(" no timing data: %s", s)
unmatched = [s for s, fs in matched_by.items() if not fs]
duplicated = {s: fs for s, fs in matched_by.items() if len(fs) > 1}
for s in unmatched:
log.error("Not matched by any filter: %s", s)
for s, fs in duplicated.items():
log.error("Matched by multiple filters %s: %s", fs, s)
if unmatched or duplicated:
sys.exit(1)
if __name__ == "__main__":
main()