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

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. 

6 

7This module provides functions to verify that resolved dependency environments 

8match the expected lower bounds declared in ``pyproject.toml``. 

9""" 

10 

11from __future__ import annotations 

12 

13import json 

14import sys 

15 

16import tomlkit 

17 

18from mafw.devtools import ensure_devtools_available 

19 

20ensure_devtools_available() 

21 

22from packaging.requirements import Requirement # noqa: E402 

23from packaging.version import Version # noqa: E402 

24from tomlkit.exceptions import TOMLKitError # noqa: E402 

25 

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 

30 

31 

32def get_expected_lower_bounds() -> dict[str, Requirement]: 

33 """ 

34 Read pyproject.toml and extract dependencies with their expected lower bounds. 

35 

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

42 

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 

47 

48 project = doc.get('project') 

49 if project is None: 

50 raise DevtoolsError(f'Missing [project] table in {PYPROJECT_FILE}.') 

51 

52 dependencies = project.get('dependencies', []) 

53 lower_bounds: dict[str, Requirement] = {} 

54 

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 

62 

63 if highest_lower_bound(req) is not None: 

64 lower_bounds[req.name.lower()] = req 

65 

66 return lower_bounds 

67 

68 

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. 

76 

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`. 

80 

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

90 

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 ] 

101 

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

107 

108 # Create a mapping for easy lookup 

109 installed_map = {pkg['name'].lower(): pkg['version'] for pkg in installed_pkgs} 

110 

111 # Prepare environment markers for evaluation 

112 marker_env = { 

113 'python_version': python_version, 

114 'sys_platform': sys.platform, 

115 } 

116 

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 

121 

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 ) 

126 

127 installed_version_str = installed_map[name] 

128 expected_version_str = highest_lower_bound(req) 

129 

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 

132 

133 installed_version = Version(installed_version_str) 

134 expected_version = Version(expected_version_str) 

135 

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