Source code for oct.docs.oct_docs

#!/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