Source code for mafw.devtools.documentation.versions

#  Copyright 2025โ€“2026 European Union
#  Author: Bulgheroni Antonio (antonio.bulgheroni@ec.europa.eu)
#  SPDX-License-Identifier: EUPL-1.2
"""
Version management helpers for MAFw versioned documentation.

This module provides functions for writing ``versions.json``, creating
redirect pages, mirroring version directories, pruning old versions,
and generating landing pages.
"""

from __future__ import annotations

import json
import shutil
from pathlib import Path

from mafw.devtools.documentation.builder import parse_version_tuple


[docs] def write_versions_json(outdir: Path, versions: list[dict[str, str]]) -> None: """ Write versions information to a JSON file. :param outdir: Output directory for the JSON file :type outdir: Path :param versions: List of version information dictionaries :type versions: list[dict[str, str]] """ p = outdir / 'versions.json' with open(p, 'w', encoding='utf-8') as f: json.dump(versions, f, indent=2) print(f'๐Ÿงพ Wrote versions.json to {p}') for v in versions: if v['label'] == 'alias': sub = v['version'] else: sub = v['path'] shutil.copy(p, outdir / sub) shutil.copy(p, outdir / sub / 'generated')
[docs] def mirror_version(outdir: Path, src_tag: str, target_tag: str, use_symlink: bool = True) -> None: """ Mirror a version directory from one tag to another. Can use symlinks for efficiency or copy for compatibility. :param outdir: Output directory containing version directories :type outdir: Path :param src_tag: Source tag directory name :type src_tag: str :param target_tag: Target tag directory name :type target_tag: str :param use_symlink: Whether to use symlink instead of copying, defaults to True :type use_symlink: bool """ src = outdir / src_tag dst = outdir / target_tag # Remove existing destination if it exists if dst.exists() or dst.is_symlink(): if dst.is_symlink(): dst.unlink() else: shutil.rmtree(dst) if use_symlink: print(f'๐Ÿ”— Symlinking {target_tag} -> {src_tag}') # Create relative symlink dst.symlink_to(src_tag, target_is_directory=True) else: print(f'๐Ÿชž Mirroring {src_tag} to {target_tag}') dst.mkdir(parents=True, exist_ok=True) shutil.copytree(src, dst, dirs_exist_ok=True)
[docs] def write_redirect_page(outdir: Path, name: str, target_tag: str) -> None: """ Create a redirect page for a version alias. :param outdir: Output directory for the redirect page :type outdir: Path :param name: Name of the redirect alias (e.g., 'stable', 'dev') :type name: str :param target_tag: Tag that the redirect should point to :type target_tag: str """ d = outdir / name d.mkdir(parents=True, exist_ok=True) target = f'../{target_tag}/index.html' # relative path from stable/index.html to tag/index html = f"""<!doctype html> <html> <head> <meta charset="utf-8"> <meta http-equiv="refresh" content="0; url={target}"> <link rel="canonical" href="{target}"> <title>Redirecting to {target_tag}</title> </head> <body> <p>Redirecting to <a href="{target}">{target}</a></p> </body> </html> """ with open(d / 'index.html', 'w', encoding='utf-8') as f: f.write(html) print(f'๐Ÿงพ Wrote redirect page {d / "index.html"} -> {target}')
[docs] def write_legacy_redirect_page(outdir: Path) -> None: """ Create a legacy redirect page at the root of the output directory. :param outdir: Output directory for the redirect page :type outdir: Path """ html = """<!doctype html> <html> <head> <meta charset="utf-8"> <script> // Detect if we're in /doc/ subdirectory and redirect accordingly const path = window.location.pathname; const targetUrl = path.startsWith('/doc/') ? '/doc/stable/index.html' : 'stable/index.html'; window.location.replace(targetUrl); </script> <meta http-equiv="refresh" content="0; url=stable/index.html"> <link rel="canonical" href="stable/index.html"> <title>Redirecting to stable documentation</title> </head> <body> <p>Redirecting to <a href="stable/index.html">Documentation of the last stable release</a></p> </body> </html> """ d = outdir / Path('index.html') with open(d, 'w', encoding='utf-8') as f: f.write(html) print(f'๐Ÿงพ Wrote legacy redirect page {d}')
[docs] def write_redirects_file(outdir: Path) -> None: """ Create a _redirects file for GitLab Pages. :param outdir: Output directory for the redirects file :type outdir: Path """ redirects_content = """# Redirects for GitLab Pages # See: https://docs.gitlab.com/ee/user/project/pages/redirects.html # Redirect old PDF URL to new PDF downloads page /doc/mafw.pdf /doc/pdf_downloads.html 301 # Redirect /doc root to stable documentation # Note: These are specific patterns to avoid redirecting /doc/pdf_downloads.html /doc/ /doc/stable/ 301 /doc/index.html /doc/stable/index.html 301 /doc/doc_tutorial.html /doc/stable/doc_tutorial.html 301 """ redirects_file = outdir / '_redirects' with open(redirects_file, 'w', encoding='utf-8') as f: f.write(redirects_content) print(f'๐Ÿ”€ Wrote _redirects file: {redirects_file}') print(' Note: Copy this file to the public/ directory root for GitLab Pages')
[docs] def write_root_landing_page(build_root: Path, project_name: str = 'MAFw') -> None: """ Create a landing page for the project root with links to documentation and coverage. :param build_root: Root build directory (should contain 'doc' subdirectory) :type build_root: Path :param project_name: Project name for the page title :type project_name: str """ html_content = f"""<!DOCTYPE html> <html> <head> <meta charset="utf-8"> <title>{project_name} - Documentation Hub</title> <link rel="shortcut icon" href="doc/stable/_static/mafw-logo.svg"/> <style> body {{ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; max-width: 1000px; margin: 0 auto; padding: 40px 20px; line-height: 1.6; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); min-height: 100vh; }} .container {{ background: white; border-radius: 10px; padding: 40px; box-shadow: 0 10px 40px rgba(0,0,0,0.1); }} h1 {{ color: #2c3e50; border-bottom: 3px solid #3498db; padding-bottom: 15px; margin-top: 0; }} .section {{ margin: 30px 0; padding: 25px; background: #f8f9fa; border-radius: 8px; border-left: 4px solid #3498db; }} .section h2 {{ color: #2c3e50; margin-top: 0; display: flex; align-items: center; gap: 10px; }} .links {{ display: flex; flex-wrap: wrap; gap: 15px; margin-top: 15px; }} .link-btn {{ display: inline-block; background: #3498db; color: white; padding: 12px 24px; text-decoration: none; border-radius: 5px; transition: all 0.3s; font-weight: 500; }} .link-btn:hover {{ background: #2980b9; transform: translateY(-2px); box-shadow: 0 4px 12px rgba(52, 152, 219, 0.4); }} .link-btn.secondary {{ background: #95a5a6; }} .link-btn.secondary:hover {{ background: #7f8c8d; }} .description {{ color: #555; margin: 10px 0; }} .icon {{ font-size: 1.5em; }} </style> </head> <body> <div class="container"> <h1>๐Ÿ“š {project_name} Documentation Hub</h1> <p class="description"> Welcome to the {project_name} project documentation portal. Access the latest documentation, download PDFs, or view test coverage reports. </p> <div class="section"> <h2><span class="icon">๐Ÿ“–</span> Documentation</h2> <p class="description"> Browse the complete documentation with tutorials, API reference, and guides. </p> <div class="links"> <a href="doc/stable/index.html" class="link-btn"> ๐Ÿ“˜ Latest Stable Documentation </a> <a href="doc/latest/index.html" class="link-btn secondary"> ๐Ÿ”ฌ Development Version </a> <a href="doc/pdf_downloads.html" class="link-btn secondary"> ๐Ÿ“„ Download PDFs </a> </div> </div> <div class="section"> <h2><span class="icon">๐Ÿงช</span> Test Coverage</h2> <p class="description"> View detailed test coverage reports showing which parts of the codebase are tested. </p> <div class="links"> <a href="coverage/index.html" class="link-btn"> ๐Ÿ“Š View Coverage Report </a> </div> </div> <div style="margin-top: 40px; padding-top: 20px; border-top: 1px solid #dee2e6; color: #6c757d; font-size: 0.9em;"> <p> ๐Ÿ’ก <strong>Tip:</strong> Bookmark the stable documentation link for quick access to the latest version. </p> </div> </div> </body> </html> """ landing_page = build_root / 'index.html' with open(landing_page, 'w', encoding='utf-8') as f: f.write(html_content) print(f'๐Ÿ  Generated root landing page: {landing_page}') print(' Note: This should be copied to public/index.html in GitLab CI')
[docs] def get_directory_size(path: Path) -> int: """ Calculate total size of a directory in bytes. :param path: Directory path :type path: Path :return: Total size in bytes :rtype: int """ total = 0 for item in path.rglob('*'): if item.is_file(): total += item.stat().st_size return total
[docs] def format_size(bytes_size: float) -> str: """ Format bytes to human-readable size. :param bytes_size: Size in bytes :type bytes_size: int :return: Formatted size string :rtype: str """ for unit in ['B', 'KB', 'MB', 'GB']: if bytes_size < 1024.0: return f'{bytes_size:.2f} {unit}' bytes_size /= 1024.0 return f'{bytes_size:.2f} TB'
[docs] def prune_old_versions(outdir: Path, max_size_mb: int = 100, dry_run: bool = False) -> tuple[list[str], int]: """ Remove oldest version directories until total size is below threshold. Always keeps 'stable', 'latest', and 'dev' (if present). :param outdir: Output directory containing version directories :type outdir: Path :param max_size_mb: Maximum size in megabytes :type max_size_mb: int :param dry_run: If True, only report what would be deleted :type dry_run: bool :return: Tuple of (list of removed versions, final size in bytes) :rtype: tuple[list[str], int] """ outdir = Path(outdir).resolve() max_size_bytes = max_size_mb * 1024 * 1024 # Get current total size current_size = get_directory_size(outdir) print(f'๐Ÿ“Š Current total size: {format_size(current_size)}') print(f'๐ŸŽฏ Target maximum: {format_size(max_size_bytes)}') if current_size <= max_size_bytes: print('โœ… Size is within limit. No pruning needed.') return [], current_size # Find all version directories protected_versions = {'stable', 'latest', 'dev'} version_dirs = [] for item in outdir.iterdir(): if item.is_dir() and item.name not in protected_versions: # Skip if it's a symlink (it's an alias) if item.is_symlink(): continue size = get_directory_size(item) version_dirs.append((item.name, size, item)) # Sort by version (oldest first) using semantic versioning version_dirs.sort(key=lambda x: parse_version_tuple(x[0])) print(f'\n๐Ÿ“ฆ Found {len(version_dirs)} version directories (excluding protected):') for name, size, _ in version_dirs: print(f' โ€ข {name}: {format_size(size)}') print(f'\n๐Ÿ›ก๏ธ Protected versions (will never be removed): {", ".join(protected_versions)}') # Remove oldest versions until we're under the limit removed = [] for name, size, path in version_dirs: if current_size <= max_size_bytes: break print(f'\n๐Ÿ—‘๏ธ {"[DRY RUN] Would remove" if dry_run else "Removing"} {name} ({format_size(size)})...') if not dry_run: shutil.rmtree(path) removed.append(name) current_size -= size print(f' New total size: {format_size(current_size)}') if not removed: print(f'\nโš ๏ธ Warning: Cannot reduce size below {format_size(max_size_bytes)}') print(' All remaining versions are protected or size target is too aggressive.') return removed, current_size
[docs] def regenerate_versions_json_after_pruning(outdir: Path, removed_versions: list[str]) -> None: """ Regenerate versions.json after pruning, excluding removed versions. :param outdir: Output directory containing version directories :type outdir: Path :param removed_versions: List of version names that were removed :type removed_versions: list[str] """ versions_file = outdir / 'versions.json' if not versions_file.exists(): print('โš ๏ธ versions.json not found, skipping regeneration') return # Read existing versions.json with open(versions_file, encoding='utf-8') as f: versions = json.load(f) # Filter out removed versions original_count = len(versions) versions = [v for v in versions if v['version'] not in removed_versions and v.get('path') not in removed_versions] removed_count = original_count - len(versions) if removed_count == 0: print('โ„น๏ธ No versions removed from versions.json') return print('\n๐Ÿ”„ Regenerating versions.json...') print(f' Removed {removed_count} entries') # Write updated versions.json write_versions_json(outdir, versions)
[docs] def ensure_versions_json_exists(outdir: Path) -> bool: """ Ensure versions.json exists in outdir. If not, try to copy from another version. :param outdir: Output directory that should contain versions.json :type outdir: Path :return: True if versions.json exists or was successfully copied :rtype: bool """ versions_file = outdir / 'versions.json' if versions_file.exists(): return True print('โš ๏ธ versions.json not found in output directory') # Look for versions.json in other version directories for item in outdir.iterdir(): if item.is_dir() and not item.is_symlink(): candidate = item / 'versions.json' if candidate.exists(): print(f'๐Ÿ“‹ Copying versions.json from {item.name}/') shutil.copy(candidate, versions_file) shutil.copy(candidate, outdir / 'generated/versions.json') return True print('โŒ Could not find versions.json in any version directory') return False