oct
Loading...
Searching...
No Matches
workspace_docs.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oct/docs/workspace_docs.py
4
5"""
6Purpose
7-------
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.
14
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
18those unchanged.
19
20Responsibilities
21----------------
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).
44
45Diagnostics
46-----------
47Domain: OCT-DOCS
48Levels:
49 L2 - orchestrator entry / exit, mode dispatch
50 L3 - per-subproject build dispatch, aggregation steps
51 L4 - per-file copy decisions
52
53Contracts
54---------
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).
62
63Dependencies
64------------
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)
72"""
73
74from __future__ import annotations
75
76import shutil
77from pathlib import Path
78from typing import Literal
79
80from oct.core.diagnostics import _dbg
81from oct.tools.fs_remove import remove_tree_or_warn
82from oct.core.workspace import (
83 Subproject,
84 Workspace,
85 WorkspaceError,
86 find_workspace_root,
87 load_workspace,
88)
89
90
91WorkspaceDocsMode = Literal["sphinx", "doxygen", "typedoc", "all", "clean"]
92
93
94#: Aggregation directory relative to the workspace root.
95SITE_DIRNAME: str = "_site"
96
97#: Where each subproject's Sphinx HTML lives after a single-project build
98#: (matches :func:`oct.docs.oct_docs.run_sphinx_build` output convention).
99_SPHINX_BUILD_REL: Path = Path("docs") / "html"
100
101#: Where each subproject's Doxygen HTML lives.
102_DOXYGEN_BUILD_REL: Path = Path("docs") / "doxygen_output" / "html"
103
104
105def is_workspace_root(path: Path) -> bool:
106 """Return True iff *path* is the root of an Option C workspace.
107
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`.
112 """
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)
117 return matches
118
119
120def _doxygen_withheld(sp: Subproject) -> str | None:
121 """Return why *sp* has no Doxygen on the site, or ``None`` when it has.
122
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.
130 """
131 if not sp.html_docs_doxygen:
132 return "html_docs.doxygen=false"
133 from oct.docs.oct_docs import _find_doxyfile
134 if _find_doxyfile(sp.path) is None:
135 return "no Doxyfile"
136 return None
137
138
140 sp: Subproject,
141 *,
142 mode: WorkspaceDocsMode,
143) -> int:
144 """Run the single-project pipeline for *sp* in ``no_workspace`` mode.
145
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.
150 """
151 func = "_build_subproject"
152 _dbg("OCT-DOCS", func, f"sp={sp.name} mode={mode}", 3)
153 rc = 0
154 if mode in ("sphinx", "all"):
155 from oct.docs.oct_docs import run_docs_sphinx
156 sphinx_rc = run_docs_sphinx(sp.path, no_workspace=True)
157 if sphinx_rc != 0:
158 print(f"[workspace docs] subproject '{sp.name}' Sphinx failed (rc={sphinx_rc})")
159 rc = rc or sphinx_rc
160 if mode in ("doxygen", "all"):
161 from oct.docs.oct_docs import run_docs_doxygen
162 # A subproject with no Doxyfile is not a Doxygen subproject, and saying
163 # "failed" about a build nobody asked for is a false alarm that made the
164 # whole workspace run non-zero (eight of twelve subprojects here). The
165 # Doxyfile's presence IS the declaration, so a new subproject can be
166 # registered without a C/C++ doc toolchain and without breaking the docs
167 # system. Announced rather than silent: a Doxyfile that ought to be there
168 # and is not must still be visible in the log.
169 #
170 # This is deliberately the WORKSPACE fan-out only. A direct
171 # ``oct docs --doxygen`` in a single project still fails loudly, because
172 # there the operator asked for a Doxygen build by name and an empty
173 # success would be a lie.
174 #
175 # The manifest switch html_docs.doxygen=false skips the step the same
176 # way for a subproject that does have a Doxyfile: its output is not
177 # wanted on the site, so building it here would only cost time. The
178 # direct single-project ask still builds, which is how the output is
179 # inspected while it is being tuned.
180 withheld = _doxygen_withheld(sp)
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)
185 else:
186 dox_rc = run_docs_doxygen(sp.path, no_workspace=True)
187 if dox_rc != 0:
188 print(f"[workspace docs] subproject '{sp.name}' Doxygen failed (rc={dox_rc})")
189 rc = rc or dox_rc
190 if mode in ("typedoc", "all"):
191 from oct.docs.typedoc import find_npx, has_typedoc, run_docs_typedoc
192 # Same declaration model as Doxygen: typedoc.json present means the
193 # subproject wants the tier, absent means it is not a TypeScript
194 # subproject and there is nothing to say. Node missing on the docs
195 # machine is a skip with a notice here rather than a failure, so a
196 # Python-only docs host still completes the workspace build; the
197 # direct --typedoc request in the single-project path fails loudly.
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)
204 else:
205 td_rc = run_docs_typedoc(sp.path, no_workspace=True)
206 if td_rc != 0:
207 print(f"[workspace docs] subproject '{sp.name}' TypeDoc failed (rc={td_rc})")
208 rc = rc or td_rc
209 _dbg("OCT-DOCS", func, f"sp={sp.name} rc={rc}", 3)
210 return rc
211
212
213def _copytree_replace(src: Path, dst: Path) -> None:
214 """Replace *dst* with a fresh copy of *src*.
215
216 No-op when *src* does not exist (allows partial builds - e.g. a
217 subproject that has only Sphinx, not Doxygen).
218 """
219 func = "_copytree_replace"
220 if not src.exists():
221 _dbg("OCT-DOCS", func, f"skip {src} -> {dst} (src missing)", 4)
222 return
223 # A *dst* that will not go would make ``copytree`` raise FileExistsError
224 # onto the aggregate. Copy into what remains instead of abandoning it: a
225 # recursive delete works bottom-up, so a refusal at the final rmdir has
226 # ALREADY taken the files and left the directory shell. The fresh output is
227 # what the caller asked for; what can survive is a file this build no longer
228 # produces, which is why the difference is printed rather than swallowed.
229 cleared = True
230 if dst.exists():
231 cleared = remove_tree_or_warn(dst, label="stale aggregated output")
232 if not cleared:
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)
238
239
241 ws_root: Path,
242 workspace: Workspace,
243 *,
244 mode: WorkspaceDocsMode,
245) -> Path:
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/``.
248
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.
253
254 Returns the resolved site root.
255 """
256 func = "_aggregate"
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)
260
261 for sp in workspace.subprojects:
262 if not sp.html_docs:
263 # Nothing was built, so there is nothing to copy - but an earlier
264 # run (before the manifest opted this subproject out) may have left
265 # a tree behind, and leaving it would serve stale pages at a live
266 # URL. Withdrawing the declaration has to withdraw the output.
267 stale = site_root / sp.name
268 if stale.exists():
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)
273 continue
274 if mode in ("sphinx", "all"):
276 sp.path / _SPHINX_BUILD_REL,
277 site_root / sp.name / "sphinx" / "html",
278 )
279 withheld = _doxygen_withheld(sp)
280 if withheld is not None:
281 # The html_docs=false rule above, one tier down. It runs in every
282 # build mode, not only doxygen and all, because the hub is
283 # re-rendered on every run and drops the Doxygen card at once; a
284 # tree left behind would stay served at a URL nothing links to.
285 # Local docs/doxygen_output/ is left alone: that is the direct
286 # single-project build, which the switch does not govern.
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",
294 )
295 if withdrawn and not any(sp_site.iterdir()):
296 # Doxygen was the only tier ever aggregated here. An empty
297 # _site/<sp>/ would still answer at its URL with a bare
298 # directory listing. A later copy this run (TypeDoc)
299 # recreates the directory it needs.
300 emptied = remove_tree_or_warn(
301 sp_site, label="empty aggregated subproject directory",
302 )
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",
310 )
311 if mode in ("typedoc", "all"):
312 from oct.docs.typedoc import has_typedoc, typedoc_output_dir
313 if has_typedoc(sp.path):
315 typedoc_output_dir(sp.path),
316 site_root / sp.name / "typedoc" / "html",
317 )
318
319 # Workspace-tier Option C Sphinx output (Phase D).
320 if mode in ("sphinx", "all"):
322 ws_root / "docs" / "_build" / "option_c" / "html",
323 site_root / "option_c" / "sphinx" / "html",
324 )
325 return site_root
326
327
329 ws_root: Path,
330 workspace: Workspace,
331 *,
332 dry_run: bool,
333) -> int:
334 """Remove ``_site/`` and every subproject's ``docs/_build/`` + ``docs/html/``.
335
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.
339 """
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:
344 targets.extend([
345 sp.path / "docs" / "html",
346 sp.path / "docs" / "_build",
347 sp.path / "docs" / "doxygen_output",
348 sp.path / "docs" / "typedoc",
349 ])
350 # Workspace tier scratch (Phase D) - sweep too.
351 targets.append(ws_root / "docs" / "_build")
352
353 removed = 0
354 survived: list[Path] = []
355 for target in targets:
356 if not target.exists():
357 continue
358 # Remove first, announce second. Announcing first (as this did) reports
359 # a read-only tree as removed while it is still on disk.
360 if not dry_run:
361 if target.is_dir():
362 if not remove_tree_or_warn(target, label="generated docs output"):
363 survived.append(target)
364 continue
365 else:
366 try:
367 target.unlink()
368 except OSError as exc:
369 print(f" could not remove {target}: {exc.strerror or exc}")
370 survived.append(target)
371 continue
372 _dbg("OCT-DOCS", func, f"{action}: {target}", 3)
373 print(f" {action}: {target}")
374 removed += 1
375
376 if removed == 0 and not survived:
377 print("Nothing to clean.")
378 if survived:
379 print(f" {len(survived)} target(s) could not be removed (warned above).")
380 return 1
381 return 0
382
383
385 ws_root: Path,
386 *,
387 mode: WorkspaceDocsMode,
388 dry_run: bool = False,
389) -> int:
390 """Workspace-mode entry point.
391
392 Parameters
393 ----------
394 ws_root:
395 Resolved path to the workspace root. The caller (dispatcher)
396 guarantees this is a real workspace root.
397 mode:
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
401 outputs.
402 dry_run:
403 Forwarded to ``--clean`` only. ``True`` reports planned removals
404 without touching the filesystem.
405
406 Returns
407 -------
408 int
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.
412 """
413 func = "run_workspace_docs"
414 _dbg("OCT-DOCS", func, f"start ws_root={ws_root} mode={mode} dry_run={dry_run}", 2)
415
416 try:
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)
421 return 2
422
423 if mode == "clean":
424 return _clean_workspace(ws_root, workspace, dry_run=dry_run)
425
426 # 1. Per-subproject builds - each in-place under <sp>/docs/.
427 overall_rc = 0
428 for sp in workspace.subprojects:
429 if not sp.html_docs:
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)
433 continue
434 sp_rc = _build_subproject(sp, mode=mode)
435 if sp_rc != 0:
436 overall_rc = overall_rc or sp_rc
437
438 # 2. Workspace-tier "Option C" Sphinx build (Phase D - wired here as
439 # a no-op stub. Phase D fills in the real implementation.)
440 if mode in ("sphinx", "all"):
441 try:
442 from oct.docs.option_c_tier import build_option_c_tier
443 tier_rc = build_option_c_tier(ws_root)
444 if tier_rc != 0:
445 overall_rc = overall_rc or tier_rc
446 except ImportError:
447 _dbg("OCT-DOCS", func, "option_c_tier module not present yet (Phase D pending)", 3)
448
449 # 3. Aggregate into _site/
450 site_root = _aggregate(ws_root, workspace, mode=mode)
451
452 # 4. Render front page. Any mode that changes what the cards point at
453 # re-renders it, so a --typedoc or --doxygen run never leaves the hub
454 # describing the previous build.
455 if mode in ("sphinx", "doxygen", "typedoc", "all"):
456 from oct.docs.front_page import render_front_page, FrontPageError
457 try:
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
463
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)
466 return overall_rc
467
468
469__all__ = [
470 "WorkspaceDocsMode",
471 "SITE_DIRNAME",
472 "is_workspace_root",
473 "run_workspace_docs",
474]
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)