Skip to content

Commit 25c8aaa

Browse files
committed
🔨 Add script to remove Python 3.9 files, migrate to Python 3.10
1 parent ede8dd0 commit 25c8aaa

File tree

1 file changed

+237
-0
lines changed

1 file changed

+237
-0
lines changed

scripts/docs.py

Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,20 @@
11
import logging
22
import os
33
import re
4+
import shutil
45
import subprocess
56
from http.server import HTTPServer, SimpleHTTPRequestHandler
67
from pathlib import Path
78

89
import mkdocs.utils
910
import typer
1011
from jinja2 import Template
12+
from ruff.__main__ import find_ruff_bin
1113

1214
logging.basicConfig(level=logging.INFO)
1315

1416
mkdocs_name = "mkdocs.yml"
17+
docs_path = Path("docs")
1518
en_docs_path = Path("")
1619

1720
app = typer.Typer()
@@ -143,5 +146,239 @@ def serve() -> None:
143146
server.serve_forever()
144147

145148

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+
146383
if __name__ == "__main__":
147384
app()

0 commit comments

Comments
 (0)