# 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)}')