mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-11 20:27:44 +02:00
472 lines
17 KiB
Python
472 lines
17 KiB
Python
"""Generate the doc-source tree from the repository (standalone pre-build step).
|
|
|
|
Zensical doesn't run MkDocs plugins (no ``mkdocs-gen-files`` / ``literate-nav``),
|
|
so instead of synthesizing virtual pages we write **real** Markdown files into
|
|
``content/`` before ``zensical build`` runs. Zensical then infers the navigation
|
|
from the directory structure, so the sidebar mirrors the repo on every run.
|
|
|
|
Pure standard library — run it with any Python ≥3.9:
|
|
|
|
python docs/gen_pages.py
|
|
|
|
The site structure mirrors the repository: **every top-level folder becomes a
|
|
sidebar section**, populated by whatever docs live under it. Root-level Markdown
|
|
(``README.md`` and friends) is grouped under a synthetic ``General`` section.
|
|
|
|
Sources are declared once in ``RULES`` (see below); the same engine discovers,
|
|
filters, and writes each of them:
|
|
|
|
1. ``**/*.py`` in real packages -> ``content/<pkg-path>/...`` (leaf modules use
|
|
``::: module`` for mkdocstrings; packages get a generated card-grid overview)
|
|
2. repo Markdown -> ``content/<top-dir>/...`` (root-level .md -> ``content/general/``)
|
|
3. ``frontend/.typedoc`` -> ``content/frontend/...`` (TypeDoc output, if present)
|
|
|
|
Discovery uses ``git ls-files`` so the generator inherits ``.gitignore`` for free
|
|
— virtualenvs (``backend/tests/.runner-venv``), caches, and build output never
|
|
leak in, and there's no denylist to keep patched. The set of generated files is
|
|
recorded in ``docs/.gen_manifest`` and removed on the next run, so cleanup is
|
|
precise. Hand-written content (``content/index.md`` and the ``assets`` /
|
|
``stylesheets`` / ``javascripts`` folders) is never touched.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import ast
|
|
import logging
|
|
import shutil
|
|
import subprocess
|
|
from collections.abc import Callable, Iterable
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
|
|
HERE = Path(__file__).resolve().parent
|
|
REPO_ROOT = HERE.parent
|
|
CONTENT = HERE / "content"
|
|
|
|
# Root-level Markdown is grouped here; TypeDoc output lands under the frontend
|
|
# folder section like any other source under ``frontend/``.
|
|
GENERAL_DIR = CONTENT / "general"
|
|
FRONTEND_DIR = CONTENT / "frontend"
|
|
|
|
# Record of everything we wrote last run, so we can clean precisely. Lives
|
|
# outside ``content/`` (the Zensical docs_dir) so it's never served, and is
|
|
# git-ignored via docs/.gitignore.
|
|
MANIFEST = HERE / ".gen_manifest"
|
|
|
|
# Hand-written / static content under ``content/`` that we must never delete.
|
|
PRESERVE = {"index.md", "assets", "stylesheets", "javascripts"}
|
|
|
|
# Top-level repo folders to never turn into sections. ``docs`` is this tool and
|
|
# its own generated output — ingesting it would be circular.
|
|
EXCLUDE_TOP_DIRS = {"docs"}
|
|
|
|
# Module path segments that never belong in the public API reference.
|
|
EXCLUDE_SEGMENTS = {"tests", "migrations", "webapp_template"}
|
|
|
|
# Only used by the rglob fallback when ``git ls-files`` is unavailable (e.g. a
|
|
# source tarball). Git's own ignore rules cover this and more in the normal path.
|
|
_FALLBACK_SKIP = {
|
|
".git", ".venv", "venv", "node_modules", "__pycache__", "site",
|
|
".pytest_cache", ".mypy_cache", "dist", "build", ".typedoc",
|
|
".runner-venv", ".ruff_cache",
|
|
}
|
|
|
|
log = logging.getLogger("gen_pages")
|
|
|
|
|
|
# --- File discovery -------------------------------------------------------
|
|
|
|
def tracked_files(root: Path, patterns: list[str]) -> list[Path]:
|
|
"""Return repository files matching ``patterns`` (git pathspecs).
|
|
|
|
Uses ``git ls-files`` so ignored paths (venvs, caches, build output) are
|
|
excluded for free. Falls back to a filtered ``rglob`` — with a loud warning
|
|
— when ``root`` isn't a git checkout, since that path can surface files git
|
|
would have hidden.
|
|
"""
|
|
try:
|
|
result = subprocess.run(
|
|
["git", "ls-files", "-z", "--", *patterns],
|
|
cwd=root,
|
|
capture_output=True,
|
|
text=True,
|
|
check=True,
|
|
)
|
|
except (FileNotFoundError, subprocess.CalledProcessError) as exc:
|
|
log.warning(
|
|
"git ls-files unavailable (%s); falling back to rglob — output may "
|
|
"include files git would ignore", exc,
|
|
)
|
|
return _rglob_fallback(root, patterns)
|
|
return [root / line for line in result.stdout.split("\0") if line]
|
|
|
|
|
|
def _rglob_fallback(root: Path, patterns: list[str]) -> list[Path]:
|
|
results: list[Path] = []
|
|
for pattern in patterns:
|
|
base, _, glob = pattern.rpartition("/")
|
|
start = root / base if base else root
|
|
suffix = glob.replace("*", "")
|
|
if not start.is_dir():
|
|
continue
|
|
for path in start.rglob(f"*{suffix}"):
|
|
if path.is_file() and not _is_skipped(path.relative_to(root)):
|
|
results.append(path)
|
|
return results
|
|
|
|
|
|
def _is_skipped(rel: Path) -> bool:
|
|
return any(part in _FALLBACK_SKIP for part in rel.parts)
|
|
|
|
|
|
# --- Filters / path mapping -----------------------------------------------
|
|
|
|
def is_documentable_top_dir(rel: Path) -> bool:
|
|
"""Whether ``rel`` lives under a top-level folder we turn into a section."""
|
|
return bool(rel.parts) and rel.parts[0] not in EXCLUDE_TOP_DIRS
|
|
|
|
|
|
def is_reference_module(rel: Path) -> bool:
|
|
"""Whether a ``*.py`` file should become an API reference page.
|
|
|
|
Excludes tests, migrations, and the app-builder scaffolding template, and
|
|
requires every path segment to be a valid (non-private) module name — which
|
|
also rejects junk like ``.runner-venv`` dotted paths in the fallback case.
|
|
Importability (a complete ``__init__.py`` chain) is checked separately.
|
|
"""
|
|
parts = rel.with_suffix("").parts
|
|
if not is_documentable_top_dir(rel):
|
|
return False
|
|
if any(seg in EXCLUDE_SEGMENTS for seg in parts):
|
|
return False
|
|
for seg in parts:
|
|
if seg == "__init__":
|
|
continue
|
|
if not seg.isidentifier():
|
|
return False
|
|
if seg.startswith("_") and not seg.startswith("__"):
|
|
return False
|
|
return True
|
|
|
|
|
|
def is_importable(py: Path) -> bool:
|
|
"""Whether ``py``'s package chain is complete enough for Griffe to collect it.
|
|
|
|
mkdocstrings resolves a dotted identifier (``backend.apps.settings.settings``)
|
|
by walking real packages, so every ancestor directory must contain an
|
|
``__init__.py``. Emitting a page for a module under an ``__init__``-less dir
|
|
(a namespace package) makes ``zensical build`` fail hard with
|
|
``Could not collect '<module>'``, so we skip those instead.
|
|
"""
|
|
dir_parts = py.relative_to(REPO_ROOT).parts[:-1]
|
|
return all(
|
|
(REPO_ROOT.joinpath(*dir_parts[:i]) / "__init__.py").exists()
|
|
for i in range(1, len(dir_parts) + 1)
|
|
)
|
|
|
|
|
|
def _is_package_dir(rel: Path) -> bool:
|
|
"""Whether ``rel``'s top-level folder is itself an importable package."""
|
|
return bool(rel.parts) and (REPO_ROOT / rel.parts[0] / "__init__.py").exists()
|
|
|
|
|
|
def _module_parts(py: Path) -> list[str]:
|
|
parts = list(py.relative_to(REPO_ROOT).with_suffix("").parts)
|
|
if parts and parts[-1] == "__init__":
|
|
parts = parts[:-1]
|
|
return parts
|
|
|
|
|
|
# --- Source rules ---------------------------------------------------------
|
|
|
|
@dataclass(frozen=True)
|
|
class SourceRule:
|
|
"""One declarative source: discover files, map each to a dest, render text."""
|
|
|
|
name: str
|
|
discover: Callable[[], Iterable[Path]]
|
|
dest: Callable[[Path], Path]
|
|
render: Callable[[Path], str]
|
|
|
|
|
|
def _discover_reference() -> Iterable[Path]:
|
|
skipped = 0
|
|
for py in tracked_files(REPO_ROOT, ["*.py"]):
|
|
rel = py.relative_to(REPO_ROOT)
|
|
if py.suffix != ".py" or not is_reference_module(rel):
|
|
continue
|
|
if not is_importable(py):
|
|
# Only flag the gap for folders that are otherwise API packages
|
|
# (a real misconfig, like a missing __init__.py in backend/). Plain
|
|
# tooling dirs that aren't packages at all are skipped silently.
|
|
if _is_package_dir(rel):
|
|
skipped += 1
|
|
log.warning(
|
|
"skipping %s: no __init__.py in its package chain "
|
|
"(mkdocstrings can't collect it)", rel,
|
|
)
|
|
continue
|
|
yield py
|
|
if skipped:
|
|
log.warning("skipped %d reference module(s) missing an __init__.py", skipped)
|
|
|
|
|
|
def _dest_reference(py: Path) -> Path:
|
|
# Mirror the repo path under content/, so the top-level package becomes its
|
|
# own sidebar section (e.g. backend/apps/foo.py -> content/backend/apps/foo.md).
|
|
parts = _module_parts(py)
|
|
if py.name == "__init__.py":
|
|
# Package → section/sub-section landing page (works with navigation.indexes).
|
|
return CONTENT.joinpath(*parts, "index.md")
|
|
return CONTENT.joinpath(*parts).with_suffix(".md")
|
|
|
|
|
|
# Card icons for the generated package-overview grids (Material icon set, shipped
|
|
# with Zensical and enabled via the pymdownx.emoji config in zensical.toml).
|
|
_SUBPKG_ICON = ":material-folder:"
|
|
_MODULE_ICON = ":material-file-code:"
|
|
|
|
# Below this many children a scoped-search box is more clutter than help, so the
|
|
# overview is just the card grid. The widget itself is wired by
|
|
# content/javascripts/pkg-search.js, keyed off the emitted ``.pkg-search`` marker.
|
|
_SEARCH_MIN_CHILDREN = 6
|
|
|
|
|
|
def _module_docstring(py: Path) -> str:
|
|
"""Return ``py``'s module-level docstring (stripped), or '' when absent.
|
|
|
|
Parsed via ``ast`` so we never import project code (no side effects, no
|
|
dependency on an importable environment). Unparseable files degrade to ''.
|
|
"""
|
|
try:
|
|
tree = ast.parse(py.read_text(encoding="utf-8"))
|
|
except (OSError, SyntaxError):
|
|
return ""
|
|
return (ast.get_docstring(tree) or "").strip()
|
|
|
|
|
|
def _doc_summary(py: Path) -> str:
|
|
"""First line of ``py``'s docstring — the one-line card summary."""
|
|
doc = _module_docstring(py)
|
|
return doc.splitlines()[0].strip() if doc else ""
|
|
|
|
|
|
def _overview_children(pkg_dir: Path) -> tuple[list, list]:
|
|
"""Documentable children of ``pkg_dir`` as ``(subpackages, modules)``.
|
|
|
|
Applies the same filters as discovery (``is_reference_module`` /
|
|
``is_importable``) so the overview only ever links to pages that actually
|
|
get generated — no dead links to skipped tests, private modules, or
|
|
``__init__``-less namespace dirs.
|
|
"""
|
|
subpackages: list[tuple[str, str, str]] = []
|
|
modules: list[tuple[str, str, str]] = []
|
|
for child in sorted(pkg_dir.iterdir()):
|
|
if child.name == "__init__.py":
|
|
continue
|
|
if child.is_dir():
|
|
init = child / "__init__.py"
|
|
if not init.is_file():
|
|
continue
|
|
rel = init.relative_to(REPO_ROOT)
|
|
if not is_reference_module(rel) or not is_importable(init):
|
|
continue
|
|
subpackages.append((child.name, f"{child.name}/index.md", _doc_summary(init)))
|
|
elif child.suffix == ".py":
|
|
rel = child.relative_to(REPO_ROOT)
|
|
if not is_reference_module(rel) or not is_importable(child):
|
|
continue
|
|
modules.append((child.stem, f"{child.stem}.md", _doc_summary(child)))
|
|
return subpackages, modules
|
|
|
|
|
|
def _card(icon: str, label: str, link: str, summary: str, kind: str) -> str:
|
|
"""One Material ``grid cards`` list item (icon + linked title + body)."""
|
|
body = summary or kind
|
|
return (
|
|
f"- {icon}{{ .lg .middle }} __[{label}]({link})__\n\n"
|
|
f" ---\n\n"
|
|
f" {body}"
|
|
)
|
|
|
|
|
|
def _render_package_overview(py: Path) -> str:
|
|
"""Landing page for a package: a card grid of its subpackages and modules.
|
|
|
|
Replaces the old bare ``::: package`` (which renders as an empty heading when
|
|
the ``__init__.py`` has no docstring or members — the common case here). Any
|
|
real package docstring is rendered as a lead paragraph above the grid.
|
|
"""
|
|
parts = _module_parts(py)
|
|
dotted = ".".join(parts)
|
|
subpackages, modules = _overview_children(py.parent)
|
|
|
|
out = [f"# {parts[-1]}", ""]
|
|
if len(parts) > 1:
|
|
out += [f"`{dotted}`", ""]
|
|
|
|
pkg_doc = _module_docstring(py)
|
|
if pkg_doc:
|
|
out += [pkg_doc, ""]
|
|
|
|
# Scoped-search marker: pkg-search.js fills this with an input that searches
|
|
# only this package's subtree of the site index (data-scope is the URL prefix
|
|
# every descendant page shares). Gated on child count so tiny packages stay
|
|
# clean; degrades to an empty (CSS-hidden) div when JS is off.
|
|
if len(subpackages) + len(modules) >= _SEARCH_MIN_CHILDREN:
|
|
scope = "/".join(parts) + "/"
|
|
out += [
|
|
f'<div class="pkg-search" data-scope="{scope}" data-label="{parts[-1]}"></div>',
|
|
"",
|
|
]
|
|
|
|
cards = [_card(_SUBPKG_ICON, n, link, s, "Subpackage") for n, link, s in subpackages]
|
|
cards += [_card(_MODULE_ICON, n, link, s, "Module") for n, link, s in modules]
|
|
|
|
if cards:
|
|
out += ['<div class="grid cards" markdown>', ""]
|
|
out.append("\n\n".join(cards))
|
|
out += ["", "</div>"]
|
|
else:
|
|
out.append("_No documented submodules._")
|
|
|
|
return "\n".join(out) + "\n"
|
|
|
|
|
|
def _render_reference(py: Path) -> str:
|
|
# Packages become a card-grid overview of their contents; leaf modules defer
|
|
# to mkdocstrings, which renders the heading from the docstring (the nav
|
|
# label is derived from the file/dir name for clean, short labels).
|
|
if py.name == "__init__.py":
|
|
return _render_package_overview(py)
|
|
return f"::: {'.'.join(_module_parts(py))}\n"
|
|
|
|
|
|
def _discover_guides() -> Iterable[Path]:
|
|
for md in tracked_files(REPO_ROOT, ["*.md"]):
|
|
if md.suffix != ".md":
|
|
continue
|
|
rel = md.relative_to(REPO_ROOT)
|
|
# Nested under an excluded top dir (our own docs/ tree) → skip. Root-level
|
|
# Markdown has its filename as parts[0], so it's never excluded here.
|
|
if rel.parts[0] in EXCLUDE_TOP_DIRS:
|
|
continue
|
|
# Skip app-builder scaffolding — its docs describe generated apps, not
|
|
# this project. (Test/migration READMEs are still legitimate guides.)
|
|
if "webapp_template" in rel.parts:
|
|
continue
|
|
yield md
|
|
|
|
|
|
def _dest_guides(md: Path) -> Path:
|
|
rel = md.relative_to(REPO_ROOT)
|
|
if len(rel.parts) == 1:
|
|
# Root-level Markdown is grouped under the synthetic "General" section.
|
|
if rel.name == "README.md":
|
|
return GENERAL_DIR / "index.md"
|
|
return GENERAL_DIR / rel.name
|
|
# Otherwise mirror the repo path so each doc lands in its folder's section.
|
|
return CONTENT / rel
|
|
|
|
|
|
def _discover_frontend() -> Iterable[Path]:
|
|
typedoc = REPO_ROOT / "frontend" / ".typedoc"
|
|
if not typedoc.is_dir():
|
|
return
|
|
# TypeDoc output is untracked (regenerated by run.sh), so git can't see it;
|
|
# walk it directly.
|
|
for md in sorted(typedoc.rglob("*.md")):
|
|
yield md
|
|
|
|
|
|
def _dest_frontend(md: Path) -> Path:
|
|
typedoc = REPO_ROOT / "frontend" / ".typedoc"
|
|
return FRONTEND_DIR / md.relative_to(typedoc)
|
|
|
|
|
|
def _read_text(path: Path) -> str:
|
|
return path.read_text(encoding="utf-8")
|
|
|
|
|
|
RULES: list[SourceRule] = [
|
|
SourceRule("reference", _discover_reference, _dest_reference, _render_reference),
|
|
SourceRule("guides", _discover_guides, _dest_guides, _read_text),
|
|
SourceRule("frontend", _discover_frontend, _dest_frontend, _read_text),
|
|
]
|
|
|
|
|
|
# --- Engine ---------------------------------------------------------------
|
|
|
|
def _clean_previous() -> None:
|
|
"""Remove the files generated last run, per the manifest.
|
|
|
|
Falls back to removing every generated section under ``content/`` (anything
|
|
that isn't hand-written/static) when no manifest exists yet — e.g. the first
|
|
run after adopting the manifest, or a manually deleted manifest.
|
|
"""
|
|
if MANIFEST.exists():
|
|
for line in MANIFEST.read_text(encoding="utf-8").splitlines():
|
|
line = line.strip()
|
|
if not line:
|
|
continue
|
|
path = HERE / line
|
|
if path.is_file():
|
|
path.unlink()
|
|
MANIFEST.unlink()
|
|
elif CONTENT.is_dir():
|
|
for child in CONTENT.iterdir():
|
|
if child.name in PRESERVE:
|
|
continue
|
|
if child.is_dir():
|
|
shutil.rmtree(child)
|
|
elif child.is_file():
|
|
child.unlink()
|
|
_prune_empty_dirs(CONTENT)
|
|
|
|
|
|
def _prune_empty_dirs(root: Path) -> None:
|
|
if not root.is_dir():
|
|
return
|
|
# Deepest-first so a dir emptied by pruning its children is itself removed.
|
|
for sub in sorted(root.rglob("*"), key=lambda p: len(p.parts), reverse=True):
|
|
if sub.is_dir() and not any(sub.iterdir()):
|
|
sub.rmdir()
|
|
|
|
|
|
def _write(dest: Path, text: str) -> None:
|
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
# Skip the rewrite when content is unchanged so ``zensical serve`` doesn't
|
|
# see a spurious modification (faster, quieter live reloads).
|
|
if dest.is_file() and dest.read_text(encoding="utf-8") == text:
|
|
return
|
|
dest.write_text(text, encoding="utf-8")
|
|
|
|
|
|
def _run_rule(rule: SourceRule, written: set[Path]) -> int:
|
|
count = 0
|
|
for src in rule.discover():
|
|
dest = rule.dest(src)
|
|
_write(dest, rule.render(src))
|
|
written.add(dest)
|
|
count += 1
|
|
return count
|
|
|
|
|
|
def main() -> int:
|
|
logging.basicConfig(level=logging.INFO, format="%(name)s: %(message)s")
|
|
_clean_previous()
|
|
|
|
written: set[Path] = set()
|
|
counts = {rule.name: _run_rule(rule, written) for rule in RULES}
|
|
|
|
manifest_lines = sorted(str(p.relative_to(HERE)) for p in written)
|
|
MANIFEST.write_text("\n".join(manifest_lines) + "\n", encoding="utf-8")
|
|
|
|
summary = ", ".join(f"{n} {name}" for name, n in counts.items())
|
|
log.info("wrote %s pages under %s", summary, CONTENT)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|