# Copyright 2025–2026 European Union
# Author: Bulgheroni Antonio (antonio.bulgheroni@ec.europa.eu)
# SPDX-License-Identifier: EUPL-1.2
"""
Sphinx documentation building helpers for MAFw versioned documentation.
This module provides functions for building Sphinx documentation across
multiple git tags, managing git worktrees, and handling documentation
zip archives.
"""
from __future__ import annotations
import re
import shutil
import subprocess
import zipfile
from pathlib import Path
from typing import Any
from mafw.devtools import DevtoolsError, ensure_devtools_available
ensure_devtools_available()
from packaging.version import InvalidVersion, Version # noqa: E402
from mafw.tools.shell_tools import run as _run # noqa: E402
# ---------------------------
# Configurable defaults
# ---------------------------
DEFAULT_MIN_TAG_REGEX = r'^v([1-9][0-9]*)\.[0-9]+\.[0-9]+(\.[0-9]+)?$'
"""Regular expression to match stable version tags."""
DOCS_SUBPATH = Path('docs') / 'source'
"""The files/directories under each worktree where docs live."""
SPHINX_BUILD_CMD = 'sphinx-build' # ensure on PATH
"""Sphinx build command name."""
OLD_VERSION_TO_BE_PATCHED = ['v1.0.0', 'v1.1.0', 'v1.2.0', 'v1.3.0', 'v1.4.0']
"""Tags that require patching with the latest conf.py."""
[docs]
def run(cmd: list[str], cwd: Path | None = None) -> subprocess.CompletedProcess[str]:
"""Helper to run commands with consistent behavior.
:param cmd: Command to execute as a list of strings
:type cmd: list[str]
:param cwd: Working directory for command execution, defaults to None
:type cwd: Path | None
:return: Completed process result
:rtype: subprocess.CompletedProcess[str]
"""
return _run(cmd, cwd=cwd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, check=False)
[docs]
def find_repo_root(start: Path | None = None) -> Path:
"""Find the repository root directory.
The root is detected by walking upwards until a ``pyproject.toml`` file is found.
If no such file is found, the starting directory is returned.
:param start: Directory from which to start searching, defaults to current working directory
:type start: Path | None
:return: Resolved repository root directory
:rtype: Path
"""
current = (start or Path.cwd()).resolve()
while True:
if (current / 'pyproject.toml').exists():
return current
if current.parent == current:
return (start or Path.cwd()).resolve()
current = current.parent
[docs]
def create_docs_zip_for_tag(outdir: Path, tag: str, zip_filepath: Path) -> Path: # pragma: no cover
"""Create a zip archive for a built documentation version directory.
The built docs are expected under ``outdir / tag`` (e.g. ``docs/build/doc/vX.Y.Z``).
The produced zip is laid out so that extracting it from the repository root recreates
the original directory structure (e.g. ``docs/build/doc/vX.Y.Z/...``).
The zip file is created at ``zip_filepath / f"mafw-docs-{tag}.zip"``.
Notes
-----
- Symlinks are skipped to avoid ambiguous extraction behavior across platforms.
:param outdir: Output directory that contains the built docs
:type outdir: Path
:param tag: Version tag (e.g. ``v2.1.0``)
:type tag: str
:param zip_filepath: Directory where the zip file is written
:type zip_filepath: Path
:return: Path to the created zip archive
:rtype: Path
:raises FileNotFoundError: If the built docs directory does not exist
:raises NotADirectoryError: If the built docs path is not a directory
"""
outdir = Path(outdir).resolve()
zip_filepath = Path(zip_filepath).resolve()
zip_filepath.mkdir(parents=True, exist_ok=True)
built_dir = outdir / tag
if not built_dir.exists():
raise FileNotFoundError(f'Built docs directory not found: {built_dir}')
if not built_dir.is_dir():
raise NotADirectoryError(f'Built docs path is not a directory: {built_dir}')
repo_root = find_repo_root()
try:
prefix = built_dir.relative_to(repo_root)
except ValueError:
prefix = Path('docs') / 'build' / 'doc' / tag
print(f'⚠️ Warning: {built_dir} is not under repo root {repo_root}. Using archive prefix {prefix}.')
zip_path = zip_filepath / f'mafw-docs-{tag}.zip'
with zipfile.ZipFile(zip_path, mode='w', compression=zipfile.ZIP_DEFLATED) as zf:
for fp in built_dir.rglob('*'):
if not fp.is_file():
continue
if fp.is_symlink():
continue
arcname = prefix / fp.relative_to(built_dir)
zf.write(fp, arcname=arcname)
return zip_path
[docs]
def filter_latest_micro(versions: list[tuple[Version, Any]]) -> list[tuple[Version, Any]]:
"""Keep only the latest micro version per minor (major.minor).
:param versions: List of (Version, tag) tuples
:type versions: list[tuple[Version, Any]]
:return: Filtered list of (Version, tag) tuples
:rtype: list[tuple[Version, Any]]
"""
latest_per_minor: dict[tuple[int, int], tuple[Version, Any]] = {}
for v, tag in versions:
key = (v.major, v.minor)
if key not in latest_per_minor or v > latest_per_minor[key][0]:
latest_per_minor[key] = (v, tag)
return sorted(latest_per_minor.values())
[docs]
def parse_version_tuple(tag: str) -> tuple[int, ...]:
"""Parse vX.Y.Z(.W) into tuple of ints for sorting.
:param tag: Version tag string
:type tag: str
:return: Tuple of integers representing the version
:rtype: tuple[int, ...]
"""
if tag.startswith('v'):
tag = tag[1:]
parts = tag.split('.')
# only take numeric parts
nums = []
for p in parts:
if p.isdigit():
nums.append(int(p))
else:
# stop on strange parts; but ideally regex filters those out
break
return tuple(nums)
[docs]
def copy_patch_files(docs_src: Path) -> None:
"""Copy patch files needed for older versions.
:param docs_src: Path to documentation source directory
:type docs_src: Path
"""
# Define the patch files to copy
patch_files = [
('docs/source/conf.py', docs_src / 'conf.py'),
('docs/source/_static/js/version-switcher.js', docs_src / '_static/js/version-switcher.js'),
('docs/source/_templates/versions.html', docs_src / '_templates/versions.html'),
('docs/source/_templates/layout.html', docs_src / '_templates/layout.html'),
('docs/source/_ext/procparams.py', docs_src / '_ext/procparams.py'),
]
# Create directories and copy files
for src_path, dst_path in patch_files:
dst_path.parent.mkdir(parents=True, exist_ok=True)
shutil.copy(Path.cwd() / src_path, dst_path)
[docs]
def parse_sphinx_log(log_content: str) -> tuple[int, int, list[str]]:
"""
Parse Sphinx build log to extract warning and error counts, and warning messages.
Only three warnings are reported
:param log_content: Sphinx build log
:type log_content: str
:return: Tuple of warning, error count, warning messages
:rtype: tuple[int, int, list[str]]
"""
warnings = 0
warning_messages = []
# Look for patterns like "build succeeded, X warning(s)."
success_pattern = re.compile(r'build succeeded(?:,\s+(\d+)\s+warning)?', re.IGNORECASE)
match = success_pattern.search(log_content)
if match:
if match.group(1):
warnings = int(match.group(1))
# Look for explicit warning lines and extract messages
warning_pattern = re.compile(r'^.*WARNING:.*$', re.MULTILINE | re.IGNORECASE)
warning_lines = warning_pattern.findall(log_content)
warnings = max(warnings, len(warning_lines))
# Extract just the relevant part of warning messages (limit to first 3)
# for line in warning_lines[:3]:
# clean_line = ' '.join(line.split())
# warning_messages.append(clean_line)
warning_messages = warning_lines[:3]
if len(warning_lines) > 3:
warning_messages.append(f'... and {len(warning_lines) - 3} more warning(s)')
# Look for error patterns
error_pattern = re.compile(r'ERROR:|CRITICAL:', re.IGNORECASE)
errors = len(error_pattern.findall(log_content))
return warnings, errors, warning_messages
[docs]
def report_build_status(tag: str, success: bool, log: str, build_type: str = 'HTML') -> None:
"""
Report build status with warning/error summary.
:param tag: Version tag being built
:type tag: str
:param success: Whether build succeeded
:type success: bool
:param log: Build log content
:type log: str
:param build_type: Type of build (HTML or PDF)
:type build_type: str
"""
warnings, errors, warning_messages = parse_sphinx_log(log)
status_icon = '✅' if success else '❌'
status_text = 'OK' if success else 'FAILED'
print(f'{status_icon} {tag} {build_type} build {status_text}', end='')
if warnings > 0 or errors > 0:
details = []
if warnings > 0:
details.append(f'⚠️ {warnings} warning(s)')
if errors > 0:
details.append(f'❌ {errors} error(s)')
print(f' ({", ".join(details)})')
# Display warning messages if present
if warning_messages:
for msg in warning_messages:
print(f' ⚠️ {msg}')
else:
print(' (no warnings)')
[docs]
def ensure_sphinx_build_available() -> None:
"""Ensure that the Sphinx Python package is available.
``doc_versioning`` is a development helper shipped with MAFw. The script is
typically executed from the optional ``[dev]`` environment (either by
activating the development environment and using the console entry point,
or via ``hatch run dev.py<version>:multidoc`` on CI/CD).
Checking for the ``sphinx-build`` executable alone is not sufficient because
the effective availability depends on which Python environment is executing
the command. Checking the import spec for ``sphinx`` validates that the
correct optional dependencies are installed for the running interpreter.
:raises DevtoolsError: If Sphinx is not available.
"""
import importlib.util
if importlib.util.find_spec('sphinx') is None:
raise DevtoolsError(
'Unable to import the "sphinx" package. '
'This usually means you are running outside the MAFw development environment. '
'Install MAFw with the optional [devtools] feature, or invoke the helper via Hatch '
'(e.g. "hatch run dev.py3.14:multidoc --help").'
)
[docs]
def check_multiversion_structure(outdir: Path) -> bool:
"""
Check if multiversion structure exists (other version directories).
:param outdir: Output directory to check
:type outdir: Path
:return: True if other versions exist
:rtype: bool
"""
if not outdir.exists():
return False
# Count non-latest version directories
version_dirs = []
for item in outdir.iterdir():
if item.is_dir() and item.name != 'latest':
# Check if it's not a symlink or if it is, count it
version_dirs.append(item.name)
return len(version_dirs) > 0
[docs]
def parse_mafw_docs_zip_filename(file_name: str) -> tuple[str, str] | None:
"""Parse and validate a mafw-docs zip filename.
The accepted filename pattern is: ``mafw-docs-vX.Y.Z.zip``.
:param file_name: File name to parse
:type file_name: str
:return: Tuple of (version, normalized_file_name) if valid, otherwise None
:rtype: tuple[str, str] | None
"""
base = Path(file_name).name
m = re.fullmatch(r'(mafw-docs)-(v[0-9]+\.[0-9]+\.[0-9]+)\.zip', base)
if not m:
return None
version = m.group(2)
return version, f'{m.group(1)}-{version}.zip'
[docs]
def normalize_registry_item(item: str) -> tuple[str, str]:
"""Normalize a registry item into (version, file_name).
The item can be either:
- a version string: ``vX.Y.Z``
- a file name: ``mafw-docs-vX.Y.Z.zip``
:param item: Input item
:type item: str
:return: Tuple of (version, file_name)
:rtype: tuple[str, str]
:raises ValueError: If the item cannot be normalized
"""
item = item.strip()
parsed = parse_mafw_docs_zip_filename(item)
if parsed is not None:
return parsed
try:
v = Version(item)
except InvalidVersion as e:
raise ValueError(f'Invalid version or zip filename: {item}') from e
if v.is_prerelease or v.is_devrelease:
raise ValueError(f'Pre-release/dev versions are not supported here: {item}')
version = item
return version, f'mafw-docs-{version}.zip'
[docs]
def iter_local_mafw_docs_zips(zip_dir: Path) -> list[tuple[str, Path]]:
"""List local mafw-docs zip files in a directory.
Only files matching ``mafw-docs-vX.Y.Z.zip`` are returned.
:param zip_dir: Directory to scan
:type zip_dir: Path
:return: List of (version, file_path) tuples
:rtype: list[tuple[str, Path]]
"""
zip_dir = Path(zip_dir).resolve()
if not zip_dir.exists():
return []
items: list[tuple[str, Path]] = []
for fp in zip_dir.iterdir():
if not fp.is_file():
continue
parsed = parse_mafw_docs_zip_filename(fp.name)
if parsed is None:
continue
version, _ = parsed
items.append((version, fp))
items.sort(key=lambda x: parse_version_tuple(x[0]))
return items
[docs]
def filter_versions_in_range(versions: list[str], from_v: str | None, to_v: str | None) -> list[str]:
"""Filter versions within an inclusive semantic-version range.
:param versions: Input versions list
:type versions: list[str]
:param from_v: Range start (inclusive)
:type from_v: str | None
:param to_v: Range end (inclusive)
:type to_v: str | None
:return: Filtered versions list
:rtype: list[str]
:raises ValueError: If range bounds are invalid
"""
if from_v is None and to_v is None:
return versions
if from_v is not None:
Version(from_v) # validate
if to_v is not None:
Version(to_v) # validate
if from_v is not None and to_v is not None:
if Version(from_v) > Version(to_v):
raise ValueError(f'Invalid range: --from {from_v} is greater than --to {to_v}')
out: list[str] = []
for v in versions:
vv = Version(v)
if from_v is not None and vv < Version(from_v):
continue
if to_v is not None and vv > Version(to_v):
continue
out.append(v)
return out
[docs]
def generate_pdf_index_page( # pragma: no cover
html_outdir: Path, pdf_info: list[dict[str, str]], project_name: str = 'Documentation'
) -> None:
"""
Generate an HTML page listing all available PDFs.
This page will be placed in the root html_versions directory.
Order: stable first, then latest, then other releases sorted by version (newest first).
:param html_outdir: Output directory for HTML files
:type html_outdir: Path
:param pdf_info: List of dictionaries containing PDF information
:type pdf_info: list[dict[str, str]]
:param project_name: Name of the project for the page title, defaults to 'Documentation'
:type project_name: str
"""
html_content = f"""<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>PDF Downloads - {project_name}</title>
<link rel="shortcut icon" href="stable/_static/mafw-logo.svg"/>
<style>
body {{
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
max-width: 900px;
margin: 40px auto;
padding: 20px;
line-height: 1.6;
}}
h1 {{
color: #2c3e50;
border-bottom: 3px solid #3498db;
padding-bottom: 10px;
}}
.pdf-list {{
list-style: none;
padding: 0;
}}
.pdf-item {{
background: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 5px;
padding: 15px 20px;
margin: 10px 0;
display: flex;
justify-content: space-between;
align-items: center;
transition: all 0.3s;
}}
.pdf-item:hover {{
background: #e9ecef;
transform: translateX(5px);
}}
.pdf-version {{
font-weight: bold;
font-size: 1.1em;
color: #2c3e50;
}}
.pdf-label {{
display: inline-block;
padding: 3px 8px;
border-radius: 3px;
font-size: 0.85em;
margin-left: 10px;
}}
.label-stable {{
background: #28a745;
color: white;
}}
.label-latest {{
background: #ffc107;
color: #000;
}}
.label-release {{
background: #6c757d;
color: white;
}}
.download-btn {{
background: #3498db;
color: white;
padding: 8px 20px;
text-decoration: none;
border-radius: 5px;
transition: background 0.3s;
}}
.download-btn:hover {{
background: #2980b9;
}}
.failed {{
opacity: 0.5;
}}
.failed .download-btn {{
background: #95a5a6;
pointer-events: none;
}}
</style>
</head>
<body>
<h1>📄 PDF Documentation Downloads</h1>
<p>Download the complete documentation in PDF format for any version:</p>
<ul class="pdf-list">
"""
# Sort: stable first, then latest, then releases by version (newest first)
sorted_info = []
stable_item = None
latest_item = None
release_items = []
for info in pdf_info:
if info['label'] == 'alias':
continue
if info['label'] == 'stable':
stable_item = info
elif info['label'] == 'latest':
latest_item = info
else: # release
release_items.append(info)
# Sort releases by version (newest first)
release_items.sort(key=lambda x: parse_version_tuple(x['version']), reverse=True)
# Build final order
if stable_item:
sorted_info.append(stable_item)
if latest_item:
sorted_info.append(latest_item)
sorted_info.extend(release_items)
for info in sorted_info:
label_class = f'label-{info["label"]}'
label_text = info['label'].upper()
item_class = '' if info['built'] else 'failed'
if info['built']:
# PDF is in the same directory as HTML for each version
pdf_link = f'{info["version"]}/{info["version"]}.pdf'
html_content += f"""
<li class="pdf-item {item_class}">
<div>
<span class="pdf-version">{info['version']}</span>
<span class="pdf-label {label_class}">{label_text}</span>
</div>
<a href="{pdf_link}" class="download-btn" download>Download PDF</a>
</li>
"""
else:
html_content += f"""
<li class="pdf-item {item_class}">
<div>
<span class="pdf-version">{info['version']}</span>
<span class="pdf-label {label_class}">{label_text}</span>
<span style="color: #e74c3c; margin-left: 10px;">(Build failed)</span>
</div>
<span class="download-btn">Unavailable</span>
</li>
"""
html_content += """
</ul>
<p style="margin-top: 40px; color: #6c757d; font-size: 0.9em;">
💡 Tip: The PDF version contains the complete documentation for offline reading.
</p>
</body>
</html>
"""
# Write to root of html_versions
pdf_page = html_outdir / 'pdf_downloads.html'
with open(pdf_page, 'w', encoding='utf-8') as f:
f.write(html_content)
print(f'📝 Generated PDF index page: {pdf_page}')
[docs]
def build_for_tag( # pragma: no cover
tag: str, outdir: Path, tmproot: Path, use_latest_conf: bool = False, keep_tmp: bool = False
) -> tuple[bool, str]:
"""Create worktree for tag, run sphinx-build, save log.
:param tag: Git tag to build documentation for
:type tag: str
:param outdir: Output directory for built documentation
:type outdir: Path
:param tmproot: Root temporary directory
:type tmproot: Path
:param use_latest_conf: Whether to use latest conf.py, defaults to False
:type use_latest_conf: bool
:param keep_tmp: Whether to keep temporary files, defaults to False
:type keep_tmp: bool
:return: Tuple of (success, log_contents)
:rtype: tuple[bool, str]
"""
worktree_path = tmproot / tag
try:
proc = run(['git', 'worktree', 'add', '-q', str(worktree_path), tag])
if proc.returncode != 0:
return False, f'git worktree add failed:\n{proc.stdout}'
docs_src = worktree_path / DOCS_SUBPATH
if not docs_src.exists():
return False, f'docs source {docs_src} does not exist for tag {tag}'
if use_latest_conf or tag in OLD_VERSION_TO_BE_PATCHED:
copy_patch_files(docs_src)
out_for_tag = outdir / tag
out_for_tag.mkdir(parents=True, exist_ok=True)
sp = run([SPHINX_BUILD_CMD, '-b', 'html', str(docs_src), str(out_for_tag)], cwd=worktree_path)
log = sp.stdout
with open(out_for_tag / 'sphinx-build.log', 'w', encoding='utf-8') as f:
f.write(log)
success = sp.returncode == 0
return success, log
finally:
if not keep_tmp:
run(['git', 'worktree', 'remove', '-f', str(worktree_path)])
[docs]
def build_pdf_for_tag( # pragma: no cover
tag: str, html_tag_dir: Path, tmproot: Path, use_latest_conf: bool = False, keep_tmp: bool = False
) -> tuple[bool, str, Path | None]:
"""Create worktree for tag, run sphinx-build with latex builder, then make PDF.
:param tag: Git tag to build PDF for
:type tag: str
:param html_tag_dir: Directory containing HTML output for the tag
:type html_tag_dir: Path
:param tmproot: Root temporary directory
:type tmproot: Path
:param use_latest_conf: Whether to use latest conf.py, defaults to False
:type use_latest_conf: bool
:param keep_tmp: Whether to keep temporary files, defaults to False
:type keep_tmp: bool
:return: Tuple of (success, log_contents, pdf_path)
:rtype: tuple[bool, str, Path | None]
"""
worktree_path = tmproot / f'{tag}_pdf'
pdf_path = None
try:
proc = run(['git', 'worktree', 'add', '-q', str(worktree_path), tag])
if proc.returncode != 0:
return False, f'git worktree add failed:\n{proc.stdout}', None
docs_src = worktree_path / DOCS_SUBPATH
if not docs_src.exists():
return False, f'docs source {docs_src} does not exist for tag {tag}', None
if use_latest_conf or tag in OLD_VERSION_TO_BE_PATCHED:
copy_patch_files(docs_src)
latex_out = tmproot / f'{tag}_latex'
latex_out.mkdir(parents=True, exist_ok=True)
sp = run([SPHINX_BUILD_CMD, '-b', 'latex', str(docs_src), str(latex_out)], cwd=worktree_path)
log = sp.stdout
if sp.returncode != 0:
return False, f'Sphinx latex build failed:\n{log}', None
makefile = latex_out / 'Makefile'
if makefile.exists():
sp_pdf = run(['make'], cwd=latex_out)
else:
tex_files = list(latex_out.glob('*.tex'))
if not tex_files:
return False, 'No .tex file found in latex output', None
sp_pdf = run(['pdflatex', '-interaction=nonstopmode', tex_files[0].name], cwd=latex_out)
log += '\n' + sp_pdf.stdout
pdf_files = list(latex_out.glob('*.pdf'))
if not pdf_files:
return False, f'PDF generation failed:\n{log}', None
html_tag_dir.mkdir(parents=True, exist_ok=True)
pdf_path = html_tag_dir / f'{tag}.pdf'
pdf_file = latex_out / 'mafw.pdf'
shutil.copy(pdf_file, pdf_path)
success = sp_pdf.returncode == 0
return success, log, pdf_path
finally:
if not keep_tmp:
run(['git', 'worktree', 'remove', '-f', str(worktree_path)])