#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# oct/docs/oct_docs.py
"""
Purpose
-------
Provide documentation generation pipelines for Option C projects:
``oct docs --sphinx``, ``oct docs --doxygen``, ``oct docs --all``,
and ``oct docs --clean``.
Responsibilities
----------------
- Set up Sphinx configuration in the project's ``docs/`` directory
(if not already present).
- Discover markdown documentation files already in ``docs/``.
- Generate API reference pages from Python module docstrings via autodoc.
- Generate dependency graph pages via ``oct deps``.
- Populate ``index.rst`` with all discovered content.
- Run ``sphinx-build`` to generate HTML output.
- Check for required Sphinx dependencies and emit a clear error if missing.
- Run Doxygen with a patched Doxyfile (correcting stale INPUT paths).
- Clean generated documentation artifacts while preserving user-authored files.
Diagnostics
-----------
Domain: OCT-DOCS
Levels:
L2 — lifecycle
L3 — semantic details
L4 — deep tracing
Contracts
---------
- Must not overwrite existing ``conf.py`` if already present.
- ``index.rst`` is regenerated on every build (auto-generated content).
- Must exit 1 with a helpful message if Sphinx dependencies are not installed.
- Template files are bundled in ``oct/templates/sphinx/``.
- Doxygen runs on a temporary copy of the Doxyfile; the original is never modified.
- ``--clean`` preserves ``_sphinx/``, ``_static/``, ``_templates/``,
``doxygen_work_dir/``, and user-authored markdown files.
Dependencies
------------
- oct.deps.oct_deps (dependency graph generation)
- oct.core.exclusions (directory exclusion lists)
"""
import datetime
import json
import re
import shutil
import subprocess
import sys
import tempfile
import time
from pathlib import Path
from oct.core.exclusions import LINTER_EXCLUDE_DIRS, WILDCARD_EXCLUDE_DIRS
_TEMPLATES_DIR = Path(__file__).parent.parent / "templates" / "sphinx"
#: OI-518 — markers that identify auto-generated files. ``oct docs --sphinx``
#: must not silently overwrite files a user has authored themselves. Any file
#: whose first line contains the appropriate marker is considered generated
#: and is safe to regenerate; all other existing files are preserved.
_AUTO_MARKER_RST: str = ".. Auto-generated by oct docs --sphinx"
_AUTO_MARKER_MD: str = "<!-- Auto-generated by oct docs --sphinx -->"
def _is_auto_generated(path: Path, marker: str) -> bool:
"""Return True if *path* is absent or was written by ``oct docs``.
OI-518: used as a write-safety guard around regeneration of
``docs/README.md``, ``docs/index.rst``, ``docs/dependencies.rst``,
and ``docs/_api/modules.rst``. A user-authored file (no marker) is
left untouched and a warning is emitted instead.
"""
if not path.exists():
return True
try:
head = path.read_text(encoding="utf-8", errors="ignore")
except OSError:
return False
first = head.splitlines()[0] if head else ""
return marker in first
_REQUIRED_PACKAGES = [
"sphinx",
"sphinx_book_theme",
"myst_parser",
"sphinxcontrib.mermaid",
"sphinx_copybutton",
]
# Subdirectories within docs/ to exclude from markdown discovery
_DOCS_EXCLUDE_DIRS = {"old", "code_reviews", "sphinx_docs", "_build",
"_static", "_templates", "_autosummary", "_sphinx",
"_api", "html", "doctrees", "doxygen_output",
"doxygen_work_dir"}
# Preferred ordering for markdown documents in the toctree.
# Documents matching these stems appear first (in this order);
# all remaining discovered docs follow alphabetically.
_PRIORITY_DOCS = [
"README",
"00_Master_Option_C_Specs_Intro_",
"01_Master_Option_C_Specs_Core_",
"02_Master_Option_C_Specs_Tooling_",
"03_Master_Option_C_Specs_Diagnostics_",
"04_Master_Option_C_Specs_AI_Protocol_",
"05_Master_Option_C_Specs_Changelog_",
"ARCHITECTURE",
"REFERENCE",
"ROADMAP",
]
# ---- Dependency checking ----
def _check_sphinx_deps() -> list[str]:
"""Check which Sphinx dependencies are missing."""
missing = []
for pkg in _REQUIRED_PACKAGES:
try:
__import__(pkg.replace("-", "_").replace(".", "_"))
except ImportError:
# Try the actual import name
try:
__import__(pkg)
except ImportError:
missing.append(pkg)
return missing
# ---- Project info ----
def _load_project_info(root: Path) -> dict:
"""Load project name, version, and author from pyproject.toml or defaults."""
info = {
"name": root.resolve().name,
"version": "0.1.0",
"author": "Option C Dev Team",
}
pyproject = root / "pyproject.toml"
if pyproject.exists():
try:
content = pyproject.read_text(encoding="utf-8")
# Simple TOML parsing for common keys (no dependency on tomli)
for line in content.splitlines():
line = line.strip()
if line.startswith("name") and "=" in line:
val = line.split("=", 1)[1].strip().strip('"').strip("'")
if val:
info["name"] = val
elif line.startswith("version") and "=" in line:
val = line.split("=", 1)[1].strip().strip('"').strip("'")
if val:
info["version"] = val
except OSError:
pass
return info
# ---- .octrc.json docs configuration ----
def _load_docs_config(root: Path) -> dict:
"""Load docs configuration from .octrc.json with defaults (Appendix H)."""
defaults = {
"sphinx_theme": "sphinx_book_theme",
"sphinx_output": "docs/html",
"include_dependency_graphs": True,
"include_mermaid_diagrams": True,
"autodoc_modules": [],
}
octrc = root / ".octrc.json"
if octrc.exists():
try:
data = json.loads(octrc.read_text(encoding="utf-8"))
docs_cfg = data.get("docs", {})
if isinstance(docs_cfg, dict):
defaults.update({k: v for k, v in docs_cfg.items()
if k in defaults})
except (json.JSONDecodeError, OSError):
pass
return defaults
# ---- Content discovery ----
def _discover_markdown(docs_dir: Path) -> list[str]:
"""Discover markdown files in the docs directory, recursing into
subdirectories that are not in :data:`_DOCS_EXCLUDE_DIRS`.
Returns a sorted list of file identifiers relative to ``docs_dir`` and
stripped of the ``.md`` extension. For files in the docs/ root these
identifiers are bare stems (e.g. ``"ARCHITECTURE"``); for files in
subdirectories they are POSIX-form relative paths
(e.g. ``"option_c_specs/00_Master_Option_C_Specs_Intro_V3_4"``).
Sphinx-MyST consumes both forms directly in the toctree.
"""
found: list[str] = []
for md_file in docs_dir.rglob("*.md"):
if not md_file.is_file():
continue
rel = md_file.relative_to(docs_dir)
# Skip files inside any excluded subdirectory.
if any(part in _DOCS_EXCLUDE_DIRS for part in rel.parts[:-1]):
continue
ident = rel.with_suffix("").as_posix()
found.append(ident)
return sorted(found)
def _order_markdown(stems: list[str]) -> list[str]:
"""Order markdown identifiers with priority docs first, then alphabetical.
Identifiers may be bare stems (root-level docs) or
``subdir/.../stem`` paths (subdirectory docs). Priority matching
compares against the basename so ``_PRIORITY_DOCS`` entries can stay
leaf-name-based even as files move into subdirectories.
"""
ordered = []
used = set()
def _basename(ident: str) -> str:
return ident.rsplit("/", 1)[-1]
for entry in _PRIORITY_DOCS:
if entry.endswith("_") or entry.endswith("-"): # prefix marker
match = next(
(s for s in stems if _basename(s).startswith(entry) and s not in used),
None,
)
if match:
ordered.append(match)
used.add(match)
else: # exact match (original behaviour)
for s in stems:
if _basename(s) == entry and s not in used:
ordered.append(s)
used.add(s)
break
remaining = sorted(s for s in stems if s not in used)
return ordered + remaining
def _should_skip_dir(path: Path, root: Path) -> bool:
"""Check if a directory should be excluded from module discovery."""
rel_parts = path.relative_to(root).parts
for part in rel_parts:
if part in LINTER_EXCLUDE_DIRS or part.startswith("."):
return True
for wc in WILDCARD_EXCLUDE_DIRS:
if part.startswith(wc.rstrip("*")):
return True
# Also skip docs, test, and scaffold directories
if rel_parts and rel_parts[0] in {"docs", "old", "tests",
"scaffold_test_project"}:
return True
return False
#: OI-426 — safety cap on recursive package discovery. Bounds the walk
#: so a pathological directory tree (symlink loop, runaway nesting) cannot
#: hang ``oct docs``.
_MAX_PACKAGE_DEPTH: int = 10
def _find_packages(root: Path, config: dict) -> list[tuple[str, Path]]:
"""Discover Python packages to document.
Walks the project tree recursively, stopping at each package (a
directory containing ``__init__.py``) rather than descending into
its subpackages — Sphinx autodoc handles subpackages automatically.
Respects ``LINTER_EXCLUDE_DIRS`` and ``WILDCARD_EXCLUDE_DIRS`` via
:func:`_should_skip_dir`.
Bounded by :data:`_MAX_PACKAGE_DEPTH` to prevent pathological walks
on misconfigured projects (OI-426).
Returns list of ``(package_name, package_path)`` tuples.
"""
autodoc_modules = config.get("autodoc_modules", [])
if autodoc_modules:
explicit: list[tuple[str, Path]] = []
for mod_dir in autodoc_modules:
mod_path = root / mod_dir
if (mod_path / "__init__.py").exists():
explicit.append((mod_dir.replace("/", "."), mod_path))
return explicit
packages: list[tuple[str, Path]] = []
def _walk(directory: Path, depth: int) -> None:
if depth > _MAX_PACKAGE_DEPTH:
return
try:
children = sorted(directory.iterdir())
except (OSError, PermissionError):
return
for child in children:
if not child.is_dir() or _should_skip_dir(child, root):
continue
if (child / "__init__.py").exists():
packages.append((child.name, child))
# Do not descend — autodoc handles subpackages.
else:
_walk(child, depth + 1)
_walk(root, 0)
return packages
def _generate_api_rst(root: Path, docs_dir: Path, config: dict) -> bool:
"""Generate per-module API documentation using sphinx-apidoc.
Uses sphinx-apidoc to create comprehensive .rst files for every
module, class, and function — similar to Doxygen's EXTRACT_ALL output.
Returns True if at least one package was documented.
"""
packages = _find_packages(root, config)
if not packages:
return False
api_dir = docs_dir / "_api"
api_dir.mkdir(exist_ok=True)
try:
from sphinx.ext.apidoc import main as apidoc_main
except ImportError:
print("Warning: sphinx.ext.apidoc not available", file=sys.stderr)
return False
for pkg_name, pkg_path in packages:
apidoc_main([
'-f', # force overwrite existing files
'-e', # separate page per module
'-M', # module-first ordering (module docs before submodules)
'-o', str(api_dir),
str(pkg_path),
'--no-toc', # we generate our own toc
])
print(f" sphinx-apidoc: {pkg_name} ({pkg_path})")
# Check if apidoc generated any files
rst_files = list(api_dir.glob("*.rst"))
if not rst_files:
return False
# Overwrite modules.rst to list only top-level packages (not all
# submodules) — each package .rst has its own toctree for submodules,
# so listing everything flat here causes "multiple toctrees" warnings.
pkg_stems = sorted(name for name, _ in packages)
lines = [
f"{_AUTO_MARKER_RST}. Edits will be overwritten on next build.",
"",
"API Reference",
"=============",
"",
".. toctree::",
" :maxdepth: 4",
"",
]
for stem in pkg_stems:
lines.append(f" {stem}")
lines.append("")
modules_path = api_dir / "modules.rst"
if _is_auto_generated(modules_path, _AUTO_MARKER_RST):
modules_path.write_text("\n".join(lines), encoding="utf-8")
else:
print(
f"OI-518: preserving user-authored {modules_path.relative_to(docs_dir)}",
file=sys.stderr,
)
return True
def _generate_deps_rst(root: Path, docs_dir: Path) -> bool:
"""Generate dependencies.rst with a Mermaid dependency graph.
Returns True if the graph has edges.
"""
try:
from oct.deps.oct_deps import build_dependency_graph, to_mermaid
except ImportError:
return False
graph = build_dependency_graph(root)
# Check if graph has any edges
has_edges = any(deps for deps in graph.values())
if not has_edges:
return False
mermaid_src = to_mermaid(graph)
lines = [
f"{_AUTO_MARKER_RST} via oct deps.",
"",
"Module Dependencies",
"====================",
"",
"The following diagram shows the inter-module dependency graph,",
"derived from the ``Dependencies`` docstring section of each module.",
"",
".. mermaid::",
"",
]
# Indent each line of the Mermaid source
for mermaid_line in mermaid_src.splitlines():
lines.append(f" {mermaid_line}")
lines.append("")
deps_path = docs_dir / "dependencies.rst"
if _is_auto_generated(deps_path, _AUTO_MARKER_RST):
deps_path.write_text("\n".join(lines), encoding="utf-8")
return True
print(
"OI-518: preserving user-authored docs/dependencies.rst",
file=sys.stderr,
)
return False
# ---- Index generation ----
def _write_index_rst(docs_dir: Path, project_name: str,
md_stems: list[str], has_api: bool,
has_deps: bool) -> None:
"""Write a populated index.rst with discovered content."""
title = f"{project_name} Documentation"
underline = "=" * len(title)
lines = [
f"{_AUTO_MARKER_RST}. Edits will be overwritten on next build.",
"",
title,
underline,
"",
]
# Main content toctree (markdown files, priority-ordered)
ordered_stems = _order_markdown(md_stems)
if ordered_stems:
lines.extend([
".. toctree::",
" :maxdepth: 2",
" :caption: Contents:",
"",
])
for stem in ordered_stems:
lines.append(f" {stem}")
lines.append("")
# API reference toctree
if has_api:
lines.extend([
".. toctree::",
" :maxdepth: 3",
" :caption: API Reference:",
"",
" _api/modules",
"",
])
# Dependencies toctree
if has_deps:
lines.extend([
".. toctree::",
" :maxdepth: 2",
" :caption: Analysis:",
"",
" dependencies",
"",
])
index_path = docs_dir / "index.rst"
if _is_auto_generated(index_path, _AUTO_MARKER_RST):
index_path.write_text("\n".join(lines), encoding="utf-8")
else:
print(
"OI-518: preserving user-authored docs/index.rst",
file=sys.stderr,
)
# ---- Sphinx setup ----
[docs]
def setup_sphinx(root: Path) -> Path:
"""Set up Sphinx configuration in the project's docs/ directory.
Uses docs/ as the Sphinx source directory directly — markdown files
already present in docs/ are included without copying.
Sphinx config files (conf.py, Makefile, make.bat) live in docs/_sphinx/
to keep the docs/ root clean. The ``-c`` flag in ``run_sphinx_build``
points Sphinx at this configuration directory.
Returns the path to the docs directory.
"""
docs_dir = root / "docs"
sphinx_dir = docs_dir / "_sphinx"
static_dir = docs_dir / "_static"
if not _TEMPLATES_DIR.exists():
print(f"Error: Sphinx templates not found at {_TEMPLATES_DIR}",
file=sys.stderr)
raise SystemExit(1)
# Create directories
docs_dir.mkdir(parents=True, exist_ok=True)
sphinx_dir.mkdir(exist_ok=True)
static_dir.mkdir(exist_ok=True)
(docs_dir / "_templates").mkdir(exist_ok=True)
info = _load_project_info(root)
year = datetime.datetime.now().year
# Copy conf.py (from template, with substitutions) — only if absent
conf_dest = sphinx_dir / "conf.py"
if not conf_dest.exists():
template = (_TEMPLATES_DIR / "conf.py.template").read_text(
encoding="utf-8")
conf_content = (
template
.replace("{{PROJECT_NAME}}", info["name"])
.replace("{{YEAR}}", str(year))
.replace("{{AUTHOR}}", info["author"])
.replace("{{VERSION}}", info["version"])
)
conf_dest.write_text(conf_content, encoding="utf-8")
# Copy static files (CSS, logo) — only if absent
for static_file in ["custom.css", "Option_C_logo.svg"]:
src = _TEMPLATES_DIR / "_static" / static_file
dest = static_dir / static_file
if src.exists() and not dest.exists():
shutil.copy2(src, dest)
# Copy Makefile and make.bat to _sphinx/ — only if absent
for build_file in ["Makefile", "make.bat"]:
src = _TEMPLATES_DIR / build_file
dest = sphinx_dir / build_file
if src.exists() and not dest.exists():
shutil.copy2(src, dest)
return docs_dir
# ---- Build ----
[docs]
def run_sphinx_build(docs_dir: Path, config: dict) -> int:
"""Run sphinx-build to generate HTML output.
Returns exit code: 0 on success, 1 on failure.
"""
docs_abs = docs_dir.resolve()
sphinx_dir = docs_abs / "_sphinx"
# Clean previous output to avoid stale files
for clean_dir in [docs_abs / "html", docs_abs / "doctrees"]:
if clean_dir.exists():
shutil.rmtree(clean_dir)
# Build into docs/ directly — produces docs/html/ and docs/doctrees/
cmd = [
sys.executable, "-m", "sphinx",
"-M", "html",
str(docs_abs), # source dir
str(docs_abs), # build dir (html/ and doctrees/ created here)
"-c", str(sphinx_dir), # conf.py location
]
print(f"Running: sphinx-build -M html {docs_abs} {docs_abs} "
f"-c {sphinx_dir}")
try:
result = subprocess.run(cmd, cwd=str(docs_abs))
if result.returncode == 0:
html_index = docs_abs / "html" / "index.html"
print(f"\nBuild complete. Open: {html_index}")
return result.returncode
except FileNotFoundError:
print("Error: sphinx-build not found.", file=sys.stderr)
return 1
# ---- Full pipeline ----
[docs]
def run_docs_sphinx(root: Path) -> int:
"""Full oct docs --sphinx pipeline.
1. Check dependencies
2. Set up Sphinx config (one-time)
3. Load .octrc.json docs configuration
4. Discover markdown files
5. Generate API reference (autodoc)
6. Generate dependency graph
7. Write populated index.rst
8. Run sphinx-build
Returns exit code.
"""
# Check dependencies first
missing = _check_sphinx_deps()
if missing:
print(
f"Error: Missing Sphinx dependencies: {', '.join(missing)}\n"
f"Install them with: pip install oct-tools[docs]\n"
f"Or individually: pip install {' '.join(missing)}",
file=sys.stderr,
)
return 1
# Set up Sphinx config (copies template if conf.py absent)
docs_dir = setup_sphinx(root)
# OI-518: Copy root README.md into docs/ for Sphinx — but never
# silently clobber a user-authored docs/README.md. We prepend an
# auto-generated marker so subsequent runs recognise their own output.
root_readme = root / "README.md"
readme_dest = docs_dir / "README.md"
if root_readme.exists():
if _is_auto_generated(readme_dest, _AUTO_MARKER_MD):
content = root_readme.read_text(encoding="utf-8")
readme_dest.write_text(
f"{_AUTO_MARKER_MD}\n\n{content}",
encoding="utf-8",
)
else:
print(
"OI-518: preserving user-authored docs/README.md "
"(root README.md not copied)",
file=sys.stderr,
)
# Load configuration from .octrc.json
config = _load_docs_config(root)
info = _load_project_info(root)
# Discover markdown files already in docs/
md_stems = _discover_markdown(docs_dir)
print(f"Discovered {len(md_stems)} markdown file(s) in docs/")
# Generate API reference
has_api = _generate_api_rst(root, docs_dir, config)
if has_api:
print("Generated API reference (api.rst)")
# Generate dependency graph
has_deps = False
if config.get("include_dependency_graphs", True):
has_deps = _generate_deps_rst(root, docs_dir)
if has_deps:
print("Generated dependency graph (dependencies.rst)")
# Write populated index.rst
_write_index_rst(docs_dir, info["name"], md_stems, has_api, has_deps)
print("Updated index.rst with discovered content")
# Build
return run_sphinx_build(docs_dir, config)
# ---- Doxygen pipeline ----
def _check_doxygen_installed() -> bool:
"""Check if doxygen is on PATH via shutil.which()."""
return shutil.which("doxygen") is not None
def _find_doxyfile(root: Path) -> Path | None:
"""Locate docs/doxygen_work_dir/Doxyfile if it exists."""
doxyfile = root / "docs" / "doxygen_work_dir" / "Doxyfile"
if doxyfile.is_file():
return doxyfile
return None
def _create_patched_doxyfile(doxyfile_path: Path, root: Path) -> Path:
"""Create a temporary copy of the Doxyfile with INPUT patched to actual root.
The existing Doxyfiles have hardcoded stale paths. This creates a temp
copy with the correct INPUT path. Does NOT modify the original.
Returns the path to the temp Doxyfile.
"""
content = doxyfile_path.read_text(encoding="utf-8", errors="replace")
# Patch INPUT line — use forward slashes for Doxygen cross-platform compat
root_forward = str(root.resolve()).replace("\\", "/")
content = re.sub(
r'^(INPUT\s*=\s*).*$',
rf'\g<1>"{root_forward}"',
content,
flags=re.MULTILINE,
)
# OI-423: stable prefix lets the stale-file sweeper distinguish OCT's
# temp Doxyfiles from unrelated files in the system temp directory.
tmp = tempfile.NamedTemporaryFile(
mode="w",
prefix="oct-doxyfile-",
suffix=".Doxyfile",
delete=False,
encoding="utf-8",
)
tmp.write(content)
tmp.close()
return Path(tmp.name)
#: OI-423 — maximum age (in seconds) a stale ``oct-doxyfile-*`` temp file
#: may reach before the sweeper considers it orphaned. 24 hours is long
#: enough to avoid racing a concurrent ``oct docs --doxygen`` run.
_STALE_DOXYFILE_MAX_AGE_SECONDS: int = 24 * 60 * 60
def _sweep_stale_doxyfiles(
max_age_seconds: int = _STALE_DOXYFILE_MAX_AGE_SECONDS,
) -> int:
"""Remove orphaned ``oct-doxyfile-*.Doxyfile`` temp files.
The ``finally`` block in :func:`run_docs_doxygen` cleans up on normal
exit, exceptions, and ``FileNotFoundError`` — but it is skipped on
``SIGKILL``, power loss, or OOM-kill. Without this sweep the system
temp directory would slowly accumulate orphaned Doxyfiles (OI-423).
Best-effort: any OS error (per-file or directory-wide) is silently
swallowed so that a failed sweep never blocks a legitimate doxygen
build.
Returns the number of files removed.
"""
removed = 0
tmp_dir = Path(tempfile.gettempdir())
now = time.time()
try:
for stale in tmp_dir.glob("oct-doxyfile-*.Doxyfile"):
try:
if now - stale.stat().st_mtime > max_age_seconds:
stale.unlink()
removed += 1
except OSError:
continue
except OSError:
pass
return removed
[docs]
def run_docs_doxygen(root: Path) -> int:
"""Full oct docs --doxygen pipeline.
1. Check doxygen is installed (shutil.which)
2. Find existing Doxyfile in docs/doxygen_work_dir/
3. Create patched temp copy with correct INPUT
4. Run doxygen on the temp Doxyfile
5. Clean up temp Doxyfile
6. Report output location (docs/doxygen_output/)
Returns exit code: 0 on success, 1 on failure.
"""
# OI-423: best-effort cleanup of orphans from earlier runs killed
# before the finally block could run. Must never block the build.
_sweep_stale_doxyfiles()
if not _check_doxygen_installed():
print(
"Error: doxygen is not installed or not on PATH.\n"
"Install it from https://www.doxygen.nl/download.html\n"
"On Windows: winget install doxygen\n"
"On macOS: brew install doxygen\n"
"On Linux: apt install doxygen",
file=sys.stderr,
)
return 1
doxyfile = _find_doxyfile(root)
if doxyfile is None:
print(
"Error: No Doxyfile found at docs/doxygen_work_dir/Doxyfile.\n"
"Run 'oct scaffold' to generate one, or create it manually.",
file=sys.stderr,
)
return 1
print(f"Using Doxyfile: {doxyfile}")
patched = _create_patched_doxyfile(doxyfile, root)
try:
print(f"Running doxygen (INPUT patched to {root.resolve()})...")
result = subprocess.run(
["doxygen", str(patched)],
cwd=str(doxyfile.parent),
)
if result.returncode == 0:
output_dir = root / "docs" / "doxygen_output"
html_index = output_dir / "html" / "index.html"
if html_index.exists():
print(f"\nDoxygen build complete. Open: {html_index}")
else:
print(f"\nDoxygen build complete. Output: {output_dir}")
return result.returncode
except FileNotFoundError:
print("Error: doxygen binary not found.", file=sys.stderr)
return 1
finally:
try:
patched.unlink()
except OSError:
pass
# ---- Clean pipeline ----
# Directories removed by --clean (relative to docs/)
_DOCS_CLEAN_DIRS = ["html", "doctrees", "_api", "doxygen_output"]
# Generated files removed by --clean (relative to docs/)
_DOCS_CLEAN_FILES = ["index.rst", "dependencies.rst"]
[docs]
def run_docs_clean(root: Path, *, dry_run: bool = False) -> int:
"""Remove generated documentation artifacts from docs/.
Cleans: html/, doctrees/, _api/, doxygen_output/,
index.rst, dependencies.rst
Preserves: _sphinx/ (user conf.py), _static/, _templates/,
user-authored .md files, doxygen_work_dir/Doxyfile
Returns 0 always.
"""
docs_dir = root / "docs"
if not docs_dir.is_dir():
print("Nothing to clean: docs/ directory does not exist.")
return 0
removed_count = 0
action = "Would remove" if dry_run else "Removed"
for dirname in _DOCS_CLEAN_DIRS:
target = docs_dir / dirname
if target.is_dir():
if not dry_run:
shutil.rmtree(target)
print(f" {action}: {target.relative_to(root)}/")
removed_count += 1
for filename in _DOCS_CLEAN_FILES:
target = docs_dir / filename
if target.is_file():
if not dry_run:
target.unlink()
print(f" {action}: {target.relative_to(root)}")
removed_count += 1
if removed_count == 0:
print("Nothing to clean: no generated artifacts found in docs/.")
else:
prefix = "[DRY RUN] " if dry_run else ""
print(f"\n{prefix}Cleaned {removed_count} artifact(s) from docs/.")
return 0
# ---- Combined pipeline ----
[docs]
def run_docs_all(root: Path) -> int:
"""Run both Sphinx and Doxygen pipelines.
Returns 0 if both succeed, 1 if either fails.
"""
print("=" * 40)
print("Running Sphinx pipeline...")
print("=" * 40)
sphinx_rc = run_docs_sphinx(root)
print()
print("=" * 40)
print("Running Doxygen pipeline...")
print("=" * 40)
doxygen_rc = run_docs_doxygen(root)
if sphinx_rc == 0 and doxygen_rc == 0:
print("\nBoth pipelines completed successfully.")
else:
failures = []
if sphinx_rc != 0:
failures.append("Sphinx")
if doxygen_rc != 0:
failures.append("Doxygen")
print(f"\nFailed: {', '.join(failures)}")
return 1 if (sphinx_rc or doxygen_rc) else 0