#!/usr/bin/env python3 """Fail closed when configured mutmut targets produce no mutants.""" from __future__ import annotations import argparse from importlib import metadata import json from pathlib import Path from typing import Any try: # Python 3.11+ import tomllib except ModuleNotFoundError: # pragma: no cover - exercised on Python 3.10 import tomli as tomllib # type: ignore[no-redef] GLOB_MARKERS = frozenset("*?[") SUPPORTED_MUTMUT_VERSION = "3.7.0" MUTMUT_NO_TEST_EXIT_CODES = frozenset({5, 33}) def load_mutmut_config(pyproject: Path) -> dict[str, Any]: """Return the `[tool.mutmut]` table from pyproject.toml.""" data = tomllib.loads(pyproject.read_text(encoding="utf-8")) mutmut_config = data.get("tool", {}).get("mutmut", {}) if not isinstance(mutmut_config, dict): raise ValueError("[tool.mutmut] must be a table") return mutmut_config def exact_python_targets(mutmut_config: dict[str, Any]) -> list[Path]: """Return exact `.py` paths that must enumerate mutants.""" raw_targets = mutmut_config.get("required_mutation_targets", mutmut_config.get("only_mutate", [])) if not isinstance(raw_targets, list) or not all(isinstance(path, str) for path in raw_targets): raise ValueError( "[tool.mutmut].required_mutation_targets must be a list of strings", ) targets: list[Path] = [] strict_required = "required_mutation_targets" in mutmut_config for raw_path in raw_targets: path = Path(raw_path) invalid = ( path.is_absolute() or ".." in path.parts or not raw_path.endswith(".py") or any(marker in raw_path for marker in GLOB_MARKERS) ) if invalid: if strict_required: raise ValueError(f"invalid required mutation target: {raw_path}") continue targets.append(path) return sorted(targets, key=str) def _owned_mutants_dir(project_root: Path, mutants_dir: Path) -> Path: if mutants_dir != Path("mutants"): raise ValueError( "mutants directory must be project-owned as the literal project-owned mutants directory", ) root = project_root.resolve() configured = root / "mutants" if configured.is_symlink(): raise ValueError( "mutants directory must be project-owned as the literal project-owned mutants directory", ) return configured def _owned_artifact(project_root: Path, mutants_dir: Path, target: Path, suffix: str) -> Path: configured = _owned_mutants_dir(project_root, mutants_dir) artifact = configured / f"{target}{suffix}" resolved = artifact.resolve() if artifact.exists() else artifact.resolve(strict=False) try: resolved.relative_to(configured) except ValueError as exc: raise ValueError(f"mutation artifact escapes project-owned mutants/: {artifact}") from exc return artifact def _validate_owned_source(project_root: Path, target: Path) -> Path: source_path = project_root / target if not source_path.is_file(): raise ValueError(f"configured mutation target does not exist: {target}") try: source_path.resolve().relative_to(project_root.resolve()) except ValueError as exc: raise ValueError(f"configured mutation target escapes project root: {target}") from exc return source_path def mutant_count(meta_path: Path) -> int: """Return the number of generated mutants recorded in a mutmut metadata file.""" try: metadata = json.loads(meta_path.read_text(encoding="utf-8")) except FileNotFoundError: return 0 except json.JSONDecodeError as exc: raise ValueError(f"{meta_path} is not valid JSON: {exc}") from exc exit_code_by_key = metadata.get("exit_code_by_key") if not isinstance(exit_code_by_key, dict): raise ValueError(f"{meta_path} does not contain an exit_code_by_key object") return len(exit_code_by_key) def _installed_mutmut_version() -> str | None: try: return metadata.version("mutmut") except metadata.PackageNotFoundError: return None def assert_supported_mutmut_no_test_codes() -> None: version = _installed_mutmut_version() if version != SUPPORTED_MUTMUT_VERSION: raise ValueError( f"unsupported mutmut version {version!r}; expected {SUPPORTED_MUTMUT_VERSION} " "before interpreting no-test exit codes", ) def no_test_mutants(meta_path: Path) -> list[str]: """Return required-target mutants that mutmut marked as having no covering tests.""" try: metadata = json.loads(meta_path.read_text(encoding="utf-8")) except FileNotFoundError: return [] except json.JSONDecodeError as exc: raise ValueError(f"{meta_path} is not valid JSON: {exc}") from exc exit_code_by_key = metadata.get("exit_code_by_key") if not isinstance(exit_code_by_key, dict): raise ValueError(f"{meta_path} does not contain an exit_code_by_key object") no_tests = sorted( key for key, exit_code in exit_code_by_key.items() if exit_code in MUTMUT_NO_TEST_EXIT_CODES ) if no_tests: assert_supported_mutmut_no_test_codes() return no_tests def stale_or_empty_targets( targets: list[Path], *, project_root: Path = Path("."), mutants_dir: Path = Path("mutants"), ) -> list[Path]: """Return configured targets whose mutant metadata is missing or empty.""" stale: list[Path] = [] _owned_mutants_dir(project_root, mutants_dir) for target in targets: _validate_owned_source(project_root, target) if mutant_count(_owned_artifact(project_root, mutants_dir, target, ".meta")) == 0: stale.append(target) return stale def prepare_required_targets( pyproject: Path = Path("pyproject.toml"), *, mutants_dir: Path = Path("mutants"), ) -> list[Path]: """Remove stale copied mutant files so mutmut regenerates required target metadata.""" targets = exact_python_targets(load_mutmut_config(pyproject)) stale = stale_or_empty_targets(targets, project_root=pyproject.parent, mutants_dir=mutants_dir) for target in stale: for suffix in ("", ".meta", ".spans"): artifact = _owned_artifact(pyproject.parent, mutants_dir, target, suffix) if artifact.exists(): artifact.unlink() return stale def validate_required_targets( pyproject: Path = Path("pyproject.toml"), *, mutants_dir: Path = Path("mutants"), ) -> list[str]: """Return fail-closed messages for exact mutation targets with zero generated mutants.""" targets = exact_python_targets(load_mutmut_config(pyproject)) failures: list[str] = [] for target in stale_or_empty_targets(targets, project_root=pyproject.parent, mutants_dir=mutants_dir): failures.append(f"configured mutation target produced zero mutants: {target}") for target in targets: no_tests = no_test_mutants(_owned_artifact(pyproject.parent, mutants_dir, target, ".meta")) if no_tests: failures.append( f"configured mutation target has {len(no_tests)} mutant(s) with no tests: {target}", ) return failures def _parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("command", choices=("prepare", "check")) parser.add_argument("--pyproject", type=Path, default=Path("pyproject.toml")) parser.add_argument("--mutants-dir", type=Path, default=Path("mutants")) return parser def main() -> int: args = _parser().parse_args() try: if args.command == "prepare": stale = prepare_required_targets(args.pyproject, mutants_dir=args.mutants_dir) if stale: joined = ", ".join(str(path) for path in stale) print(f"mutation target guard invalidated stale metadata for: {joined}") else: print("mutation target guard found no stale required targets") return 0 failures = validate_required_targets(args.pyproject, mutants_dir=args.mutants_dir) except (OSError, ValueError) as exc: print(f"mutation target guard failed: {exc}") return 1 if failures: for failure in failures: print(f"mutation target guard failed: {failure}") return 1 print("mutation target guard passed") return 0 if __name__ == "__main__": raise SystemExit(main())