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
« 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.
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 re
16from pathlib import Path
17from typing import Any
19import tomlkit
21from mafw.devtools import DevtoolsError
22from mafw.devtools.documentation.builder import find_repo_root
24REQUIREMENTS_GROUPS = ['base', 'seaborn', 'devtools']
25"""Dependency groups to generate requirements documentation for."""
27PYTHON_VERSIONS_REQUIREMENTS_FILENAME = 'python_versions.rst'
28"""Filename for the generated Python substitution file."""
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.
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.
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
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'
50 if not pyproject_path.exists():
51 return
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, {})
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, [])
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)
69 # Format name with extras
70 name = req.name
71 if req.extras:
72 name += f'[{",".join(sorted(req.extras))}]'
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(), '')}
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
88 sorted_specs = sorted(list(req.specifier), key=sort_key)
89 parts.append(', '.join(str(s) for s in sorted_specs))
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))
94 grouped_deps[name]['versions'].append('; '.join(parts) if parts else 'any')
96 if not grouped_deps:
97 return
99 # Table headers
100 headers = ['Dependency', 'Minimum supported version', 'Description']
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']))
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) + '+'
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 ]
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 ''
131 row = (
132 '| ' + c1.ljust(col_widths[0]) + ' | ' + v.ljust(col_widths[1]) + ' | ' + c3.ljust(col_widths[2]) + ' |'
133 )
134 formatted_lines.append(row)
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)
150 formatted_lines.append(border)
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)}')
159def _load_supported_python_versions(*, repo_root: 'Path | None' = None) -> list[str]:
160 """Load the supported Python versions declared in ``pyproject.toml``.
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'
171 if not pyproject_path.exists():
172 raise DevtoolsError(f'Unable to find {pyproject_path}.')
174 doc = tomlkit.loads(pyproject_path.read_text(encoding='utf-8'))
175 tool_mafw = doc.get('tool', {}).get('mafw', {})
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}.')
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}'))
198 validated.sort()
199 return [item[2] for item in dict.fromkeys(validated)]
202def generate_python_versions_rst(*, repo_root: 'Path | None' = None) -> None:
203 """Generate the RST substitution file for the supported Python range.
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.
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
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]}'
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)}')