"""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())