import ast import warnings from dataclasses import dataclass from enum import Enum, auto from typing import List, Optional, Tuple __all__ = [ "HWBackend", "CIRegistry", "collect_tests", "auto_partition", "register_cpu_ci", "register_cuda_ci", "register_amd_ci", "register_musa_ci", "register_npu_ci", "register_xpu_ci", "register_musa_ci", "register_mlx_ci", "ut_parse_one_file", ] # `suite` stays in positional slot 2 for backward compat with existing # `register_cpu_ci(5, "base-a-test-cpu")` style positional calls. New fields # (`stage`, `runner_config`) are kwarg-only. _PARAM_ORDER = ("est_time", "suite", "nightly", "disabled") _KWARG_ONLY = ("stage", "runner_config") _ALL_PARAMS = _PARAM_ORDER + _KWARG_ONLY _UNSET = object() class HWBackend(Enum): CPU = auto() CUDA = auto() AMD = auto() NPU = auto() XPU = auto() MUSA = auto() MLX = auto() @dataclass class CIRegistry: backend: HWBackend filename: str est_time: float stage: Optional[str] = None runner_config: Optional[str] = None # Legacy single-string suite; kept for nightly/stress/weekly + AMD/CPU/NPU # suites whose names don't follow `{stage}-test-{runner_config}` shape. suite: Optional[str] = None nightly: bool = False disabled: Optional[str] = None @property def effective_suite(self) -> Optional[str]: if self.stage is not None and self.runner_config is not None: return f"{self.stage}-test-{self.runner_config}" return self.suite def register_cpu_ci( est_time: float, suite: Optional[str] = None, nightly: bool = False, disabled: Optional[str] = None, *, stage: Optional[str] = None, runner_config: Optional[str] = None, ): """Marker for CPU CI registration (parsed via AST; runtime no-op).""" return None def register_cuda_ci( est_time: float, suite: Optional[str] = None, nightly: bool = False, disabled: Optional[str] = None, *, stage: Optional[str] = None, runner_config: Optional[str] = None, ): """Marker for CUDA CI registration (parsed via AST; runtime no-op).""" return None def register_amd_ci( est_time: float, suite: Optional[str] = None, nightly: bool = False, disabled: Optional[str] = None, *, stage: Optional[str] = None, runner_config: Optional[str] = None, ): """Marker for AMD CI registration (parsed via AST; runtime no-op).""" return None def register_musa_ci( est_time: float, suite: Optional[str] = None, nightly: bool = False, disabled: Optional[str] = None, *, stage: Optional[str] = None, runner_config: Optional[str] = None, ): """Marker for MUSA CI registration (parsed via AST; runtime no-op).""" return None def register_npu_ci( est_time: float, suite: Optional[str] = None, nightly: bool = False, disabled: Optional[str] = None, *, stage: Optional[str] = None, runner_config: Optional[str] = None, ): """Marker for NPU CI registration (parsed via AST; runtime no-op).""" return None def register_xpu_ci( est_time: float, suite: Optional[str] = None, nightly: bool = False, disabled: Optional[str] = None, *, stage: Optional[str] = None, runner_config: Optional[str] = None, ): """Marker for XPU CI registration (parsed via AST; runtime no-op).""" return None def register_musa_ci( est_time: float, suite: Optional[str] = None, nightly: bool = False, disabled: Optional[str] = None, *, stage: Optional[str] = None, runner_config: Optional[str] = None, ): """Marker for MUSA CI registration (parsed via AST; runtime no-op).""" return None def register_mlx_ci( est_time: float, suite: Optional[str] = None, nightly: bool = False, disabled: Optional[str] = None, *, stage: Optional[str] = None, runner_config: Optional[str] = None, ): """Marker for MLX CI registration (parsed via AST; runtime no-op).""" return None REGISTER_MAPPING = { "register_cpu_ci": HWBackend.CPU, "register_cuda_ci": HWBackend.CUDA, "register_amd_ci": HWBackend.AMD, "register_musa_ci": HWBackend.MUSA, "register_npu_ci": HWBackend.NPU, "register_xpu_ci": HWBackend.XPU, "register_musa_ci": HWBackend.MUSA, "register_mlx_ci": HWBackend.MLX, } class RegistryVisitor(ast.NodeVisitor): def __init__(self, filename: str): self.filename = filename self.registries: list[CIRegistry] = [] self.has_main_entry: bool = False def _constant_value(self, node: ast.AST) -> object: if isinstance(node, ast.Constant): return node.value return _UNSET def _parse_call_args(self, func_call: ast.Call) -> dict: args = {name: _UNSET for name in _ALL_PARAMS} seen = set() if any(isinstance(arg, ast.Starred) for arg in func_call.args): raise ValueError( f"{self.filename}: starred arguments are not supported in {func_call.func.id}()" ) if len(func_call.args) > len(_PARAM_ORDER): raise ValueError( f"{self.filename}: too many positional arguments in {func_call.func.id}()" ) for name, arg in zip(_PARAM_ORDER, func_call.args): seen.add(name) args[name] = self._constant_value(arg) for kw in func_call.keywords: if kw.arg is None: raise ValueError( f"{self.filename}: **kwargs are not supported in {func_call.func.id}()" ) if kw.arg not in args: raise ValueError( f"{self.filename}: unknown argument '{kw.arg}' in {func_call.func.id}()" ) if kw.arg in seen: raise ValueError( f"{self.filename}: duplicated argument '{kw.arg}' in {func_call.func.id}()" ) seen.add(kw.arg) args[kw.arg] = self._constant_value(kw.value) if args["est_time"] is _UNSET: raise ValueError( f"{self.filename}: est_time is a required constant in {func_call.func.id}()" ) # The only valid (stage, runner_config, suite) shapes are: # (set, set, unset) -> new-style pair # (unset, unset, set) -> legacy single-string # Any other combination is rejected with the actual triple in the error. stage_set = args["stage"] is not _UNSET runner_set = args["runner_config"] is not _UNSET suite_set = args["suite"] is not _UNSET valid_shape = (stage_set and runner_set and not suite_set) or ( not stage_set and not runner_set and suite_set ) if not valid_shape: raise ValueError( f"{self.filename}: {func_call.func.id}() must specify exactly one of " f"(stage, runner_config) pair or suite; got stage={stage_set}, " f"runner_config={runner_set}, suite={suite_set}" ) est_time = args["est_time"] if not isinstance(est_time, (int, float)): raise ValueError( f"{self.filename}: est_time must be a number in {func_call.func.id}()" ) suite = args["suite"] if suite_set else None if suite is not None and not isinstance(suite, str): raise ValueError( f"{self.filename}: suite must be a string in {func_call.func.id}()" ) stage = args["stage"] if stage_set else None runner_config = args["runner_config"] if runner_set else None for name, value in (("stage", stage), ("runner_config", runner_config)): if value is not None and not isinstance(value, str): raise ValueError( f"{self.filename}: {name} must be a string in {func_call.func.id}()" ) nightly_value = args["nightly"] if nightly_value is _UNSET: nightly = False elif isinstance(nightly_value, bool): nightly = nightly_value else: raise ValueError( f"{self.filename}: nightly must be a boolean in {func_call.func.id}()" ) disabled = args["disabled"] if args["disabled"] is not _UNSET else None if disabled is not None and not isinstance(disabled, str): raise ValueError( f"{self.filename}: disabled must be a string in {func_call.func.id}()" ) return { "est_time": float(est_time), "stage": stage, "runner_config": runner_config, "suite": suite, "nightly": nightly, "disabled": disabled, } def _collect_ci_registry(self, func_call: ast.Call): if not isinstance(func_call.func, ast.Name): return None backend = REGISTER_MAPPING.get(func_call.func.id) if backend is None: return None parsed = self._parse_call_args(func_call) return CIRegistry( backend=backend, filename=self.filename, **parsed, ) @staticmethod def _is_main_block_with_call(stmt: ast.If) -> bool: """True iff `stmt` is `if __name__ == "__main__":` with a body that contains at least one call (i.e. actually runs something, not just `pass`). This is what makes `python3 file.py` execute tests.""" test = stmt.test if not isinstance(test, ast.Compare): return False if not (isinstance(test.left, ast.Name) and test.left.id == "__name__"): return False if len(test.ops) != 1 or not isinstance(test.ops[0], ast.Eq): return False if len(test.comparators) != 1: return False rhs = test.comparators[0] if not (isinstance(rhs, ast.Constant) and rhs.value == "__main__"): return False for child in ast.walk(ast.Module(body=stmt.body, type_ignores=[])): if isinstance(child, ast.Call): return True return False def visit_Module(self, node): for stmt in node.body: if isinstance(stmt, ast.Expr) and isinstance(stmt.value, ast.Call): cr = self._collect_ci_registry(stmt.value) if cr is not None: self.registries.append(cr) elif isinstance(stmt, ast.If) and self._is_main_block_with_call(stmt): self.has_main_entry = True self.generic_visit(node) def ut_parse_one_file(filename: str) -> Tuple[List[CIRegistry], bool]: """Parse a test file and return (registries, has_main_entry). `has_main_entry` is True iff the file has `if __name__ == "__main__":` with a call in its body -- required for `python3 file.py` to actually run tests (the CI runner's invocation pattern). """ with open(filename, "r") as f: file_content = f.read() tree = ast.parse(file_content, filename=filename) visitor = RegistryVisitor(filename=filename) visitor.visit(tree) return visitor.registries, visitor.has_main_entry def auto_partition( files: List[CIRegistry], rank: int, size: int, live_est: Optional[dict] = None, ) -> List[CIRegistry]: """Partition files into `size` sublists with approximately equal sums of estimated times using a greedy algorithm (LPT heuristic), and return the partition for the specified rank. `live_est`: optional `filename -> est seconds` overrides; missing files fall back to in-source `est_time`. """ if not files or size <= 0: return [] def est_of(f: CIRegistry) -> float: if live_est is not None and f.filename in live_est: return live_est[f.filename] return f.est_time # Sort by estimated_time descending; filename as tie-breaker for # deterministic partitioning regardless of glob ordering. sorted_files = sorted(files, key=lambda f: (-est_of(f), f.filename)) partitions: List[List[CIRegistry]] = [[] for _ in range(size)] partition_sums = [0.0] * size # Greedily assign each file to the partition with the smallest current total time for file in sorted_files: min_sum_idx = min(range(size), key=partition_sums.__getitem__) partitions[min_sum_idx].append(file) partition_sums[min_sum_idx] += est_of(file) if rank < size: return partitions[rank] return [] def collect_tests(files: list[str], sanity_check: bool = True) -> List[CIRegistry]: ci_tests = [] for file in files: registries, has_main_entry = ut_parse_one_file(file) if len(registries) == 0: msg = f"No CI registry found in {file}" if sanity_check: raise ValueError(msg) else: warnings.warn(msg) continue # Every file with at least one enabled registry must have an # executable `if __name__ == "__main__":` block; otherwise # `python3 file.py -f` (how run_unittest_files invokes tests) # silently exits and the file shows green without running. has_enabled = any(r.disabled is None for r in registries) if sanity_check and has_enabled and not has_main_entry: raise ValueError( f'{file}: missing `if __name__ == "__main__":` entry. ' f"Pytest-style tests in this file will silently skip under " f"`python3 file.py -f`. Add `unittest.main()` (for " f"unittest.TestCase) or `sys.exit(pytest.main([__file__, " f'"-v"]))` (for pytest-style).' ) ci_tests.extend(registries) return ci_tests