Coverage for src / mafw / devtools / dependencies / verify.py: 93%
61 statements
« prev ^ index » next coverage.py v7.14.0, created at 2026-06-28 13:34 +0000
« prev ^ index » next coverage.py v7.14.0, created at 2026-06-28 13:34 +0000
1# Copyright 2026 European Union
2# Author: Bulgheroni Antonio (antonio.bulgheroni@ec.europa.eu)
3# SPDX-License-Identifier: EUPL-1.2
4"""
5Dependency resolution verification utilities for MAFw.
7This module provides functions to verify that resolved dependency environments
8match the expected lower bounds declared in ``pyproject.toml``.
9"""
11from __future__ import annotations
13import json
14import sys
16import tomlkit
18from mafw.devtools import ensure_devtools_available
20ensure_devtools_available()
22from packaging.requirements import Requirement # noqa: E402
23from packaging.version import Version # noqa: E402
24from tomlkit.exceptions import TOMLKitError # noqa: E402
26from mafw.devtools import DevtoolsError # noqa: E402
27from mafw.devtools.dependencies.compile import PYPROJECT_FILE # noqa: E402
28from mafw.devtools.dependencies.freeze import highest_lower_bound # noqa: E402
29from mafw.tools.shell_tools import CONSOLE, run_stdout # noqa: E402
32def get_expected_lower_bounds() -> dict[str, Requirement]:
33 """
34 Read pyproject.toml and extract dependencies with their expected lower bounds.
36 :return: A dictionary mapping lowercase package names to their parsed Requirement objects.
37 :rtype: dict[str, Requirement]
38 :raises DevtoolsError: If the pyproject.toml cannot be parsed.
39 """
40 if not PYPROJECT_FILE.exists():
41 raise DevtoolsError(f'Unable to find {PYPROJECT_FILE}.')
43 try:
44 doc = tomlkit.loads(PYPROJECT_FILE.read_text(encoding='utf-8'))
45 except TOMLKitError as exc:
46 raise DevtoolsError(f'Unable to parse {PYPROJECT_FILE} as TOML.') from exc
48 project = doc.get('project')
49 if project is None:
50 raise DevtoolsError(f'Missing [project] table in {PYPROJECT_FILE}.')
52 dependencies = project.get('dependencies', [])
53 lower_bounds: dict[str, Requirement] = {}
55 for dep_str in dependencies:
56 if not isinstance(dep_str, str): 56 ↛ 57line 56 didn't jump to line 57 because the condition on line 56 was never true
57 continue
58 try:
59 req = Requirement(dep_str)
60 except Exception as exc:
61 raise DevtoolsError(f'Unable to parse dependency requirement "{dep_str}".') from exc
63 if highest_lower_bound(req) is not None:
64 lower_bounds[req.name.lower()] = req
66 return lower_bounds
69def verify_lowest_resolution(
70 env_name: str,
71 python_version: str,
72 expected_bounds: dict[str, Requirement],
73) -> None:
74 """
75 Verify that the specified environment has the expected lowest dependency versions.
77 This function executes `uv pip list` in the given environment, parses the
78 output, and compares installed versions against the expected lower bounds
79 extracted from `pyproject.toml`.
81 :param env_name: The name of the environment (e.g., 'hatch-test', 'types').
82 :type env_name: str
83 :param python_version: The Python version string (e.g., '3.11').
84 :type python_version: str
85 :param expected_bounds: Mapping of package names to their Requirement objects.
86 :type expected_bounds: dict[str, Requirement]
87 :raises DevtoolsError: If a required dependency is missing.
88 """
89 CONSOLE.print(f'Verifying lowest resolution for {env_name} (Python {python_version})...')
91 # Construct the command: hatch run <env_name>.py<python_version>:uv pip list --format json
92 command = [
93 'hatch',
94 'run',
95 f'{env_name}.py{python_version}:uv',
96 'pip',
97 'list',
98 '--format',
99 'json',
100 ]
102 try:
103 output = run_stdout(command)
104 installed_pkgs = json.loads(output)
105 except Exception as exc:
106 raise DevtoolsError(f'Failed to retrieve installed packages for {env_name}.py{python_version}: {exc}')
108 # Create a mapping for easy lookup
109 installed_map = {pkg['name'].lower(): pkg['version'] for pkg in installed_pkgs}
111 # Prepare environment markers for evaluation
112 marker_env = {
113 'python_version': python_version,
114 'sys_platform': sys.platform,
115 }
117 for name, req in expected_bounds.items():
118 # Skip if marker doesn't apply to this python version
119 if req.marker and not req.marker.evaluate(marker_env):
120 continue
122 if name not in installed_map:
123 raise DevtoolsError(
124 f'Required dependency "{req.name}" is missing in environment {env_name}.py{python_version}.'
125 )
127 installed_version_str = installed_map[name]
128 expected_version_str = highest_lower_bound(req)
130 if expected_version_str is None: 130 ↛ 131line 130 didn't jump to line 131 because the condition on line 130 was never true
131 continue # Should not happen given _get_expected_lower_bounds logic
133 installed_version = Version(installed_version_str)
134 expected_version = Version(expected_version_str)
136 if installed_version > expected_version:
137 CONSOLE.print(
138 f' WARNING: {req.name} version {installed_version_str} is newer than '
139 f'the expected lower bound {expected_version_str}.'
140 )
141 elif installed_version < expected_version: 141 ↛ 142line 141 didn't jump to line 142 because the condition on line 141 was never true
142 CONSOLE.print(
143 f' INFO: {req.name} version {installed_version_str} is older than '
144 f'the expected lower bound {expected_version_str}.'
145 )
146 else:
147 CONSOLE.print(f' OK: {req.name} is at version {installed_version_str}.')