Source code for mafw.devtools.dependencies.verify

#  Copyright 2026 European Union
#  Author: Bulgheroni Antonio (antonio.bulgheroni@ec.europa.eu)
#  SPDX-License-Identifier: EUPL-1.2
"""
Dependency resolution verification utilities for MAFw.

This module provides functions to verify that resolved dependency environments
match the expected lower bounds declared in ``pyproject.toml``.
"""

from __future__ import annotations

import json
import sys

import tomlkit

from mafw.devtools import ensure_devtools_available

ensure_devtools_available()

from packaging.requirements import Requirement  # noqa: E402
from packaging.version import Version  # noqa: E402
from tomlkit.exceptions import TOMLKitError  # noqa: E402

from mafw.devtools import DevtoolsError  # noqa: E402
from mafw.devtools.dependencies.compile import PYPROJECT_FILE  # noqa: E402
from mafw.devtools.dependencies.freeze import highest_lower_bound  # noqa: E402
from mafw.tools.shell_tools import CONSOLE, run_stdout  # noqa: E402


[docs] def get_expected_lower_bounds() -> dict[str, Requirement]: """ Read pyproject.toml and extract dependencies with their expected lower bounds. :return: A dictionary mapping lowercase package names to their parsed Requirement objects. :rtype: dict[str, Requirement] :raises DevtoolsError: If the pyproject.toml cannot be parsed. """ if not PYPROJECT_FILE.exists(): raise DevtoolsError(f'Unable to find {PYPROJECT_FILE}.') try: doc = tomlkit.loads(PYPROJECT_FILE.read_text(encoding='utf-8')) except TOMLKitError as exc: raise DevtoolsError(f'Unable to parse {PYPROJECT_FILE} as TOML.') from exc project = doc.get('project') if project is None: raise DevtoolsError(f'Missing [project] table in {PYPROJECT_FILE}.') dependencies = project.get('dependencies', []) lower_bounds: dict[str, Requirement] = {} for dep_str in dependencies: if not isinstance(dep_str, str): continue try: req = Requirement(dep_str) except Exception as exc: raise DevtoolsError(f'Unable to parse dependency requirement "{dep_str}".') from exc if highest_lower_bound(req) is not None: lower_bounds[req.name.lower()] = req return lower_bounds
[docs] def verify_lowest_resolution( env_name: str, python_version: str, expected_bounds: dict[str, Requirement], ) -> None: """ Verify that the specified environment has the expected lowest dependency versions. This function executes `uv pip list` in the given environment, parses the output, and compares installed versions against the expected lower bounds extracted from `pyproject.toml`. :param env_name: The name of the environment (e.g., 'hatch-test', 'types'). :type env_name: str :param python_version: The Python version string (e.g., '3.11'). :type python_version: str :param expected_bounds: Mapping of package names to their Requirement objects. :type expected_bounds: dict[str, Requirement] :raises DevtoolsError: If a required dependency is missing. """ CONSOLE.print(f'Verifying lowest resolution for {env_name} (Python {python_version})...') # Construct the command: hatch run <env_name>.py<python_version>:uv pip list --format json command = [ 'hatch', 'run', f'{env_name}.py{python_version}:uv', 'pip', 'list', '--format', 'json', ] try: output = run_stdout(command) installed_pkgs = json.loads(output) except Exception as exc: raise DevtoolsError(f'Failed to retrieve installed packages for {env_name}.py{python_version}: {exc}') # Create a mapping for easy lookup installed_map = {pkg['name'].lower(): pkg['version'] for pkg in installed_pkgs} # Prepare environment markers for evaluation marker_env = { 'python_version': python_version, 'sys_platform': sys.platform, } for name, req in expected_bounds.items(): # Skip if marker doesn't apply to this python version if req.marker and not req.marker.evaluate(marker_env): continue if name not in installed_map: raise DevtoolsError( f'Required dependency "{req.name}" is missing in environment {env_name}.py{python_version}.' ) installed_version_str = installed_map[name] expected_version_str = highest_lower_bound(req) if expected_version_str is None: continue # Should not happen given _get_expected_lower_bounds logic installed_version = Version(installed_version_str) expected_version = Version(expected_version_str) if installed_version > expected_version: CONSOLE.print( f' WARNING: {req.name} version {installed_version_str} is newer than ' f'the expected lower bound {expected_version_str}.' ) elif installed_version < expected_version: CONSOLE.print( f' INFO: {req.name} version {installed_version_str} is older than ' f'the expected lower bound {expected_version_str}.' ) else: CONSOLE.print(f' OK: {req.name} is at version {installed_version_str}.')