blob: 49250bedcbc6d47f144ff602eeaa18647d8a2958 [file]
#!/usr/bin/env python3
# SPDX-License-Identifier: Apache-2.0
# Copyright The Zephyr Project Contributors
"""Sync test identifiers in MAINTAINERS.yml based on actual test coverage.
For each area in MAINTAINERS.yml that has tests/ or samples/ directory paths
in its ``files`` section, this script runs a single ``twister --list-tests``
call (passing all paths via repeated ``-T``) and extracts covering test
identifiers. IDs are grouped by their first two dot-separated components and
the longest common prefix within each group becomes one candidate identifier.
Candidates already present (or subsumed by an existing entry) are skipped.
Typical usage
-------------
Run from the Zephyr root (ZEPHYR_BASE is inferred automatically):
python3 scripts/ci/sync_maintainer_tests.py
Preview changes without writing them:
python3 scripts/ci/sync_maintainer_tests.py --dry-run
Restrict to one area:
python3 scripts/ci/sync_maintainer_tests.py --area "Drivers: PWM"
Verify existing test identifiers without modifying the file (fast, using a
pre-generated list from twister):
./scripts/twister --list-tests > /tmp/tests.txt
python3 scripts/ci/sync_maintainer_tests.py --verify --tests-file /tmp/tests.txt
Verify using a live global twister run (slow — scans the full tree):
python3 scripts/ci/sync_maintainer_tests.py --verify
"""
import argparse
import logging
import os
import re
import subprocess
import sys
from pathlib import Path
try:
from ruamel.yaml import YAML
from ruamel.yaml.comments import CommentedSeq
except ImportError:
sys.exit("ruamel.yaml is required. Install with: pip install ruamel.yaml")
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
_GLOB_CHARS = re.compile(r"[*?\[]")
# Namespaces where 2-component grouping is too broad; a third component
# (the driver class, subsystem name, etc.) is required for a useful identifier.
_DEEP_GROUP_PREFIXES: frozenset[str] = frozenset(
{
"sample.drivers",
}
)
def _is_plain_dir_path(path: str) -> bool:
"""Return True when *path* is a plain (non-glob) directory path."""
return path.endswith("/") and not _GLOB_CHARS.search(path)
def collect_candidate_paths(area_dict: dict) -> list[str]:
"""Return ``tests/`` and ``samples/`` directory paths from *area_dict*.
Only plain (no glob characters) directory paths (ending with ``/``) that
exist under ``tests/`` or ``samples/`` are returned. The trailing slash
is stripped so the paths can be passed directly to twister.
"""
result = []
for f in area_dict.get("files", []):
if (f.startswith("tests/") or f.startswith("samples/")) and _is_plain_dir_path(f):
result.append(f.rstrip("/"))
return result
def _run_twister_list_tests(zephyr_base: Path, paths: list[str] | None = None) -> list[str]:
"""Run ``twister --list-tests`` and return the discovered test IDs.
When *paths* is provided, each path is passed as a ``-T`` argument.
Returns an empty list when twister fails.
"""
cmd = [str(zephyr_base / "scripts" / "twister"), "--list-tests"]
if paths:
for p in paths:
cmd += ["-T", p]
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
cwd=str(zephyr_base),
)
except OSError as exc:
logging.warning("Failed to execute twister: %s", exc)
return []
ids = []
for line in result.stdout.splitlines():
stripped = line.strip()
if stripped.startswith("- "):
ids.append(stripped[2:])
return ids
def get_test_ids(zephyr_base: Path, paths: list[str]) -> list[str]:
"""Run one ``twister --list-tests`` call for all *paths* and return test IDs.
Each existing path is passed as a separate ``-T`` argument so that twister
discovers tests across all of them in a single invocation. Returns an
empty list when no paths exist or twister fails.
"""
valid = [p for p in paths if (zephyr_base / p).is_dir()]
if not valid:
return []
ids = _run_twister_list_tests(zephyr_base, valid)
logging.debug(" %d path(s) -> %d test IDs", len(valid), len(ids))
return ids
def load_all_test_ids(zephyr_base: Path, tests_file: Path | None) -> set[str]:
"""Return the complete set of test IDs for use during verification.
When *tests_file* is given its contents are parsed (same ``- <id>`` format
that ``twister --list-tests`` produces). Otherwise a global
``twister --list-tests`` run (no ``-T`` restriction) is executed, which
discovers every test in the tree. This can be slow; prefer supplying a
pre-generated *tests_file*.
"""
if tests_file is not None:
ids: set[str] = set()
try:
with open(tests_file) as fh:
for line in fh:
stripped = line.strip()
if stripped.startswith("- "):
ids.add(stripped[2:])
except OSError as exc:
logging.error("Cannot read tests file %s: %s", tests_file, exc)
return ids
logging.info("Running global 'twister --list-tests' (this may be slow) …")
test_list = _run_twister_list_tests(zephyr_base)
if not test_list:
logging.error("Failed to execute twister")
return set()
ids = set(test_list)
logging.info(" %d test IDs collected.", len(ids))
return ids
def common_prefix(test_ids: list[str]) -> str | None:
"""Return the longest common dot-separated prefix across all *test_ids*.
Returns ``None`` when *test_ids* is empty or there is no common prefix.
"""
if not test_ids:
return None
split = [tid.split(".") for tid in test_ids]
candidate = split[0]
for parts in split[1:]:
# Truncate candidate to the length of the shared leading components.
new_len = 0
for a, b in zip(candidate, parts, strict=False):
if a == b:
new_len += 1
else:
break
candidate = candidate[:new_len]
if not candidate:
return None
return ".".join(candidate) or None
def get_prefixes(test_ids: list[str]) -> list[str]:
"""Derive a minimal covering set of test-ID prefixes from *test_ids*.
IDs are grouped by their first two dot-separated components (e.g.
``drivers.pwm`` or ``sample.basic``). Within each group the longest
common prefix is computed, yielding at most one prefix per group. This
preserves the granularity of per-path prefixes while requiring only a
single combined twister invocation.
Namespaces listed in ``_DEEP_GROUP_PREFIXES`` (e.g. ``sample.drivers``)
require a third component for a useful identifier and are therefore
grouped by their first three components instead.
"""
groups: dict[str, list[str]] = {}
for tid in test_ids:
parts = tid.split(".", 3)
two = ".".join(parts[:2]) if len(parts) >= 2 else parts[0]
if two in _DEEP_GROUP_PREFIXES:
key = ".".join(parts[:3]) if len(parts) >= 3 else two
else:
key = two
groups.setdefault(key, []).append(tid)
prefixes = []
for ids in groups.values():
prefix = common_prefix(ids)
if prefix:
prefixes.append(prefix)
return sorted(prefixes)
# ---------------------------------------------------------------------------
# Main logic
# ---------------------------------------------------------------------------
def process_area(
area_name: str,
area_dict: dict,
zephyr_base: Path,
) -> list[str]:
"""Return a sorted list of new test prefixes to add to *area_name*.
Issues a single twister invocation for all candidate paths and collects
prefixes that are not yet in the area's ``tests`` list.
"""
paths = collect_candidate_paths(area_dict)
if not paths:
return []
existing = set(area_dict.get("tests", []))
def _is_new(prefix: str, pending: list[str]) -> bool:
"""Return True if *prefix* is not already covered by an existing or
pending test identifier. A candidate is covered when an existing entry
equals it or is a dot-separated prefix of it (e.g. ``sample.foo``
covers ``sample.foo.bar``).
"""
return all(not (prefix == e or prefix.startswith(e + ".")) for e in existing | set(pending))
test_ids = get_test_ids(zephyr_base, paths)
candidates = get_prefixes(test_ids)
new_prefixes: list[str] = []
for prefix in candidates:
if _is_new(prefix, new_prefixes):
logging.debug(" new prefix: %s", prefix)
new_prefixes.append(prefix)
return new_prefixes
def verify_area(
area_name: str,
area_dict: dict,
all_test_ids: set[str],
) -> list[str]:
"""Return the test identifiers in *area_dict* that match no known test ID.
An identifier is considered valid when at least one element of
*all_test_ids* either equals it exactly or starts with it followed by a
dot (i.e. the identifier is a proper dot-separated prefix of a real test).
Identifiers that match nothing are returned as-is.
"""
invalid = []
for identifier in area_dict.get("tests", []):
prefix_dot = identifier + "."
matched = any(tid == identifier or tid.startswith(prefix_dot) for tid in all_test_ids)
if not matched:
invalid.append(identifier)
return invalid
def _ensure_tests_key(area_dict: dict, new_prefixes: list[str]) -> None:
"""Append *new_prefixes* to area_dict['tests'], creating the key if absent."""
if "tests" not in area_dict:
seq = CommentedSeq()
for p in new_prefixes:
seq.append(p)
area_dict["tests"] = seq
else:
seq = area_dict["tests"]
last_idx = len(seq) - 1
# The trailing comment on the last item (typically a blank line that
# separates areas) must be moved to the new last item; otherwise
# ruamel.yaml emits it between the old last item and the first new one.
trailing = None
if last_idx in seq.ca.items and seq.ca.items[last_idx][0] is not None:
trailing = seq.ca.items[last_idx][0]
seq.ca.items[last_idx][0] = None
for p in new_prefixes:
seq.append(p)
if trailing is not None:
new_last = len(seq) - 1
if new_last not in seq.ca.items:
seq.ca.items[new_last] = [None, None, None, None]
seq.ca.items[new_last][0] = trailing
def main() -> int:
parser = argparse.ArgumentParser(
description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter,
allow_abbrev=False,
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Print proposed changes without modifying MAINTAINERS.yml.",
)
parser.add_argument(
"--area",
metavar="AREA",
help="Restrict processing to a single area name.",
)
parser.add_argument(
"--maintainers",
default="MAINTAINERS.yml",
metavar="FILE",
help="Path to MAINTAINERS.yml relative to ZEPHYR_BASE (default: MAINTAINERS.yml).",
)
parser.add_argument(
"-v",
"--verbose",
action="store_true",
help="Enable debug logging.",
)
parser.add_argument(
"--verify",
action="store_true",
help=(
"Verify that every test identifier already recorded in "
"MAINTAINERS.yml matches at least one real test ID. "
"Exits with a non-zero status when invalid identifiers are found. "
"Mutually exclusive with --dry-run."
),
)
parser.add_argument(
"--tests-file",
metavar="FILE",
type=Path,
help=(
"Path to a file containing the output of "
"'./scripts/twister --list-tests'. "
"Used by --verify to avoid a slow global twister run. "
"Generate with: ./scripts/twister --list-tests > tests.txt"
),
)
args = parser.parse_args()
if args.verify and args.dry_run:
parser.error("--verify and --dry-run are mutually exclusive")
logging.basicConfig(
format="%(levelname)s: %(message)s",
level=logging.DEBUG if args.verbose else logging.INFO,
)
zephyr_base = Path(os.environ.get("ZEPHYR_BASE", Path(__file__).parents[2]))
maintainers_path = zephyr_base / args.maintainers
if not maintainers_path.exists():
logging.error("MAINTAINERS file not found: %s", maintainers_path)
return 1
yaml = YAML()
yaml.preserve_quotes = True
yaml.indent(mapping=2, sequence=4, offset=2)
yaml.width = 4096 # Prevent line wrapping when writing back
with open(maintainers_path) as fh:
data = yaml.load(fh)
# ------------------------------------------------------------------
# Verification mode: check that existing test identifiers are valid.
# ------------------------------------------------------------------
if args.verify:
all_test_ids = load_all_test_ids(zephyr_base, args.tests_file)
if not all_test_ids:
logging.error("No test IDs available for verification; aborting.")
return 1
total_areas = 0
invalid_count = 0
for area_name, area_dict in data.items():
if not isinstance(area_dict, dict):
continue
if args.area and area_name != args.area:
continue
if not area_dict.get("tests"):
continue
total_areas += 1
invalid = verify_area(area_name, area_dict, all_test_ids)
if invalid:
for ident in invalid:
logging.warning(
"[%s] invalid test identifier (no matching test): %s",
area_name,
ident,
)
invalid_count += len(invalid)
else:
logging.debug(" [%s] all identifiers valid", area_name)
if invalid_count:
logging.error(
"Verification failed: %d invalid identifier(s) across %d area(s).",
invalid_count,
total_areas,
)
return 1
logging.info(
"Verification passed: all test identifiers in %d area(s) are valid.",
total_areas,
)
return 0
# ------------------------------------------------------------------
# Normal sync mode.
# ------------------------------------------------------------------
total_areas = 0
updated_areas = 0
for area_name, area_dict in data.items():
if not isinstance(area_dict, dict):
continue
if args.area and area_name != args.area:
continue
total_areas += 1
logging.info("Processing: %s", area_name)
# Meta-areas (meta: true in MAINTAINERS.yml) intentionally cover large
# swaths of tests/ or samples/; identifiers derived from their file
# patterns would be too broad to be useful. This reads the raw YAML,
# which get_maintainer.py's boolean check has not vetted, so compare
# against True rather than trusting truthiness.
if area_dict.get("meta") is True:
logging.debug(" [%s] skipping meta-area", area_name)
continue
new_prefixes = process_area(area_name, area_dict, zephyr_base)
if new_prefixes:
updated_areas += 1
logging.info(" [%s] adding: %s", area_name, new_prefixes)
if not args.dry_run:
_ensure_tests_key(area_dict, new_prefixes)
else:
logging.debug(" [%s] nothing new to add", area_name)
logging.info(
"Scanned %d area(s); %d area(s) %s new test identifier(s).",
total_areas,
updated_areas,
"would receive" if args.dry_run else "received",
)
if not args.dry_run and updated_areas:
with open(maintainers_path, "w") as fh:
yaml.dump(data, fh)
logging.info("Wrote updated %s", maintainers_path)
return 0
if __name__ == "__main__":
sys.exit(main())