Option C Tools
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``,
10and ``oct docs --clean``.
11
12Responsibilities
13----------------
14- Set up Sphinx configuration in the project's ``docs/`` directory
15 (if not already present).
16- Discover markdown documentation files already in ``docs/``.
17- Generate API reference pages from Python module docstrings via autodoc.
18- Generate dependency graph pages via ``oct deps``.
19- Populate ``index.rst`` with all discovered content.
20- Run ``sphinx-build`` to generate HTML output.
21- Check for required Sphinx dependencies and emit a clear error if missing.
22- Run Doxygen with a patched Doxyfile (correcting stale INPUT paths).
23- Clean generated documentation artifacts while preserving user-authored files.
24
25Diagnostics
26-----------
27Domain: OCT-DOCS
28Levels:
29 L2 — lifecycle
30 L3 — semantic details
31 L4 — deep tracing
32
33Contracts
34---------
35- Must not overwrite existing ``conf.py`` if already present.
36- ``index.rst`` is regenerated on every build (auto-generated content).
37- Must exit 1 with a helpful message if Sphinx dependencies are not installed.
38- Template files are bundled in ``oct/templates/sphinx/``.
39- Doxygen runs on a temporary copy of the Doxyfile; the original is never modified.
40- ``--clean`` preserves ``_sphinx/``, ``_static/``, ``_templates/``,
41 ``doxygen_work_dir/``, and user-authored markdown files.
42
43Dependencies
44------------
45- oct.deps.oct_deps (dependency graph generation)
46- oct.core.exclusions (directory exclusion lists)
47"""
48
49import datetime
50import json
51import re
52import shutil
53import subprocess
54import sys
55import tempfile
56import time
57from pathlib import Path
58
59from oct.core.exclusions import LINTER_EXCLUDE_DIRS, WILDCARD_EXCLUDE_DIRS
60
61
62_TEMPLATES_DIR = Path(__file__).parent.parent / "templates" / "sphinx"
63
64#: OI-518 — markers that identify auto-generated files. ``oct docs --sphinx``
65#: must not silently overwrite files a user has authored themselves. Any file
66#: whose first line contains the appropriate marker is considered generated
67#: and is safe to regenerate; all other existing files are preserved.
68_AUTO_MARKER_RST: str = ".. Auto-generated by oct docs --sphinx"
69_AUTO_MARKER_MD: str = "<!-- Auto-generated by oct docs --sphinx -->"
70
71
72def _is_auto_generated(path: Path, marker: str) -> bool:
73 """Return True if *path* is absent or was written by ``oct docs``.
74
75 OI-518: used as a write-safety guard around regeneration of
76 ``docs/README.md``, ``docs/index.rst``, ``docs/dependencies.rst``,
77 and ``docs/_api/modules.rst``. A user-authored file (no marker) is
78 left untouched and a warning is emitted instead.
79 """
80 if not path.exists():
81 return True
82 try:
83 head = path.read_text(encoding="utf-8", errors="ignore")
84 except OSError:
85 return False
86 first = head.splitlines()[0] if head else ""
87 return marker in first
88
89_REQUIRED_PACKAGES = [
90 "sphinx",
91 "sphinx_book_theme",
92 "myst_parser",
93 "sphinxcontrib.mermaid",
94 "sphinx_copybutton",
95]
96
97# Subdirectories within docs/ to exclude from markdown discovery
98_DOCS_EXCLUDE_DIRS = {"old", "code_reviews", "sphinx_docs", "_build",
99 "_static", "_templates", "_autosummary", "_sphinx",
100 "_api", "html", "doctrees", "doxygen_output",
101 "doxygen_work_dir"}
102
103# Preferred ordering for markdown documents in the toctree.
104# Documents matching these stems appear first (in this order);
105# all remaining discovered docs follow alphabetically.
106_PRIORITY_DOCS = [
107 "README",
108 "00_Master_Option_C_Specs_Intro_",
109 "01_Master_Option_C_Specs_Core_",
110 "02_Master_Option_C_Specs_Tooling_",
111 "03_Master_Option_C_Specs_Diagnostics_",
112 "04_Master_Option_C_Specs_AI_Protocol_",
113 "05_Master_Option_C_Specs_Changelog_",
114 "ARCHITECTURE",
115 "REFERENCE",
116 "ROADMAP",
117]
118
119
120# ---- Dependency checking ----
121
122def _check_sphinx_deps() -> list[str]:
123 """Check which Sphinx dependencies are missing."""
124 missing = []
125 for pkg in _REQUIRED_PACKAGES:
126 try:
127 __import__(pkg.replace("-", "_").replace(".", "_"))
128 except ImportError:
129 # Try the actual import name
130 try:
131 __import__(pkg)
132 except ImportError:
133 missing.append(pkg)
134 return missing
135
136
137# ---- Project info ----
138
139def _load_project_info(root: Path) -> dict:
140 """Load project name, version, and author from pyproject.toml or defaults."""
141 info = {
142 "name": root.resolve().name,
143 "version": "0.1.0",
144 "author": "Option C Dev Team",
145 }
146
147 pyproject = root / "pyproject.toml"
148 if pyproject.exists():
149 try:
150 content = pyproject.read_text(encoding="utf-8")
151 # Simple TOML parsing for common keys (no dependency on tomli)
152 for line in content.splitlines():
153 line = line.strip()
154 if line.startswith("name") and "=" in line:
155 val = line.split("=", 1)[1].strip().strip('"').strip("'")
156 if val:
157 info["name"] = val
158 elif line.startswith("version") and "=" in line:
159 val = line.split("=", 1)[1].strip().strip('"').strip("'")
160 if val:
161 info["version"] = val
162 except OSError:
163 pass
164
165 return info
166
167
168# ---- .octrc.json docs configuration ----
169
170def _load_docs_config(root: Path) -> dict:
171 """Load docs configuration from .octrc.json with defaults (Appendix H)."""
172 defaults = {
173 "sphinx_theme": "sphinx_book_theme",
174 "sphinx_output": "docs/html",
175 "include_dependency_graphs": True,
176 "include_mermaid_diagrams": True,
177 "autodoc_modules": [],
178 }
179
180 octrc = root / ".octrc.json"
181 if octrc.exists():
182 try:
183 data = json.loads(octrc.read_text(encoding="utf-8"))
184 docs_cfg = data.get("docs", {})
185 if isinstance(docs_cfg, dict):
186 defaults.update({k: v for k, v in docs_cfg.items()
187 if k in defaults})
188 except (json.JSONDecodeError, OSError):
189 pass
190
191 return defaults
192
193
194# ---- Content discovery ----
195
196def _discover_markdown(docs_dir: Path) -> list[str]:
197 """Discover markdown files in the docs directory, recursing into
198 subdirectories that are not in :data:`_DOCS_EXCLUDE_DIRS`.
199
200 Returns a sorted list of file identifiers relative to ``docs_dir`` and
201 stripped of the ``.md`` extension. For files in the docs/ root these
202 identifiers are bare stems (e.g. ``"ARCHITECTURE"``); for files in
203 subdirectories they are POSIX-form relative paths
204 (e.g. ``"option_c_specs/00_Master_Option_C_Specs_Intro_V3_4"``).
205 Sphinx-MyST consumes both forms directly in the toctree.
206 """
207 found: list[str] = []
208 for md_file in docs_dir.rglob("*.md"):
209 if not md_file.is_file():
210 continue
211 rel = md_file.relative_to(docs_dir)
212 # Skip files inside any excluded subdirectory.
213 if any(part in _DOCS_EXCLUDE_DIRS for part in rel.parts[:-1]):
214 continue
215 ident = rel.with_suffix("").as_posix()
216 found.append(ident)
217 return sorted(found)
218
219
220def _order_markdown(stems: list[str]) -> list[str]:
221 """Order markdown identifiers with priority docs first, then alphabetical.
222
223 Identifiers may be bare stems (root-level docs) or
224 ``subdir/.../stem`` paths (subdirectory docs). Priority matching
225 compares against the basename so ``_PRIORITY_DOCS`` entries can stay
226 leaf-name-based even as files move into subdirectories.
227 """
228 ordered = []
229 used = set()
230
231 def _basename(ident: str) -> str:
232 return ident.rsplit("/", 1)[-1]
233
234 for entry in _PRIORITY_DOCS:
235 if entry.endswith("_") or entry.endswith("-"): # prefix marker
236 match = next(
237 (s for s in stems if _basename(s).startswith(entry) and s not in used),
238 None,
239 )
240 if match:
241 ordered.append(match)
242 used.add(match)
243 else: # exact match (original behaviour)
244 for s in stems:
245 if _basename(s) == entry and s not in used:
246 ordered.append(s)
247 used.add(s)
248 break
249 remaining = sorted(s for s in stems if s not in used)
250 return ordered + remaining
251
252
253def _should_skip_dir(path: Path, root: Path) -> bool:
254 """Check if a directory should be excluded from module discovery."""
255 rel_parts = path.relative_to(root).parts
256 for part in rel_parts:
257 if part in LINTER_EXCLUDE_DIRS or part.startswith("."):
258 return True
259 for wc in WILDCARD_EXCLUDE_DIRS:
260 if part.startswith(wc.rstrip("*")):
261 return True
262 # Also skip docs, test, and scaffold directories
263 if rel_parts and rel_parts[0] in {"docs", "old", "tests",
264 "scaffold_test_project"}:
265 return True
266 return False
267
268
269#: OI-426 — safety cap on recursive package discovery. Bounds the walk
270#: so a pathological directory tree (symlink loop, runaway nesting) cannot
271#: hang ``oct docs``.
272_MAX_PACKAGE_DEPTH: int = 10
273
274
275def _find_packages(root: Path, config: dict) -> list[tuple[str, Path]]:
276 """Discover Python packages to document.
277
278 Walks the project tree recursively, stopping at each package (a
279 directory containing ``__init__.py``) rather than descending into
280 its subpackages — Sphinx autodoc handles subpackages automatically.
281 Respects ``LINTER_EXCLUDE_DIRS`` and ``WILDCARD_EXCLUDE_DIRS`` via
282 :func:`_should_skip_dir`.
283
284 Bounded by :data:`_MAX_PACKAGE_DEPTH` to prevent pathological walks
285 on misconfigured projects (OI-426).
286
287 Returns list of ``(package_name, package_path)`` tuples.
288 """
289 autodoc_modules = config.get("autodoc_modules", [])
290
291 if autodoc_modules:
292 explicit: list[tuple[str, Path]] = []
293 for mod_dir in autodoc_modules:
294 mod_path = root / mod_dir
295 if (mod_path / "__init__.py").exists():
296 explicit.append((mod_dir.replace("/", "."), mod_path))
297 return explicit
298
299 packages: list[tuple[str, Path]] = []
300
301 def _walk(directory: Path, depth: int) -> None:
302 if depth > _MAX_PACKAGE_DEPTH:
303 return
304 try:
305 children = sorted(directory.iterdir())
306 except (OSError, PermissionError):
307 return
308 for child in children:
309 if not child.is_dir() or _should_skip_dir(child, root):
310 continue
311 if (child / "__init__.py").exists():
312 packages.append((child.name, child))
313 # Do not descend — autodoc handles subpackages.
314 else:
315 _walk(child, depth + 1)
316
317 _walk(root, 0)
318 return packages
319
320
321def _generate_api_rst(root: Path, docs_dir: Path, config: dict) -> bool:
322 """Generate per-module API documentation using sphinx-apidoc.
323
324 Uses sphinx-apidoc to create comprehensive .rst files for every
325 module, class, and function — similar to Doxygen's EXTRACT_ALL output.
326
327 Returns True if at least one package was documented.
328 """
329 packages = _find_packages(root, config)
330 if not packages:
331 return False
332
333 api_dir = docs_dir / "_api"
334 api_dir.mkdir(exist_ok=True)
335
336 try:
337 from sphinx.ext.apidoc import main as apidoc_main
338 except ImportError:
339 print("Warning: sphinx.ext.apidoc not available", file=sys.stderr)
340 return False
341
342 for pkg_name, pkg_path in packages:
343 apidoc_main([
344 '-f', # force overwrite existing files
345 '-e', # separate page per module
346 '-M', # module-first ordering (module docs before submodules)
347 '-o', str(api_dir),
348 str(pkg_path),
349 '--no-toc', # we generate our own toc
350 ])
351 print(f" sphinx-apidoc: {pkg_name} ({pkg_path})")
352
353 # Check if apidoc generated any files
354 rst_files = list(api_dir.glob("*.rst"))
355 if not rst_files:
356 return False
357
358 # Overwrite modules.rst to list only top-level packages (not all
359 # submodules) — each package .rst has its own toctree for submodules,
360 # so listing everything flat here causes "multiple toctrees" warnings.
361 pkg_stems = sorted(name for name, _ in packages)
362 lines = [
363 f"{_AUTO_MARKER_RST}. Edits will be overwritten on next build.",
364 "",
365 "API Reference",
366 "=============",
367 "",
368 ".. toctree::",
369 " :maxdepth: 4",
370 "",
371 ]
372 for stem in pkg_stems:
373 lines.append(f" {stem}")
374 lines.append("")
375 modules_path = api_dir / "modules.rst"
376 if _is_auto_generated(modules_path, _AUTO_MARKER_RST):
377 modules_path.write_text("\n".join(lines), encoding="utf-8")
378 else:
379 print(
380 f"OI-518: preserving user-authored {modules_path.relative_to(docs_dir)}",
381 file=sys.stderr,
382 )
383
384 return True
385
386
387def _generate_deps_rst(root: Path, docs_dir: Path) -> bool:
388 """Generate dependencies.rst with a Mermaid dependency graph.
389
390 Returns True if the graph has edges.
391 """
392 try:
393 from oct.deps.oct_deps import build_dependency_graph, to_mermaid
394 except ImportError:
395 return False
396
397 graph = build_dependency_graph(root)
398
399 # Check if graph has any edges
400 has_edges = any(deps for deps in graph.values())
401 if not has_edges:
402 return False
403
404 mermaid_src = to_mermaid(graph)
405
406 lines = [
407 f"{_AUTO_MARKER_RST} via oct deps.",
408 "",
409 "Module Dependencies",
410 "====================",
411 "",
412 "The following diagram shows the inter-module dependency graph,",
413 "derived from the ``Dependencies`` docstring section of each module.",
414 "",
415 ".. mermaid::",
416 "",
417 ]
418 # Indent each line of the Mermaid source
419 for mermaid_line in mermaid_src.splitlines():
420 lines.append(f" {mermaid_line}")
421
422 lines.append("")
423
424 deps_path = docs_dir / "dependencies.rst"
425 if _is_auto_generated(deps_path, _AUTO_MARKER_RST):
426 deps_path.write_text("\n".join(lines), encoding="utf-8")
427 return True
428
429 print(
430 "OI-518: preserving user-authored docs/dependencies.rst",
431 file=sys.stderr,
432 )
433 return False
434
435
436# ---- Index generation ----
437
438def _write_index_rst(docs_dir: Path, project_name: str,
439 md_stems: list[str], has_api: bool,
440 has_deps: bool) -> None:
441 """Write a populated index.rst with discovered content."""
442 title = f"{project_name} Documentation"
443 underline = "=" * len(title)
444
445 lines = [
446 f"{_AUTO_MARKER_RST}. Edits will be overwritten on next build.",
447 "",
448 title,
449 underline,
450 "",
451 ]
452
453 # Main content toctree (markdown files, priority-ordered)
454 ordered_stems = _order_markdown(md_stems)
455 if ordered_stems:
456 lines.extend([
457 ".. toctree::",
458 " :maxdepth: 2",
459 " :caption: Contents:",
460 "",
461 ])
462 for stem in ordered_stems:
463 lines.append(f" {stem}")
464 lines.append("")
465
466 # API reference toctree
467 if has_api:
468 lines.extend([
469 ".. toctree::",
470 " :maxdepth: 3",
471 " :caption: API Reference:",
472 "",
473 " _api/modules",
474 "",
475 ])
476
477 # Dependencies toctree
478 if has_deps:
479 lines.extend([
480 ".. toctree::",
481 " :maxdepth: 2",
482 " :caption: Analysis:",
483 "",
484 " dependencies",
485 "",
486 ])
487
488 index_path = docs_dir / "index.rst"
489 if _is_auto_generated(index_path, _AUTO_MARKER_RST):
490 index_path.write_text("\n".join(lines), encoding="utf-8")
491 else:
492 print(
493 "OI-518: preserving user-authored docs/index.rst",
494 file=sys.stderr,
495 )
496
497
498# ---- Sphinx setup ----
499
500def setup_sphinx(root: Path) -> Path:
501 """Set up Sphinx configuration in the project's docs/ directory.
502
503 Uses docs/ as the Sphinx source directory directly — markdown files
504 already present in docs/ are included without copying.
505
506 Sphinx config files (conf.py, Makefile, make.bat) live in docs/_sphinx/
507 to keep the docs/ root clean. The ``-c`` flag in ``run_sphinx_build``
508 points Sphinx at this configuration directory.
509
510 Returns the path to the docs directory.
511 """
512 docs_dir = root / "docs"
513 sphinx_dir = docs_dir / "_sphinx"
514 static_dir = docs_dir / "_static"
515
516 if not _TEMPLATES_DIR.exists():
517 print(f"Error: Sphinx templates not found at {_TEMPLATES_DIR}",
518 file=sys.stderr)
519 raise SystemExit(1)
520
521 # Create directories
522 docs_dir.mkdir(parents=True, exist_ok=True)
523 sphinx_dir.mkdir(exist_ok=True)
524 static_dir.mkdir(exist_ok=True)
525 (docs_dir / "_templates").mkdir(exist_ok=True)
526
527 info = _load_project_info(root)
528 year = datetime.datetime.now().year
529
530 # Copy conf.py (from template, with substitutions) — only if absent
531 conf_dest = sphinx_dir / "conf.py"
532 if not conf_dest.exists():
533 template = (_TEMPLATES_DIR / "conf.py.template").read_text(
534 encoding="utf-8")
535 conf_content = (
536 template
537 .replace("{{PROJECT_NAME}}", info["name"])
538 .replace("{{YEAR}}", str(year))
539 .replace("{{AUTHOR}}", info["author"])
540 .replace("{{VERSION}}", info["version"])
541 )
542 conf_dest.write_text(conf_content, encoding="utf-8")
543
544 # Copy static files (CSS, logo) — only if absent
545 for static_file in ["custom.css", "Option_C_logo.svg"]:
546 src = _TEMPLATES_DIR / "_static" / static_file
547 dest = static_dir / static_file
548 if src.exists() and not dest.exists():
549 shutil.copy2(src, dest)
550
551 # Copy Makefile and make.bat to _sphinx/ — only if absent
552 for build_file in ["Makefile", "make.bat"]:
553 src = _TEMPLATES_DIR / build_file
554 dest = sphinx_dir / build_file
555 if src.exists() and not dest.exists():
556 shutil.copy2(src, dest)
557
558 return docs_dir
559
560
561# ---- Build ----
562
563def run_sphinx_build(docs_dir: Path, config: dict) -> int:
564 """Run sphinx-build to generate HTML output.
565
566 Returns exit code: 0 on success, 1 on failure.
567 """
568 docs_abs = docs_dir.resolve()
569 sphinx_dir = docs_abs / "_sphinx"
570
571 # Clean previous output to avoid stale files
572 for clean_dir in [docs_abs / "html", docs_abs / "doctrees"]:
573 if clean_dir.exists():
574 shutil.rmtree(clean_dir)
575
576 # Build into docs/ directly — produces docs/html/ and docs/doctrees/
577 cmd = [
578 sys.executable, "-m", "sphinx",
579 "-M", "html",
580 str(docs_abs), # source dir
581 str(docs_abs), # build dir (html/ and doctrees/ created here)
582 "-c", str(sphinx_dir), # conf.py location
583 ]
584
585 print(f"Running: sphinx-build -M html {docs_abs} {docs_abs} "
586 f"-c {sphinx_dir}")
587 try:
588 result = subprocess.run(cmd, cwd=str(docs_abs))
589 if result.returncode == 0:
590 html_index = docs_abs / "html" / "index.html"
591 print(f"\nBuild complete. Open: {html_index}")
592 return result.returncode
593 except FileNotFoundError:
594 print("Error: sphinx-build not found.", file=sys.stderr)
595 return 1
596
597
598# ---- Full pipeline ----
599
600def run_docs_sphinx(root: Path) -> int:
601 """Full oct docs --sphinx pipeline.
602
603 1. Check dependencies
604 2. Set up Sphinx config (one-time)
605 3. Load .octrc.json docs configuration
606 4. Discover markdown files
607 5. Generate API reference (autodoc)
608 6. Generate dependency graph
609 7. Write populated index.rst
610 8. Run sphinx-build
611
612 Returns exit code.
613 """
614 # Check dependencies first
615 missing = _check_sphinx_deps()
616 if missing:
617 print(
618 f"Error: Missing Sphinx dependencies: {', '.join(missing)}\n"
619 f"Install them with: pip install oct-tools[docs]\n"
620 f"Or individually: pip install {' '.join(missing)}",
621 file=sys.stderr,
622 )
623 return 1
624
625 # Set up Sphinx config (copies template if conf.py absent)
626 docs_dir = setup_sphinx(root)
627
628 # OI-518: Copy root README.md into docs/ for Sphinx — but never
629 # silently clobber a user-authored docs/README.md. We prepend an
630 # auto-generated marker so subsequent runs recognise their own output.
631 root_readme = root / "README.md"
632 readme_dest = docs_dir / "README.md"
633 if root_readme.exists():
634 if _is_auto_generated(readme_dest, _AUTO_MARKER_MD):
635 content = root_readme.read_text(encoding="utf-8")
636 readme_dest.write_text(
637 f"{_AUTO_MARKER_MD}\n\n{content}",
638 encoding="utf-8",
639 )
640 else:
641 print(
642 "OI-518: preserving user-authored docs/README.md "
643 "(root README.md not copied)",
644 file=sys.stderr,
645 )
646
647 # Load configuration from .octrc.json
648 config = _load_docs_config(root)
649 info = _load_project_info(root)
650
651 # Discover markdown files already in docs/
652 md_stems = _discover_markdown(docs_dir)
653 print(f"Discovered {len(md_stems)} markdown file(s) in docs/")
654
655 # Generate API reference
656 has_api = _generate_api_rst(root, docs_dir, config)
657 if has_api:
658 print("Generated API reference (api.rst)")
659
660 # Generate dependency graph
661 has_deps = False
662 if config.get("include_dependency_graphs", True):
663 has_deps = _generate_deps_rst(root, docs_dir)
664 if has_deps:
665 print("Generated dependency graph (dependencies.rst)")
666
667 # Write populated index.rst
668 _write_index_rst(docs_dir, info["name"], md_stems, has_api, has_deps)
669 print("Updated index.rst with discovered content")
670
671 # Build
672 return run_sphinx_build(docs_dir, config)
673
674
675# ---- Doxygen pipeline ----
676
678 """Check if doxygen is on PATH via shutil.which()."""
679 return shutil.which("doxygen") is not None
680
681
682def _find_doxyfile(root: Path) -> Path | None:
683 """Locate docs/doxygen_work_dir/Doxyfile if it exists."""
684 doxyfile = root / "docs" / "doxygen_work_dir" / "Doxyfile"
685 if doxyfile.is_file():
686 return doxyfile
687 return None
688
689
690def _create_patched_doxyfile(doxyfile_path: Path, root: Path) -> Path:
691 """Create a temporary copy of the Doxyfile with INPUT patched to actual root.
692
693 The existing Doxyfiles have hardcoded stale paths. This creates a temp
694 copy with the correct INPUT path. Does NOT modify the original.
695
696 Returns the path to the temp Doxyfile.
697 """
698 content = doxyfile_path.read_text(encoding="utf-8", errors="replace")
699
700 # Patch INPUT line — use forward slashes for Doxygen cross-platform compat
701 root_forward = str(root.resolve()).replace("\\", "/")
702 content = re.sub(
703 r'^(INPUT\s*=\s*).*$',
704 rf'\g<1>"{root_forward}"',
705 content,
706 flags=re.MULTILINE,
707 )
708
709 # OI-423: stable prefix lets the stale-file sweeper distinguish OCT's
710 # temp Doxyfiles from unrelated files in the system temp directory.
711 tmp = tempfile.NamedTemporaryFile(
712 mode="w",
713 prefix="oct-doxyfile-",
714 suffix=".Doxyfile",
715 delete=False,
716 encoding="utf-8",
717 )
718 tmp.write(content)
719 tmp.close()
720 return Path(tmp.name)
721
722
723#: OI-423 — maximum age (in seconds) a stale ``oct-doxyfile-*`` temp file
724#: may reach before the sweeper considers it orphaned. 24 hours is long
725#: enough to avoid racing a concurrent ``oct docs --doxygen`` run.
726_STALE_DOXYFILE_MAX_AGE_SECONDS: int = 24 * 60 * 60
727
728
730 max_age_seconds: int = _STALE_DOXYFILE_MAX_AGE_SECONDS,
731) -> int:
732 """Remove orphaned ``oct-doxyfile-*.Doxyfile`` temp files.
733
734 The ``finally`` block in :func:`run_docs_doxygen` cleans up on normal
735 exit, exceptions, and ``FileNotFoundError`` — but it is skipped on
736 ``SIGKILL``, power loss, or OOM-kill. Without this sweep the system
737 temp directory would slowly accumulate orphaned Doxyfiles (OI-423).
738
739 Best-effort: any OS error (per-file or directory-wide) is silently
740 swallowed so that a failed sweep never blocks a legitimate doxygen
741 build.
742
743 Returns the number of files removed.
744 """
745 removed = 0
746 tmp_dir = Path(tempfile.gettempdir())
747 now = time.time()
748 try:
749 for stale in tmp_dir.glob("oct-doxyfile-*.Doxyfile"):
750 try:
751 if now - stale.stat().st_mtime > max_age_seconds:
752 stale.unlink()
753 removed += 1
754 except OSError:
755 continue
756 except OSError:
757 pass
758 return removed
759
760
761def run_docs_doxygen(root: Path) -> int:
762 """Full oct docs --doxygen pipeline.
763
764 1. Check doxygen is installed (shutil.which)
765 2. Find existing Doxyfile in docs/doxygen_work_dir/
766 3. Create patched temp copy with correct INPUT
767 4. Run doxygen on the temp Doxyfile
768 5. Clean up temp Doxyfile
769 6. Report output location (docs/doxygen_output/)
770
771 Returns exit code: 0 on success, 1 on failure.
772 """
773 # OI-423: best-effort cleanup of orphans from earlier runs killed
774 # before the finally block could run. Must never block the build.
776
778 print(
779 "Error: doxygen is not installed or not on PATH.\n"
780 "Install it from https://www.doxygen.nl/download.html\n"
781 "On Windows: winget install doxygen\n"
782 "On macOS: brew install doxygen\n"
783 "On Linux: apt install doxygen",
784 file=sys.stderr,
785 )
786 return 1
787
788 doxyfile = _find_doxyfile(root)
789 if doxyfile is None:
790 print(
791 "Error: No Doxyfile found at docs/doxygen_work_dir/Doxyfile.\n"
792 "Run 'oct scaffold' to generate one, or create it manually.",
793 file=sys.stderr,
794 )
795 return 1
796
797 print(f"Using Doxyfile: {doxyfile}")
798 patched = _create_patched_doxyfile(doxyfile, root)
799
800 try:
801 print(f"Running doxygen (INPUT patched to {root.resolve()})...")
802 result = subprocess.run(
803 ["doxygen", str(patched)],
804 cwd=str(doxyfile.parent),
805 )
806 if result.returncode == 0:
807 output_dir = root / "docs" / "doxygen_output"
808 html_index = output_dir / "html" / "index.html"
809 if html_index.exists():
810 print(f"\nDoxygen build complete. Open: {html_index}")
811 else:
812 print(f"\nDoxygen build complete. Output: {output_dir}")
813 return result.returncode
814 except FileNotFoundError:
815 print("Error: doxygen binary not found.", file=sys.stderr)
816 return 1
817 finally:
818 try:
819 patched.unlink()
820 except OSError:
821 pass
822
823
824# ---- Clean pipeline ----
825
826# Directories removed by --clean (relative to docs/)
827_DOCS_CLEAN_DIRS = ["html", "doctrees", "_api", "doxygen_output"]
828
829# Generated files removed by --clean (relative to docs/)
830_DOCS_CLEAN_FILES = ["index.rst", "dependencies.rst"]
831
832
833def run_docs_clean(root: Path, *, dry_run: bool = False) -> int:
834 """Remove generated documentation artifacts from docs/.
835
836 Cleans: html/, doctrees/, _api/, doxygen_output/,
837 index.rst, dependencies.rst
838
839 Preserves: _sphinx/ (user conf.py), _static/, _templates/,
840 user-authored .md files, doxygen_work_dir/Doxyfile
841
842 Returns 0 always.
843 """
844 docs_dir = root / "docs"
845 if not docs_dir.is_dir():
846 print("Nothing to clean: docs/ directory does not exist.")
847 return 0
848
849 removed_count = 0
850 action = "Would remove" if dry_run else "Removed"
851
852 for dirname in _DOCS_CLEAN_DIRS:
853 target = docs_dir / dirname
854 if target.is_dir():
855 if not dry_run:
856 shutil.rmtree(target)
857 print(f" {action}: {target.relative_to(root)}/")
858 removed_count += 1
859
860 for filename in _DOCS_CLEAN_FILES:
861 target = docs_dir / filename
862 if target.is_file():
863 if not dry_run:
864 target.unlink()
865 print(f" {action}: {target.relative_to(root)}")
866 removed_count += 1
867
868 if removed_count == 0:
869 print("Nothing to clean: no generated artifacts found in docs/.")
870 else:
871 prefix = "[DRY RUN] " if dry_run else ""
872 print(f"\n{prefix}Cleaned {removed_count} artifact(s) from docs/.")
873
874 return 0
875
876
877# ---- Combined pipeline ----
878
879def run_docs_all(root: Path) -> int:
880 """Run both Sphinx and Doxygen pipelines.
881
882 Returns 0 if both succeed, 1 if either fails.
883 """
884 print("=" * 40)
885 print("Running Sphinx pipeline...")
886 print("=" * 40)
887 sphinx_rc = run_docs_sphinx(root)
888
889 print()
890 print("=" * 40)
891 print("Running Doxygen pipeline...")
892 print("=" * 40)
893 doxygen_rc = run_docs_doxygen(root)
894
895 if sphinx_rc == 0 and doxygen_rc == 0:
896 print("\nBoth pipelines completed successfully.")
897 else:
898 failures = []
899 if sphinx_rc != 0:
900 failures.append("Sphinx")
901 if doxygen_rc != 0:
902 failures.append("Doxygen")
903 print(f"\nFailed: {', '.join(failures)}")
904
905 return 1 if (sphinx_rc or doxygen_rc) else 0
bool _should_skip_dir(Path path, Path root)
Definition oct_docs.py:253
bool _generate_deps_rst(Path root, Path docs_dir)
Definition oct_docs.py:387
int run_docs_clean(Path root, *, bool dry_run=False)
Definition oct_docs.py:833
int run_docs_sphinx(Path root)
Definition oct_docs.py:600
int run_docs_doxygen(Path root)
Definition oct_docs.py:761
list[str] _discover_markdown(Path docs_dir)
Definition oct_docs.py:196
int _sweep_stale_doxyfiles(int max_age_seconds=_STALE_DOXYFILE_MAX_AGE_SECONDS)
Definition oct_docs.py:731
Path setup_sphinx(Path root)
Definition oct_docs.py:500
bool _check_doxygen_installed()
Definition oct_docs.py:677
dict _load_docs_config(Path root)
Definition oct_docs.py:170
int run_docs_all(Path root)
Definition oct_docs.py:879
list[str] _check_sphinx_deps()
Definition oct_docs.py:122
list[tuple[str, Path]] _find_packages(Path root, dict config)
Definition oct_docs.py:275
Path|None _find_doxyfile(Path root)
Definition oct_docs.py:682
None _write_index_rst(Path docs_dir, str project_name, list[str] md_stems, bool has_api, bool has_deps)
Definition oct_docs.py:440
Path _create_patched_doxyfile(Path doxyfile_path, Path root)
Definition oct_docs.py:690
list[str] _order_markdown(list[str] stems)
Definition oct_docs.py:220
dict _load_project_info(Path root)
Definition oct_docs.py:139
bool _generate_api_rst(Path root, Path docs_dir, dict config)
Definition oct_docs.py:321
bool _is_auto_generated(Path path, str marker)
Definition oct_docs.py:72
int run_sphinx_build(Path docs_dir, dict config)
Definition oct_docs.py:563