oct
Loading...
Searching...
No Matches
oct_docs.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oct/docs/oct_docs.py
4
5"""
6Purpose
7-------
8Provide documentation generation pipelines for Option C projects:
9``oct docs --sphinx``, ``oct docs --doxygen``, ``oct docs --all``, and ``oct docs --clean``.
10
11Responsibilities
12----------------
13- Set up Sphinx configuration in the project's ``docs/`` directory (if not already present).
14- Discover markdown documentation files already in ``docs/``.
15- Generate API reference pages from Python module docstrings via autodoc.
16- Generate dependency graph pages via ``oct deps``.
17- Populate ``index.rst`` with all discovered content.
18- Run ``sphinx-build`` to generate HTML output.
19- Check for required Sphinx dependencies and emit a clear error if missing.
20- Run Doxygen with a patched Doxyfile (correcting stale INPUT paths).
21- Clean generated documentation artifacts while preserving user-authored files.
22
23Diagnostics
24-----------
25Domain: OCT-DOCS
26Levels:
27 L2 - lifecycle
28 L3 - semantic details
29 L4 - deep tracing
30
31Contracts
32---------
33- Must not overwrite existing ``oct/docs/_sphinx/conf.py`` if already present.
34- ``oct/docs/_sphinx/index.rst`` is regenerated on every build (auto-generated content).
35- Must exit 1 with a helpful message if Sphinx dependencies are not installed.
36- Template files are bundled in ``oct/templates/sphinx/``.
37- Doxygen runs on a temporary copy of the Doxyfile; the original is never modified.
38- ``--clean`` preserves ``_sphinx/``, ``_static/``, ``_templates/``, ``doxygen_work_dir/``, and user-authored markdown files.
39
40Dependencies
41------------
42- oct.core.exclusions (directory exclusion lists)
43- oct.core.diagnostics (_dbg structured logger)
44- oct.tools.fs_remove (remove_tree_or_warn - removes generated output, warning on a tree that refuses to go)
45- oct.core.option_c_dir (lazy; resolve_octrc - finds the octrc that holds the docs settings)
46- oct.deps.import_graph (lazy; build_import_graph and its renderers - the dependencies page)
47- oct.docs.doxyfile_init (lazy; DOXYFILE_REL - where a project's Doxyfile lives)
48- oct.docs.typedoc (lazy; has_typedoc, run_docs_typedoc - the TypeDoc step of --all)
49- oct.docs.workspace_docs (lazy; is_workspace_root, run_workspace_docs - the workspace fan-out)
50- sphinx.ext.apidoc (lazy; main - writes the per-module API pages)
51- tomli (lazy; reads pyproject.toml on Python 3.10, where tomllib is absent)
52"""
53
54import datetime
55import json
56import re
57import shutil
58import subprocess
59import sys
60import tempfile
61import time
62from pathlib import Path
63
64from oct.core.exclusions import LINTER_EXCLUDE_DIRS, WILDCARD_EXCLUDE_DIRS
65from oct.core.diagnostics import _dbg
66from oct.tools.fs_remove import remove_tree_or_warn
67
68
69_TEMPLATES_DIR = Path(__file__).parent.parent / "templates" / "sphinx"
70
71#: OI-518 - markers that identify auto-generated files. ``oct docs --sphinx``
72#: must not silently overwrite files a user has authored themselves. Any file
73#: whose first line contains the appropriate marker is considered generated
74#: and is safe to regenerate; all other existing files are preserved.
75_AUTO_MARKER_RST: str = ".. Auto-generated by oct docs --sphinx"
76_AUTO_MARKER_MD: str = "<!-- Auto-generated by oct docs --sphinx -->"
77
78
79def _is_auto_generated(path: Path, marker: str) -> bool:
80 """Return True if *path* is absent or was written by ``oct docs``.
81
82 OI-518: used as a write-safety guard around regeneration of
83 ``docs/README.md``, ``docs/index.rst``, ``docs/dependencies.rst``,
84 and ``docs/_api/modules.rst``. A user-authored file (no marker) is
85 left untouched and a warning is emitted instead.
86 """
87 if not path.exists():
88 return True
89 try:
90 head = path.read_text(encoding="utf-8", errors="ignore")
91 except OSError:
92 return False
93 first = head.splitlines()[0] if head else ""
94 return marker in first
95
96_REQUIRED_PACKAGES = [
97 "sphinx",
98 "sphinx_book_theme",
99 "myst_parser",
100 "sphinxcontrib.mermaid",
101 "sphinx_copybutton",
102]
103
104# Subdirectories within docs/ to exclude from markdown discovery
105_DOCS_EXCLUDE_DIRS = {"old", "code_reviews", "sphinx_docs", "_build",
106 "_static", "_templates", "_autosummary", "_sphinx",
107 "_api", "html", "doctrees", "doxygen_output",
108 "doxygen_work_dir"}
109
110# Preferred ordering for markdown documents in the toctree.
111# Documents matching these stems appear first (in this order);
112# all remaining discovered docs follow alphabetically.
113_PRIORITY_DOCS = [
114 "README",
115 "00_Master_Option_C_Specs_Intro_",
116 "01_Master_Option_C_Specs_Core_",
117 "02_Master_Option_C_Specs_Tooling_",
118 "03_Master_Option_C_Specs_Diagnostics_",
119 "04_Master_Option_C_Specs_AI_Protocol_",
120 "05_Master_Option_C_Specs_Changelog_",
121 "ARCHITECTURE",
122 "REFERENCE",
123 "ROADMAP",
124]
125
126
127# ---- Dependency checking ----
128
129def _check_sphinx_deps() -> list[str]:
130 """Check which Sphinx dependencies are missing."""
131 missing = []
132 for pkg in _REQUIRED_PACKAGES:
133 try:
134 __import__(pkg.replace("-", "_").replace(".", "_"))
135 except ImportError:
136 # Try the actual import name
137 try:
138 __import__(pkg)
139 except ImportError:
140 missing.append(pkg)
141 return missing
142
143
144# ---- Project info ----
145
146def _load_project_info(root: Path) -> dict:
147 """Load project name, version, and author from pyproject.toml or defaults."""
148 info = {
149 "name": root.resolve().name,
150 "version": "0.1.0",
151 "author": "Option C Dev Team",
152 }
153
154 pyproject = root / "pyproject.toml"
155 if not pyproject.exists():
156 return info
157
158 # Parsed as TOML rather than scanned line by line. The previous version
159 # matched any line starting "name"/"version" anywhere in the file and let
160 # the LAST match win, so a `name =` inside `[tool.*]` or a dependency table
161 # silently became the documented project name. It happened to be correct for
162 # every pyproject in this workspace, which is exactly why it survived.
163 try:
164 try:
165 import tomllib
166 except ImportError: # pragma: no cover - Python 3.10, where tomllib is absent
167 import tomli as tomllib # type: ignore[no-redef]
168 # TOMLDecodeError subclasses ValueError, so the handler below covers a
169 # malformed file without naming the module's own exception type.
170 data = tomllib.loads(pyproject.read_text(encoding="utf-8"))
171 except (OSError, ImportError, ValueError):
172 return info
173
174 project = data.get("project")
175 if not isinstance(project, dict):
176 return info
177 for key in ("name", "version"):
178 value = project.get(key)
179 if isinstance(value, str) and value:
180 info[key] = value
181
182 return info
183
184
185# ---- .octrc.json docs configuration ----
186
187def _load_docs_config(root: Path) -> dict:
188 """Load docs configuration from the project's octrc with defaults (Appendix H).
189
190 Resolved through :func:`oct.core.option_c_dir.resolve_octrc`, which prefers
191 ``.option_c/octrc.json`` and falls back to the legacy ``<root>/.octrc.json``.
192 This function used to hardcode the legacy path, so on a MIGRATED project --
193 which has no root ``.octrc.json`` -- the whole ``docs`` block was silently
194 ignored and the defaults below were always used. ``oct`` itself is migrated,
195 so its own configured ``autodoc_modules`` was being dropped: discovery fell
196 back to walking the tree, which picked up the stale ``build/lib/oct`` copy
197 alongside the real package and ran ``sphinx-apidoc`` over both.
198 """
199 # ``sphinx_theme`` and ``sphinx_output`` were removed on 2026-09-25: both
200 # were read into this dict and used nowhere. The theme comes from the
201 # generated ``docs/_sphinx/conf.py``, and the output path is fixed by
202 # ``run_sphinx_build``. Setting either in an octrc never had any effect, and
203 # while they sat here they looked configurable. An octrc that still carries
204 # them is unaffected: the filter below adopts only keys present here.
205 defaults = {
206 "include_dependency_graphs": True,
207 "include_mermaid_diagrams": True,
208 "autodoc_modules": [],
209 }
210
211 from oct.core.option_c_dir import resolve_octrc
212
213 octrc = resolve_octrc(root)
214 if octrc.exists():
215 try:
216 data = json.loads(octrc.read_text(encoding="utf-8"))
217 docs_cfg = data.get("docs", {})
218 if isinstance(docs_cfg, dict):
219 defaults.update({k: v for k, v in docs_cfg.items()
220 if k in defaults})
221 except (json.JSONDecodeError, OSError):
222 pass
223
224 return defaults
225
226
227# ---- Content discovery ----
228
229def _discover_markdown(docs_dir: Path) -> list[str]:
230 """Discover markdown files in the docs directory, recursing into
231 subdirectories that are not in :data:`_DOCS_EXCLUDE_DIRS`.
232
233 Returns a sorted list of file identifiers relative to ``docs_dir`` and
234 stripped of the ``.md`` extension. For files in the docs/ root these
235 identifiers are bare stems (e.g. ``"ARCHITECTURE"``); for files in
236 subdirectories they are POSIX-form relative paths
237 (e.g. ``"option_c_specs/00_Master_Option_C_Specs_Intro_V3_5"``).
238 Sphinx-MyST consumes both forms directly in the toctree.
239 """
240 found: list[str] = []
241 for md_file in docs_dir.rglob("*.md"):
242 if not md_file.is_file():
243 continue
244 rel = md_file.relative_to(docs_dir)
245 # Skip files inside any excluded subdirectory.
246 if any(part in _DOCS_EXCLUDE_DIRS for part in rel.parts[:-1]):
247 continue
248 ident = rel.with_suffix("").as_posix()
249 found.append(ident)
250 return sorted(found)
251
252
253def _order_markdown(stems: list[str]) -> list[str]:
254 """Order markdown identifiers with priority docs first, then alphabetical.
255
256 Identifiers may be bare stems (root-level docs) or
257 ``subdir/.../stem`` paths (subdirectory docs). Priority matching
258 compares against the basename so ``_PRIORITY_DOCS`` entries can stay
259 leaf-name-based even as files move into subdirectories.
260 """
261 ordered = []
262 used = set()
263
264 def _basename(ident: str) -> str:
265 return ident.rsplit("/", 1)[-1]
266
267 for entry in _PRIORITY_DOCS:
268 if entry.endswith("_") or entry.endswith("-"): # prefix marker
269 match = next(
270 (s for s in stems if _basename(s).startswith(entry) and s not in used),
271 None,
272 )
273 if match:
274 ordered.append(match)
275 used.add(match)
276 else: # exact match (original behaviour)
277 for s in stems:
278 if _basename(s) == entry and s not in used:
279 ordered.append(s)
280 used.add(s)
281 break
282 remaining = sorted(s for s in stems if s not in used)
283 return ordered + remaining
284
285
286def _should_skip_dir(path: Path, root: Path) -> bool:
287 """Check if a directory should be excluded from module discovery."""
288 rel_parts = path.relative_to(root).parts
289 for part in rel_parts:
290 if part in LINTER_EXCLUDE_DIRS or part.startswith("."):
291 return True
292 for wc in WILDCARD_EXCLUDE_DIRS:
293 if part.startswith(wc.rstrip("*")):
294 return True
295 # Also skip docs, test, and scaffold directories
296 if rel_parts and rel_parts[0] in {"docs", "old", "tests",
297 "scaffold_test_project"}:
298 return True
299 return False
300
301
302#: OI-426 - safety cap on recursive package discovery. Bounds the walk
303#: so a pathological directory tree (symlink loop, runaway nesting) cannot
304#: hang ``oct docs``.
305_MAX_PACKAGE_DEPTH: int = 10
306
307
308def _find_packages(root: Path, config: dict) -> list[tuple[str, Path]]:
309 """Discover Python packages to document.
310
311 Walks the project tree recursively, stopping at each package (a
312 directory containing ``__init__.py``) rather than descending into
313 its subpackages - Sphinx autodoc handles subpackages automatically.
314 Respects ``LINTER_EXCLUDE_DIRS`` and ``WILDCARD_EXCLUDE_DIRS`` via
315 :func:`_should_skip_dir`.
316
317 Bounded by :data:`_MAX_PACKAGE_DEPTH` to prevent pathological walks
318 on misconfigured projects (OI-426).
319
320 Returns list of ``(package_name, package_path)`` tuples.
321 """
322 autodoc_modules = config.get("autodoc_modules", [])
323
324 if autodoc_modules:
325 explicit: list[tuple[str, Path]] = []
326 for mod_dir in autodoc_modules:
327 mod_path = root / mod_dir
328 if (mod_path / "__init__.py").exists():
329 explicit.append((mod_dir.replace("/", "."), mod_path))
330 return explicit
331
332 packages: list[tuple[str, Path]] = []
333
334 def _walk(directory: Path, depth: int) -> None:
335 if depth > _MAX_PACKAGE_DEPTH:
336 return
337 try:
338 children = sorted(directory.iterdir())
339 except (OSError, PermissionError):
340 return
341 for child in children:
342 if not child.is_dir() or _should_skip_dir(child, root):
343 continue
344 if (child / "__init__.py").exists():
345 packages.append((child.name, child))
346 # Do not descend - autodoc handles subpackages.
347 else:
348 _walk(child, depth + 1)
349
350 _walk(root, 0)
351 return packages
352
353
354def _generate_api_rst(root: Path, docs_dir: Path, config: dict) -> bool:
355 """Generate per-module API documentation using sphinx-apidoc.
356
357 Uses sphinx-apidoc to create comprehensive .rst files for every
358 module, class, and function - similar to Doxygen's EXTRACT_ALL output.
359
360 Returns True if at least one package was documented.
361 """
362 packages = _find_packages(root, config)
363 if not packages:
364 return False
365
366 api_dir = docs_dir / "_api"
367 api_dir.mkdir(exist_ok=True)
368
369 try:
370 from sphinx.ext.apidoc import main as apidoc_main
371 except ImportError:
372 print("Warning: sphinx.ext.apidoc not available", file=sys.stderr)
373 return False
374
375 for pkg_name, pkg_path in packages:
376 apidoc_main([
377 '-f', # force overwrite existing files
378 '-e', # separate page per module
379 '-M', # module-first ordering (module docs before submodules)
380 '-o', str(api_dir),
381 str(pkg_path),
382 '--no-toc', # we generate our own toc
383 ])
384 print(f" sphinx-apidoc: {pkg_name} ({pkg_path})")
385
386 # Check if apidoc generated any files
387 rst_files = list(api_dir.glob("*.rst"))
388 if not rst_files:
389 return False
390
391 # Overwrite modules.rst to list only top-level packages (not all
392 # submodules) - each package .rst has its own toctree for submodules,
393 # so listing everything flat here causes "multiple toctrees" warnings.
394 pkg_stems = sorted(name for name, _ in packages)
395 lines = [
396 f"{_AUTO_MARKER_RST}. Edits will be overwritten on next build.",
397 "",
398 "API Reference",
399 "=============",
400 "",
401 ".. toctree::",
402 " :maxdepth: 4",
403 "",
404 ]
405 for stem in pkg_stems:
406 lines.append(f" {stem}")
407 lines.append("")
408 modules_path = api_dir / "modules.rst"
409 if _is_auto_generated(modules_path, _AUTO_MARKER_RST):
410 modules_path.write_text("\n".join(lines), encoding="utf-8")
411 else:
412 print(
413 f"OI-518: preserving user-authored {modules_path.relative_to(docs_dir)}",
414 file=sys.stderr,
415 )
416
417 return True
418
419
420#: Where the dependency page's generated assets go, relative to docs/.
421#: Under _build/ because that is gitignored: a generated SVG must never
422#: land in the tracked tree (the same rule as the root-README include).
423_DEPS_ASSET_REL: Path = Path("_build") / "deps"
424
425
426def _render_dot_svg(dot_src: str, target: Path) -> bool:
427 """Render *dot_src* to *target* with Graphviz; False when dot is absent or fails."""
428 func = "_render_dot_svg"
429 dot = shutil.which("dot")
430 if dot is None:
431 _dbg("OCT-DOCS", func, "dot not on PATH", 3)
432 return False
433 target.parent.mkdir(parents=True, exist_ok=True)
434 try:
435 result = subprocess.run(
436 [dot, "-Tsvg", "-o", str(target)], input=dot_src, text=True,
437 capture_output=True, timeout=300,
438 )
439 except (OSError, subprocess.TimeoutExpired) as exc:
440 _dbg("OCT-DOCS", func, f"dot failed: {exc}", 2)
441 return False
442 if result.returncode != 0:
443 _dbg("OCT-DOCS", func, f"dot rc={result.returncode}: {result.stderr[:200]}", 2)
444 return False
445 return target.is_file()
446
447
448def _generate_deps_rst(root: Path, docs_dir: Path) -> bool:
449 """Generate the ``dependencies`` page: the subproject's dependency overview.
450
451 Two levels on one page (plan A, docs/DOCS_OVERVIEW_CHARTS_PLAN_2026-09-27.md):
452 a package-level Mermaid chart a person can read in one look, and the
453 full module-level graph rendered by Graphviz to a zoomable SVG. Both are
454 derived from the ``import`` statements, not the ``Dependencies``
455 docstring declarations that ``oct deps`` reports, because a structural
456 picture that can drift from the code is worse than none.
457
458 Returns True when a page was written (the graph has at least one edge),
459 so the caller can add it to the toctree.
460 """
461 func = "_generate_deps_rst"
462 try:
463 from oct.deps.import_graph import (
464 build_import_graph, collapse_to_packages, to_dot, to_mermaid_flowchart,
465 )
466 except ImportError:
467 return False
468
469 graph = build_import_graph(root)
470 if graph.edge_count == 0:
471 _dbg("OCT-DOCS", func, "no internal import edges; page skipped", 3)
472 return False
473
474 packages = collapse_to_packages(graph.edges)
475 n_packages = len({n for edge in packages for n in edge})
476 svg_path = docs_dir / _DEPS_ASSET_REL / "modules.svg"
477 have_svg = _render_dot_svg(to_dot(graph.edges, title=root.name), svg_path)
478
479 lines = [
480 f"{_AUTO_MARKER_RST} from the import statements.",
481 "",
482 "Dependency overview",
483 "===================",
484 "",
485 f"{len(graph.edges)} modules, {graph.edge_count} internal import edges, "
486 f"{n_packages} packages. Tests are excluded. Derived from the ``import`` "
487 f"statements on every build, so this is what the code does, not what the "
488 f"docstrings declare (``oct deps`` reports the declarations).",
489 "",
490 "Packages",
491 "--------",
492 "",
493 "One node per package; an edge where any module in one imports any module "
494 "in the other, labelled with how many module-level imports it stands for. "
495 "This is the picture to read first.",
496 "",
497 ".. mermaid::",
498 "",
499 ]
500 lines.extend(f" {line}" for line in to_mermaid_flowchart(packages).splitlines())
501 lines.extend(["", "Modules", "-------", ""])
502 if have_svg:
503 rel = _DEPS_ASSET_REL.as_posix() + "/modules.svg"
504 lines.extend([
505 "Every module, clustered by package, cross-package edges drawn. Rendered "
506 "by Graphviz; open the image on its own to zoom.",
507 "",
508 f".. image:: {rel}",
509 " :width: 100%",
510 f" :alt: module-level import graph of {root.name}",
511 "",
512 ])
513 else:
514 lines.extend([
515 "The module-level graph is rendered with Graphviz, which was not found "
516 "on this machine (``dot`` is not on PATH), so only the package level is "
517 "shown. Install Graphviz and rebuild to get it.",
518 "",
519 ])
520 if graph.external:
521 top = ", ".join(f"``{name}`` ({count})" for name, count in graph.external.most_common(8))
522 lines.extend([
523 "External imports",
524 "----------------",
525 "",
526 f"Not drawn; the chart is about this project's own structure. The most "
527 f"imported external names, with the number of importing modules: {top}.",
528 "",
529 ])
530
531 deps_path = docs_dir / "dependencies.rst"
532 if _is_auto_generated(deps_path, _AUTO_MARKER_RST):
533 deps_path.write_text("\n".join(lines), encoding="utf-8")
534 return True
535
536 print(
537 "OI-518: preserving user-authored docs/dependencies.rst",
538 file=sys.stderr,
539 )
540 return False
541
542
543# ---- Index generation ----
544
545def _write_index_rst(docs_dir: Path, project_name: str,
546 md_stems: list[str], has_api: bool,
547 has_deps: bool, *,
548 include_root_readme: bool = False) -> None:
549 """Write a populated index.rst with discovered content.
550
551 With *include_root_readme* the project's root ``README.md`` is rendered
552 in place at the top of the index through docutils ``include`` with the
553 MyST parser, so the landing page opens with the project's own
554 introduction and no copy of the README is written into ``docs/``.
555 """
556 title = f"{project_name} Documentation"
557 underline = "=" * len(title)
558
559 lines = [
560 f"{_AUTO_MARKER_RST}. Edits will be overwritten on next build.",
561 "",
562 title,
563 underline,
564 "",
565 ]
566
567 if include_root_readme:
568 lines.extend([
569 ".. include:: ../README.md",
570 " :parser: myst_parser.sphinx_",
571 "",
572 ])
573
574 # Main content toctree (markdown files, priority-ordered)
575 ordered_stems = _order_markdown(md_stems)
576 if ordered_stems:
577 lines.extend([
578 ".. toctree::",
579 " :maxdepth: 2",
580 " :caption: Contents:",
581 "",
582 ])
583 for stem in ordered_stems:
584 lines.append(f" {stem}")
585 lines.append("")
586
587 # API reference toctree
588 if has_api:
589 lines.extend([
590 ".. toctree::",
591 " :maxdepth: 3",
592 " :caption: API Reference:",
593 "",
594 " _api/modules",
595 "",
596 ])
597
598 # Dependencies toctree
599 if has_deps:
600 lines.extend([
601 ".. toctree::",
602 " :maxdepth: 2",
603 " :caption: Analysis:",
604 "",
605 " dependencies",
606 "",
607 ])
608
609 index_path = docs_dir / "index.rst"
610 if _is_auto_generated(index_path, _AUTO_MARKER_RST):
611 index_path.write_text("\n".join(lines), encoding="utf-8")
612 else:
613 print(
614 "OI-518: preserving user-authored docs/index.rst",
615 file=sys.stderr,
616 )
617
618
619# ---- Sphinx setup ----
620
621def setup_sphinx(root: Path) -> Path:
622 """Set up Sphinx configuration in the project's docs/ directory.
623
624 Uses docs/ as the Sphinx source directory directly - markdown files
625 already present in docs/ are included without copying.
626
627 Sphinx config files (conf.py, Makefile, make.bat) live in docs/_sphinx/
628 to keep the docs/ root clean. The ``-c`` flag in ``run_sphinx_build``
629 points Sphinx at this configuration directory.
630
631 Returns the path to the docs directory.
632 """
633 func = "setup_sphinx"
634 _dbg("OCT-DOCS", func, f"scaffolding sphinx under {root}", 3)
635 docs_dir = root / "docs"
636 sphinx_dir = docs_dir / "_sphinx"
637 static_dir = docs_dir / "_static"
638
639 if not _TEMPLATES_DIR.exists():
640 print(f"Error: Sphinx templates not found at {_TEMPLATES_DIR}",
641 file=sys.stderr)
642 raise SystemExit(1)
643
644 # Create directories
645 docs_dir.mkdir(parents=True, exist_ok=True)
646 sphinx_dir.mkdir(exist_ok=True)
647 static_dir.mkdir(exist_ok=True)
648 (docs_dir / "_templates").mkdir(exist_ok=True)
649
650 info = _load_project_info(root)
651 year = datetime.datetime.now().year
652
653 # Copy conf.py (from template, with substitutions) - only if absent
654 conf_dest = sphinx_dir / "conf.py"
655 if not conf_dest.exists():
656 template = (_TEMPLATES_DIR / "conf.py.template").read_text(
657 encoding="utf-8")
658 conf_content = (
659 template
660 .replace("{{PROJECT_NAME}}", info["name"])
661 .replace("{{YEAR}}", str(year))
662 .replace("{{AUTHOR}}", info["author"])
663 .replace("{{VERSION}}", info["version"])
664 )
665 conf_dest.write_text(conf_content, encoding="utf-8")
666
667 # Copy static files (CSS, logo) - only if absent
668 for static_file in ["custom.css", "Option_C_logo.svg"]:
669 src = _TEMPLATES_DIR / "_static" / static_file
670 dest = static_dir / static_file
671 if src.exists() and not dest.exists():
672 shutil.copy2(src, dest)
673
674 # Copy Makefile and make.bat to _sphinx/ - only if absent
675 for build_file in ["Makefile", "make.bat"]:
676 src = _TEMPLATES_DIR / build_file
677 dest = sphinx_dir / build_file
678 if src.exists() and not dest.exists():
679 shutil.copy2(src, dest)
680
681 return docs_dir
682
683
684# ---- Build ----
685
686def run_sphinx_build(docs_dir: Path, config: dict) -> int:
687 """Run sphinx-build to generate HTML output.
688
689 Returns exit code: 0 on success, 1 on failure.
690 """
691 docs_abs = docs_dir.resolve()
692 sphinx_dir = docs_abs / "_sphinx"
693
694 # Clean previous output to avoid stale files. A tree that refuses to go
695 # (ENV-03: the read-only attribute a Drive mirror sets on every synced
696 # directory) is reported and the build carries on - Sphinx overwrites what
697 # it rebuilds, so a surviving cache costs freshness, not the whole run.
698 # A bare ``shutil.rmtree`` here ended ``oct docs --all`` in a traceback.
699 for clean_dir, what in ((docs_abs / "html", "previous HTML output"),
700 (docs_abs / "doctrees", "Sphinx doctree cache")):
701 remove_tree_or_warn(clean_dir, label=what)
702
703 # Build into docs/ directly - produces docs/html/ and docs/doctrees/
704 cmd = [
705 sys.executable, "-m", "sphinx",
706 "-M", "html",
707 str(docs_abs), # source dir
708 str(docs_abs), # build dir (html/ and doctrees/ created here)
709 "-c", str(sphinx_dir), # conf.py location
710 ]
711
712 print(f"Running: sphinx-build -M html {docs_abs} {docs_abs} "
713 f"-c {sphinx_dir}")
714 try:
715 result = subprocess.run(cmd, cwd=str(docs_abs))
716 if result.returncode == 0:
717 html_index = docs_abs / "html" / "index.html"
718 print(f"\nBuild complete. Open: {html_index}")
719 return result.returncode
720 except FileNotFoundError:
721 print("Error: sphinx-build not found.", file=sys.stderr)
722 return 1
723
724
725# ---- Full pipeline ----
726
727def run_docs_sphinx(root: Path, *, no_workspace: bool = False) -> int:
728 """Full oct docs --sphinx pipeline.
729
730 1. Check dependencies
731 2. Set up Sphinx config (one-time)
732 3. Load .octrc.json docs configuration
733 4. Discover markdown files
734 5. Generate API reference (autodoc)
735 6. Generate dependency graph
736 7. Write populated index.rst
737 8. Run sphinx-build
738
739 Returns exit code.
740
741 Workspace mode (FS-WS-DOCS): when ``root`` is a workspace root
742 (``.option_c_workspace.json`` present) and *no_workspace* is
743 ``False``, dispatches to
744 :func:`oct.docs.workspace_docs.run_workspace_docs` for fan-out
745 + aggregation. Subproject invocations and ``--no-workspace``
746 keep the existing single-project body.
747 """
748 func = "run_docs_sphinx"
749 _dbg("OCT-DOCS", func, f"sphinx build under {root}", 3)
750 if not no_workspace:
751 from oct.docs.workspace_docs import is_workspace_root, run_workspace_docs
752 if is_workspace_root(root):
753 return run_workspace_docs(root, mode="sphinx")
754
755 # Check dependencies first
756 missing = _check_sphinx_deps()
757 if missing:
758 print(
759 f"Error: Missing Sphinx dependencies: {', '.join(missing)}\n"
760 f"Install them with: pip install oct-tools[docs]\n"
761 f"Or individually: pip install {' '.join(missing)}",
762 file=sys.stderr,
763 )
764 return 1
765
766 # Set up Sphinx config (copies template if conf.py absent)
767 docs_dir = setup_sphinx(root)
768
769 # The root README is pulled into the index by an ``include`` directive
770 # rather than copied into docs/. The copy (with an auto-generated marker
771 # so later runs recognised it) was a generated file landing in the
772 # tracked source tree: committing it meant every root README edit
773 # dirtied the copy until the next build, and ignoring docs/README.md
774 # would have hidden the hand-authored one that oct and oc_diagnostics
775 # rely on. An include leaves nothing behind. A copy from before this
776 # change is withdrawn here, since it would otherwise be discovered as a
777 # second, stale README page; a hand-authored docs/README.md (no marker)
778 # is untouched and is discovered like any other document.
779 readme_dest = docs_dir / "README.md"
780 if readme_dest.exists() and _is_auto_generated(readme_dest, _AUTO_MARKER_MD):
781 readme_dest.unlink()
782 # OCT-LINT: disable=no_hardcoded_secrets reason="Fixed user-facing message; no credential material (operator-ruled 2026-09-27, same waiver the replaced line carried)"
783 print("Removed the auto-generated docs/README.md copy; the root "
784 "README is now included in place (2026-09-27).")
785 include_root_readme = (root / "README.md").is_file()
786
787 # Load configuration from .octrc.json
788 config = _load_docs_config(root)
789 info = _load_project_info(root)
790
791 # Discover markdown files already in docs/
792 md_stems = _discover_markdown(docs_dir)
793 print(f"Discovered {len(md_stems)} markdown file(s) in docs/")
794
795 # Generate API reference
796 has_api = _generate_api_rst(root, docs_dir, config)
797 if has_api:
798 print("Generated API reference (api.rst)")
799
800 # Generate dependency graph
801 has_deps = False
802 if config.get("include_dependency_graphs", True):
803 has_deps = _generate_deps_rst(root, docs_dir)
804 if has_deps:
805 print("Generated dependency graph (dependencies.rst)")
806
807 # Write populated index.rst
808 _write_index_rst(docs_dir, info["name"], md_stems, has_api, has_deps,
809 include_root_readme=include_root_readme)
810 print("Updated index.rst with discovered content")
811
812 # Build
813 return run_sphinx_build(docs_dir, config)
814
815
816# ---- Doxygen pipeline ----
817
819 """Check if doxygen is on PATH via shutil.which()."""
820 return shutil.which("doxygen") is not None
821
822
823def _find_doxyfile(root: Path) -> Path | None:
824 """Locate the project's Doxyfile if it exists.
825
826 The path is :data:`oct.docs.doxyfile_init.DOXYFILE_REL`, shared with the
827 workspace fan-out, the hub card filter and the scaffold so that "has a
828 Doxyfile" means the same thing everywhere.
829 """
830 from oct.docs.doxyfile_init import DOXYFILE_REL
831 doxyfile = root / DOXYFILE_REL
832 if doxyfile.is_file():
833 return doxyfile
834 return None
835
836
837#: Files Doxygen writes into every HTML output directory it owns. One of
838#: these must be present before :func:`_clear_doxygen_pages` will
839#: delete a directory, so a mis-set ``OUTPUT_DIRECTORY`` cannot turn a
840#: docs build into a recursive delete of someone's source tree.
841_DOXYGEN_HTML_MARKERS: tuple[str, ...] = (
842 "index.html", "doxygen.css", "doxygen.svg", "doxygen_crawl.html",
843)
844
845
846def _doxygen_setting(content: str, key: str) -> str | None:
847 """Return the value of Doxyfile tag *key*, or ``None`` when unset.
848
849 Only single-line assignments are read, which is what both settings
850 this module cares about (``OUTPUT_DIRECTORY``, ``HTML_OUTPUT``) are
851 in every Doxyfile Doxygen itself generates. Surrounding quotes are
852 stripped; a tag present but empty reads as ``None``.
853 """
854 match = re.search(
855 rf'^{re.escape(key)}\s*=\s*(.*)$', content, flags=re.MULTILINE,
856 )
857 if match is None:
858 return None
859 value = match.group(1).strip().strip('"')
860 return value or None
861
862
863def _doxygen_html_output_dir(doxyfile_path: Path) -> Path:
864 """Resolve the HTML output directory *doxyfile_path* writes to.
865
866 Doxygen resolves a relative ``OUTPUT_DIRECTORY`` against its working
867 directory, and this module runs it with ``cwd=doxyfile.parent``, so
868 that is the base used here. ``HTML_OUTPUT`` is then resolved against
869 ``OUTPUT_DIRECTORY``. Both default the way Doxygen defaults them.
870 """
871 content = doxyfile_path.read_text(encoding="utf-8", errors="replace")
872 base = doxyfile_path.parent
873 output_dir = _doxygen_setting(content, "OUTPUT_DIRECTORY")
874 resolved = base if output_dir is None else (base / output_dir)
875 html_output = _doxygen_setting(content, "HTML_OUTPUT") or "html"
876 return (resolved / html_output).resolve()
877
878
879#: Suffixes of Doxygen's dot cache. For every graph Doxygen writes the
880#: rendered ``.svg``, an image ``.map``, and a ``.md5`` of the dot source;
881#: on the next run it compares the md5 and skips ``dot`` when the graph is
882#: unchanged. Kept across builds by :func:`_clear_doxygen_pages`.
883_DOT_CACHE_SUFFIXES: frozenset[str] = frozenset({".svg", ".md5", ".map"})
884
885
886def _clear_doxygen_pages(doxyfile_path: Path, root: Path) -> None:
887 """Delete the previous run's pages but keep the graph cache.
888
889 Doxygen regenerates every page it still has an input for, but it
890 never deletes the page for an input it no longer sees. Excluding a
891 file therefore leaves its generated page on disk indefinitely: the
892 navigation stops linking it, yet it keeps being copied into the
893 published site and keeps answering at its old URL. Clearing the pages
894 first is what makes the output mean "what the current configuration
895 produces".
896
897 The graphs are deliberately NOT cleared. Doxygen keys every dot graph
898 on an md5 of its source and skips ``dot`` when the rendered file
899 already matches, and that cache is most of the build: oc_pricing
900 rebuilds in 0.8 s with 0 dot invocations when the cache is present
901 and in 79 s with 304 when it is not (measured 2026-09-27). The
902 2026-09-26 version of this function removed the whole directory and
903 so threw the cache away on every build - oct's 3,185 graphs were
904 being re-rendered from scratch each time. Graphs whose page no
905 longer exists are pruned after the run by
906 :func:`_prune_unreferenced_graphs`, which is what keeps the original
907 orphan problem solved.
908
909 Two guards, because this deletes inside a directory whose location
910 comes from a config file: the target must resolve inside *root*, and
911 it must look like Doxygen's own output. Anything else is left alone
912 with a warning - a stale page is a much smaller problem than a
913 deleted source tree.
914
915 Failure is never fatal. Whatever cannot be removed is reported, and
916 the build still runs.
917 """
918 func = "_clear_doxygen_pages"
919 try:
920 html_dir = _doxygen_html_output_dir(doxyfile_path)
921 except OSError as exc:
922 _dbg("OCT-DOCS", func, f"could not read Doxyfile: {exc}", 3)
923 return
924
925 if not html_dir.is_dir():
926 _dbg("OCT-DOCS", func, f"nothing to clear at {html_dir}", 4)
927 return
928
929 try:
930 html_dir.relative_to(root.resolve())
931 except ValueError:
932 print(f"Warning: Doxygen HTML output {html_dir} is outside the "
933 f"project; not clearing it. Pages for inputs you have since "
934 f"excluded may survive there.")
935 _dbg("OCT-DOCS", func, f"refused: {html_dir} outside {root}", 2)
936 return
937
938 if not any((html_dir / marker).exists() for marker in _DOXYGEN_HTML_MARKERS):
939 print(f"Warning: {html_dir} does not look like Doxygen output "
940 f"(no {_DOXYGEN_HTML_MARKERS[0]}); not clearing it.")
941 _dbg("OCT-DOCS", func, f"refused: {html_dir} has no marker file", 2)
942 return
943
944 removed = 0
945 failed: list[Path] = []
946 for entry in html_dir.iterdir():
947 if entry.is_dir():
948 # search/ and any other subtree are page-side; no cache lives there.
949 if remove_tree_or_warn(entry, label="previous Doxygen output"):
950 removed += 1
951 else:
952 failed.append(entry)
953 continue
954 if entry.suffix in _DOT_CACHE_SUFFIXES:
955 continue
956 try:
957 entry.unlink()
958 removed += 1
959 except OSError as exc:
960 print(f" could not remove {entry}: {exc.strerror or exc}")
961 failed.append(entry)
962 _dbg("OCT-DOCS", func,
963 f"cleared {removed} page-side entries in {html_dir}, {len(failed)} failed", 3)
964
965
966def _prune_unreferenced_graphs(html_dir: Path) -> int:
967 """Remove graph files no page in *html_dir* embeds; return the count.
968
969 The complement of :func:`_clear_doxygen_pages`: pages are always
970 rebuilt fresh, so after a run the set of ``.svg`` files the pages
971 reference is exactly the set of live graphs. Everything else in the
972 dot cache belongs to a function or class Doxygen no longer sees and
973 is removed - the rendered ``.svg``, the ``_org.svg`` original that
974 ``INTERACTIVE_SVG`` keeps beside it, and the ``.map`` and ``.md5``
975 that would otherwise keep it "cached" forever.
976
977 Doxygen's own non-graph SVGs (``doxygen.svg``, the legend and the
978 theme's icons) are referenced from the pages like any graph and so
979 survive this on the same rule, with no special-casing.
980 """
981 func = "_prune_unreferenced_graphs"
982 if not html_dir.is_dir():
983 return 0
984 referenced: set[str] = set()
985 for page in html_dir.rglob("*.html"):
986 try:
987 text = page.read_text(encoding="utf-8", errors="ignore")
988 except OSError:
989 continue
990 referenced.update(re.findall(r'([A-Za-z0-9_.-]+)\.svg', text))
991 # An interactive graph X.svg loads X_org.svg itself; keep the pair.
992 live = set(referenced) | {f"{name}_org" for name in referenced}
993
994 pruned = 0
995 for entry in html_dir.iterdir():
996 if entry.suffix not in _DOT_CACHE_SUFFIXES or entry.stem in live:
997 continue
998 try:
999 entry.unlink()
1000 pruned += 1
1001 except OSError as exc:
1002 print(f" could not remove {entry}: {exc.strerror or exc}")
1003 _dbg("OCT-DOCS", func, f"{len(referenced)} graphs referenced, {pruned} files pruned", 3)
1004 return pruned
1005
1006
1007def _create_patched_doxyfile(doxyfile_path: Path, root: Path) -> Path:
1008 """Create a temporary copy of the Doxyfile with INPUT patched to actual root.
1009
1010 The existing Doxyfiles have hardcoded stale paths. This creates a temp
1011 copy with the correct INPUT path. Does NOT modify the original.
1012
1013 Returns the path to the temp Doxyfile.
1014 """
1015 content = doxyfile_path.read_text(encoding="utf-8", errors="replace")
1016
1017 # Patch INPUT line - use forward slashes for Doxygen cross-platform compat
1018 root_forward = str(root.resolve()).replace("\\", "/")
1019 content = re.sub(
1020 r'^(INPUT\s*=\s*).*$',
1021 rf'\g<1>"{root_forward}"',
1022 content,
1023 flags=re.MULTILINE,
1024 )
1025
1026 # OI-423: stable prefix lets the stale-file sweeper distinguish OCT's
1027 # temp Doxyfiles from unrelated files in the system temp directory.
1028 tmp = tempfile.NamedTemporaryFile(
1029 mode="w",
1030 prefix="oct-doxyfile-",
1031 suffix=".Doxyfile",
1032 delete=False,
1033 encoding="utf-8",
1034 )
1035 tmp.write(content)
1036 tmp.close()
1037 return Path(tmp.name)
1038
1039
1040#: OI-423 - maximum age (in seconds) a stale ``oct-doxyfile-*`` temp file
1041#: may reach before the sweeper considers it orphaned. 24 hours is long
1042#: enough to avoid racing a concurrent ``oct docs --doxygen`` run.
1043_STALE_DOXYFILE_MAX_AGE_SECONDS: int = 24 * 60 * 60
1044
1045
1047 max_age_seconds: int = _STALE_DOXYFILE_MAX_AGE_SECONDS,
1048) -> int:
1049 """Remove orphaned ``oct-doxyfile-*.Doxyfile`` temp files.
1050
1051 The ``finally`` block in :func:`run_docs_doxygen` cleans up on normal
1052 exit, exceptions, and ``FileNotFoundError`` - but it is skipped on
1053 ``SIGKILL``, power loss, or OOM-kill. Without this sweep the system
1054 temp directory would slowly accumulate orphaned Doxyfiles (OI-423).
1055
1056 Best-effort: any OS error (per-file or directory-wide) is silently
1057 swallowed so that a failed sweep never blocks a legitimate doxygen
1058 build.
1059
1060 Returns the number of files removed.
1061 """
1062 removed = 0
1063 tmp_dir = Path(tempfile.gettempdir())
1064 now = time.time()
1065 try:
1066 for stale in tmp_dir.glob("oct-doxyfile-*.Doxyfile"):
1067 try:
1068 if now - stale.stat().st_mtime > max_age_seconds:
1069 stale.unlink()
1070 removed += 1
1071 except OSError:
1072 continue
1073 except OSError:
1074 pass
1075 return removed
1076
1077
1078def run_docs_doxygen(root: Path, *, no_workspace: bool = False) -> int:
1079 """Full oct docs --doxygen pipeline.
1080
1081 1. Check doxygen is installed (shutil.which)
1082 2. Find existing Doxyfile in docs/doxygen_work_dir/
1083 3. Create patched temp copy with correct INPUT
1084 4. Run doxygen on the temp Doxyfile
1085 5. Clean up temp Doxyfile
1086 6. Report output location (docs/doxygen_output/)
1087
1088 Returns exit code: 0 on success, 1 on failure.
1089
1090 Workspace mode (FS-WS-DOCS): when ``root`` is a workspace root and
1091 *no_workspace* is ``False``, dispatches to
1092 :func:`oct.docs.workspace_docs.run_workspace_docs`.
1093 """
1094 if not no_workspace:
1095 from oct.docs.workspace_docs import is_workspace_root, run_workspace_docs
1096 if is_workspace_root(root):
1097 return run_workspace_docs(root, mode="doxygen")
1098
1099 # OI-423: best-effort cleanup of orphans from earlier runs killed
1100 # before the finally block could run. Must never block the build.
1102
1103 if not _check_doxygen_installed():
1104 print(
1105 # OCT-LINT: disable=no_hardcoded_secrets reason="Fixed user-facing message; no credential material"
1106 "Error: doxygen is not installed or not on PATH.\n"
1107 "Install it from https://www.doxygen.nl/download.html\n"
1108 "On Windows: winget install doxygen\n"
1109 "On macOS: brew install doxygen\n"
1110 "On Linux: apt install doxygen",
1111 file=sys.stderr,
1112 )
1113 return 1
1114
1115 doxyfile = _find_doxyfile(root)
1116 if doxyfile is None:
1117 print(
1118 "Error: No Doxyfile found at docs/doxygen_work_dir/Doxyfile.\n"
1119 "Run 'oct scaffold' to generate one, or create it manually.",
1120 file=sys.stderr,
1121 )
1122 return 1
1123
1124 print(f"Using Doxyfile: {doxyfile}")
1125 # Before the run, not after: Doxygen leaves the page for every input it
1126 # no longer sees, so without this an EXCLUDE_PATTERNS edit never actually
1127 # takes pages out of the published site. Graphs are kept (the dot cache
1128 # is most of the build time) and pruned to the live set afterwards.
1129 _clear_doxygen_pages(doxyfile, root)
1130 patched = _create_patched_doxyfile(doxyfile, root)
1131
1132 try:
1133 print(f"Running doxygen (INPUT patched to {root.resolve()})...")
1134 result = subprocess.run(
1135 ["doxygen", str(patched)],
1136 cwd=str(doxyfile.parent),
1137 )
1138 if result.returncode == 0:
1139 html_dir = _doxygen_html_output_dir(doxyfile)
1140 pruned = _prune_unreferenced_graphs(html_dir)
1141 if pruned:
1142 print(f"Pruned {pruned} graph file(s) no page references.")
1143 html_index = html_dir / "index.html"
1144 if html_index.exists():
1145 print(f"\nDoxygen build complete. Open: {html_index}")
1146 else:
1147 print(f"\nDoxygen build complete. Output: {html_dir}")
1148 return result.returncode
1149 except FileNotFoundError:
1150 print("Error: doxygen binary not found.", file=sys.stderr)
1151 return 1
1152 finally:
1153 try:
1154 patched.unlink()
1155 except OSError:
1156 pass
1157
1158
1159# ---- Clean pipeline ----
1160
1161# Directories removed by --clean (relative to docs/)
1162_DOCS_CLEAN_DIRS = ["html", "doctrees", "_api", "doxygen_output", "typedoc"]
1163
1164# Generated files removed by --clean (relative to docs/)
1165_DOCS_CLEAN_FILES = ["index.rst", "dependencies.rst"]
1166
1167
1168def run_docs_clean(root: Path, *, dry_run: bool = False, no_workspace: bool = False) -> int:
1169 """Remove generated documentation artifacts from docs/.
1170
1171 Cleans: html/, doctrees/, _api/, doxygen_output/, typedoc/,
1172 index.rst, dependencies.rst
1173
1174 Preserves: _sphinx/ (user conf.py), _static/, _templates/,
1175 user-authored .md files, doxygen_work_dir/Doxyfile
1176
1177 Returns 0 when the artifacts are gone (or when there were none, or under
1178 *dry_run*), and 1 when one or more could not be removed - the count it
1179 prints is of directories verified absent afterwards, never of removal
1180 calls made, so a refused delete cannot report as a clean.
1181
1182 Workspace mode (FS-WS-DOCS): from a workspace root, dispatches to
1183 :func:`oct.docs.workspace_docs.run_workspace_docs` with
1184 ``mode="clean"`` (Phase F implementation). ``--no-workspace`` falls
1185 back to single-project clean.
1186 """
1187 func = "run_docs_clean"
1188 _dbg("OCT-DOCS", func, f"cleaning generated docs under {root}", 3)
1189 if not no_workspace:
1190 from oct.docs.workspace_docs import is_workspace_root, run_workspace_docs
1191 if is_workspace_root(root):
1192 return run_workspace_docs(root, mode="clean", dry_run=dry_run)
1193
1194 docs_dir = root / "docs"
1195 if not docs_dir.is_dir():
1196 print("Nothing to clean: docs/ directory does not exist.")
1197 return 0
1198
1199 removed_count = 0
1200 survived: list[Path] = []
1201 action = "Would remove" if dry_run else "Removed"
1202
1203 for dirname in _DOCS_CLEAN_DIRS:
1204 target = docs_dir / dirname
1205 if target.is_dir():
1206 # Count what is really gone. Reporting a directory as removed while
1207 # it is still on disk is the 2026-09-07 `oct clean` defect (947 of
1208 # them), and a read-only tree under a Drive mirror reproduces it.
1209 if not dry_run and not remove_tree_or_warn(
1210 target, label="generated docs directory"):
1211 survived.append(target)
1212 continue
1213 print(f" {action}: {target.relative_to(root)}/")
1214 removed_count += 1
1215
1216 for filename in _DOCS_CLEAN_FILES:
1217 target = docs_dir / filename
1218 if target.is_file():
1219 if not dry_run:
1220 target.unlink()
1221 print(f" {action}: {target.relative_to(root)}")
1222 removed_count += 1
1223
1224 # A docs/README.md is only ours when it carries the marker: builds
1225 # before 2026-09-27 copied the root README there, and a hand-authored
1226 # one (oct, oc_diagnostics) has no marker and must survive a clean.
1227 readme = docs_dir / "README.md"
1228 if readme.is_file() and _is_auto_generated(readme, _AUTO_MARKER_MD):
1229 if not dry_run:
1230 readme.unlink()
1231 print(f" {action}: {readme.relative_to(root)} (auto-generated copy)")
1232 removed_count += 1
1233
1234 if removed_count == 0 and not survived:
1235 print("Nothing to clean: no generated artifacts found in docs/.")
1236 elif removed_count:
1237 prefix = "[DRY RUN] " if dry_run else ""
1238 print(f"\n{prefix}Cleaned {removed_count} artifact(s) from docs/.")
1239
1240 if survived:
1241 # A non-zero exit is the honest verdict: the caller asked for these to be
1242 # gone and they are not. Each one already printed its own warning.
1243 print(f"\n{len(survived)} artifact(s) could not be removed:", file=sys.stderr)
1244 for target in survived:
1245 print(f" {target.relative_to(root)}/", file=sys.stderr)
1246 return 1
1247
1248 return 0
1249
1250
1251# ---- Combined pipeline ----
1252
1253def run_docs_all(root: Path, *, no_workspace: bool = False) -> int:
1254 """Run both Sphinx and Doxygen pipelines.
1255
1256 Returns 0 if both succeed, 1 if either fails.
1257
1258 Workspace mode (FS-WS-DOCS): from a workspace root, dispatches to
1259 :func:`oct.docs.workspace_docs.run_workspace_docs` with
1260 ``mode="all"``.
1261 """
1262 if not no_workspace:
1263 from oct.docs.workspace_docs import is_workspace_root, run_workspace_docs
1264 if is_workspace_root(root):
1265 return run_workspace_docs(root, mode="all")
1266
1267 print("=" * 40)
1268 print("Running Sphinx pipeline...")
1269 print("=" * 40)
1270 sphinx_rc = run_docs_sphinx(root, no_workspace=True)
1271
1272 print()
1273 print("=" * 40)
1274 print("Running Doxygen pipeline...")
1275 print("=" * 40)
1276 doxygen_rc = run_docs_doxygen(root, no_workspace=True)
1277
1278 # TypeDoc only when declared: a Python project has no typedoc.json and
1279 # asking it to build one would be a false failure in every --all run.
1280 # Doxygen above is NOT gated the same way, deliberately - a Doxyfile is
1281 # the norm for a Python project here and its absence is worth failing.
1282 from oct.docs.typedoc import has_typedoc, run_docs_typedoc
1283 typedoc_rc = 0
1284 if has_typedoc(root):
1285 print()
1286 print("=" * 40)
1287 print("Running TypeDoc pipeline...")
1288 print("=" * 40)
1289 typedoc_rc = run_docs_typedoc(root, no_workspace=True)
1290
1291 failures = [name for name, rc in (("Sphinx", sphinx_rc),
1292 ("Doxygen", doxygen_rc),
1293 ("TypeDoc", typedoc_rc)) if rc]
1294 if failures:
1295 print(f"\nFailed: {', '.join(failures)}")
1296 else:
1297 print("\nAll pipelines completed successfully.")
1298
1299 return 1 if failures else 0
int run_docs_doxygen(Path root, *, bool no_workspace=False)
Definition oct_docs.py:1078
int run_docs_sphinx(Path root, *, bool no_workspace=False)
Definition oct_docs.py:727
bool _should_skip_dir(Path path, Path root)
Definition oct_docs.py:286
None _write_index_rst(Path docs_dir, str project_name, list[str] md_stems, bool has_api, bool has_deps, *, bool include_root_readme=False)
Definition oct_docs.py:548
bool _render_dot_svg(str dot_src, Path target)
Definition oct_docs.py:426
bool _generate_deps_rst(Path root, Path docs_dir)
Definition oct_docs.py:448
None _clear_doxygen_pages(Path doxyfile_path, Path root)
Definition oct_docs.py:886
Path _doxygen_html_output_dir(Path doxyfile_path)
Definition oct_docs.py:863
int run_docs_clean(Path root, *, bool dry_run=False, bool no_workspace=False)
Definition oct_docs.py:1168
int run_docs_all(Path root, *, bool no_workspace=False)
Definition oct_docs.py:1253
list[str] _discover_markdown(Path docs_dir)
Definition oct_docs.py:229
int _sweep_stale_doxyfiles(int max_age_seconds=_STALE_DOXYFILE_MAX_AGE_SECONDS)
Definition oct_docs.py:1048
Path setup_sphinx(Path root)
Definition oct_docs.py:621
bool _check_doxygen_installed()
Definition oct_docs.py:818
dict _load_docs_config(Path root)
Definition oct_docs.py:187
list[str] _check_sphinx_deps()
Definition oct_docs.py:129
list[tuple[str, Path]] _find_packages(Path root, dict config)
Definition oct_docs.py:308
Path|None _find_doxyfile(Path root)
Definition oct_docs.py:823
int _prune_unreferenced_graphs(Path html_dir)
Definition oct_docs.py:966
Path _create_patched_doxyfile(Path doxyfile_path, Path root)
Definition oct_docs.py:1007
list[str] _order_markdown(list[str] stems)
Definition oct_docs.py:253
dict _load_project_info(Path root)
Definition oct_docs.py:146
bool _generate_api_rst(Path root, Path docs_dir, dict config)
Definition oct_docs.py:354
str|None _doxygen_setting(str content, str key)
Definition oct_docs.py:846
bool _is_auto_generated(Path path, str marker)
Definition oct_docs.py:79
int run_sphinx_build(Path docs_dir, dict config)
Definition oct_docs.py:686