prplos/.gitlab/tests/cram/scripts/cram_component_bucketing.py
Petr Štetiar 15c1b5bd17
ci: cram: add diff-based component selection
Automatic component jobs need one reviewed source for path-to-board and
path-to-component decisions. Keeping that policy beside the existing
test buckets lets shared code classify changed tests consistently.

Add the six-board selection matrix and all board-specific,
cross-cutting, and explicit no-op paths. Validate the matrix while
loading the manifest.
Add a resolver for variable and label precedence, Git diffs, all-match
component unions, full collapse, empty reasons, and report output.

Exercise union routing, test classification, collapse, precedence,
stand-down reasons, defensive label errors, and six-board reporting
in an offline synthetic repository. Component coverage stays green.

Assisted-by: codex:gpt-5.6-sol
References: CI-9
Signed-off-by: Petr Štetiar <petr.stetiar@prplfoundation.org>
2026-08-26 20:37:08 +02:00

629 lines
22 KiB
Python

#!/usr/bin/env python3
"""Shared test discovery and component bucketing for the cram suite."""
from __future__ import annotations
import glob
import os
import re
from dataclasses import dataclass
from pathlib import Path, PurePosixPath
from typing import Dict, Iterable, List, Mapping, Sequence, Set, Tuple
import yaml
SCHEMA_VERSION = 1
COMPONENT_NAMES = ("lcm", "prplmesh")
RESERVED_NAMES = {"full", "prplos", "sanity", "smoke", "hw_only"}
SELECTION_COMPONENT_ORDER = ("prplmesh", "lcm", "prplos")
class BucketingError(Exception):
"""An input or bucketing invariant is invalid."""
@dataclass(frozen=True)
class SelectionBoard:
"""A DUT board available to diff-based selection."""
name: str
alias: str
priority: bool
@dataclass(frozen=True)
class SelectionPath:
"""One repository path mapping in the selection matrix."""
glob: str
boards: Tuple[str, ...]
component: str
@dataclass(frozen=True)
class Selection:
"""Validated diff-based selection data."""
boards: Mapping[str, SelectionBoard]
paths: Tuple[SelectionPath, ...]
@dataclass(frozen=True)
class Manifest:
"""Validated component manifest and its expanded file matches."""
path: Path
sanity_patterns: Tuple[str, ...]
component_patterns: Mapping[str, Tuple[str, ...]]
smoke_patterns: Tuple[str, ...]
hw_only_patterns: Tuple[str, ...]
selection: Selection
@dataclass(frozen=True)
class ExpandedManifest:
"""Manifest globs expanded relative to a particular cram test root."""
manifest: Manifest
sanity_matches: Set[str]
component_matches: Mapping[str, Set[str]]
smoke_matches: Set[str]
hw_only_matches: Set[str]
def _path_text(path: os.PathLike[str] | str) -> str:
return os.fspath(path)
def resolve_manifest_path(test_root: Path, manifest: Path) -> Path:
"""Resolve a manifest path, with relative paths rooted at test_root."""
if manifest.is_absolute():
return manifest
return test_root / manifest
def suite_roots(test_root: Path, board: str) -> List[Path]:
"""Return the five legacy CRAM_TEST_SUITE roots for one board."""
board_path = PurePosixPath(board)
if (
not board
or board_path.is_absolute()
or len(board_path.parts) != 1
or board_path.parts[0] in {".", ".."}
):
raise BucketingError(f"invalid board directory name: {board!r}")
return [
test_root / "generic" / "acceleration-plan-components",
test_root / board / "acceleration-plan-components",
test_root / "generic" / "lcm",
test_root / "generic",
test_root / board,
]
def _walk_cram_directory(top: Path) -> Iterable[Path]:
"""Match cram 0.7's os.walk traversal and per-directory file ordering."""
for root, _dirs, files in os.walk(_path_text(top)):
root_path = Path(root)
if root_path.name.startswith("."):
continue
for filename in sorted(files):
if not filename.startswith(".") and filename.endswith(".t"):
yield Path(os.path.normpath(os.path.join(root, filename)))
def collect_cram_tests(roots: Sequence[Path]) -> List[Path]:
"""Collect roots as cram 0.7 does, retaining the first duplicate only.
Unlike cram 0.7's runner, this collector does not stat a selected test.
A broken ``*.t`` symlink is therefore retained in the output.
"""
ordered: List[Path] = []
seen: Set[str] = set()
for root in roots:
if not os.path.lexists(_path_text(root)):
raise BucketingError(f"suite root does not exist: {root}")
candidates: Iterable[Path]
if root.is_dir():
candidates = _walk_cram_directory(root)
else:
candidates = (Path(os.path.normpath(_path_text(root))),)
for candidate in candidates:
identity = os.path.abspath(_path_text(candidate))
if identity in seen:
continue
seen.add(identity)
ordered.append(candidate)
return ordered
def collect_board_tests(test_root: Path, board: str) -> List[Path]:
"""Collect the ordered legacy suite for board, failing on missing roots."""
roots = suite_roots(test_root, board)
missing = [root for root in roots if not root.is_dir()]
if missing:
rendered = "\n".join(f" - {root}" for root in missing)
raise BucketingError(f"required suite roots are missing:\n{rendered}")
return collect_cram_tests(roots)
def _require_mapping(value: object, context: str) -> Mapping[str, object]:
if not isinstance(value, dict):
raise BucketingError(f"{context} must be a mapping")
if any(not isinstance(key, str) for key in value):
raise BucketingError(f"{context} keys must be strings")
return value
def _validate_include_section(
value: object, context: str, allowed_fields: Set[str]
) -> Tuple[str, ...]:
section = _require_mapping(value, context)
unknown = set(section) - allowed_fields
if unknown:
raise BucketingError(
f"{context} has unknown fields: {', '.join(sorted(unknown))}"
)
include = section.get("include")
if not isinstance(include, list) or not include:
raise BucketingError(f"{context}.include must be a non-empty list")
patterns: List[str] = []
for index, pattern in enumerate(include):
if not isinstance(pattern, str) or not pattern:
raise BucketingError(
f"{context}.include[{index}] must be a non-empty string"
)
posix_pattern = PurePosixPath(pattern)
if posix_pattern.is_absolute() or ".." in posix_pattern.parts:
raise BucketingError(
f"{context}.include[{index}] must stay below the test root: "
f"{pattern!r}"
)
patterns.append(pattern)
return tuple(patterns)
def _glob_regex(pattern: str) -> re.Pattern[str]:
"""Compile a repository glob where ``*`` never crosses a slash."""
expression = ""
index = 0
while index < len(pattern):
character = pattern[index]
if character != "*":
expression += re.escape(character)
index += 1
continue
if pattern[index : index + 2] == "**":
index += 2
if index < len(pattern) and pattern[index] == "/":
expression += "(?:.*/)?"
index += 1
else:
expression += ".*"
else:
expression += "[^/]*"
index += 1
return re.compile(f"^{expression}$")
def path_matches_glob(path: str, pattern: str) -> bool:
"""Return whether a POSIX repository path matches a validated glob."""
return bool(_glob_regex(pattern).fullmatch(path))
def _validate_selection(value: object) -> Selection:
selection = _require_mapping(value, "selection")
unknown = set(selection) - {"schema", "boards", "paths"}
missing = {"schema", "boards", "paths"} - set(selection)
if missing:
raise BucketingError(
f"selection is missing fields: {', '.join(sorted(missing))}"
)
if unknown:
raise BucketingError(
f"selection has unknown fields: {', '.join(sorted(unknown))}"
)
if selection["schema"] != SCHEMA_VERSION:
raise BucketingError(
f"selection.schema must be {SCHEMA_VERSION}, got "
f"{selection['schema']!r}"
)
raw_boards = _require_mapping(selection["boards"], "selection.boards")
if not raw_boards:
raise BucketingError("selection.boards must not be empty")
boards: Dict[str, SelectionBoard] = {}
aliases: Set[str] = set()
for name, raw_board in raw_boards.items():
board = _require_mapping(raw_board, f"selection.boards.{name}")
if set(board) != {"alias", "priority"}:
raise BucketingError(
f"selection.boards.{name} must contain alias and priority"
)
alias = board["alias"]
priority = board["priority"]
if not isinstance(alias, str) or not alias:
raise BucketingError(f"selection.boards.{name}.alias must be a string")
if alias in aliases:
raise BucketingError(f"duplicate selection board alias: {alias}")
if not isinstance(priority, bool):
raise BucketingError(f"selection.boards.{name}.priority must be a boolean")
aliases.add(alias)
boards[name] = SelectionBoard(name, alias, priority)
raw_paths = selection["paths"]
if not isinstance(raw_paths, list) or not raw_paths:
raise BucketingError("selection.paths must be a non-empty list")
paths: List[SelectionPath] = []
seen_globs: Set[str] = set()
allowed_components = {"full", "prplmesh", "lcm", "prplos", "by-test", "noop"}
for index, raw_path in enumerate(raw_paths):
context = f"selection.paths[{index}]"
path = _require_mapping(raw_path, context)
if set(path) - {"glob", "boards", "component"}:
raise BucketingError(f"{context} has unknown fields")
if {"glob", "component"} - set(path):
raise BucketingError(f"{context} requires glob and component")
pattern = path["glob"]
component = path["component"]
if not isinstance(pattern, str) or not pattern:
raise BucketingError(f"{context}.glob must be a non-empty string")
posix_pattern = PurePosixPath(pattern)
if (
posix_pattern.is_absolute()
or ".." in posix_pattern.parts
or "\\" in pattern
or any(character in pattern for character in "?[]")
):
raise BucketingError(f"{context}.glob is invalid: {pattern!r}")
_glob_regex(pattern)
if pattern in seen_globs:
raise BucketingError(f"duplicate selection glob: {pattern}")
seen_globs.add(pattern)
if component not in allowed_components:
raise BucketingError(f"{context}.component is invalid: {component!r}")
raw_targets = path.get("boards")
if component == "noop":
if raw_targets is not None:
raise BucketingError(f"{context} noop must not name boards")
targets: Tuple[str, ...] = ()
elif raw_targets == "priority":
targets = tuple(name for name, board in boards.items() if board.priority)
if not targets:
raise BucketingError(f"{context} priority selects no boards")
elif isinstance(raw_targets, list) and raw_targets:
if any(not isinstance(target, str) for target in raw_targets):
raise BucketingError(f"{context}.boards must contain strings")
targets = tuple(raw_targets)
if len(targets) != len(set(targets)):
raise BucketingError(f"{context}.boards contains duplicates")
missing_targets = set(targets) - set(boards)
if missing_targets:
raise BucketingError(
f"{context}.boards names unknown boards: "
f"{', '.join(sorted(missing_targets))}"
)
else:
raise BucketingError(f"{context}.boards must be priority or a list")
paths.append(SelectionPath(pattern, targets, component))
return Selection(boards=boards, paths=tuple(paths))
def load_manifest(path: Path) -> Manifest:
"""Load and validate manifest structure without expanding its globs."""
try:
with path.open("r", encoding="utf-8") as stream:
raw = yaml.safe_load(stream)
except (OSError, UnicodeError) as error:
raise BucketingError(f"cannot read manifest {path}: {error}") from error
except yaml.YAMLError as error:
raise BucketingError(f"cannot parse manifest {path}: {error}") from error
document = _require_mapping(raw, "manifest")
expected_top_level = {
"version",
"sanity",
"components",
"smoke",
"hw_only",
"selection",
}
missing = expected_top_level - set(document)
unknown = set(document) - expected_top_level
if missing:
raise BucketingError(
f"manifest is missing sections: {', '.join(sorted(missing))}"
)
if unknown:
raise BucketingError(
f"manifest has unknown sections: {', '.join(sorted(unknown))}"
)
if document["version"] != SCHEMA_VERSION:
raise BucketingError(
f"manifest version must be {SCHEMA_VERSION}, got "
f"{document['version']!r}"
)
components = _require_mapping(document["components"], "components")
component_names = set(components)
if component_names != set(COMPONENT_NAMES):
raise BucketingError(
"components must contain exactly: " + ", ".join(COMPONENT_NAMES)
)
if component_names & RESERVED_NAMES:
names = ", ".join(sorted(component_names & RESERVED_NAMES))
raise BucketingError(f"reserved names cannot be explicit components: {names}")
component_patterns: Dict[str, Tuple[str, ...]] = {}
for name, value in components.items():
component_patterns[name] = _validate_include_section(
value,
f"components.{name}",
{"description", "include"},
)
section = _require_mapping(value, f"components.{name}")
description = section.get("description")
if description is not None and not isinstance(description, str):
raise BucketingError(f"components.{name}.description must be a string")
sanity_patterns = _validate_include_section(
document["sanity"], "sanity", {"include"}
)
smoke_patterns = _validate_include_section(document["smoke"], "smoke", {"include"})
hw_only_patterns = _validate_include_section(
document["hw_only"], "hw_only", {"include"}
)
selection = _validate_selection(document["selection"])
return Manifest(
path=path,
sanity_patterns=sanity_patterns,
component_patterns=component_patterns,
smoke_patterns=smoke_patterns,
hw_only_patterns=hw_only_patterns,
selection=selection,
)
def _relative_test_path(test_root: Path, path: Path) -> str:
try:
return path.relative_to(test_root).as_posix()
except ValueError as error:
raise BucketingError(
f"test is outside test root {test_root}: {path}"
) from error
def discover_tests(test_root: Path, include_post: bool = False) -> List[Path]:
"""Discover all test paths below test_root, including broken symlinks."""
if not test_root.is_dir():
raise BucketingError(f"test root is not a directory: {test_root}")
tests = list(_walk_cram_directory(test_root))
if include_post:
return tests
return [
path
for path in tests
if not _relative_test_path(test_root, path).startswith("post/")
]
def _expand_pattern(test_root: Path, pattern: str) -> Set[str]:
native_pattern = os.path.join(_path_text(test_root), *pattern.split("/"))
matches: Set[str] = set()
for match in glob.iglob(native_pattern, recursive=True):
match_path = Path(os.path.normpath(match))
if match_path.name.startswith(".") or not match_path.name.endswith(".t"):
continue
if os.path.isdir(match):
continue
matches.add(_relative_test_path(test_root, match_path))
return matches
def expand_manifest(test_root: Path, manifest: Manifest) -> ExpandedManifest:
"""Expand every manifest include glob and reject stale patterns."""
stale: List[str] = []
sanity_matches: Set[str] = set()
for pattern in manifest.sanity_patterns:
matches = _expand_pattern(test_root, pattern)
if not matches:
stale.append(f"sanity: {pattern}")
sanity_matches.update(matches)
component_matches: Dict[str, Set[str]] = {}
for name, patterns in manifest.component_patterns.items():
component_matches[name] = set()
for pattern in patterns:
matches = _expand_pattern(test_root, pattern)
if not matches:
stale.append(f"components.{name}: {pattern}")
component_matches[name].update(matches)
smoke_matches: Set[str] = set()
for pattern in manifest.smoke_patterns:
matches = _expand_pattern(test_root, pattern)
if not matches:
stale.append(f"smoke: {pattern}")
smoke_matches.update(matches)
hw_only_matches: Set[str] = set()
for pattern in manifest.hw_only_patterns:
matches = _expand_pattern(test_root, pattern)
if not matches:
stale.append(f"hw_only: {pattern}")
hw_only_matches.update(matches)
if stale:
rendered = "\n".join(f" - {item}" for item in stale)
raise BucketingError(f"manifest include globs matched no tests:\n{rendered}")
return ExpandedManifest(
manifest=manifest,
sanity_matches=sanity_matches,
component_matches=component_matches,
smoke_matches=smoke_matches,
hw_only_matches=hw_only_matches,
)
def partition_tests(
tests: Sequence[Path], test_root: Path, expanded: ExpandedManifest
) -> Mapping[str, List[Path]]:
"""Partition ordered tests into sanity, explicit, and remainder buckets."""
buckets: Dict[str, List[Path]] = {
name: [] for name in expanded.manifest.component_patterns
}
buckets["sanity"] = []
buckets["prplos"] = []
overlaps: List[Tuple[str, Tuple[str, ...]]] = []
for path in tests:
relative = _relative_test_path(test_root, path)
explicit = tuple(
name
for name, matches in expanded.component_matches.items()
if relative in matches
)
is_sanity = relative in expanded.sanity_matches
if is_sanity and explicit:
overlaps.append((relative, ("sanity", *explicit)))
elif len(explicit) > 1:
overlaps.append((relative, explicit))
elif is_sanity:
buckets["sanity"].append(path)
elif explicit:
buckets[explicit[0]].append(path)
else:
buckets["prplos"].append(path)
if overlaps:
rendered = "\n".join(
f" - {path}: {', '.join(names)}" for path, names in overlaps
)
raise BucketingError("tests match more than one partition bucket:\n" + rendered)
return buckets
def select_component_tests(
full: Sequence[Path],
buckets: Mapping[str, List[Path]],
components: Sequence[str],
) -> List[Path]:
"""Select an ordered component union with the sanity tests first."""
allowed = {"full", "sanity", "prplmesh", "lcm", "prplos"}
unknown = sorted(set(components) - allowed)
if unknown:
raise BucketingError(f"unknown components: {', '.join(unknown)}")
if not components:
raise BucketingError("at least one component is required")
unique = tuple(dict.fromkeys(components))
if "full" in unique:
return list(full)
if len(unique) == 1:
component = unique[0]
if component == "sanity":
return list(buckets["sanity"])
return list(buckets["sanity"] + buckets[component])
selected = set(buckets["sanity"])
for component in unique:
if component != "sanity":
selected.update(buckets[component])
sanity = list(buckets["sanity"])
sanity_set = set(sanity)
return sanity + [
path for path in full if path in selected and path not in sanity_set
]
def classify_changed_test(manifest: Manifest, repository_path: str) -> str | None:
"""Classify one changed cram test path for automatic selection."""
prefix = ".gitlab/tests/cram/"
if not repository_path.startswith(prefix):
raise BucketingError(f"not a cram test path: {repository_path}")
relative = repository_path[len(prefix) :]
if not relative.endswith(".t"):
return None
if relative.startswith("post/") or any(
path_matches_glob(relative, pattern) for pattern in manifest.sanity_patterns
):
return "prplos"
explicit = [
component
for component, patterns in manifest.component_patterns.items()
if any(path_matches_glob(relative, pattern) for pattern in patterns)
]
if len(explicit) > 1:
raise BucketingError(
f"changed test matches multiple components: {repository_path}: "
f"{', '.join(explicit)}"
)
return explicit[0] if explicit else "prplos"
def select_changed_components(
manifest: Manifest, board: str, changed_paths: Sequence[str]
) -> tuple[str, bool]:
"""Resolve changed paths to one board's ordered component selection."""
if board not in manifest.selection.boards:
raise BucketingError(f"unknown selection board: {board}")
selected: Set[str] = set()
matched_for_board = False
for changed_path in changed_paths:
for mapping in manifest.selection.paths:
if not path_matches_glob(changed_path, mapping.glob):
continue
if board not in mapping.boards:
continue
matched_for_board = True
component = mapping.component
if component == "by-test":
component = classify_changed_test(manifest, changed_path)
if component is None:
continue
if component == "full":
return "full", False
if component != "noop":
selected.add(component)
if set(SELECTION_COMPONENT_ORDER).issubset(selected):
return "full", False
ordered = [name for name in SELECTION_COMPONENT_ORDER if name in selected]
return ",".join(ordered), matched_for_board and not ordered
def render_test_paths(paths: Sequence[Path]) -> str:
"""Render ordered test paths for cram's command-line arguments."""
if not paths:
return ""
return "".join(f"{os.path.normpath(_path_text(path))}\n" for path in paths)