# 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}.')