Source code for benchbox.utils.dependency_validation

"""Utilities for validating BenchBox dependency definitions.

The dependency cleanup workstream relies on a single source of truth in
``pyproject.toml`` and a pre-resolved ``uv.lock``. This module loads those files
and verifies that all declared dependencies have corresponding locked versions
that satisfy the declared specifiers. The CLI entry point can also emit a short
compatibility matrix summarising Python support and optional extras.
"""

from __future__ import annotations

import argparse
import sys
from collections.abc import Iterable, Mapping, Sequence
from pathlib import Path

try:  # Python 3.11+
    import tomllib  # type: ignore[attr-defined]
except ModuleNotFoundError:  # pragma: no cover - Python < 3.11 fallback
    import tomli as tomllib  # type: ignore

from packaging.requirements import Requirement
from packaging.utils import canonicalize_name
from packaging.version import Version

# Default file locations relative to the repository root
_PYPROJECT_PATH = Path("pyproject.toml")
_UV_LOCK_PATH = Path("uv.lock")


class DependencyValidationError(RuntimeError):
    """Raised when dependency validation fails."""


def _load_toml(path: Path) -> Mapping[str, object]:
    if not path.exists():  # pragma: no cover - defensive guard
        raise FileNotFoundError(f"Missing required file: {path}")
    return tomllib.loads(path.read_text())


def _collect_locked_versions(lock_data: Mapping[str, object]) -> dict[str, set[Version]]:
    """Return a mapping of package name -> locked versions."""

    packages: dict[str, set[Version]] = {}
    package_list: list[object] = list(lock_data.get("package", []))  # type: ignore[arg-type]
    for pkg in package_list:
        if not isinstance(pkg, Mapping):  # pragma: no cover - sanity
            continue
        name_obj = pkg.get("name")
        version_obj = pkg.get("version")
        if not isinstance(name_obj, str) or not isinstance(version_obj, str):
            continue
        canonical = canonicalize_name(name_obj)
        packages.setdefault(canonical, set()).add(Version(version_obj))
    return packages


def _requirements_from_strings(values: Iterable[str]) -> list[Requirement]:
    requirements: list[Requirement] = []
    for raw in values:
        requirement = Requirement(raw)
        requirements.append(requirement)
    return requirements


def _validate_requirement(requirement: Requirement, versions: set[Version]) -> bool:
    if not versions:
        return False
    if requirement.specifier:
        return any(requirement.specifier.contains(version, prereleases=True) for version in versions)
    # No specifier means any available version is acceptable
    return True


def _format_requirement(requirement: Requirement) -> str:
    if requirement.specifier:
        return f"{requirement.name}{requirement.specifier}"
    return requirement.name


[docs] def validate_dependency_versions( pyproject_data: Mapping[str, object], lock_data: Mapping[str, object], ) -> list[str]: """Validate that every declared dependency has a satisfying locked version. Returns a list of problems discovered (empty list indicates success). """ project_section = pyproject_data.get("project") if not isinstance(project_section, Mapping): # pragma: no cover - misconfiguration guard raise DependencyValidationError("pyproject.toml missing [project] section") packages = _collect_locked_versions(lock_data) problems: list[str] = [] declared_dependencies = project_section.get("dependencies", []) requirements = _requirements_from_strings(declared_dependencies) for requirement in requirements: name = canonicalize_name(requirement.name) versions = packages.get(name, set()) if not _validate_requirement(requirement, versions): problems.append(f"Missing satisfying lock entry for {_format_requirement(requirement)}") optional_deps = project_section.get("optional-dependencies", {}) if isinstance(optional_deps, Mapping): for extra, entries in optional_deps.items(): if not isinstance(entries, Sequence): # pragma: no cover continue for requirement in _requirements_from_strings(entries): name = canonicalize_name(requirement.name) versions = packages.get(name, set()) if not _validate_requirement(requirement, versions): problems.append( f"Missing satisfying lock entry for {_format_requirement(requirement)} (extra: {extra})" ) return problems
[docs] def build_matrix_summary( pyproject_data: Mapping[str, object], lock_data: Mapping[str, object], ) -> dict[str, object]: """Return a summary dictionary used for documentation and CLI output.""" project_section = pyproject_data.get("project") optional_deps = {} if isinstance(project_section, Mapping): raw_extras = project_section.get("optional-dependencies", {}) if isinstance(raw_extras, Mapping): optional_deps = { extra: list(entries) for extra, entries in sorted(raw_extras.items()) if isinstance(entries, Sequence) } matrix = { "python_requires": lock_data.get("requires-python", "unspecified"), "resolution_markers": list(lock_data.get("resolution-markers", [])), "optional_dependencies": optional_deps, } return matrix
def _print_matrix(summary: Mapping[str, object]) -> None: python_range = summary.get("python_requires", "unspecified") markers: list[str] = list(summary.get("resolution_markers", [])) # type: ignore[arg-type] optional = summary.get("optional_dependencies", {}) print("Python compatibility") print(f" Supported range: {python_range}") if markers: print(" Solver markers:") for marker in markers: print(f" - {marker}") print() if optional: print("Optional dependency groups") for extra, deps in optional.items(): printable = ", ".join(deps) print(f" * {extra}: {printable}") else: print("No optional dependencies declared.") def main(argv: Sequence[str] | None = None) -> int: parser = argparse.ArgumentParser(description="Validate BenchBox dependency declarations.") parser.add_argument( "--matrix", action="store_true", help="Display the compatibility matrix summary in addition to running validation.", ) parser.add_argument( "--pyproject", type=Path, default=_PYPROJECT_PATH, help="Path to pyproject.toml", ) parser.add_argument( "--lock", type=Path, default=_UV_LOCK_PATH, help="Path to uv.lock", ) args = parser.parse_args(list(argv) if argv is not None else None) pyproject_data = _load_toml(args.pyproject) lock_data = _load_toml(args.lock) problems = validate_dependency_versions(pyproject_data, lock_data) if problems: for problem in problems: print(problem, file=sys.stderr) return 1 if args.matrix: summary = build_matrix_summary(pyproject_data, lock_data) _print_matrix(summary) return 0 if __name__ == "__main__": # pragma: no cover - exercised via CLI sys.exit(main())