diff --git a/scripts/docs.py b/scripts/docs.py index a424f177b4..ba41c57ca6 100644 --- a/scripts/docs.py +++ b/scripts/docs.py @@ -1,6 +1,7 @@ import logging import os import re +import shutil import subprocess from http.server import HTTPServer, SimpleHTTPRequestHandler from pathlib import Path @@ -8,10 +9,12 @@ import mkdocs.utils import typer from jinja2 import Template +from ruff.__main__ import find_ruff_bin logging.basicConfig(level=logging.INFO) mkdocs_name = "mkdocs.yml" +docs_path = Path("docs") en_docs_path = Path("") app = typer.Typer() @@ -143,5 +146,239 @@ def serve() -> None: server.serve_forever() +@app.command() +def generate_docs_src_versions_for_file(file_path: Path) -> None: + target_versions = ["py39", "py310"] + full_path_str = str(file_path) + for target_version in target_versions: + if f"_{target_version}" in full_path_str: + logging.info( + f"Skipping {file_path}, already a version file for {target_version}" + ) + return + 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) + # Determine where the version label should go: in the parent directory + # name or in the file name, matching the source structure. + label_in_parent = False + for v in target_versions: + if f"_{v}" in file_path.parent.name: + label_in_parent = True + break + if label_in_parent: + parent_name = file_path.parent.name + for v in target_versions: + parent_name = parent_name.replace(f"_{v}", "") + new_parent = file_path.parent.parent / f"{parent_name}_{target_version}" + new_parent.mkdir(parents=True, exist_ok=True) + version_file = new_parent / file_path.name + else: + base_name = file_path.stem + for v in target_versions: + if base_name.endswith(f"_{v}"): + base_name = base_name[: -len(f"_{v}")] + break + version_file = file_path.with_name(f"{base_name}_{target_version}.py") + logging.info(f"Writing to {version_file}") + version_file.write_text(content_format, encoding="utf-8") + + +@app.command() +def generate_docs_src_versions() -> None: + """ + Generate Python version-specific files for all .py files in docs_src. + """ + docs_src_path = Path("docs_src") + for py_file in sorted(docs_src_path.rglob("*.py")): + generate_docs_src_versions_for_file(py_file) + + +@app.command() +def copy_py39_to_py310() -> None: + """ + For each docs_src file/directory with a _py39 label that has no _py310 + counterpart, copy it with the _py310 label. + """ + docs_src_path = Path("docs_src") + # Handle directory-level labels (e.g. app_b_an_py39/) + for dir_path in sorted(docs_src_path.rglob("*_py39")): + if not dir_path.is_dir(): + continue + py310_dir = dir_path.parent / dir_path.name.replace("_py39", "_py310") + if py310_dir.exists(): + continue + logging.info(f"Copying directory {dir_path} -> {py310_dir}") + shutil.copytree(dir_path, py310_dir) + # Handle file-level labels (e.g. tutorial001_py39.py) + for file_path in sorted(docs_src_path.rglob("*_py39.py")): + if not file_path.is_file(): + continue + # Skip files inside _py39 directories (already handled above) + if "_py39" in file_path.parent.name: + continue + py310_file = file_path.with_name( + file_path.name.replace("_py39.py", "_py310.py") + ) + if py310_file.exists(): + continue + logging.info(f"Copying file {file_path} -> {py310_file}") + shutil.copy2(file_path, py310_file) + + +@app.command() +def update_docs_includes_py39_to_py310() -> None: + """ + Update .md files in docs/en/ to replace _py39 includes with _py310 versions. + + For each include line referencing a _py39 file or directory in docs_src, replace + the _py39 label with _py310. + """ + include_pattern = re.compile(r"\{[^}]*docs_src/[^}]*_py39[^}]*\.py[^}]*\}") + count = 0 + for md_file in sorted(en_docs_path.rglob("*.md")): + content = md_file.read_text(encoding="utf-8") + if "_py39" not in content: + continue + new_content = include_pattern.sub( + lambda m: m.group(0).replace("_py39", "_py310"), content + ) + if new_content != content: + md_file.write_text(new_content, encoding="utf-8") + count += 1 + logging.info(f"Updated includes in {md_file}") + print(f"Updated {count} file(s) ✅") + + +@app.command() +def remove_unused_docs_src() -> None: + """ + Delete .py files in docs_src that are not included in any .md file under docs/. + """ + docs_src_path = Path("docs_src") + # Collect all docs .md content referencing docs_src + all_docs_content = "" + for md_file in docs_path.rglob("*.md"): + all_docs_content += md_file.read_text(encoding="utf-8") + # Build a set of directory-based package roots (e.g. docs_src/bigger_applications/app_py39) + # where at least one file is referenced in docs. All files in these directories + # should be kept since they may be internally imported by the referenced files. + used_package_dirs: set[Path] = set() + for py_file in docs_src_path.rglob("*.py"): + if py_file.name == "__init__.py": + continue + rel_path = str(py_file) + if rel_path in all_docs_content: + parts = py_file.relative_to(docs_src_path).parts + if len(parts) > 2 and not py_file.name.startswith("tutorial"): + # File is inside a package directory (e.g. + # docs_src/tutorial/fastapi/app_testing/tutorial001_py310/). + # Mark the immediate parent as a used package so sibling + # files (likely imported by the referenced file) are kept. + used_package_dirs.add(py_file.parent) + removed = 0 + for py_file in sorted(docs_src_path.rglob("*.py")): + if py_file.name == "__init__.py": + continue + # Build the relative path as it appears in includes (e.g. docs_src/first_steps/tutorial001.py) + rel_path = str(py_file) + if rel_path in all_docs_content: + continue + # If this file is inside a directory-based package where any sibling is + # referenced, keep it (it's likely imported internally). + if py_file.parent in used_package_dirs: + continue + # Check if the _an counterpart (or non-_an counterpart) is referenced. + # If either variant is included, keep both. + # Handle both file-level _an (tutorial001_an.py) and directory-level _an + # (app_an/main.py) + counterpart_found = False + full_path_str = str(py_file) + if "_an" in py_file.stem: + # This is an _an file, check if the non-_an version is referenced + counterpart = full_path_str.replace( + f"/{py_file.stem}", f"/{py_file.stem.replace('_an', '', 1)}" + ) + if counterpart in all_docs_content: + counterpart_found = True + else: + # This is a non-_an file, check if there's an _an version referenced + # Insert _an before any version suffix or at the end of the stem + stem = py_file.stem + for suffix in ("_py39", "_py310"): + if suffix in stem: + an_stem = stem.replace(suffix, f"_an{suffix}", 1) + break + else: + an_stem = f"{stem}_an" + counterpart = full_path_str.replace(f"/{stem}.", f"/{an_stem}.") + if counterpart in all_docs_content: + counterpart_found = True + # Also check directory-level _an counterparts + if not counterpart_found: + parent_name = py_file.parent.name + if "_an" in parent_name: + counterpart_parent = parent_name.replace("_an", "", 1) + counterpart_dir = str(py_file).replace( + f"/{parent_name}/", f"/{counterpart_parent}/" + ) + if counterpart_dir in all_docs_content: + counterpart_found = True + else: + # Try inserting _an into parent directory name + for suffix in ("_py39", "_py310"): + if suffix in parent_name: + an_parent = parent_name.replace(suffix, f"_an{suffix}", 1) + break + else: + an_parent = f"{parent_name}_an" + counterpart_dir = str(py_file).replace( + f"/{parent_name}/", f"/{an_parent}/" + ) + if counterpart_dir in all_docs_content: + counterpart_found = True + if counterpart_found: + continue + logging.info(f"Removing unused file: {py_file}") + py_file.unlink() + removed += 1 + # Clean up directories that are empty or only contain __init__.py / __pycache__ + for dir_path in sorted(docs_src_path.rglob("*"), reverse=True): + if not dir_path.is_dir(): + continue + remaining = [ + f + for f in dir_path.iterdir() + if f.name != "__pycache__" and f.name != "__init__.py" + ] + if not remaining: + logging.info(f"Removing empty/init-only directory: {dir_path}") + shutil.rmtree(dir_path) + print(f"Removed {removed} unused file(s) ✅") + + if __name__ == "__main__": app()