"""
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 (