Coverage for src / mafw / devtools / documentation / requirements.py: 90%

119 statements  

« prev     ^ index     » next       coverage.py v7.14.0, created at 2026-06-28 13:34 +0000

1# Copyright 2025–2026 European Union 

2# Author: Bulgheroni Antonio (antonio.bulgheroni@ec.europa.eu) 

3# SPDX-License-Identifier: EUPL-1.2 

4""" 

5Requirements documentation constants and generators for MAFw. 

6 

7This module holds the shared constants used by both the ``multiversion-doc`` 

8CLI and other development tools (e.g. dependency freeze, release workflow), 

9as well as the RST generation functions for dependency tables and Python 

10version substitutions. 

11""" 

12 

13from __future__ import annotations 

14 

15import re 

16from pathlib import Path 

17from typing import Any 

18 

19import tomlkit 

20 

21from mafw.devtools import DevtoolsError 

22from mafw.devtools.documentation.builder import find_repo_root 

23 

24REQUIREMENTS_GROUPS = ['base', 'seaborn', 'devtools'] 

25"""Dependency groups to generate requirements documentation for.""" 

26 

27PYTHON_VERSIONS_REQUIREMENTS_FILENAME = 'python_versions.rst' 

28"""Filename for the generated Python substitution file.""" 

29 

30 

31def generate_requirements_rst(group_name: str = 'base', *, repo_root: 'Path | None' = None) -> None: 

32 """Generate an RST file with the dependencies of a given group. 

33 

34 The function parses pyproject.toml to retrieve the dependencies and their 

35 descriptions from the tool.mafw.dependency-description section. 

36 The generated file is saved as <group_name>_requirements.rst in the 

37 docs/source directory. 

38 

39 :param group_name: The name of the dependency group (e.g., 'base', 'seaborn'), defaults to 'base' 

40 :type group_name: str 

41 :param repo_root: Repository root directory, defaults to auto-detection 

42 :type repo_root: Path | None 

43 """ 

44 from packaging.requirements import Requirement 

45 

46 if repo_root is None: 46 ↛ 47line 46 didn't jump to line 47 because the condition on line 46 was never true

47 repo_root = find_repo_root() 

48 pyproject_path = repo_root / 'pyproject.toml' 

49 

50 if not pyproject_path.exists(): 

51 return 

52 

53 doc = tomlkit.loads(pyproject_path.read_text(encoding='utf-8')) 

54 project = doc.get('project', {}) 

55 tool_mafw = doc.get('tool', {}).get('mafw', {}) 

56 descriptions = tool_mafw.get('dependency-description', {}).get(group_name, {}) 

57 

58 if group_name == 'base': 

59 deps_list = project.get('dependencies', []) 

60 else: 

61 optional = project.get('optional-dependencies', {}) 

62 deps_list = optional.get(group_name, []) 

63 

64 # Group dependencies by name 

65 grouped_deps: dict[str, dict[str, Any]] = {} 

66 for dep_str in deps_list: 

67 req = Requirement(dep_str) 

68 

69 # Format name with extras 

70 name = req.name 

71 if req.extras: 

72 name += f'[{",".join(sorted(req.extras))}]' 

73 

74 if name not in grouped_deps: 74 ↛ 78line 74 didn't jump to line 78 because the condition on line 74 was always true

75 grouped_deps[name] = {'versions': [], 'description': descriptions.get(name.lower(), '')} 

76 

77 # Format specifiers and markers 

78 parts = [] 

79 if req.specifier: 79 ↛ 91line 79 didn't jump to line 91 because the condition on line 79 was always true

80 # Sort specifiers: lower bounds first (>=, >, ~=), then others (==, etc.), then upper bounds (<=, <) 

81 def sort_key(s: Any) -> int: 

82 if s.operator in {'>=', '>', '~='}: 

83 return 0 

84 if s.operator in {'<=', '<'}: 84 ↛ 86line 84 didn't jump to line 86 because the condition on line 84 was always true

85 return 2 

86 return 1 

87 

88 sorted_specs = sorted(list(req.specifier), key=sort_key) 

89 parts.append(', '.join(str(s) for s in sorted_specs)) 

90 

91 if req.marker: 91 ↛ 92line 91 didn't jump to line 92 because the condition on line 91 was never true

92 parts.append(str(req.marker)) 

93 

94 grouped_deps[name]['versions'].append('; '.join(parts) if parts else 'any') 

95 

96 if not grouped_deps: 

97 return 

98 

99 # Table headers 

100 headers = ['Dependency', 'Minimum supported version', 'Description'] 

101 

102 # Calculate column widths 

103 col_widths = [len(h) for h in headers] 

104 for name, data in grouped_deps.items(): 

105 col_widths[0] = max(col_widths[0], len(name)) 

106 for v in data['versions']: 

107 col_widths[1] = max(col_widths[1], len(v)) 

108 col_widths[2] = max(col_widths[2], len(data['description'])) 

109 

110 # Build the table 

111 border = '+' + '+'.join('-' * (w + 2) for w in col_widths) + '+' 

112 header_sep = '+' + '+'.join('=' * (w + 2) for w in col_widths) + '+' 

113 

114 formatted_lines = [ 

115 '.. autogenerated file. do not edit manually', 

116 '', 

117 '.. rst-class:: wrap-table-last', 

118 '', 

119 border, 

120 '| ' + ' | '.join(h.ljust(w) for h, w in zip(headers, col_widths)) + ' |', 

121 header_sep, 

122 ] 

123 

124 for name, data in grouped_deps.items(): 

125 versions = data['versions'] 

126 description = data['description'] 

