Source code for mafw.devtools.documentation.builder

#  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 extract_docs_zip_to_repo_root(zip_path: Path, repo_root: Path | None = None) -> None: # pragma: no cover """Extract a documentation zip archive into the repository root and remove the zip. The zip archive is expected to contain paths rooted at the repository (e.g. ``docs/build/doc/vX.Y.Z/...``) so that extraction recreates the same structure as a normal documentation build. :param zip_path: Zip archive to extract :type zip_path: Path :param repo_root: Repository root directory, defaults to auto-detection :type repo_root: Path | None :raises FileNotFoundError: If ``zip_path`` does not exist :raises zipfile.BadZipFile: If the archive is invalid """ zip_path = Path(zip_path).resolve() if not zip_path.exists(): raise FileNotFoundError(f'Zip file not found: {zip_path}') root = (repo_root or find_repo_root()).resolve() with zipfile.ZipFile(zip_path) as zf: zf.extractall(path=root) zip_path.unlink()
[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 filter_stable_tags(tags: list[str], regex: str) -> list[str]: """Filter tags based on a regular expression pattern. :param tags: List of tag strings to filter :type tags: list[str] :param regex: Regular expression pattern to match against :type regex: str :return: Filtered list of matching tags :rtype: list[str] """ pattern = re.compile(regex) return [t for t in tags if pattern.match(t)]
[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)])