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
« 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.
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"""
13from __future__ import annotations
15import functools
16import re
17import warnings
18from pathlib import Path
19from typing import Any
21import tomlkit
23from mafw.devtools import DevtoolsError
24from mafw.devtools.documentation.builder import find_repo_root
26REQUIREMENTS_GROUPS = ['base', 'seaborn', 'devtools']
27"""Dependency groups to generate requirements documentation for."""
29PYTHON_VERSIONS_REQUIREMENTS_FILENAME = 'python_versions.rst'
30"""Filename for the generated Python substitution file."""
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.
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.
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
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'
52 if not pyproject_path.exists():
53 return
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, {})
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, [])
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)
71 # Format name with extras
72 name = req.name
73 if req.extras:
74 name += f'[{",".join(sorted(req.extras))}]'
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(), '')}
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
90 sorted_specs = sorted(list(req.specifier), key=sort_key)
91 parts.append(', '.join(str(s) for s in sorted_specs))
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))
96 grouped_deps[name]['versions'].append('; '.join(parts) if parts else 'any')
98 if not grouped_deps:
99 return
101 # Table headers
102 headers = ['Dependency', 'Minimum supported version', 'Description']
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']))
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) + '+'
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 ]
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 ''
133 row = (
134 '| ' + c1.ljust(col_widths[0]) + ' | ' + v.ljust(col_widths[1]) + ' | ' + c3.ljust(col_widths[2]) + ' |'
135 )
136 formatted_lines.append(row)
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)
152 formatted_lines.append(border)
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)}')
161def _load_supported_python_versions(*, repo_root: Path | None = None) -> list[str]:
162 """Load the supported Python versions declared in ``pyproject.toml``.
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'
173 if not pyproject_path.exists():
174 raise DevtoolsError(f'Unable to find {pyproject_path}.')
176 doc = tomlkit.loads(pyproject_path.read_text(encoding='utf-8'))
177 tool_mafw = doc.get('tool', {}).get('mafw', {})
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}.')
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}'))
200 validated.sort()
201 return [item[2] for item in dict.fromkeys(validated)]
204@functools.cache
205def _load_default_python_version(repo_root: Path | None = None) -> str:
206 """Load the default Python version declared in ``pyproject.toml``.
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.
214 The result is cached via :func:`functools.cache` to avoid repeated TOML
215 parsing across multiple call sites.
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()
226 # Load the supported versions (validates pyproject.toml presence and format).
227 supported_versions = _load_supported_python_versions(repo_root=repo_root)
229 # The latest supported version serves as the fallback value.
230 latest_supported = supported_versions[-1]
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', {})
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
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
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
261 return default_python
264def generate_python_versions_rst(*, repo_root: Path | None = None) -> None:
265 """Generate the RST substitution file for the supported Python range.
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.
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
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]}'
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)}')