8Provide documentation generation pipelines for Option C projects:
9``oct docs --sphinx``, ``oct docs --doxygen``, ``oct docs --all``,
10and ``oct docs --clean``.
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.
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.
45- oct.deps.oct_deps (dependency graph generation)
46- oct.core.exclusions (directory exclusion lists)
57from pathlib
import Path
59from oct.core.exclusions
import LINTER_EXCLUDE_DIRS, WILDCARD_EXCLUDE_DIRS
62_TEMPLATES_DIR = Path(__file__).parent.parent /
"templates" /
"sphinx"
68_AUTO_MARKER_RST: str =
".. Auto-generated by oct docs --sphinx"
69_AUTO_MARKER_MD: str =
"<!-- Auto-generated by oct docs --sphinx -->"
73 """Return True if *path* is absent or was written by ``oct docs``.
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.
83 head = path.read_text(encoding=
"utf-8", errors=
"ignore")
86 first = head.splitlines()[0]
if head
else ""
87 return marker
in first
93 "sphinxcontrib.mermaid",
98_DOCS_EXCLUDE_DIRS = {
"old",
"code_reviews",
"sphinx_docs",
"_build",
99 "_static",
"_templates",
"_autosummary",
"_sphinx",
100 "_api",
"html",
"doctrees",
"doxygen_output",
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_",
123 """Check which Sphinx dependencies are missing."""
125 for pkg
in _REQUIRED_PACKAGES:
127 __import__(pkg.replace(
"-",
"_").replace(
".",
"_"))
140 """Load project name, version, and author from pyproject.toml or defaults."""
142 "name": root.resolve().name,
144 "author":
"Option C Dev Team",
147 pyproject = root /
"pyproject.toml"
148 if pyproject.exists():
150 content = pyproject.read_text(encoding=
"utf-8")
152 for line
in content.splitlines():
154 if line.startswith(
"name")
and "=" in line:
155 val = line.split(
"=", 1)[1].strip().strip(
'"').strip(
"'")
158 elif line.startswith(
"version")
and "=" in line:
159 val = line.split(
"=", 1)[1].strip().strip(
'"').strip(
"'")
161 info[
"version"] = val
171 """Load docs configuration from .octrc.json with defaults (Appendix H)."""
173 "sphinx_theme":
"sphinx_book_theme",
174 "sphinx_output":
"docs/html",
175 "include_dependency_graphs":
True,
176 "include_mermaid_diagrams":
True,
177 "autodoc_modules": [],
180 octrc = root /
".octrc.json"
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()
188 except (json.JSONDecodeError, OSError):
197 """Discover markdown files in the docs directory, recursing into
198 subdirectories that are not in :data:`_DOCS_EXCLUDE_DIRS`.
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.
207 found: list[str] = []
208 for md_file
in docs_dir.rglob(
"*.md"):
209 if not md_file.is_file():
211 rel = md_file.relative_to(docs_dir)
213 if any(part
in _DOCS_EXCLUDE_DIRS
for part
in rel.parts[:-1]):
215 ident = rel.with_suffix(
"").as_posix()
221 """Order markdown identifiers with priority docs first, then alphabetical.
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.
231 def _basename(ident: str) -> str:
232 return ident.rsplit(
"/", 1)[-1]
234 for entry
in _PRIORITY_DOCS:
235 if entry.endswith(
"_")
or entry.endswith(
"-"):
237 (s
for s
in stems
if _basename(s).startswith(entry)
and s
not in used),
241 ordered.append(match)
245 if _basename(s) == entry
and s
not in used:
249 remaining = sorted(s
for s
in stems
if s
not in used)
250 return ordered + remaining
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(
"."):
259 for wc
in WILDCARD_EXCLUDE_DIRS:
260 if part.startswith(wc.rstrip(
"*")):
263 if rel_parts
and rel_parts[0]
in {
"docs",
"old",
"tests",
264 "scaffold_test_project"}:
272_MAX_PACKAGE_DEPTH: int = 10
276 """Discover Python packages to document.
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`.
284 Bounded by :data:`_MAX_PACKAGE_DEPTH` to prevent pathological walks
285 on misconfigured projects (OI-426).
287 Returns list of ``(package_name, package_path)`` tuples.
289 autodoc_modules = config.get(
"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))
299 packages: list[tuple[str, Path]] = []
301 def _walk(directory: Path, depth: int) ->
None:
302 if depth > _MAX_PACKAGE_DEPTH:
305 children = sorted(directory.iterdir())
306 except (OSError, PermissionError):
308 for child
in children:
311 if (child /
"__init__.py").exists():
312 packages.append((child.name, child))
315 _walk(child, depth + 1)
322 """Generate per-module API documentation using sphinx-apidoc.
324 Uses sphinx-apidoc to create comprehensive .rst files for every
325 module, class, and function — similar to Doxygen's EXTRACT_ALL output.
327 Returns True if at least one package was documented.
333 api_dir = docs_dir /
"_api"
334 api_dir.mkdir(exist_ok=
True)
337 from sphinx.ext.apidoc
import main
as apidoc_main
339 print(
"Warning: sphinx.ext.apidoc not available", file=sys.stderr)
342 for pkg_name, pkg_path
in packages:
351 print(f
" sphinx-apidoc: {pkg_name} ({pkg_path})")
354 rst_files = list(api_dir.glob(
"*.rst"))
361 pkg_stems = sorted(name
for name, _
in packages)
363 f
"{_AUTO_MARKER_RST}. Edits will be overwritten on next build.",
372 for stem
in pkg_stems:
373 lines.append(f
" {stem}")
375 modules_path = api_dir /
"modules.rst"
377 modules_path.write_text(
"\n".join(lines), encoding=
"utf-8")
380 f
"OI-518: preserving user-authored {modules_path.relative_to(docs_dir)}",
388 """Generate dependencies.rst with a Mermaid dependency graph.
390 Returns True if the graph has edges.
397 graph = build_dependency_graph(root)
400 has_edges = any(deps
for deps
in graph.values())
404 mermaid_src = to_mermaid(graph)
407 f
"{_AUTO_MARKER_RST} via oct deps.",
409 "Module Dependencies",
410 "====================",
412 "The following diagram shows the inter-module dependency graph,",
413 "derived from the ``Dependencies`` docstring section of each module.",
419 for mermaid_line
in mermaid_src.splitlines():
420 lines.append(f
" {mermaid_line}")
424 deps_path = docs_dir /
"dependencies.rst"
426 deps_path.write_text(
"\n".join(lines), encoding=
"utf-8")
430 "OI-518: preserving user-authored docs/dependencies.rst",
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)
446 f
"{_AUTO_MARKER_RST}. Edits will be overwritten on next build.",
459 " :caption: Contents:",
462 for stem
in ordered_stems:
463 lines.append(f
" {stem}")
471 " :caption: API Reference:",
482 " :caption: Analysis:",
488 index_path = docs_dir /
"index.rst"
490 index_path.write_text(
"\n".join(lines), encoding=
"utf-8")
493 "OI-518: preserving user-authored docs/index.rst",
501 """Set up Sphinx configuration in the project's docs/ directory.
503 Uses docs/ as the Sphinx source directory directly — markdown files
504 already present in docs/ are included without copying.
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.
510 Returns the path to the docs directory.
512 docs_dir = root /
"docs"
513 sphinx_dir = docs_dir /
"_sphinx"
514 static_dir = docs_dir /
"_static"
516 if not _TEMPLATES_DIR.exists():
517 print(f
"Error: Sphinx templates not found at {_TEMPLATES_DIR}",
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)
528 year = datetime.datetime.now().year
531 conf_dest = sphinx_dir /
"conf.py"
532 if not conf_dest.exists():
533 template = (_TEMPLATES_DIR /
"conf.py.template").read_text(
537 .replace(
"{{PROJECT_NAME}}", info[
"name"])
538 .replace(
"{{YEAR}}", str(year))
539 .replace(
"{{AUTHOR}}", info[
"author"])
540 .replace(
"{{VERSION}}", info[
"version"])
542 conf_dest.write_text(conf_content, encoding=
"utf-8")
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)
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)
564 """Run sphinx-build to generate HTML output.
566 Returns exit code: 0 on success, 1 on failure.
568 docs_abs = docs_dir.resolve()
569 sphinx_dir = docs_abs /
"_sphinx"
572 for clean_dir
in [docs_abs /
"html", docs_abs /
"doctrees"]:
573 if clean_dir.exists():
574 shutil.rmtree(clean_dir)
578 sys.executable,
"-m",
"sphinx",
582 "-c", str(sphinx_dir),
585 print(f
"Running: sphinx-build -M html {docs_abs} {docs_abs} "
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)
601 """Full oct docs --sphinx pipeline.
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
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)}",
631 root_readme = root /
"README.md"
632 readme_dest = docs_dir /
"README.md"
633 if root_readme.exists():
635 content = root_readme.read_text(encoding=
"utf-8")
636 readme_dest.write_text(
637 f
"{_AUTO_MARKER_MD}\n\n{content}",
642 "OI-518: preserving user-authored docs/README.md "
643 "(root README.md not copied)",
653 print(f
"Discovered {len(md_stems)} markdown file(s) in docs/")
658 print(
"Generated API reference (api.rst)")
662 if config.get(
"include_dependency_graphs",
True):
665 print(
"Generated dependency graph (dependencies.rst)")
669 print(
"Updated index.rst with discovered content")
678 """Check if doxygen is on PATH via shutil.which()."""
679 return shutil.which(
"doxygen")
is not None
683 """Locate docs/doxygen_work_dir/Doxyfile if it exists."""
684 doxyfile = root /
"docs" /
"doxygen_work_dir" /
"Doxyfile"
685 if doxyfile.is_file():
691 """Create a temporary copy of the Doxyfile with INPUT patched to actual root.
693 The existing Doxyfiles have hardcoded stale paths. This creates a temp
694 copy with the correct INPUT path. Does NOT modify the original.
696 Returns the path to the temp Doxyfile.
698 content = doxyfile_path.read_text(encoding=
"utf-8", errors=
"replace")
701 root_forward = str(root.resolve()).replace(
"\\",
"/")
703 r'^(INPUT\s*=\s*).*$',
704 rf
'\g<1>"{root_forward}"',
711 tmp = tempfile.NamedTemporaryFile(
713 prefix=
"oct-doxyfile-",
720 return Path(tmp.name)
726_STALE_DOXYFILE_MAX_AGE_SECONDS: int = 24 * 60 * 60
730 max_age_seconds: int = _STALE_DOXYFILE_MAX_AGE_SECONDS,
732 """Remove orphaned ``oct-doxyfile-*.Doxyfile`` temp files.
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).
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
743 Returns the number of files removed.
746 tmp_dir = Path(tempfile.gettempdir())
749 for stale
in tmp_dir.glob(
"oct-doxyfile-*.Doxyfile"):
751 if now - stale.stat().st_mtime > max_age_seconds:
762 """Full oct docs --doxygen pipeline.
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/)
771 Returns exit code: 0 on success, 1 on failure.
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",
791 "Error: No Doxyfile found at docs/doxygen_work_dir/Doxyfile.\n"
792 "Run 'oct scaffold' to generate one, or create it manually.",
797 print(f
"Using Doxyfile: {doxyfile}")
801 print(f
"Running doxygen (INPUT patched to {root.resolve()})...")
802 result = subprocess.run(
803 [
"doxygen", str(patched)],
804 cwd=str(doxyfile.parent),
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}")
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)
827_DOCS_CLEAN_DIRS = [
"html",
"doctrees",
"_api",
"doxygen_output"]
830_DOCS_CLEAN_FILES = [
"index.rst",
"dependencies.rst"]
834 """Remove generated documentation artifacts from docs/.
836 Cleans: html/, doctrees/, _api/, doxygen_output/,
837 index.rst, dependencies.rst
839 Preserves: _sphinx/ (user conf.py), _static/, _templates/,
840 user-authored .md files, doxygen_work_dir/Doxyfile
844 docs_dir = root /
"docs"
845 if not docs_dir.is_dir():
846 print(
"Nothing to clean: docs/ directory does not exist.")
850 action =
"Would remove" if dry_run
else "Removed"
852 for dirname
in _DOCS_CLEAN_DIRS:
853 target = docs_dir / dirname
856 shutil.rmtree(target)
857 print(f
" {action}: {target.relative_to(root)}/")
860 for filename
in _DOCS_CLEAN_FILES:
861 target = docs_dir / filename
865 print(f
" {action}: {target.relative_to(root)}")
868 if removed_count == 0:
869 print(
"Nothing to clean: no generated artifacts found in docs/.")
871 prefix =
"[DRY RUN] " if dry_run
else ""
872 print(f
"\n{prefix}Cleaned {removed_count} artifact(s) from docs/.")
880 """Run both Sphinx and Doxygen pipelines.
882 Returns 0 if both succeed, 1 if either fails.
885 print(
"Running Sphinx pipeline...")
891 print(
"Running Doxygen pipeline...")
895 if sphinx_rc == 0
and doxygen_rc == 0:
896 print(
"\nBoth pipelines completed successfully.")
900 failures.append(
"Sphinx")
902 failures.append(
"Doxygen")
903 print(f
"\nFailed: {', '.join(failures)}")
905 return 1
if (sphinx_rc
or doxygen_rc)
else 0
bool _should_skip_dir(Path path, Path root)
bool _generate_deps_rst(Path root, Path docs_dir)
int run_docs_clean(Path root, *, bool dry_run=False)
int run_docs_sphinx(Path root)
int run_docs_doxygen(Path root)
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)
int run_docs_all(Path root)
list[str] _check_sphinx_deps()
list[tuple[str, Path]] _find_packages(Path root, dict config)
Path|None _find_doxyfile(Path root)
None _write_index_rst(Path docs_dir, str project_name, list[str] md_stems, bool has_api, bool has_deps)
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)
bool _is_auto_generated(Path path, str marker)
int run_sphinx_build(Path docs_dir, dict config)