|
1 | 1 | import logging |
2 | 2 | import os |
3 | 3 | import re |
| 4 | +import shutil |
4 | 5 | import subprocess |
5 | 6 | from http.server import HTTPServer, SimpleHTTPRequestHandler |
6 | 7 | from pathlib import Path |
7 | 8 |
|
8 | 9 | import mkdocs.utils |
9 | 10 | import typer |
10 | 11 | from jinja2 import Template |
| 12 | +from ruff.__main__ import find_ruff_bin |
11 | 13 |
|
12 | 14 | logging.basicConfig(level=logging.INFO) |
13 | 15 |
|
14 | 16 | mkdocs_name = "mkdocs.yml" |
| 17 | +docs_path = Path("docs") |
15 | 18 | en_docs_path = Path("") |
16 | 19 |
|
17 | 20 | app = typer.Typer() |
@@ -143,5 +146,239 @@ def serve() -> None: |
143 | 146 | server.serve_forever() |
144 | 147 |
|
145 | 148 |
|
| 149 | +@app.command() |
| 150 | +def generate_docs_src_versions_for_file(file_path: Path) -> None: |
| 151 | + target_versions = ["py39", "py310"] |
| 152 | + full_path_str = str(file_path) |
| 153 | + for target_version in target_versions: |
| 154 | + if f"_{target_version}" in full_path_str: |
| 155 | + logging.info( |
| 156 | + f"Skipping {file_path}, already a version file for {target_version}" |
| 157 | + ) |
| 158 | + return |
| 159 | + base_content = file_path.read_text(encoding="utf-8") |
| 160 | + previous_content = {base_content} |
| 161 | + for target_version in target_versions: |
| 162 | + version_result = subprocess.run( |
| 163 | + [ |
| 164 | + find_ruff_bin(), |
| 165 | + "check", |
| 166 | + "--target-version", |
| 167 | + target_version, |
| 168 | + "--fix", |
| 169 | + "--unsafe-fixes", |
| 170 | + "-", |
| 171 | + ], |
| 172 | + input=base_content.encode("utf-8"), |
| 173 | + capture_output=True, |
| 174 | + ) |
| 175 | + content_target = version_result.stdout.decode("utf-8") |
| 176 | + format_result = subprocess.run( |
| 177 | + [find_ruff_bin(), "format", "-"], |
| 178 | + input=content_target.encode("utf-8"), |
| 179 | + capture_output=True, |
| 180 | + ) |
| 181 | + content_format = format_result.stdout.decode("utf-8") |
| 182 | + if content_format in previous_content: |
| 183 | + continue |
| 184 | + previous_content.add(content_format) |
| 185 | + # Determine where the version label should go: in the parent directory |
| 186 | + # name or in the file name, matching the source structure. |
| 187 | + label_in_parent = False |
| 188 | + for v in target_versions: |
| 189 | + if f"_{v}" in file_path.parent.name: |
| 190 | + label_in_parent = True |
| 191 | + break |
| 192 | + if label_in_parent: |
| 193 | + parent_name = file_path.parent.name |
| 194 | + for v in target_versions: |
| 195 | + parent_name = parent_name.replace(f"_{v}", "") |
| 196 | + new_parent = file_path.parent.parent / f"{parent_name}_{target_version}" |
| 197 | + new_parent.mkdir(parents=True, exist_ok=True) |
| 198 | + version_file = new_parent / file_path.name |
| 199 | + else: |
| 200 | + base_name = file_path.stem |
| 201 | + for v in target_versions: |
| 202 | + if base_name.endswith(f"_{v}"): |
| 203 | + base_name = base_name[: -len(f"_{v}")] |
| 204 | + break |
| 205 | + version_file = file_path.with_name(f"{base_name}_{target_version}.py") |
| 206 | + logging.info(f"Writing to {version_file}") |
| 207 | + version_file.write_text(content_format, encoding="utf-8") |
| 208 | + |
| 209 | + |
| 210 | +@app.command() |
| 211 | +def generate_docs_src_versions() -> None: |
| 212 | + """ |
| 213 | + Generate Python version-specific files for all .py files in docs_src. |
| 214 | + """ |
| 215 | + docs_src_path = Path("docs_src") |
| 216 | + for py_file in sorted(docs_src_path.rglob("*.py")): |
| 217 | + generate_docs_src_versions_for_file(py_file) |
| 218 | + |
| 219 | + |
| 220 | +@app.command() |
| 221 | +def copy_py39_to_py310() -> None: |
| 222 | + """ |
| 223 | + For each docs_src file/directory with a _py39 label that has no _py310 |
| 224 | + counterpart, copy it with the _py310 label. |
| 225 | + """ |
| 226 | + docs_src_path = Path("docs_src") |
| 227 | + # Handle directory-level labels (e.g. app_b_an_py39/) |
| 228 | + for dir_path in sorted(docs_src_path.rglob("*_py39")): |
| 229 | + if not dir_path.is_dir(): |
| 230 | + continue |
| 231 | + py310_dir = dir_path.parent / dir_path.name.replace("_py39", "_py310") |
| 232 | + if py310_dir.exists(): |
| 233 | + continue |
| 234 | + logging.info(f"Copying directory {dir_path} -> {py310_dir}") |
| 235 | + shutil.copytree(dir_path, py310_dir) |
| 236 | + # Handle file-level labels (e.g. tutorial001_py39.py) |
| 237 | + for file_path in sorted(docs_src_path.rglob("*_py39.py")): |
| 238 | + if not file_path.is_file(): |
| 239 | + continue |
| 240 | + # Skip files inside _py39 directories (already handled above) |
| 241 | + if "_py39" in file_path.parent.name: |
| 242 | + continue |
| 243 | + py310_file = file_path.with_name( |
| 244 | + file_path.name.replace("_py39.py", "_py310.py") |
| 245 | + ) |
| 246 | + if py310_file.exists(): |
| 247 | + continue |
| 248 | + logging.info(f"Copying file {file_path} -> {py310_file}") |
| 249 | + shutil.copy2(file_path, py310_file) |
| 250 | + |
| 251 | + |
| 252 | +@app.command() |
| 253 | +def update_docs_includes_py39_to_py310() -> None: |
| 254 | + """ |
| 255 | + Update .md files in docs/en/ to replace _py39 includes with _py310 versions. |
| 256 | +
|
| 257 | + For each include line referencing a _py39 file or directory in docs_src, replace |
| 258 | + the _py39 label with _py310. |
| 259 | + """ |
| 260 | + include_pattern = re.compile(r"\{[^}]*docs_src/[^}]*_py39[^}]*\.py[^}]*\}") |
| 261 | + count = 0 |
| 262 | + for md_file in sorted(en_docs_path.rglob("*.md")): |
| 263 | + content = md_file.read_text(encoding="utf-8") |
| 264 | + if "_py39" not in content: |
| 265 | + continue |
| 266 | + new_content = include_pattern.sub( |
| 267 | + lambda m: m.group(0).replace("_py39", "_py310"), content |
| 268 | + ) |
| 269 | + if new_content != content: |
| 270 | + md_file.write_text(new_content, encoding="utf-8") |
| 271 | + count += 1 |
| 272 | + logging.info(f"Updated includes in {md_file}") |
| 273 | + print(f"Updated {count} file(s) ✅") |
| 274 | + |
| 275 | + |
| 276 | +@app.command() |
| 277 | +def remove_unused_docs_src() -> None: |
| 278 | + """ |
| 279 | + Delete .py files in docs_src that are not included in any .md file under docs/. |
| 280 | + """ |
| 281 | + docs_src_path = Path("docs_src") |
| 282 | + # Collect all docs .md content referencing docs_src |
| 283 | + all_docs_content = "" |
| 284 | + for md_file in docs_path.rglob("*.md"): |
| 285 | + all_docs_content += md_file.read_text(encoding="utf-8") |
| 286 | + # Build a set of directory-based package roots (e.g. docs_src/bigger_applications/app_py39) |
| 287 | + # where at least one file is referenced in docs. All files in these directories |
| 288 | + # should be kept since they may be internally imported by the referenced files. |
| 289 | + used_package_dirs: set[Path] = set() |
| 290 | + for py_file in docs_src_path.rglob("*.py"): |
| 291 | + if py_file.name == "__init__.py": |
| 292 | + continue |
| 293 | + rel_path = str(py_file) |
| 294 | + if rel_path in all_docs_content: |
| 295 | + parts = py_file.relative_to(docs_src_path).parts |
| 296 | + if len(parts) > 2 and not py_file.name.startswith("tutorial"): |
| 297 | + # File is inside a package directory (e.g. |
| 298 | + # docs_src/tutorial/fastapi/app_testing/tutorial001_py310/). |
| 299 | + # Mark the immediate parent as a used package so sibling |
| 300 | + # files (likely imported by the referenced file) are kept. |
| 301 | + used_package_dirs.add(py_file.parent) |
| 302 | + removed = 0 |
| 303 | + for py_file in sorted(docs_src_path.rglob("*.py")): |
| 304 | + if py_file.name == "__init__.py": |
| 305 | + continue |
| 306 | + # Build the relative path as it appears in includes (e.g. docs_src/first_steps/tutorial001.py) |
| 307 | + rel_path = str(py_file) |
| 308 | + if rel_path in all_docs_content: |
| 309 | + continue |
| 310 | + # If this file is inside a directory-based package where any sibling is |
| 311 | + # referenced, keep it (it's likely imported internally). |
| 312 | + if py_file.parent in used_package_dirs: |
| 313 | + continue |
| 314 | + # Check if the _an counterpart (or non-_an counterpart) is referenced. |
| 315 | + # If either variant is included, keep both. |
| 316 | + # Handle both file-level _an (tutorial001_an.py) and directory-level _an |
| 317 | + # (app_an/main.py) |
| 318 | + counterpart_found = False |
| 319 | + full_path_str = str(py_file) |
| 320 | + if "_an" in py_file.stem: |
| 321 | + # This is an _an file, check if the non-_an version is referenced |
| 322 | + counterpart = full_path_str.replace( |
| 323 | + f"/{py_file.stem}", f"/{py_file.stem.replace('_an', '', 1)}" |
| 324 | + ) |
| 325 | + if counterpart in all_docs_content: |
| 326 | + counterpart_found = True |
| 327 | + else: |
| 328 | + # This is a non-_an file, check if there's an _an version referenced |
| 329 | + # Insert _an before any version suffix or at the end of the stem |
| 330 | + stem = py_file.stem |
| 331 | + for suffix in ("_py39", "_py310"): |
| 332 | + if suffix in stem: |
| 333 | + an_stem = stem.replace(suffix, f"_an{suffix}", 1) |
| 334 | + break |
| 335 | + else: |
| 336 | + an_stem = f"{stem}_an" |
| 337 | + counterpart = full_path_str.replace(f"/{stem}.", f"/{an_stem}.") |
| 338 | + if counterpart in all_docs_content: |
| 339 | + counterpart_found = True |
| 340 | + # Also check directory-level _an counterparts |
| 341 | + if not counterpart_found: |
| 342 | + parent_name = py_file.parent.name |
| 343 | + if "_an" in parent_name: |
| 344 | + counterpart_parent = parent_name.replace("_an", "", 1) |
| 345 | + counterpart_dir = str(py_file).replace( |
| 346 | + f"/{parent_name}/", f"/{counterpart_parent}/" |
| 347 | + ) |
| 348 | + if counterpart_dir in all_docs_content: |
| 349 | + counterpart_found = True |
| 350 | + else: |
| 351 | + # Try inserting _an into parent directory name |
| 352 | + for suffix in ("_py39", "_py310"): |
| 353 | + if suffix in parent_name: |
| 354 | + an_parent = parent_name.replace(suffix, f"_an{suffix}", 1) |
| 355 | + break |
| 356 | + else: |
| 357 | + an_parent = f"{parent_name}_an" |
| 358 | + counterpart_dir = str(py_file).replace( |
| 359 | + f"/{parent_name}/", f"/{an_parent}/" |
| 360 | + ) |
| 361 | + if counterpart_dir in all_docs_content: |
| 362 | + counterpart_found = True |
| 363 | + if counterpart_found: |
| 364 | + continue |
| 365 | + logging.info(f"Removing unused file: {py_file}") |
| 366 | + py_file.unlink() |
| 367 | + removed += 1 |
| 368 | + # Clean up directories that are empty or only contain __init__.py / __pycache__ |
| 369 | + for dir_path in sorted(docs_src_path.rglob("*"), reverse=True): |
| 370 | + if not dir_path.is_dir(): |
| 371 | + continue |
| 372 | + remaining = [ |
| 373 | + f |
| 374 | + for f in dir_path.iterdir() |
| 375 | + if f.name != "__pycache__" and f.name != "__init__.py" |
| 376 | + ] |
| 377 | + if not remaining: |
| 378 | + logging.info(f"Removing empty/init-only directory: {dir_path}") |
| 379 | + shutil.rmtree(dir_path) |
| 380 | + print(f"Removed {removed} unused file(s) ✅") |
| 381 | + |
| 382 | + |
146 | 383 | if __name__ == "__main__": |
147 | 384 | app() |
0 commit comments