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

141 statements  

« prev     ^ index     » next       coverage.py v7.15.0, created at 2026-09-01 19:49 +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 functools 

16import re 

17import warnings 

18from pathlib import Path 

19from typing import Any 

20 

21import tomlkit 

22 

23from mafw.devtools import DevtoolsError 

24from mafw.devtools.documentation.builder import find_repo_root 

25 

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

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

28 

29PYTHON_VERSIONS_REQUIREMENTS_FILENAME = 'python_versions.rst' 

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

31 

32 

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

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

35 

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

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

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

39 docs/source directory. 

40 

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

42 :type group_name: str 

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

44 :type repo_root: Path | None 

45 """ 

46 from packaging.requirements import Requirement 

47 

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

49 repo_root = find_repo_root() 

50 pyproject_path = repo_root / 'pyproject.toml' 

51 

52 if not pyproject_path.exists(): 

53 return 

54 

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

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

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

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

59 

60 if group_name == 'base': 

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

62 else: 

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

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

65 

66 # Group dependencies by name 

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

68 for dep_str in deps_list: 

69 req = Requirement(dep_str) 

70 

71 # Format name with extras 

72 name = req.name 

73 if req.extras: 

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

75 

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

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

78 

79 # Format specifiers and markers 

80 parts = [] 

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

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

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

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

85 return 0 

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

87 return 2 

88 return 1 

89 

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

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

92 

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

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

95 

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

97 

98 if not grouped_deps: 

99 return 

100 

101 # Table headers 

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

103 

104 # Calculate column widths 

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

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

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

108 for v in data['versions']: 

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

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

111 

112 # Build the table 

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

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

115 

116 formatted_lines = [ 

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

118 '', 

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

120 '', 

121 border, 

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

123 header_sep, 

124 ] 

125 

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

127 versions = data['versions'] 

128 description = data['description'] 

129 for i, v in enumerate(versions): 

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

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

132 

133 row = ( 

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

135 ) 

136 formatted_lines.append(row) 

137 

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

139 # Vertical merge for name and description columns: 

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

141 mid_border = ( 

142 '| ' 

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

144 + ' +' 

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

146 + '+ ' 

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

148 + ' |' 

149 ) 

150 formatted_lines.append(mid_border) 

151 

152 formatted_lines.append(border) 

153 

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

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

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

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

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

159 

160 

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

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

163 

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

165 :type repo_root: Path | None 

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

167 :rtype: list[str] 

168 """ 

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

170 repo_root = find_repo_root() 

171 pyproject_path = repo_root / 'pyproject.toml' 

172 

173 if not pyproject_path.exists(): 

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

175 

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

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

178 

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

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

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

182 

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

184 for item in supported_python: 

185 if not isinstance(item, str): 

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

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

188 if match is None: 

189 raise DevtoolsError( 

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

191 ) 

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

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

194 if major != 3: 

195 raise DevtoolsError( 

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

197 ) 

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

199 

200 validated.sort() 

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

202 

203 

204@functools.cache 

205def _load_default_python_version(repo_root: Path | None = None) -> str: 

206 """Load the default Python version declared in ``pyproject.toml``. 

207 

208 Reads ``tool.mafw.default-python`` from pyproject.toml. If the key is 

209 absent, the latest (highest) version from ``tool.mafw.supported-python`` 

210 is used as fallback. If the declared default version is not among the 

211 supported versions, a warning is emitted and the latest supported version 

212 is returned instead. 

213 

214 The result is cached via :func:`functools.cache` to avoid repeated TOML 

215 parsing across multiple call sites. 

216 

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

218 :type repo_root: Path | None 

219 :return: The default Python version as a dotted string (e.g. ``"3.14"``). 

220 :rtype: str 

221 :raises DevtoolsError: If ``tool.mafw.supported-python`` cannot be loaded. 

222 """ 

223 if repo_root is None: 

224 repo_root = find_repo_root() 

225 

226 # Load the supported versions (validates pyproject.toml presence and format). 

227 supported_versions = _load_supported_python_versions(repo_root=repo_root) 

228 

229 # The latest supported version serves as the fallback value. 

230 latest_supported = supported_versions[-1] 

231 

232 pyproject_path = repo_root / 'pyproject.toml' 

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

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

235 

236 default_python = tool_mafw.get('default-python') 

237 if default_python is None: 

238 # Key absent — silently fall back to the latest supported version. 

239 return latest_supported 

240 

241 if not isinstance(default_python, str): 

242 warnings.warn( 

243 f'tool.mafw.default-python must be a string, got {type(default_python).__name__}. ' 

244 f'Falling back to latest supported version: {latest_supported}.', 

245 UserWarning, 

246 stacklevel=2, 

247 ) 

248 return latest_supported 

249 

250 default_python = default_python.strip() 

251 if default_python not in supported_versions: 

252 warnings.warn( 

253 f'tool.mafw.default-python = "{default_python}" is not among the supported Python ' 

254 f'versions ({", ".join(supported_versions)}). ' 

255 f'Falling back to latest supported version: {latest_supported}.', 

256 UserWarning, 

257 stacklevel=2, 

258 ) 

259 return latest_supported 

260 

261 return default_python 

262 

263 

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

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

266 

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

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

269 

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

271 :type repo_root: Path | None 

272 """ 

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

274 repo_root = find_repo_root() 

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

276 supported_versions = _load_supported_python_versions(repo_root=repo_root) 

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

278 return 

279 

280 minimum_supported_python = supported_versions[0] 

281 maximum_supported_python = supported_versions[-1] 

282 supported_python_range = f'{minimum_supported_python}–{maximum_supported_python}' 

283 if len(supported_versions) == 1: 

284 supported_python_versions = supported_versions[0] 

285 elif len(supported_versions) == 2: 

286 supported_python_versions = ' and '.join(supported_versions) 

287 else: 

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

289 

290 lines = [ 

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

292 '', 

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

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

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

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

297 '', 

298 ] 

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

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