Files
mxpic_forge/runs/generate_handbook.py
T

273 lines
8.1 KiB
Python

from __future__ import annotations
import ast
import os
from dataclasses import dataclass
from pathlib import Path
from typing import Optional
ROOT = Path(__file__).resolve().parents[1]
DEFAULT_SRC_ROOT = ROOT / "mxpic" / "components"
DEFAULT_DOCS_ROOT = ROOT / "docs" / "source" / "mxpic" / "components"
DEFAULT_IMAGE_ROOT = ROOT / "docs" / "source" / "images"
DEFAULT_PACKAGE_ROOT = "mxpic.components"
@dataclass(frozen=True)
class ModuleDoc:
"""Documentation targets discovered in one Python module."""
source_path: Path
relative_path: Path
module_name: str
classes: tuple[str, ...]
functions: tuple[str, ...]
@property
def output_path(self) -> Path:
return self.relative_path.with_suffix(".md")
def public_top_level_members(py_file: Path) -> tuple[tuple[str, ...], tuple[str, ...]]:
"""Return public top-level classes and functions without importing a module."""
tree = ast.parse(py_file.read_text(encoding="utf-8"), filename=str(py_file))
classes: list[str] = []
functions: list[str] = []
seen_classes: set[str] = set()
seen_functions: set[str] = set()
for node in tree.body:
if (
isinstance(node, ast.ClassDef)
and not node.name.startswith("_")
and node.name not in seen_classes
):
classes.append(node.name)
seen_classes.add(node.name)
continue
if (
isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
and not node.name.startswith("_")
and node.name not in seen_functions
):
functions.append(node.name)
seen_functions.add(node.name)
return tuple(classes), tuple(functions)
def discover_modules(
src_root: Path = DEFAULT_SRC_ROOT,
package_root: str = DEFAULT_PACKAGE_ROOT,
) -> list[ModuleDoc]:
"""Discover Python modules that should receive generated Markdown pages."""
src_root = Path(src_root)
modules: list[ModuleDoc] = []
for py_file in sorted(src_root.rglob("*.py")):
if py_file.name == "__init__.py":
continue
relative_path = py_file.relative_to(src_root)
module_suffix = ".".join(relative_path.with_suffix("").parts)
classes, functions = public_top_level_members(py_file)
modules.append(
ModuleDoc(
source_path=py_file,
relative_path=relative_path,
module_name=f"{package_root}.{module_suffix}",
classes=classes,
functions=functions,
)
)
return modules
def remove_generated_markdown(docs_root: Path) -> None:
"""Remove generated Markdown while preserving images and other assets."""
docs_root = Path(docs_root)
if not docs_root.exists():
return
for md_file in docs_root.rglob("*.md"):
md_file.unlink()
def primitive_image_subdir(module: ModuleDoc) -> Optional[Path]:
parts = module.relative_path.with_suffix("").parts
if len(parts) < 3 or parts[0] != "primitives":
return None
return Path(*parts[1:-1])
def image_path_for_class(
module: ModuleDoc,
class_name: str,
image_root: Path,
) -> Optional[Path]:
image_subdir = primitive_image_subdir(module)
if image_subdir is None:
return None
image_path = Path(image_root) / image_subdir / f"{class_name}.png"
if image_path.exists():
return image_path
return None
def relative_image_reference(image_path: Path, md_file_path: Path) -> str:
relative_path = os.path.relpath(image_path, start=md_file_path.parent)
return relative_path.replace("\\", "/")
def write_module_page(module: ModuleDoc, docs_root: Path, image_root: Path) -> None:
"""Write a Markdown page for one Python module."""
md_file_path = Path(docs_root) / module.output_path
md_file_path.parent.mkdir(parents=True, exist_ok=True)
lines = [
f"# {module.module_name}",
"",
"```{eval-rst}",
f".. automodule:: {module.module_name}",
" :no-members:",
"```",
"",
]
for class_name in module.classes:
image_path = image_path_for_class(module, class_name, image_root)
lines.extend([f"## {class_name}", "", "```{eval-rst}"])
if image_path is not None:
image_reference = relative_image_reference(image_path, md_file_path)
lines.extend(
[
f".. image:: {image_reference}",
" :align: center",
" :width: 600px",
"",
]
)
lines.extend(
[
f".. autoclass:: {module.module_name}.{class_name}",
" :members:",
" :undoc-members:",
" :show-inheritance:",
"```",
"",
]
)
for function_name in module.functions:
lines.extend(
[
f"## {function_name}",
"",
"```{eval-rst}",
f".. autofunction:: {module.module_name}.{function_name}",
"```",
"",
]
)
md_file_path.write_text("\n".join(lines), encoding="utf-8")
def title_for_directory(relative_dir: Path) -> str:
"""Build a readable title for a generated directory index."""
if relative_dir == Path("."):
return "Components"
return relative_dir.name.replace("_", " ").title()
def write_index_page(docs_root: Path, relative_dir: Path, entries: list[str]) -> None:
"""Write a MyST toctree index for one docs directory."""
index_path = Path(docs_root) / relative_dir / "index.md"
index_path.parent.mkdir(parents=True, exist_ok=True)
lines = [
f"# {title_for_directory(relative_dir)}",
"",
"```{toctree}",
" :maxdepth: 2",
"",
]
lines.extend(entries)
lines.extend(["```", ""])
index_path.write_text("\n".join(lines), encoding="utf-8")
def write_index_pages(modules: list[ModuleDoc], docs_root: Path) -> None:
"""Create recursive index.md files that include all generated module pages."""
directories: set[Path] = {Path(".")}
module_entries: dict[Path, list[str]] = {}
for module in modules:
directory = module.output_path.parent
directories.add(directory)
module_entries.setdefault(directory, []).append(module.output_path.stem)
current = directory
while current != Path("."):
current = current.parent
directories.add(current)
sorted_dirs = sorted(
directories,
key=lambda path: (len(path.parts), path.as_posix()),
reverse=True,
)
for directory in sorted_dirs:
child_dirs = sorted(
child
for child in directories
if child != directory and child.parent == directory
)
entries = [f"{child.name}/index" for child in child_dirs]
entries.extend(sorted(module_entries.get(directory, [])))
if entries or directory == Path("."):
write_index_page(docs_root, directory, entries)
def generate_markdown_handbook(
src_root: Path = DEFAULT_SRC_ROOT,
docs_root: Path = DEFAULT_DOCS_ROOT,
image_root: Path = DEFAULT_IMAGE_ROOT,
package_root: str = DEFAULT_PACKAGE_ROOT,
) -> list[ModuleDoc]:
"""Generate Sphinx Markdown pages for mxpic component modules."""
print("Starting mxPIC Markdown generation...")
src_root = Path(src_root)
docs_root = Path(docs_root)
image_root = Path(image_root)
if not src_root.exists():
raise FileNotFoundError(f"Source directory not found: {src_root}")
docs_root.mkdir(parents=True, exist_ok=True)
remove_generated_markdown(docs_root)
modules = discover_modules(src_root=src_root, package_root=package_root)
for module in modules:
write_module_page(module, docs_root, image_root)
print(f"Generated docs for: {module.module_name}")
write_index_pages(modules, docs_root)
print(f"Markdown generation complete. Updated {len(modules)} module pages.")
return modules
if __name__ == "__main__":
generate_markdown_handbook()