8Provide documentation generation pipelines for Option C projects:
9``oct docs --sphinx``, ``oct docs --doxygen``, ``oct docs --all``, and ``oct docs --clean``.
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.
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.
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)
62from pathlib
import Path
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
69_TEMPLATES_DIR = Path(__file__).parent.parent /
"templates" /
"sphinx"
75_AUTO_MARKER_RST: str =
".. Auto-generated by oct docs --sphinx"
76_AUTO_MARKER_MD: str =
"<!-- Auto-generated by oct docs --sphinx -->"
80 """Return True if *path* is absent or was written by ``oct docs``.
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.
90 head = path.read_text(encoding=
"utf-8", errors=
"ignore")
93 first = head.splitlines()[0]
if head
else ""
94 return marker
in first
100 "sphinxcontrib.mermaid",
105_DOCS_EXCLUDE_DIRS = {
"old",
"code_reviews",
"sphinx_docs",
"_build",
106 "_static",
"_templates",
"_autosummary",
"_sphinx",
107 "_api",
"html",
"doctrees",
"doxygen_output",
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_",
130 """Check which Sphinx dependencies are missing."""
132 for pkg
in _REQUIRED_PACKAGES:
134 __import__(pkg.replace(
"-",
"_").replace(
".",
"_"))
147 """Load project name, version, and author from pyproject.toml or defaults."""
149 "name": root.resolve().name,
151 "author":
"Option C Dev Team",
154 pyproject = root /
"pyproject.toml"
155 if not pyproject.exists():
167 import tomli
as tomllib
170 data = tomllib.loads(pyproject.read_text(encoding=
"utf-8"))
171 except (OSError, ImportError, ValueError):
174 project = data.get(
"project")
175 if not isinstance(project, dict):
177 for key
in (
"name",
"version"):
178 value = project.get(key)
179 if isinstance(value, str)
and value:
188 """Load docs configuration from the project's octrc with defaults (Appendix H).
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.
206 "include_dependency_graphs":
True,
207 "include_mermaid_diagrams":
True,
208 "autodoc_modules": [],
211 from oct.core.option_c_dir
import resolve_octrc
213 octrc = resolve_octrc(root)
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()
221 except (json.JSONDecodeError, OSError):
230 """Discover markdown files in the docs directory, recursing into
231 subdirectories that are not in :data:`_DOCS_EXCLUDE_DIRS`.
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.
240 found: list[str] = []
241 for md_file
in docs_dir.rglob(
"*.md"):
242 if not md_file.is_file():
244 rel = md_file.relative_to(docs_dir)
246 if any(part
in _DOCS_EXCLUDE_DIRS
for part
in rel.parts[:-1]):
248 ident = rel.with_suffix(
"").as_posix()
254 """Order markdown identifiers with priority docs first, then alphabetical.
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.
264 def _basename(ident: str) -> str:
265 return ident.rsplit(
"/", 1)[-1]
267 for entry
in _PRIORITY_DOCS:
268 if entry.endswith(
"_")
or entry.endswith(
"-"):
270 (s
for s
in stems
if _basename(s).startswith(entry)
and s
not in used),
274 ordered.append(match)
278 if _basename(s) == entry
and s
not in used:
282 remaining = sorted(s
for s
in stems
if s
not in used)
283 return ordered + remaining
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(
"."):
292 for wc
in WILDCARD_EXCLUDE_DIRS:
293 if part.startswith(wc.rstrip(
"*")):
296 if rel_parts
and rel_parts[0]
in {
"docs",
"old",
"tests",
297 "scaffold_test_project"}:
305_MAX_PACKAGE_DEPTH: int = 10
309 """Discover Python packages to document.
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`.
317 Bounded by :data:`_MAX_PACKAGE_DEPTH` to prevent pathological walks
318 on misconfigured projects (OI-426).
320 Returns list of ``(package_name, package_path)`` tuples.
322 autodoc_modules = config.get(
"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))
332 packages: list[tuple[str, Path]] = []
334 def _walk(directory: Path, depth: int) ->
None:
335 if depth > _MAX_PACKAGE_DEPTH:
338 children = sorted(directory.iterdir())
339 except (OSError, PermissionError):
341 for child
in children:
344 if (child /
"__init__.py").exists():
345 packages.append((child.name, child))
348 _walk(child, depth + 1)
355 """Generate per-module API documentation using sphinx-apidoc.
357 Uses sphinx-apidoc to create comprehensive .rst files for every
358 module, class, and function - similar to Doxygen's EXTRACT_ALL output.
360 Returns True if at least one package was documented.
366 api_dir = docs_dir /
"_api"
367 api_dir.mkdir(exist_ok=
True)
370 from sphinx.ext.apidoc
import main
as apidoc_main
372 print(
"Warning: sphinx.ext.apidoc not available", file=sys.stderr)
375 for pkg_name, pkg_path
in packages:
384 print(f
" sphinx-apidoc: {pkg_name} ({pkg_path})")
387 rst_files = list(api_dir.glob(
"*.rst"))
394 pkg_stems = sorted(name
for name, _
in packages)
396 f
"{_AUTO_MARKER_RST}. Edits will be overwritten on next build.",
405 for stem
in pkg_stems:
406 lines.append(f
" {stem}")
408 modules_path = api_dir /
"modules.rst"
410 modules_path.write_text(
"\n".join(lines), encoding=
"utf-8")
413 f
"OI-518: preserving user-authored {modules_path.relative_to(docs_dir)}",
423_DEPS_ASSET_REL: Path = Path(
"_build") /
"deps"
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")
431 _dbg(
"OCT-DOCS", func,
"dot not on PATH", 3)
433 target.parent.mkdir(parents=
True, exist_ok=
True)
435 result = subprocess.run(
436 [dot,
"-Tsvg",
"-o", str(target)], input=dot_src, text=
True,
437 capture_output=
True, timeout=300,
439 except (OSError, subprocess.TimeoutExpired)
as exc:
440 _dbg(
"OCT-DOCS", func, f
"dot failed: {exc}", 2)
442 if result.returncode != 0:
443 _dbg(
"OCT-DOCS", func, f
"dot rc={result.returncode}: {result.stderr[:200]}", 2)
445 return target.is_file()
449 """Generate the ``dependencies`` page: the subproject's dependency overview.
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.
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.
461 func =
"_generate_deps_rst"
464 build_import_graph, collapse_to_packages, to_dot, to_mermaid_flowchart,
469 graph = build_import_graph(root)
470 if graph.edge_count == 0:
471 _dbg(
"OCT-DOCS", func,
"no internal import edges; page skipped", 3)
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)
480 f
"{_AUTO_MARKER_RST} from the import statements.",
482 "Dependency overview",
483 "===================",
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).",
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.",
500 lines.extend(f
" {line}" for line
in to_mermaid_flowchart(packages).splitlines())
501 lines.extend([
"",
"Modules",
"-------",
""])
503 rel = _DEPS_ASSET_REL.as_posix() +
"/modules.svg"
505 "Every module, clustered by package, cross-package edges drawn. Rendered "
506 "by Graphviz; open the image on its own to zoom.",
510 f
" :alt: module-level import graph of {root.name}",
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.",
521 top =
", ".join(f
"``{name}`` ({count})" for name, count
in graph.external.most_common(8))
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}.",
531 deps_path = docs_dir /
"dependencies.rst"
533 deps_path.write_text(
"\n".join(lines), encoding=
"utf-8")
537 "OI-518: preserving user-authored docs/dependencies.rst",
546 md_stems: list[str], has_api: bool,
548 include_root_readme: bool =
False) ->
None:
549 """Write a populated index.rst with discovered content.
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/``.
556 title = f
"{project_name} Documentation"
557 underline =
"=" * len(title)
560 f
"{_AUTO_MARKER_RST}. Edits will be overwritten on next build.",
567 if include_root_readme:
569 ".. include:: ../README.md",
570 " :parser: myst_parser.sphinx_",
580 " :caption: Contents:",
583 for stem
in ordered_stems:
584 lines.append(f
" {stem}")
592 " :caption: API Reference:",
603 " :caption: Analysis:",
609 index_path = docs_dir /
"index.rst"
611 index_path.write_text(
"\n".join(lines), encoding=
"utf-8")
614 "OI-518: preserving user-authored docs/index.rst",
622 """Set up Sphinx configuration in the project's docs/ directory.
624 Uses docs/ as the Sphinx source directory directly - markdown files
625 already present in docs/ are included without copying.
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.
631 Returns the path to the docs directory.
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"
639 if not _TEMPLATES_DIR.exists():
640 print(f
"Error: Sphinx templates not found at {_TEMPLATES_DIR}",
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)
651 year = datetime.datetime.now().year
654 conf_dest = sphinx_dir /
"conf.py"
655 if not conf_dest.exists():
656 template = (_TEMPLATES_DIR /
"conf.py.template").read_text(
660 .replace(
"{{PROJECT_NAME}}", info[
"name"])
661 .replace(
"{{YEAR}}", str(year))
662 .replace(
"{{AUTHOR}}", info[
"author"])
663 .replace(
"{{VERSION}}", info[
"version"])
665 conf_dest.write_text(conf_content, encoding=
"utf-8")
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)
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)
687 """Run sphinx-build to generate HTML output.
689 Returns exit code: 0 on success, 1 on failure.
691 docs_abs = docs_dir.resolve()
692 sphinx_dir = docs_abs /
"_sphinx"
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)
705 sys.executable,
"-m",
"sphinx",
709 "-c", str(sphinx_dir),
712 print(f
"Running: sphinx-build -M html {docs_abs} {docs_abs} "
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)
728 """Full oct docs --sphinx pipeline.
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
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.
748 func =
"run_docs_sphinx"
749 _dbg(
"OCT-DOCS", func, f
"sphinx build under {root}", 3)
752 if is_workspace_root(root):
753 return run_workspace_docs(root, mode=
"sphinx")
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)}",
779 readme_dest = docs_dir /
"README.md"
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()
793 print(f
"Discovered {len(md_stems)} markdown file(s) in docs/")
798 print(
"Generated API reference (api.rst)")
802 if config.get(
"include_dependency_graphs",
True):
805 print(
"Generated dependency graph (dependencies.rst)")
809 include_root_readme=include_root_readme)
810 print(
"Updated index.rst with discovered content")
819 """Check if doxygen is on PATH via shutil.which()."""
820 return shutil.which(
"doxygen")
is not None
824 """Locate the project's Doxyfile if it exists.
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.
831 doxyfile = root / DOXYFILE_REL
832 if doxyfile.is_file():
841_DOXYGEN_HTML_MARKERS: tuple[str, ...] = (
842 "index.html",
"doxygen.css",
"doxygen.svg",
"doxygen_crawl.html",
847 """Return the value of Doxyfile tag *key*, or ``None`` when unset.
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``.
855 rf
'^{re.escape(key)}\s*=\s*(.*)$', content, flags=re.MULTILINE,
859 value = match.group(1).strip().strip(
'"')
864 """Resolve the HTML output directory *doxyfile_path* writes to.
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.
871 content = doxyfile_path.read_text(encoding=
"utf-8", errors=
"replace")
872 base = doxyfile_path.parent
874 resolved = base
if output_dir
is None else (base / output_dir)
876 return (resolved / html_output).resolve()
883_DOT_CACHE_SUFFIXES: frozenset[str] = frozenset({
".svg",
".md5",
".map"})
887 """Delete the previous run's pages but keep the graph cache.
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
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.
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
915 Failure is never fatal. Whatever cannot be removed is reported, and
916 the build still runs.
918 func =
"_clear_doxygen_pages"
921 except OSError
as exc:
922 _dbg(
"OCT-DOCS", func, f
"could not read Doxyfile: {exc}", 3)
925 if not html_dir.is_dir():
926 _dbg(
"OCT-DOCS", func, f
"nothing to clear at {html_dir}", 4)
930 html_dir.relative_to(root.resolve())
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)
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)
945 failed: list[Path] = []
946 for entry
in html_dir.iterdir():
949 if remove_tree_or_warn(entry, label=
"previous Doxygen output"):
954 if entry.suffix
in _DOT_CACHE_SUFFIXES:
959 except OSError
as exc:
960 print(f
" could not remove {entry}: {exc.strerror or exc}")
962 _dbg(
"OCT-DOCS", func,
963 f
"cleared {removed} page-side entries in {html_dir}, {len(failed)} failed", 3)
967 """Remove graph files no page in *html_dir* embeds; return the count.
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.
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.
981 func =
"_prune_unreferenced_graphs"
982 if not html_dir.is_dir():
984 referenced: set[str] = set()
985 for page
in html_dir.rglob(
"*.html"):
987 text = page.read_text(encoding=
"utf-8", errors=
"ignore")
990 referenced.update(re.findall(
r'([A-Za-z0-9_.-]+)\.svg', text))
992 live = set(referenced) | {f
"{name}_org" for name
in referenced}
995 for entry
in html_dir.iterdir():
996 if entry.suffix
not in _DOT_CACHE_SUFFIXES
or entry.stem
in live:
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)
1008 """Create a temporary copy of the Doxyfile with INPUT patched to actual root.
1010 The existing Doxyfiles have hardcoded stale paths. This creates a temp
1011 copy with the correct INPUT path. Does NOT modify the original.
1013 Returns the path to the temp Doxyfile.
1015 content = doxyfile_path.read_text(encoding=
"utf-8", errors=
"replace")
1018 root_forward = str(root.resolve()).replace(
"\\",
"/")
1020 r'^(INPUT\s*=\s*).*$',
1021 rf
'\g<1>"{root_forward}"',
1028 tmp = tempfile.NamedTemporaryFile(
1030 prefix=
"oct-doxyfile-",
1037 return Path(tmp.name)
1043_STALE_DOXYFILE_MAX_AGE_SECONDS: int = 24 * 60 * 60
1047 max_age_seconds: int = _STALE_DOXYFILE_MAX_AGE_SECONDS,
1049 """Remove orphaned ``oct-doxyfile-*.Doxyfile`` temp files.
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).
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
1060 Returns the number of files removed.
1063 tmp_dir = Path(tempfile.gettempdir())
1066 for stale
in tmp_dir.glob(
"oct-doxyfile-*.Doxyfile"):
1068 if now - stale.stat().st_mtime > max_age_seconds:
1079 """Full oct docs --doxygen pipeline.
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/)
1088 Returns exit code: 0 on success, 1 on failure.
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`.
1094 if not no_workspace:
1096 if is_workspace_root(root):
1097 return run_workspace_docs(root, mode=
"doxygen")
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",
1116 if doxyfile
is None:
1118 "Error: No Doxyfile found at docs/doxygen_work_dir/Doxyfile.\n"
1119 "Run 'oct scaffold' to generate one, or create it manually.",
1124 print(f
"Using Doxyfile: {doxyfile}")
1133 print(f
"Running doxygen (INPUT patched to {root.resolve()})...")
1134 result = subprocess.run(
1135 [
"doxygen", str(patched)],
1136 cwd=str(doxyfile.parent),
1138 if result.returncode == 0:
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}")
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)
1162_DOCS_CLEAN_DIRS = [
"html",
"doctrees",
"_api",
"doxygen_output",
"typedoc"]
1165_DOCS_CLEAN_FILES = [
"index.rst",
"dependencies.rst"]
1168def run_docs_clean(root: Path, *, dry_run: bool =
False, no_workspace: bool =
False) -> int:
1169 """Remove generated documentation artifacts from docs/.
1171 Cleans: html/, doctrees/, _api/, doxygen_output/, typedoc/,
1172 index.rst, dependencies.rst
1174 Preserves: _sphinx/ (user conf.py), _static/, _templates/,
1175 user-authored .md files, doxygen_work_dir/Doxyfile
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.
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.
1187 func =
"run_docs_clean"
1188 _dbg(
"OCT-DOCS", func, f
"cleaning generated docs under {root}", 3)
1189 if not no_workspace:
1191 if is_workspace_root(root):
1192 return run_workspace_docs(root, mode=
"clean", dry_run=dry_run)
1194 docs_dir = root /
"docs"
1195 if not docs_dir.is_dir():
1196 print(
"Nothing to clean: docs/ directory does not exist.")
1200 survived: list[Path] = []
1201 action =
"Would remove" if dry_run
else "Removed"
1203 for dirname
in _DOCS_CLEAN_DIRS:
1204 target = docs_dir / dirname
1209 if not dry_run
and not remove_tree_or_warn(
1210 target, label=
"generated docs directory"):
1211 survived.append(target)
1213 print(f
" {action}: {target.relative_to(root)}/")
1216 for filename
in _DOCS_CLEAN_FILES:
1217 target = docs_dir / filename
1218 if target.is_file():
1221 print(f
" {action}: {target.relative_to(root)}")
1227 readme = docs_dir /
"README.md"
1231 print(f
" {action}: {readme.relative_to(root)} (auto-generated copy)")
1234 if removed_count == 0
and not survived:
1235 print(
"Nothing to clean: no generated artifacts found in docs/.")
1237 prefix =
"[DRY RUN] " if dry_run
else ""
1238 print(f
"\n{prefix}Cleaned {removed_count} artifact(s) from docs/.")
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)
1254 """Run both Sphinx and Doxygen pipelines.
1256 Returns 0 if both succeed, 1 if either fails.
1258 Workspace mode (FS-WS-DOCS): from a workspace root, dispatches to
1259 :func:`oct.docs.workspace_docs.run_workspace_docs` with
1262 if not no_workspace:
1264 if is_workspace_root(root):
1265 return run_workspace_docs(root, mode=
"all")
1268 print(
"Running Sphinx pipeline...")
1274 print(
"Running Doxygen pipeline...")
1284 if has_typedoc(root):
1287 print(
"Running TypeDoc pipeline...")
1289 typedoc_rc = run_docs_typedoc(root, no_workspace=
True)
1291 failures = [name
for name, rc
in ((
"Sphinx", sphinx_rc),
1292 (
"Doxygen", doxygen_rc),
1293 (
"TypeDoc", typedoc_rc))
if rc]
1295 print(f
"\nFailed: {', '.join(failures)}")
1297 print(
"\nAll pipelines completed successfully.")
1299 return 1
if failures
else 0
int run_docs_doxygen(Path root, *, bool no_workspace=False)
int run_docs_sphinx(Path root, *, bool no_workspace=False)
bool _should_skip_dir(Path path, Path root)
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)
bool _render_dot_svg(str dot_src, Path target)
bool _generate_deps_rst(Path root, Path docs_dir)
None _clear_doxygen_pages(Path doxyfile_path, Path root)
Path _doxygen_html_output_dir(Path doxyfile_path)
int run_docs_clean(Path root, *, bool dry_run=False, bool no_workspace=False)
int run_docs_all(Path root, *, bool no_workspace=False)
list[str] _discover_markdown(Path docs_dir)
int _sweep_stale_doxyfiles(int max_age_seconds=_STALE_DOXYFILE_MAX_AGE_SECONDS)
Path setup_sphinx(Path root)
bool _check_doxygen_installed()
dict _load_docs_config(Path root)
list[str] _check_sphinx_deps()
list[tuple[str, Path]] _find_packages(Path root, dict config)
Path|None _find_doxyfile(Path root)
int _prune_unreferenced_graphs(Path html_dir)
Path _create_patched_doxyfile(Path doxyfile_path, Path root)
list[str] _order_markdown(list[str] stems)
dict _load_project_info(Path root)
bool _generate_api_rst(Path root, Path docs_dir, dict config)
str|None _doxygen_setting(str content, str key)
bool _is_auto_generated(Path path, str marker)
int run_sphinx_build(Path docs_dir, dict config)