pythonasyncioapiasyncfastapiframeworkjsonjson-schemaopenapiopenapi3pydanticpython-typespython3redocreststarletteswaggerswagger-uiuvicornweb
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
617 lines
20 KiB
617 lines
20 KiB
"""
|
|
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 -%}
|
|
<a href="{{ sponsor.url }}" target="_blank" title="{{ sponsor.title }}"><img src="{{ sponsor.img }}"></a>
|
|
{% endfor -%}
|
|
{%- for sponsor in sponsors.silver -%}
|
|
<a href="{{ sponsor.url }}" target="_blank" title="{{ sponsor.title }}"><img src="{{ sponsor.img }}"></a>
|
|
{% 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"</style>\n\n", content)
|
|
match_start = re.search(r"<!-- sponsors -->", content)
|
|
match_end = re.search(r"<!-- /sponsors -->", 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 (<style>) in index.md")
|
|
frontmatter_end = match_pre.end()
|
|
pre_end = match_start.end()
|
|
post_start = match_end.start()
|
|
template = Template(index_sponsors_template)
|
|
message = template.render(sponsors=sponsors)
|
|
pre_content = content[frontmatter_end:pre_end]
|
|
post_content = content[post_start:]
|
|
new_content = pre_content + message + post_content
|
|
# Remove content between <!-- only-mkdocs --> and <!-- /only-mkdocs -->
|
|
new_content = re.sub(
|
|
r"<!-- only-mkdocs -->.*?<!-- /only-mkdocs -->",
|
|
"",
|
|
new_content,
|
|
flags=re.DOTALL,
|
|
)
|
|
return new_content
|
|
|
|
|
|
@app.command()
|
|
def generate_readme() -> None:
|
|
"""
|
|
Generate README.md file from the main English documentation index.md.
|
|
|
|
This command processes the main documentation index file to create
|
|
a GitHub-appropriate README with sponsors information and without
|
|
mkdocs-specific elements.
|
|
"""
|
|
typer.echo("Generating README")
|
|
readme_path = Path("README.md")
|
|
new_content = generate_readme_content()
|
|
readme_path.write_text(new_content, encoding="utf-8")
|
|
|
|
|
|
@app.command()
|
|
def verify_readme() -> None:
|
|
"""
|
|
Verify that README.md is up-to-date with the main documentation index.
|
|
|
|
Compares the current README content with what would be generated
|
|
from the main index.md file and reports any discrepancies.
|
|
|
|
Raises:
|
|
typer.Abort: If README.md is outdated
|
|
"""
|
|
typer.echo("Verifying README")
|
|
readme_path = Path("README.md")
|
|
generated_content = generate_readme_content()
|
|
readme_content = readme_path.read_text("utf-8")
|
|
if generated_content != readme_content:
|
|
typer.secho(
|
|
"README.md outdated from the latest index.md", color=typer.colors.RED
|
|
)
|
|
raise typer.Abort()
|
|
typer.echo("Valid README ✅")
|
|
|
|
|
|
@app.command()
|
|
def build_all() -> None:
|
|
"""
|
|
Build documentation for all available languages.
|
|
|
|
This command:
|
|
1. Updates language configurations
|
|
2. Cleans the output directory
|
|
3. Builds documentation for all languages in parallel
|
|
4. Places the final output in the site/ directory
|
|
|
|
Uses multiprocessing for efficient parallel builds across all CPU cores.
|
|
"""
|
|
update_languages()
|
|
shutil.rmtree(site_path, ignore_errors=True)
|
|
langs = [lang.name for lang in get_lang_paths() if lang.is_dir()]
|
|
cpu_count = os.cpu_count() or 1
|
|
process_pool_size = cpu_count * 4
|
|
typer.echo(f"Using process pool size: {process_pool_size}")
|
|
with Pool(process_pool_size) as p:
|
|
p.map(build_lang, langs)
|
|
|
|
|
|
@app.command()
|
|
def update_languages() -> None:
|
|
"""
|
|
Update the mkdocs.yml file with all available languages.
|
|
|
|
This command regenerates the language configuration section in the
|
|
main mkdocs.yml file to include all currently available translations.
|
|
"""
|
|
update_config()
|
|
|
|
|
|
@app.command()
|
|
def serve() -> None:
|
|
"""
|
|
Start a simple HTTP server to preview the built documentation site.
|
|
|
|
This is a basic server for previewing the complete built site with
|
|
all translations. For development, use the 'live' command instead.
|
|
|
|
Note: Requires running 'build-all' first to generate the site.
|
|
"""
|
|
typer.echo("Warning: this is a very simple server.")
|
|
typer.echo("For development, use the command live instead.")
|
|
typer.echo("This is here only to preview a site with translations already built.")
|
|
typer.echo("Make sure you run the build-all command first.")
|
|
os.chdir("site")
|
|
server_address = ("", 8008)
|
|
server = HTTPServer(server_address, SimpleHTTPRequestHandler)
|
|
typer.echo("Serving at: http://127.0.0.1:8008")
|
|
server.serve_forever()
|
|
|
|
|
|
@app.command()
|
|
def live(
|
|
lang: str = typer.Argument(
|
|
None, callback=lang_callback, autocompletion=complete_existing_lang
|
|
),
|
|
dirty: bool = False,
|
|
) -> None:
|
|
"""
|
|
Serve documentation with live reload for a specific language.
|
|
|
|
This command starts a mkdocs development server with livereload
|
|
functionality for real-time preview during documentation development.
|
|
|
|
Args:
|
|
lang: Language to serve (defaults to 'en' if not specified)
|
|
dirty: Use dirty build mode for faster rebuilds
|
|
"""
|
|
# Enable line numbers during local development to make it easier to highlight
|
|
if lang is None:
|
|
lang = "en"
|
|
lang_path: Path = docs_path / lang
|
|
# Enable line numbers during local development to make it easier to highlight
|
|
args = ["mkdocs", "serve", "--dev-addr", "127.0.0.1:8008"]
|
|
if dirty:
|
|
args.append("--dirty")
|
|
subprocess.run(
|
|
args, env={**os.environ, "LINENUMS": "true"}, cwd=lang_path, check=True
|
|
)
|
|
|
|
|
|
def get_updated_config_content() -> Dict[str, Any]:
|
|
"""
|
|
Generate updated mkdocs configuration with all available languages.
|
|
|
|
Reads the language names from language_names.yml and generates the
|
|
complete alternate language configuration for the mkdocs.yml file.
|
|
|
|
Returns:
|
|
Dict[str, Any]: Updated mkdocs configuration with all languages
|
|
|
|
Raises:
|
|
typer.Abort: If a language code is missing from language_names.yml
|
|
"""
|
|
config = get_en_config()
|
|
languages = [{"en": "/"}]
|
|
new_alternate: List[Dict[str, str]] = []
|
|
# Language names sourced from https://quickref.me/iso-639-1
|
|
# Contributors may wish to update or change these, e.g. to fix capitalization.
|
|
language_names_path = Path(__file__).parent / "../docs/language_names.yml"
|
|
local_language_names: Dict[str, str] = mkdocs.utils.yaml_load(
|
|
language_names_path.read_text(encoding="utf-8")
|
|
)
|
|
for lang_path in get_lang_paths():
|
|
if lang_path.name in {"en", "em"} or not lang_path.is_dir():
|
|
continue
|
|
code = lang_path.name
|
|
languages.append({code: f"/{code}/"})
|
|
for lang_dict in languages:
|
|
code = list(lang_dict.keys())[0]
|
|
url = lang_dict[code]
|
|
if code not in local_language_names:
|
|
print(
|
|
f"Missing language name for: {code}, "
|
|
"update it in docs/language_names.yml"
|
|
)
|
|
raise typer.Abort()
|
|
use_name = f"{code} - {local_language_names[code]}"
|
|
new_alternate.append({"link": url, "name": use_name})
|
|
new_alternate.append({"link": "/em/", "name": "😉"})
|
|
config["extra"]["alternate"] = new_alternate
|
|
return config
|
|
|
|
|
|
def update_config() -> None:
|
|
"""
|
|
Update the main English mkdocs.yml file with current language configuration.
|
|
|
|
Writes the updated configuration generated by get_updated_config_content()
|
|
back to the mkdocs.yml file.
|
|
"""
|
|
config = get_updated_config_content()
|
|
en_config_path.write_text(
|
|
yaml.dump(config, sort_keys=False, width=200, allow_unicode=True),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
|
|
@app.command()
|
|
def verify_config() -> None:
|
|
"""
|
|
Verify that mkdocs.yml is up-to-date with language names.
|
|
|
|
Checks if the current mkdocs.yml configuration matches what would be
|
|
generated from the current language_names.yml file.
|
|
|
|
Raises:
|
|
typer.Abort: If mkdocs.yml is outdated
|
|
"""
|
|
typer.echo("Verifying mkdocs.yml")
|
|
config = get_en_config()
|
|
updated_config = get_updated_config_content()
|
|
if config != updated_config:
|
|
typer.secho(
|
|
"docs/en/mkdocs.yml outdated from docs/language_names.yml, "
|
|
"update language_names.yml and run "
|
|
"python ./scripts/docs.py update-languages",
|
|
color=typer.colors.RED,
|
|
)
|
|
raise typer.Abort()
|
|
typer.echo("Valid mkdocs.yml ✅")
|
|
|
|
|
|
@app.command()
|
|
def verify_non_translated() -> None:
|
|
"""
|
|
Verify that non-translatable sections don't exist in translation directories.
|
|
|
|
Checks all language directories (except English) to ensure they don't
|
|
contain files or directories that are marked as non-translatable.
|
|
|
|
Raises:
|
|
typer.Abort: If non-translatable content is found in translation directories
|
|
"""
|
|
print("Verifying non translated pages")
|
|
lang_paths = get_lang_paths()
|
|
error_paths = []
|
|
for lang in lang_paths:
|
|
if lang.name == "en":
|
|
continue
|
|
for non_translatable in non_translated_sections:
|
|
non_translatable_path = lang / "docs" / non_translatable
|
|
if non_translatable_path.exists():
|
|
error_paths.append(non_translatable_path)
|
|
if error_paths:
|
|
print("Non-translated pages found, remove them:")
|
|
for error_path in error_paths:
|
|
print(error_path)
|
|
raise typer.Abort()
|
|
print("No non-translated pages found ✅")
|
|
|
|
|
|
@app.command()
|
|
def verify_docs() -> None:
|
|
"""
|
|
Run all documentation verification checks.
|
|
|
|
This command runs a comprehensive verification including:
|
|
- README.md validation
|
|
- mkdocs.yml configuration validation
|
|
- Non-translated content validation
|
|
|
|
This is useful as a pre-commit or CI check to ensure documentation integrity.
|
|
"""
|
|
verify_readme()
|
|
verify_config()
|
|
verify_non_translated()
|
|
|
|
|
|
@app.command()
|
|
def langs_json() -> None:
|
|
"""
|
|
Output all available language codes as JSON.
|
|
|
|
Prints a JSON array containing all available language codes found
|
|
in the docs directory. Useful for scripting and automation.
|
|
"""
|
|
langs = []
|
|
for lang_path in get_lang_paths():
|
|
if lang_path.is_dir():
|
|
langs.append(lang_path.name)
|
|
print(json.dumps(langs))
|
|
|
|
|
|
@app.command()
|
|
def generate_docs_src_versions_for_file(file_path: Path) -> None:
|
|
"""
|
|
Generate Python source file versions for different Python versions.
|
|
|
|
Uses ruff to generate version-specific Python files for documentation
|
|
examples, targeting different Python versions.
|
|
|
|
Args:
|
|
file_path: Path to the source Python file to generate versions for
|
|
"""
|
|
target_versions = ["py39", "py310"]
|
|
base_content = file_path.read_text(encoding="utf-8")
|
|
previous_content = {base_content}
|
|
for target_version in target_versions:
|
|
version_result = subprocess.run(
|
|
[
|
|
find_ruff_bin(),
|
|
"check",
|
|
"--target-version",
|
|
target_version,
|
|
"--fix",
|
|
"--unsafe-fixes",
|
|
"-",
|
|
],
|
|
input=base_content.encode("utf-8"),
|
|
capture_output=True,
|
|
)
|
|
content_target = version_result.stdout.decode("utf-8")
|
|
format_result = subprocess.run(
|
|
[find_ruff_bin(), "format", "-"],
|
|
input=content_target.encode("utf-8"),
|
|
capture_output=True,
|
|
)
|
|
content_format = format_result.stdout.decode("utf-8")
|
|
if content_format in previous_content:
|
|
continue
|
|
previous_content.add(content_format)
|
|
version_file = file_path.with_name(
|
|
file_path.name.replace(".py", f"_{target_version}.py")
|
|
)
|
|
logging.info(f"Writing to {version_file}")
|
|
version_file.write_text(content_format, encoding="utf-8")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
app()
|
|
|