127 for i, v in enumerate(versions): 

128 c1 = name if i == 0 else '' 

129 c3 = description if i == 0 else '' 

130 

131 row = ( 

132 '| ' + c1.ljust(col_widths[0]) + ' | ' + v.ljust(col_widths[1]) + ' | ' + c3.ljust(col_widths[2]) + ' |' 

133 ) 

134 formatted_lines.append(row) 

135 

136 if i < len(versions) - 1: 136 ↛ 139line 136 didn't jump to line 139 because the condition on line 136 was never true

137 # Vertical merge for name and description columns: 

138 # Use '+' only for the middle column boundaries, spaces for merged columns 

139 mid_border = ( 

140 '| ' 

141 + ' '.ljust(col_widths[0]) 

142 + ' +' 

143 + '-' * (col_widths[1] + 2) 

144 + '+ ' 

145 + ' '.ljust(col_widths[2]) 

146 + ' |' 

147 ) 

148 formatted_lines.append(mid_border) 

149 

150 formatted_lines.append(border) 

151 

152 out_dir = repo_root / 'docs' / 'source' / 'requirements' 

153 out_dir.mkdir(parents=True, exist_ok=True) 

154 out_file = out_dir / f'{group_name}_requirements.rst' 

155 out_file.write_text('\n'.join(formatted_lines) + '\n', encoding='utf-8') 

156 print(f'📝 Generated {out_file.relative_to(repo_root)}') 

157 

158 

159def _load_supported_python_versions(*, repo_root: 'Path | None' = None) -> list[str]: 

160 """Load the supported Python versions declared in ``pyproject.toml``. 

161 

162 :param repo_root: Repository root directory, defaults to auto-detection 

163 :type repo_root: Path | None 

164 :return: Sorted list of supported ``major.minor`` versions. 

165 :rtype: list[str] 

166 """ 

167 if repo_root is None: 167 ↛ 168line 167 didn't jump to line 168 because the condition on line 167 was never true

168 repo_root = find_repo_root() 

169 pyproject_path = repo_root / 'pyproject.toml' 

170 

171 if not pyproject_path.exists(): 

172 raise DevtoolsError(f'Unable to find {pyproject_path}.') 

173 

174 doc = tomlkit.loads(pyproject_path.read_text(encoding='utf-8')) 

175 tool_mafw = doc.get('tool', {}).get('mafw', {}) 

176 

177 supported_python = tool_mafw.get('supported-python') 

178 if not isinstance(supported_python, list) or not supported_python: 

179 raise DevtoolsError(f'Missing tool.mafw.supported-python in {pyproject_path}.') 

180 

181 validated: list[tuple[int, int, str]] = [] 

182 for item in supported_python: 

183 if not isinstance(item, str): 

184 raise DevtoolsError('tool.mafw.supported-python must contain only strings.') 

185 match = re.fullmatch(r'(\d+)\.(\d+)', item.strip()) 

186 if match is None: 

187 raise DevtoolsError( 

188 f'Invalid Python version in tool.mafw.supported-python: {item}. Expected major.minor, e.g. 3.14.' 

189 ) 

190 major = int(match.group(1)) 

191 minor = int(match.group(2)) 

192 if major != 3: 

193 raise DevtoolsError( 

194 f'Unsupported Python version in tool.mafw.supported-python: {item}. Only Python 3.x is supported.' 

195 ) 

196 validated.append((major, minor, f'{major}.{minor}')) 

197 

198 validated.sort() 

199 return [item[2] for item in dict.fromkeys(validated)] 

200 

201 

202def generate_python_versions_rst(*, repo_root: 'Path | None' = None) -> None: 

203 """Generate the RST substitution file for the supported Python range. 

204 

205 The file is emitted under ``docs/source/requirements`` so it can be included 

206 by the general documentation and copied into the README update block. 

207 

208 :param repo_root: Repository root directory, defaults to auto-detection 

209 :type repo_root: Path | None 

210 """ 

211 if repo_root is None: 211 ↛ 212line 211 didn't jump to line 212 because the condition on line 211 was never true

212 repo_root = find_repo_root() 

213 out_path = repo_root / 'docs' / 'source' / 'requirements' / PYTHON_VERSIONS_REQUIREMENTS_FILENAME 

214 supported_versions = _load_supported_python_versions(repo_root=repo_root) 

215 if not supported_versions: 215 ↛ 216line 215 didn't jump to line 216 because the condition on line 215 was never true

216 return 

217 

218 minimum_supported_python = supported_versions[0] 

219 maximum_supported_python = supported_versions[-1] 

220 supported_python_range = f'{minimum_supported_python}{maximum_supported_python}' 

221 if len(supported_versions) == 1: 

222 supported_python_versions = supported_versions[0] 

223 elif len(supported_versions) == 2: 

224 supported_python_versions = ' and '.join(supported_versions) 

225 else: 

226 supported_python_versions = ', '.join(supported_versions[:-1]) + f' and {supported_versions[-1]}' 

227 

228 lines = [ 

229 '.. autogenerated file. do not edit manually', 

230 '', 

231 f'.. |minimum_supported_python| replace:: {minimum_supported_python}', 

232 f'.. |maximum_supported_python| replace:: {maximum_supported_python}', 

233 f'.. |supported_python_range| replace:: {supported_python_range}', 

234 f'.. |supported_python_versions| replace:: {supported_python_versions}', 

235 '', 

236 ] 

237 out_path.write_text('\n'.join(lines), encoding='utf-8') 

238 print(f'📝 Generated Python version substitutions: {out_path.relative_to(repo_root)}')