273 lines
8.1 KiB
Python
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()
|