oct
Loading...
Searching...
No Matches
option_c_tier.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oct/docs/option_c_tier.py
4
5"""
6Purpose
7-------
8Build the workspace-tier "Option C" Sphinx tree from the workspace's
9``README.md`` (as the toctree root) and ``docs/option_c_specs/*.md``
10(spec / philosophy files rendered via myst-parser). This is the third
11tier in the workspace docs hub - alongside per-subproject Sphinx and
12Doxygen builds - and corresponds to the front-page card linking to
13``option_c/sphinx/html/``.
14
15The build runs against a generated source directory under
16``<workspace>/docs/_build/option_c_src/`` so the workspace's own
17``docs/`` directory is never modified. Output lands under
18``<workspace>/docs/_build/option_c/html/`` and is later copied into
19``_site/option_c/sphinx/html/`` by
20:func:`oct.docs.workspace_docs._aggregate`.
21
22Responsibilities
23----------------
24- Generate a minimal Sphinx source tree on disk that pulls in the
25 workspace ``README.md`` plus the V3.4 spec markdown files.
26- Configure Sphinx with ``myst-parser`` and the project's preferred
27 theme (matching subproject conf for visual consistency).
28- Run ``sphinx-build`` and report the result.
29- Be a no-op when the workspace has no ``docs/option_c_specs/``
30 directory (so workspaces that don't ship a spec aren't forced to).
31
32Diagnostics
33-----------
34Domain: OCT-DOCS
35Levels:
36 L2 - build entry / exit
37 L3 - source generation steps, Sphinx invocation
38 L4 - per-file decisions
39
40Contracts
41---------
42- Generated source tree at ``docs/_build/option_c_src/`` is treated as
43 ephemeral - fully regenerated on every build, never committed.
44- Output HTML goes under ``docs/_build/option_c/html/``; the orchestrator
45 is responsible for copying that into ``_site/option_c/sphinx/html/``.
46- Sphinx invocation is a subprocess so plugin failures don't take down
47 the parent ``oct docs`` process.
48
49Dependencies
50------------
51- oct.core.diagnostics (_dbg structured logger)
52- oct.tools.fs_remove (remove_tree_or_warn - clears the scratch and output trees, warning when one will not go)
53"""
54
55from __future__ import annotations
56
57import shutil
58import subprocess
59import sys
60from pathlib import Path
61
62from oct.core.diagnostics import _dbg
63from oct.tools.fs_remove import remove_tree_or_warn
64
65
66_SPEC_DIR_REL: Path = Path("docs") / "option_c_specs"
67_SCRATCH_REL: Path = Path("docs") / "_build" / "option_c_src"
68_OUTPUT_REL: Path = Path("docs") / "_build" / "option_c"
69
70
71# OCT-LINT: disable=no_hardcoded_secrets reason="Sphinx conf.py template - project/author/copyright/theme settings and an extension list, no credential material (operator-ruled 2026-09-25)"
72_CONF_PY_TEMPLATE: str = '''\
73# Auto-generated by oct.docs.option_c_tier - do not edit.
74project = "Option C"
75author = "Option C Development Team"
76copyright = "2026, Option C Development Team"
77extensions = ["myst_parser", "sphinxcontrib.mermaid"]
78# Both halves are needed, as in oct/templates/sphinx/conf.py.template: the
79# extension supplies the directive, and this line is what turns a markdown
80# ```mermaid fence INTO that directive. Without it the specs' nine diagrams
81# rendered as plain code blocks in this tier while the subproject tier drew
82# them (found 2026-09-27).
83myst_fence_as_directive = {"mermaid"}
84source_suffix = {".md": "markdown", ".rst": "restructuredtext"}
85master_doc = "index"
86exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"]
87html_theme = "sphinx_book_theme"
88# Off explicitly: the theme's add_source_buttons handler fails the build (rc=2)
89# when the button is on without a repository_url, and this tier has no
90# repository to point at. See oct/templates/sphinx/conf.py.template.
91html_theme_options = {"use_repository_button": False}
92html_title = "Option C"
93myst_enable_extensions = ["colon_fence", "deflist", "linkify", "tasklist"]
94myst_heading_anchors = 3
95suppress_warnings = ["myst.header"]
96'''
97
98
99def _ordered_spec_files(specs_dir: Path) -> list[Path]:
100 """Return spec files in their numeric prefix order (00..05).
101
102 Falls back to alphabetical if files don't follow the expected
103 ``NN_*.md`` pattern.
104 """
105 files = sorted(specs_dir.glob("*.md"))
106 return files
107
108
109def _write_index_md(scratch: Path, readme_text: str | None, spec_stems: list[str]) -> None:
110 """Write the synthetic ``index.md`` at the root of the scratch tree.
111
112 The README content is included verbatim at the top (so the tier's
113 home page introduces Option C in the user's own words). Below it,
114 a Sphinx ``{toctree}`` directive lists the spec files.
115 """
116 parts: list[str] = []
117 if readme_text is not None:
118 parts.append(readme_text.rstrip())
119 parts.append("")
120 parts.append("```{toctree}")
121 parts.append(":maxdepth: 2")
122 parts.append(":caption: Specification")
123 parts.append("")
124 for stem in spec_stems:
125 parts.append(f"option_c_specs/{stem}")
126 parts.append("```")
127 parts.append("")
128
129 (scratch / "index.md").write_text("\n".join(parts), encoding="utf-8")
130
131
132def _populate_scratch(ws_root: Path, scratch: Path) -> tuple[bool, list[str]]:
133 """Copy spec files + write conf.py and index.md into *scratch*.
134
135 Returns ``(has_specs, spec_stems)``. ``has_specs`` is False when no
136 spec files were found (caller should skip the build).
137 """
138 func = "_populate_scratch"
139 specs_src = ws_root / _SPEC_DIR_REL
140 if not specs_src.is_dir():
141 _dbg("OCT-DOCS", func, f"no specs dir at {specs_src}; skipping tier build", 3)
142 return False, []
143
144 # A scratch dir that will not clear must not crash the following mkdir
145 # (ENV-03 read-only attribute). Build over it instead: conf.py, index.md and
146 # the spec copies are all rewritten, so what survives is at worst a stray
147 # file Sphinx warns about being outside the toctree.
148 if scratch.exists() and not remove_tree_or_warn(
149 scratch, label="workspace-tier scratch"):
150 print("[option_c tier] building over the scratch dir that would not "
151 "clear; stale files there may add Sphinx warnings")
152 scratch.mkdir(parents=True, exist_ok=True)
153
154 specs_dest = scratch / "option_c_specs"
155 specs_dest.mkdir(exist_ok=True)
156 spec_stems: list[str] = []
157 for src in _ordered_spec_files(specs_src):
158 dest = specs_dest / src.name
159 shutil.copy2(src, dest)
160 spec_stems.append(src.stem)
161 _dbg("OCT-DOCS", func, f"copied spec {src.name}", 4)
162
163 if not spec_stems:
164 _dbg("OCT-DOCS", func, "specs dir is empty; skipping tier build", 3)
165 return False, []
166
167 readme = ws_root / "README.md"
168 readme_text = readme.read_text(encoding="utf-8") if readme.is_file() else None
169 if readme_text is None:
170 _dbg("OCT-DOCS", func, "no workspace README.md; tier index will be toctree-only", 3)
171 _write_index_md(scratch, readme_text, spec_stems)
172 (scratch / "conf.py").write_text(_CONF_PY_TEMPLATE, encoding="utf-8")
173 return True, spec_stems
174
175
176def build_option_c_tier(ws_root: Path) -> int:
177 """Build the workspace-tier Sphinx HTML.
178
179 Returns
180 -------
181 int
182 ``0`` on success or when the tier is skipped (no specs); non-zero
183 on Sphinx failure.
184 """
185 func = "build_option_c_tier"
186 _dbg("OCT-DOCS", func, f"start ws_root={ws_root}", 2)
187
188 scratch = ws_root / _SCRATCH_REL
189 output = ws_root / _OUTPUT_REL
190
191 has_specs, _ = _populate_scratch(ws_root, scratch)
192 if not has_specs:
193 print("[option_c tier] no docs/option_c_specs/ - skipping workspace-tier build")
194 _dbg("OCT-DOCS", func, "no specs; skip", 2)
195 return 0
196
197 if output.exists() and not remove_tree_or_warn(
198 output, label="workspace-tier HTML output"):
199 print("[option_c tier] building over the previous output; pages Sphinx "
200 "no longer generates may survive there")
201 output.mkdir(parents=True, exist_ok=True)
202
203 cmd = [
204 sys.executable, "-m", "sphinx",
205 "-M", "html",
206 str(scratch.resolve()), # source dir
207 str(output.resolve()), # build dir (puts html/ under it)
208 "-c", str(scratch.resolve()), # conf.py location
209 ]
210 print(f"[option_c tier] running: {' '.join(cmd[3:])}")
211 _dbg("OCT-DOCS", func, f"sphinx-build cmd={cmd}", 3)
212 try:
213 result = subprocess.run(cmd, cwd=str(scratch))
214 except FileNotFoundError:
215 print("Error: sphinx-build not found; install with: pip install -e .[docs]")
216 _dbg("OCT-DOCS", func, "sphinx-build missing", 1)
217 return 1
218
219 if result.returncode == 0:
220 html_index = output / "html" / "index.html"
221 print(f"[option_c tier] build complete -> {html_index}")
222 else:
223 print(f"[option_c tier] sphinx-build failed (rc={result.returncode})")
224
225 _dbg("OCT-DOCS", func, f"end rc={result.returncode}", 2)
226 return result.returncode
227
228
229__all__ = [
230 "build_option_c_tier",
231]
tuple[bool, list[str]] _populate_scratch(Path ws_root, Path scratch)
list[Path] _ordered_spec_files(Path specs_dir)
int build_option_c_tier(Path ws_root)
None _write_index_md(Path scratch, str|None readme_text, list[str] spec_stems)