""" FastAPI Documentation Management Script. This script provides a comprehensive set of commands for managing multi-language documentation for FastAPI. It handles building, serving, verifying, and maintaining documentation across multiple languages. Key features: - Building documentation for individual languages or all languages - Generating and verifying README content - Managing language configurations - Serving documentation for development - Verifying documentation integrity The script uses Typer for CLI interface and supports parallel processing for efficient builds. """ import json import logging import os import re import shutil import subprocess from functools import lru_cache from http.server import HTTPServer, SimpleHTTPRequestHandler from importlib import metadata from multiprocessing import Pool from pathlib import Path from typing import Any, Dict, List, Optional, Union import mkdocs.utils import typer import yaml from jinja2 import Template from ruff.__main__ import find_ruff_bin # Configure logging logging.basicConfig(level=logging.INFO) # Initialize Typer app app = typer.Typer() # Constants mkdocs_name = "mkdocs.yml" missing_translation_snippet = """ {!../../docs/missing-translation.md!} """ # Sections that should not be translated non_translated_sections = [ "reference/", "release-notes.md", "fastapi-people.md", "external-links.md", "newsletter.md", "management-tasks.md", "management.md", "contributing.md", ] # Path configurations docs_path = Path("docs") en_docs_path = Path("docs/en") en_config_path: Path = en_docs_path / mkdocs_name site_path = Path("site").absolute() build_site_path = Path("site_build").absolute() # Regex pattern for header permalinks header_with_permalink_pattern = re.compile(r"^(#{1,6}) (.+?)(\s*\{\s*#.*\s*\})\s*$") @lru_cache def is_mkdocs_insiders() -> bool: """ Check if mkdocs-material insiders version is installed. Returns: bool: True if insiders version is detected, False otherwise """ version = metadata.version("mkdocs-material") return "insiders" in version def get_en_config() -> Dict[str, Any]: """ Load and parse the English documentation configuration file. Returns: Dict[str, Any]: Parsed YAML content from mkdocs.yml """ return mkdocs.utils.yaml_load(en_config_path.read_text(encoding="utf-8")) def get_lang_paths() -> List[Path]: """ Get all language directory paths from the docs directory. Returns: List[Path]: Sorted list of Path objects for each language directory """ return sorted(docs_path.iterdir()) def lang_callback(lang: Optional[str]) -> Union[str, None]: """ Typer callback function to normalize language input. Args: lang: Language code to normalize Returns: Normalized lowercase language code or None if input is None """ if lang is None: return None lang = lang.lower() return lang def complete_existing_lang(incomplete: str): """ Autocomplete function for existing languages. Args: incomplete: Partial language name to complete Yields: str: Matching language directory names """ lang_path: Path for lang_path in get_lang_paths(): if lang_path.is_dir() and lang_path.name.startswith(incomplete): yield lang_path.name @app.callback() def callback() -> None: """ Global callback function executed before any command. Sets up environment variables for mkdocs-insiders and MacOS Cairo support. """ if is_mkdocs_insiders(): os.environ["INSIDERS_FILE"] = "../en/mkdocs.insiders.yml" # For MacOS with insiders and Cairo os.environ["DYLD_FALLBACK_LIBRARY_PATH"] = "/opt/homebrew/lib" @app.command() def new_lang(lang: str = typer.Argument(..., callback=lang_callback)): """ Generate a new docs translation directory for the specified language. Creates the necessary directory structure, configuration file, and initial content for a new language translation. Args: lang: Language code for the new translation (e.g., 'es', 'fr') Raises: typer.Abort: If the language directory already exists """ new_path: Path = Path("docs") / lang if new_path.exists(): typer.echo(f"The language was already created: {lang}") raise typer.Abort() new_path.mkdir() new_config_path: Path = Path(new_path) / mkdocs_name new_config_path.write_text("INHERIT: ../en/mkdocs.yml\n", encoding="utf-8") new_config_docs_path: Path = new_path / "docs" new_config_docs_path.mkdir() en_index_path: Path = en_docs_path / "docs" / "index.md" new_index_path: Path = new_config_docs_path / "index.md" en_index_content = en_index_path.read_text(encoding="utf-8") new_index_content = f"{missing_translation_snippet}\n\n{en_index_content}" new_index_path.write_text(new_index_content, encoding="utf-8") typer.secho(f"Successfully initialized: {new_path}", color=typer.colors.GREEN) update_languages() @app.command() def build_lang( lang: str = typer.Argument( ..., callback=lang_callback, autocompletion=complete_existing_lang ), ) -> None: """ Build the documentation for a specific language. Uses mkdocs to build the documentation site for the specified language and copies the output to the appropriate location. Args: lang: Language code to build (e.g., 'en', 'es') Raises: typer.Abort: If the language directory doesn't exist """ insiders_env_file = os.environ.get("INSIDERS_FILE") print(f"Insiders file {insiders_env_file}") if is_mkdocs_insiders(): print("Using insiders") lang_path: Path = Path("docs") / lang if not lang_path.is_dir(): typer.echo(f"The language translation doesn't seem to exist yet: {lang}") raise typer.Abort() typer.echo(f"Building docs for: {lang}") build_site_dist_path = build_site_path / lang if lang == "en": dist_path = site_path # Don't remove en dist_path as it might already contain other languages. # When running build_all(), that function already removes site_path. # All this is only relevant locally, on GitHub Actions all this is done through # artifacts and multiple workflows, so it doesn't matter if directories are # removed or not. else: dist_path = site_path / lang shutil.rmtree(dist_path, ignore_errors=True) current_dir = os.getcwd() os.chdir(lang_path) shutil.rmtree(build_site_dist_path, ignore_errors=True) subprocess.run(["mkdocs", "build", "--site-dir", build_site_dist_path], check=True) shutil.copytree(build_site_dist_path, dist_path, dirs_exist_ok=True) os.chdir(current_dir) typer.secho(f"Successfully built docs for: {lang}", color=typer.colors.GREEN) # Template for sponsors section in README index_sponsors_template = """ {% if sponsors %} {% for sponsor in sponsors.gold -%} {% endfor -%} {%- for sponsor in sponsors.silver -%} {% endfor %} {% endif %} """ def remove_header_permalinks(content: str) -> str: """ Remove permalinks from markdown headers. Args: content: Markdown content with header permalinks Returns: str: Markdown content with permalinks removed from headers """ lines: list[str] = [] for line in content.split("\n"): match = header_with_permalink_pattern.match(line) if match: hashes, title, *_ = match.groups() line = f"{hashes} {title}" lines.append(line) return "\n".join(lines) def generate_readme_content() -> str: """ Generate README.md content from the main English index.md file. Processes the index.md file to remove mkdocs-specific elements and generates appropriate content for GitHub README. Returns: str: Generated README content Raises: RuntimeError: If required sections are not found in the source content """ en_index = en_docs_path / "docs" / "index.md" content = en_index.read_text("utf-8") content = remove_header_permalinks(content) # remove permalinks from headers match_pre = re.search(r"\n\n", content) match_start = re.search(r"", content) match_end = re.search(r"", content) sponsors_data_path = en_docs_path / "data" / "sponsors.yml" sponsors = mkdocs.utils.yaml_load(sponsors_data_path.read_text(encoding="utf-8")) if not (match_start and match_end): raise RuntimeError("Couldn't auto-generate sponsors section") if not match_pre: raise RuntimeError("Couldn't find pre section (