prplos/.gitlab/tests/cram/scripts/cram_component_bucketing.py
Petr Štetiar d001612fee
ci: cram: resolve comma-separated component unions
Multi-component changes currently require separate CRAM jobs. They
repeat the board boot. The automatic selector needs one resolver
invocation that can cover all affected component buckets.

Accept comma-separated components and build their ordered union from
the legacy full-suite order. Prefix sanity tests once, remove duplicate
tests and retain the existing behavior for every single component and
full.

Compare all five legacy selections on all six boards with the CI-7 base.
All 30 outputs are byte-identical. Verify forward and reverse
two-component unions on all six boards, including stable ordering and
uniqueness.

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

403 lines
13 KiB
Python

#!/usr/bin/env python3
"""Shared test discovery and component bucketing for the cram suite."""
from __future__ import annotations
import glob
import os
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"}
class BucketingError(Exception):
"""An input or bucketing invariant is invalid."""
@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, ...]
@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 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"}
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"}
)
return Manifest(
path=path,
sanity_patterns=sanity_patterns,
component_patterns=component_patterns,
smoke_patterns=smoke_patterns,
hw_only_patterns=hw_only_patterns,
)
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 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)