Source code for mafw.devtools.documentation.requirements

#  Copyright 2025–2026 European Union
#  Author: Bulgheroni Antonio (antonio.bulgheroni@ec.europa.eu)
#  SPDX-License-Identifier: EUPL-1.2
"""
Requirements documentation constants and generators for MAFw.

This module holds the shared constants used by both the ``multiversion-doc``
CLI and other development tools (e.g. dependency freeze, release workflow),
as well as the RST generation functions for dependency tables and Python
version substitutions.
"""

from __future__ import annotations

import re
from pathlib import Path
from typing import Any

import tomlkit

from mafw.devtools import DevtoolsError
from mafw.devtools.documentation.builder import find_repo_root

REQUIREMENTS_GROUPS = ['base', 'seaborn', 'devtools']
"""Dependency groups to generate requirements documentation for."""

PYTHON_VERSIONS_REQUIREMENTS_FILENAME = 'python_versions.rst'
"""Filename for the generated Python substitution file."""


[docs] def generate_requirements_rst(group_name: str = 'base', *, repo_root: Path | None = None) -> None: """Generate an RST file with the dependencies of a given group. The function parses pyproject.toml to retrieve the dependencies and their descriptions from the tool.mafw.dependency-description section. The generated file is saved as <group_name>_requirements.rst in the docs/source directory. :param group_name: The name of the dependency group (e.g., 'base', 'seaborn'), defaults to 'base' :type group_name: str :param repo_root: Repository root directory, defaults to auto-detection :type repo_root: Path | None """ from packaging.requirements import Requirement if repo_root is None: repo_root = find_repo_root() pyproject_path = repo_root / 'pyproject.toml' if not pyproject_path.exists(): return doc = tomlkit.loads(pyproject_path.read_text(encoding='utf-8')) project = doc.get('project', {}) tool_mafw = doc.get('tool', {}).get('mafw', {}) descriptions = tool_mafw.get('dependency-description', {}).get(group_name, {}) if group_name == 'base': deps_list = project.get('dependencies', []) else: optional = project.get('optional-dependencies', {}) deps_list = optional.get(group_name, []) # Group dependencies by name grouped_deps: dict[str, dict[str, Any]] = {} for dep_str in deps_list: req = Requirement(dep_str) # Format name with extras name = req.name if req.extras: name += f'[{",".join(sorted(req.extras))}]' if name not in grouped_deps: grouped_deps[name] = {'versions': [], 'description': descriptions.get(name.lower(), '')} # Format specifiers and markers parts = [] if req.specifier: # Sort specifiers: lower bounds first (>=, >, ~=), then others (==, etc.), then upper bounds (<=, <) def sort_key(s: Any) -> int: if s.operator in {'>=', '>', '~='}: return 0 if s.operator in {'<=', '<'}: return 2 return 1 sorted_specs = sorted(list(req.specifier), key=sort_key) parts.append(', '.join(str(s) for s in sorted_specs)) if req.marker: parts.append(str(req.marker)) grouped_deps[name]['versions'].append('; '.join(parts) if parts else 'any') if not grouped_deps: return # Table headers headers = ['Dependency', 'Minimum supported version', 'Description'] # Calculate column widths col_widths = [len(h) for h in headers] for name, data in grouped_deps.items(): col_widths[0] = max(col_widths[0], len(name)) for v in data['versions']: col_widths[1] = max(col_widths[1], len(v)) col_widths[2] = max(col_widths[2], len(data['description'])) # Build the table border = '+' + '+'.join('-' * (w + 2) for w in col_widths) + '+' header_sep = '+' + '+'.join('=' * (w + 2) for w in col_widths) + '+' formatted_lines = [ '.. autogenerated file. do not edit manually', '', '.. rst-class:: wrap-table-last', '', border, '| ' + ' | '.join(h.ljust(w) for h, w in zip(headers, col_widths)) + ' |', header_sep, ] for name, data in grouped_deps.items(): versions = data['versions'] description = data['description'] for i, v in enumerate(versions): c1 = name if i == 0 else '' c3 = description if i == 0 else '' row = ( '| ' + c1.ljust(col_widths[0]) + ' | ' + v.ljust(col_widths[1]) + ' | ' + c3.ljust(col_widths[2]) + ' |' ) formatted_lines.append(row) if i < len(versions) - 1: # Vertical merge for name and description columns: # Use '+' only for the middle column boundaries, spaces for merged columns mid_border = ( '| ' + ' '.ljust(col_widths[0]) + ' +' + '-' * (col_widths[1] + 2) + '+ ' + ' '.ljust(col_widths[2]) + ' |' ) formatted_lines.append(mid_border) formatted_lines.append(border) out_dir = repo_root / 'docs' / 'source' / 'requirements' out_dir.mkdir(parents=True, exist_ok=True) out_file = out_dir / f'{group_name}_requirements.rst' out_file.write_text('\n'.join(formatted_lines) + '\n', encoding='utf-8') print(f'📝 Generated {out_file.relative_to(repo_root)}')
[docs] def _load_supported_python_versions(*, repo_root: Path | None = None) -> list[str]: """Load the supported Python versions declared in ``pyproject.toml``. :param repo_root: Repository root directory, defaults to auto-detection :type repo_root: Path | None :return: Sorted list of supported ``major.minor`` versions. :rtype: list[str] """ if repo_root is None: repo_root = find_repo_root() pyproject_path = repo_root / 'pyproject.toml' if not pyproject_path.exists(): raise DevtoolsError(f'Unable to find {pyproject_path}.') doc = tomlkit.loads(pyproject_path.read_text(encoding='utf-8')) tool_mafw = doc.get('tool', {}).get('mafw', {}) supported_python = tool_mafw.get('supported-python') if not isinstance(supported_python, list) or not supported_python: raise DevtoolsError(f'Missing tool.mafw.supported-python in {pyproject_path}.') validated: list[tuple[int, int, str]] = [] for item in supported_python: if not isinstance(item, str): raise DevtoolsError('tool.mafw.supported-python must contain only strings.') match = re.fullmatch(r'(\d+)\.(\d+)', item.strip()) if match is None: raise DevtoolsError( f'Invalid Python version in tool.mafw.supported-python: {item}. Expected major.minor, e.g. 3.14.' ) major = int(match.group(1)) minor = int(match.group(2)) if major != 3: raise DevtoolsError( f'Unsupported Python version in tool.mafw.supported-python: {item}. Only Python 3.x is supported.' ) validated.append((major, minor, f'{major}.{minor}')) validated.sort() return [item[2] for item in dict.fromkeys(validated)]
[docs] def generate_python_versions_rst(*, repo_root: Path | None = None) -> None: """Generate the RST substitution file for the supported Python range. The file is emitted under ``docs/source/requirements`` so it can be included by the general documentation and copied into the README update block. :param repo_root: Repository root directory, defaults to auto-detection :type repo_root: Path | None """ if repo_root is None: repo_root = find_repo_root() out_path = repo_root / 'docs' / 'source' / 'requirements' / PYTHON_VERSIONS_REQUIREMENTS_FILENAME supported_versions = _load_supported_python_versions(repo_root=repo_root) if not supported_versions: return minimum_supported_python = supported_versions[0] maximum_supported_python = supported_versions[-1] supported_python_range = f'{minimum_supported_python}{maximum_supported_python}' if len(supported_versions) == 1: supported_python_versions = supported_versions[0] elif len(supported_versions) == 2: supported_python_versions = ' and '.join(supported_versions) else: supported_python_versions = ', '.join(supported_versions[:-1]) + f' and {supported_versions[-1]}' lines = [ '.. autogenerated file. do not edit manually', '', f'.. |minimum_supported_python| replace:: {minimum_supported_python}', f'.. |maximum_supported_python| replace:: {maximum_supported_python}', f'.. |supported_python_range| replace:: {supported_python_range}', f'.. |supported_python_versions| replace:: {supported_python_versions}', '', ] out_path.write_text('\n'.join(lines), encoding='utf-8') print(f'📝 Generated Python version substitutions: {out_path.relative_to(repo_root)}')