8Workspace-mode orchestrator for ``oct docs``. When the docs command is
9invoked from a workspace root (a directory containing
10``.option_c_workspace.json``), this module fans out the build across
11each subproject, builds the workspace-tier "Option C" Sphinx tree from
12the spec markdown, and aggregates everything under
13``<workspace>/_site/`` with a Jinja-rendered front page.
15Single-project invocations (from inside ``oct/`` or ``oc_diagnostics/``,
16or from any non-workspace directory) bypass this module entirely - the
17existing single-project pipelines in :mod:`oct.docs.oct_docs` handle
22- Detect workspace mode at the dispatcher boundary and refuse to run
23 outside a real workspace root.
24- Walk ``workspace.subprojects`` from the manifest and invoke the
25 single-project Sphinx / Doxygen pipelines for each, suppressing
26 recursion via ``no_workspace=True``. A subproject whose manifest entry
27 sets ``html_docs: false`` is skipped and its aggregated output
28 withdrawn; ``--clean`` still sweeps it, because opting out of building
29 is not opting out of tidying up.
30- Keep a subproject's Doxygen off the site when its manifest entry sets
31 ``html_docs: {"doxygen": false}`` (2026-09-28) or it has no Doxyfile:
32 the fan-out neither builds nor aggregates it, and any
33 ``_site/<sp>/doxygen/`` an earlier run left is withdrawn in every build
34 mode. A direct ``oct docs --doxygen`` inside the subproject is not
35 routed through here and still builds.
36- Build the Option C tier Sphinx tree from
37 ``<workspace>/docs/option_c_specs/*.md`` plus the workspace
38 ``README.md`` as the toctree root (Phase D).
39- Aggregate every per-subproject ``docs/_build/html/`` into
40 ``_site/<sp>/sphinx/html/``; same for Doxygen output.
41- Render the front page via :mod:`oct.docs.front_page` (Phase B).
42- Implement ``--clean`` semantics that strip ``_site/`` and every
43 subproject's ``docs/_build/`` (Phase F).
49 L2 - orchestrator entry / exit, mode dispatch
50 L3 - per-subproject build dispatch, aggregation steps
51 L4 - per-file copy decisions
55- ``run_workspace_docs`` is the single entry point. It must never be
56 called from inside a subproject root - the dispatcher in
57 :mod:`oct.docs.oct_docs` is responsible for that gate.
58- The function returns a non-zero exit code on any sub-build failure;
59 failures in one subproject do not abort the others, so the user sees
60 every problem in one run.
61- Aggregation uses :func:`shutil.copytree` not symlinks (Windows-safe).
65- oct.core.workspace (manifest loader, find_workspace_root)
66- oct.core.diagnostics (_dbg structured logger)
67- oct.tools.fs_remove (remove_tree_or_warn - clears aggregated and generated output, warning on a tree that will not go)
68- oct.docs.front_page (lazy; render_front_page, FrontPageError - the hub front page)
69- oct.docs.oct_docs (lazy; run_docs_sphinx, run_docs_doxygen, _find_doxyfile - the single-project pipelines)
70- oct.docs.option_c_tier (lazy; build_option_c_tier - the workspace-tier Option C Sphinx tree)
71- oct.docs.typedoc (lazy; has_typedoc, run_docs_typedoc, typedoc_output_dir - the TypeDoc tier)
74from __future__
import annotations
77from pathlib
import Path
78from typing
import Literal
80from oct.core.diagnostics
import _dbg
81from oct.tools.fs_remove
import remove_tree_or_warn
82from oct.core.workspace
import (
91WorkspaceDocsMode = Literal[
"sphinx",
"doxygen",
"typedoc",
"all",
"clean"]
95SITE_DIRNAME: str =
"_site"
99_SPHINX_BUILD_REL: Path = Path(
"docs") /
"html"
102_DOXYGEN_BUILD_REL: Path = Path(
"docs") /
"doxygen_output" /
"html"
106 """Return True iff *path* is the root of an Option C workspace.
108 A workspace root holds a ``.option_c_workspace.json`` manifest
109 *directly* (not inherited from a parent). This is the gate the
110 dispatcher uses to decide whether to route to
111 :func:`run_workspace_docs`.
113 func =
"is_workspace_root"
114 ws = find_workspace_root(path)
115 matches = ws
is not None and ws.resolve() == path.resolve()
116 _dbg(
"OCT-DOCS", func, f
"path={path} match={matches}", 4)
121 """Return why *sp* has no Doxygen on the site, or ``None`` when it has.
123 Two causes share one consequence. The manifest switch
124 ``html_docs: {"doxygen": false}`` keeps a declared Doxygen tier off the
125 site, and a subproject with no Doxyfile has no Doxygen tier to publish.
126 Either way the fan-out builds nothing and aggregates nothing, and any
127 tree an earlier run left under ``_site/<sp>/doxygen/`` is withdrawn, so
128 one answer serves the build step and the aggregation step alike. The
129 reason is worded to follow ``has`` in the printed line.
131 if not sp.html_docs_doxygen:
132 return "html_docs.doxygen=false"
134 if _find_doxyfile(sp.path)
is None:
142 mode: WorkspaceDocsMode,
144 """Run the single-project pipeline for *sp* in ``no_workspace`` mode.
146 Returns the per-build exit code so the caller can aggregate. A subproject
147 with no ``docs/doxygen_work_dir/Doxyfile``, or whose manifest entry sets
148 ``html_docs: {"doxygen": false}``, has its Doxygen step SKIPPED rather
149 than failed - see the comment at that branch.
151 func =
"_build_subproject"
152 _dbg(
"OCT-DOCS", func, f
"sp={sp.name} mode={mode}", 3)
154 if mode
in (
"sphinx",
"all"):
156 sphinx_rc = run_docs_sphinx(sp.path, no_workspace=
True)
158 print(f
"[workspace docs] subproject '{sp.name}' Sphinx failed (rc={sphinx_rc})")
160 if mode
in (
"doxygen",
"all"):
181 if withheld
is not None:
182 print(f
"[workspace docs] subproject '{sp.name}' has {withheld} "
183 f
"- skipping Doxygen")
184 _dbg(
"OCT-DOCS", func, f
"sp={sp.name} doxygen skipped ({withheld})", 3)
186 dox_rc = run_docs_doxygen(sp.path, no_workspace=
True)
188 print(f
"[workspace docs] subproject '{sp.name}' Doxygen failed (rc={dox_rc})")
190 if mode
in (
"typedoc",
"all"):
198 if not has_typedoc(sp.path):
199 _dbg(
"OCT-DOCS", func, f
"sp={sp.name} typedoc skipped (no typedoc.json)", 4)
200 elif find_npx()
is None:
201 print(f
"[workspace docs] subproject '{sp.name}' declares TypeDoc "
202 f
"but npx is not on PATH - skipping TypeDoc")
203 _dbg(
"OCT-DOCS", func, f
"sp={sp.name} typedoc skipped (no npx)", 3)
205 td_rc = run_docs_typedoc(sp.path, no_workspace=
True)
207 print(f
"[workspace docs] subproject '{sp.name}' TypeDoc failed (rc={td_rc})")
209 _dbg(
"OCT-DOCS", func, f
"sp={sp.name} rc={rc}", 3)
214 """Replace *dst* with a fresh copy of *src*.
216 No-op when *src* does not exist (allows partial builds - e.g. a
217 subproject that has only Sphinx, not Doxygen).
219 func =
"_copytree_replace"
221 _dbg(
"OCT-DOCS", func, f
"skip {src} -> {dst} (src missing)", 4)
231 cleared = remove_tree_or_warn(dst, label=
"stale aggregated output")
233 print(f
" note: {dst} could not be cleared first; copying into it. "
234 f
"Files this build no longer produces may survive there.")
235 dst.parent.mkdir(parents=
True, exist_ok=
True)
236 shutil.copytree(src, dst, dirs_exist_ok=
not cleared)
237 _dbg(
"OCT-DOCS", func, f
"copied {src} -> {dst} (cleared={cleared})", 4)
242 workspace: Workspace,
244 mode: WorkspaceDocsMode,
246 """Copy each subproject's HTML into ``<ws_root>/_site/<sp>/<tool>/html/``,
247 plus the workspace-tier Option C Sphinx output into ``_site/option_c/``.
249 A subproject with ``html_docs=false`` loses its whole ``_site/<sp>/``
250 tree; one whose Doxygen is withheld (see :func:`_doxygen_withheld`)
251 loses only ``_site/<sp>/doxygen/``, plus ``_site/<sp>/`` itself when
252 that withdrawal leaves it empty. Both withdrawals run in every mode.
254 Returns the resolved site root.
257 site_root = ws_root / SITE_DIRNAME
258 site_root.mkdir(parents=
True, exist_ok=
True)
259 _dbg(
"OCT-DOCS", func, f
"site_root={site_root} mode={mode}", 3)
261 for sp
in workspace.subprojects:
267 stale = site_root / sp.name
269 print(f
"[workspace docs] subproject '{sp.name}' has "
270 f
"html_docs=false - removing its aggregated output")
271 remove_tree_or_warn(stale, label=
"withdrawn aggregated output")
272 _dbg(
"OCT-DOCS", func, f
"sp={sp.name} aggregation skipped (html_docs=false)", 3)
274 if mode
in (
"sphinx",
"all"):
276 sp.path / _SPHINX_BUILD_REL,
277 site_root / sp.name /
"sphinx" /
"html",
280 if withheld
is not None:
287 stale_dox = site_root / sp.name /
"doxygen"
288 if stale_dox.exists():
289 print(f
"[workspace docs] subproject '{sp.name}' has "
290 f
"{withheld} - removing its aggregated Doxygen output")
291 sp_site = stale_dox.parent
292 withdrawn = remove_tree_or_warn(
293 stale_dox, label=
"withdrawn aggregated Doxygen output",
295 if withdrawn
and not any(sp_site.iterdir()):
300 emptied = remove_tree_or_warn(
301 sp_site, label=
"empty aggregated subproject directory",
303 _dbg(
"OCT-DOCS", func,
304 f
"sp={sp.name} empty {sp_site} removed={emptied}", 3)
305 _dbg(
"OCT-DOCS", func, f
"sp={sp.name} doxygen aggregation skipped ({withheld})", 3)
306 elif mode
in (
"doxygen",
"all"):
308 sp.path / _DOXYGEN_BUILD_REL,
309 site_root / sp.name /
"doxygen" /
"html",
311 if mode
in (
"typedoc",
"all"):
313 if has_typedoc(sp.path):
315 typedoc_output_dir(sp.path),
316 site_root / sp.name /
"typedoc" /
"html",
320 if mode
in (
"sphinx",
"all"):
322 ws_root /
"docs" /
"_build" /
"option_c" /
"html",
323 site_root /
"option_c" /
"sphinx" /
"html",
330 workspace: Workspace,
334 """Remove ``_site/`` and every subproject's ``docs/_build/`` + ``docs/html/``.
336 Returns 0 when every target is gone (or under *dry_run*), 1 when one or
337 more survived. Still best-effort in that one unremovable target never stops
338 the sweep - but a survivor is reported rather than counted as removed.
340 func =
"_clean_workspace"
341 action =
"Would remove" if dry_run
else "Removed"
342 targets: list[Path] = [ws_root / SITE_DIRNAME]
343 for sp
in workspace.subprojects:
345 sp.path /
"docs" /
"html",
346 sp.path /
"docs" /
"_build",
347 sp.path /
"docs" /
"doxygen_output",
348 sp.path /
"docs" /
"typedoc",
351 targets.append(ws_root /
"docs" /
"_build")
354 survived: list[Path] = []
355 for target
in targets:
356 if not target.exists():
362 if not remove_tree_or_warn(target, label=
"generated docs output"):
363 survived.append(target)
368 except OSError
as exc:
369 print(f
" could not remove {target}: {exc.strerror or exc}")
370 survived.append(target)
372 _dbg(
"OCT-DOCS", func, f
"{action}: {target}", 3)
373 print(f
" {action}: {target}")
376 if removed == 0
and not survived:
377 print(
"Nothing to clean.")
379 print(f
" {len(survived)} target(s) could not be removed (warned above).")
387 mode: WorkspaceDocsMode,
388 dry_run: bool =
False,
390 """Workspace-mode entry point.
395 Resolved path to the workspace root. The caller (dispatcher)
396 guarantees this is a real workspace root.
398 Which subset of the pipeline to run. ``"sphinx"`` / ``"doxygen"``
399 / ``"all"`` build subprojects + Option C tier + aggregate.
400 ``"clean"`` removes ``_site/`` and every subproject's docs build
403 Forwarded to ``--clean`` only. ``True`` reports planned removals
404 without touching the filesystem.
409 ``0`` on success; non-zero when any sub-build or cleanup step
410 fails. Failures in one subproject do not abort the others - every
411 problem is reported in a single run.
413 func =
"run_workspace_docs"
414 _dbg(
"OCT-DOCS", func, f
"start ws_root={ws_root} mode={mode} dry_run={dry_run}", 2)
417 workspace = load_workspace(ws_root)
418 except WorkspaceError
as exc:
419 print(f
"Error: failed to load workspace manifest: {exc}")
420 _dbg(
"OCT-DOCS", func, f
"manifest load failed: {exc}", 1)
428 for sp
in workspace.subprojects:
430 print(f
"[workspace docs] subproject '{sp.name}' "
431 f
"html_docs=false - skipping")
432 _dbg(
"OCT-DOCS", func, f
"sp={sp.name} skipped (html_docs=false)", 3)
436 overall_rc = overall_rc
or sp_rc
440 if mode
in (
"sphinx",
"all"):
443 tier_rc = build_option_c_tier(ws_root)
445 overall_rc = overall_rc
or tier_rc
447 _dbg(
"OCT-DOCS", func,
"option_c_tier module not present yet (Phase D pending)", 3)
450 site_root =
_aggregate(ws_root, workspace, mode=mode)
455 if mode
in (
"sphinx",
"doxygen",
"typedoc",
"all"):
458 render_front_page(ws_root, site_root, workspace)
459 except FrontPageError
as exc:
460 print(f
"Warning: front page not rendered: {exc}")
461 _dbg(
"OCT-DOCS", func, f
"front page failed: {exc}", 1)
462 overall_rc = overall_rc
or 1
464 print(f
"\nWorkspace docs build {'failed' if overall_rc else 'complete'} -> {site_root}")
465 _dbg(
"OCT-DOCS", func, f
"end rc={overall_rc}", 2)
473 "run_workspace_docs",
Path _aggregate(Path ws_root, Workspace workspace, *, WorkspaceDocsMode mode)
int _clean_workspace(Path ws_root, Workspace workspace, *, bool dry_run)
int _build_subproject(Subproject sp, *, WorkspaceDocsMode mode)
str|None _doxygen_withheld(Subproject sp)
int run_workspace_docs(Path ws_root, *, WorkspaceDocsMode mode, bool dry_run=False)
bool is_workspace_root(Path path)
None _copytree_replace(Path src, Path dst)