# tractor: distributed structured concurrency. ''' ``.. d2::`` - embed d2lang_ diagrams in sphinx docs with build-time rendering and a committed-SVG fallback. Rendering policy, - when a ``d2`` binary is found (see discovery order below) any out-of-date SVG is (re)rendered from its ``.d2`` source (normally kept in ``docs/diagrams/``) into ``docs/_diagrams/``, - otherwise any pre-rendered (and git committed) SVG already in ``docs/_diagrams/`` is used as-is, - when neither is possible the diagram *source* is emitted as a literal block so no content is ever silently dropped. A render that is *attempted and fails* (a ``d2`` bin is present but errors on the source) is NOT silently degraded to the stale committed SVG: it raises a docutils error (so ``sphinx-build -W`` fails the build). The render is also atomic — a failed/torn render can never clobber a good committed SVG — so the last-good diagram survives a bad edit. Binary discovery order, - the ``D2_BIN`` env var; may contain args which are split via `shlex`, eg. ``D2_BIN='nix run nixpkgs#d2 --'``, - the ``d2_bin`` sphinx config value, - ``shutil.which('d2')``. Usage, .. code:: rst .. d2:: diagrams/actor_tree.d2 :caption: A tree of ``trio``-task-trees. :margin: .. _d2lang: https://d2lang.com ''' from __future__ import annotations import enum import hashlib import os from pathlib import Path import re import shlex import shutil import subprocess as sp import tempfile from docutils import nodes from docutils.parsers.rst import directives from sphinx.application import Sphinx from sphinx.util import logging from sphinx.util.docutils import SphinxDirective log = logging.getLogger(__name__) # subdir (under the sphinx srcdir) holding rendered, # git-committed, fallback SVG outputs. _outdir: str = '_diagrams' # per-build map of {output-svg-name: source-relpath} used # to detect 2 distinct `.d2` sources colliding on a single # output stem; reset on each `builder-inited` (see `setup`). _seen_outputs: dict[str, str] = {} class RenderResult(enum.Enum): ''' Outcome of a `render_svg()` call. ''' OK = 'ok' # fresh SVG exists on disk FELL_BACK = 'fell_back' # no bin; serving committed SVG NO_OUTPUT = 'no_output' # no bin AND no committed SVG FAILED = 'failed' # bin present, render errored def find_d2( app: Sphinx, ) -> list[str]|None: ''' Resolve the d2 render command as an argv list or `None` when no binary can be found. ''' if env_bin := os.environ.get('D2_BIN'): return shlex.split(env_bin) if cfg_bin := app.config.d2_bin: return shlex.split(cfg_bin) if path_bin := shutil.which('d2'): return [path_bin] return None def render_svg( app: Sphinx, src: Path, out: Path, ) -> RenderResult: ''' Maybe (re)render `src` -> `out` and report the outcome as a `RenderResult`. A render is performed only when `out` is missing or older than `src` AND a `d2` binary is available. The write is atomic (temp-file + `os.replace`) so a failed or torn render never clobbers an existing (committed) SVG. ''' fresh: bool = ( out.exists() and src.stat().st_mtime <= out.stat().st_mtime ) if fresh: return RenderResult.OK d2cmd: list[str]|None = find_d2(app) if d2cmd is None: # no binary: fall back to whatever is committed, # else signal the caller to emit the raw source. if out.exists(): log.info( f'no d2 binary; using committed svg ' f'for {src.name}' ) return RenderResult.FELL_BACK return RenderResult.NO_OUTPUT out.parent.mkdir( parents=True, exist_ok=True, ) # render into a sibling temp-file first so a bad # `d2` exit (or a crash mid-write) leaves any prior # committed SVG fully intact. fd, tmpname = tempfile.mkstemp( dir=str(out.parent), prefix=f'.{out.stem}.', suffix='.svg.tmp', ) os.close(fd) tmp = Path(tmpname) argv: list[str] = ( d2cmd + list(app.config.d2_args) + [str(src), str(tmp)] ) try: proc = sp.run( argv, capture_output=True, text=True, timeout=120, ) except ( OSError, sp.TimeoutExpired, ) as err: tmp.unlink(missing_ok=True) log.warning( f'd2 invocation failed for {src.name}: ' f'{err}' ) return RenderResult.FAILED if proc.returncode != 0: tmp.unlink(missing_ok=True) log.warning( f'd2 render error for {src.name}:\n' f'{proc.stderr}' ) return RenderResult.FAILED # atomic swap-in of the freshly rendered SVG. os.replace(tmp, out) return RenderResult.OK class D2Diagram(SphinxDirective): ''' Render a ``.d2`` source file (path relative to the sphinx srcdir) as an SVG figure. ''' required_arguments = 1 has_content = False option_spec = { 'caption': directives.unchanged, 'alt': directives.unchanged, 'width': ( directives.length_or_percentage_or_unitless ), 'margin': directives.flag, 'class': directives.class_option, 'name': directives.unchanged, } def run(self) -> list[nodes.Node]: relsrc: str = self.arguments[0] srcdir = Path(self.env.srcdir) src: Path = srcdir / relsrc self.env.note_dependency(relsrc) if not src.exists(): err = self.state_machine.reporter.error( f'd2 source not found: {relsrc}', line=self.lineno, ) return [err] out: Path = ( srcdir / _outdir / f'{src.stem}.svg' ) # collision guard: two distinct sources must not # map onto the same output stem within a build. prior: str|None = _seen_outputs.get(out.name) if ( prior is not None and prior != relsrc ): err = self.state_machine.reporter.error( f'd2 output collision: {relsrc!r} and ' f'{prior!r} both render to ' f'{_outdir}/{out.name}; rename one ' f'`.d2` stem', line=self.lineno, ) return [err] _seen_outputs[out.name] = relsrc result: RenderResult = render_svg( self.env.app, src, out, ) if result is RenderResult.FAILED: # loud, build-failing (under `-W`) signal; the # prior committed SVG, if any, is untouched. err = self.state_machine.reporter.error( f'd2 render failed for {relsrc} (see ' f'build log); the committed svg, if any, ' f'was left untouched', line=self.lineno, ) return [err] if result is RenderResult.NO_OUTPUT: # last resort: emit the raw d2 source so no # content is ever silently dropped. log.warning( f'no svg available for {relsrc}; ' f'emitting d2 source as literal block' ) src_text: str = src.read_text() literal = nodes.literal_block( src_text, src_text, ) literal['language'] = 'text' return [literal] # OK | FELL_BACK -> embed the SVG figure. img = nodes.image( uri=f'/{_outdir}/{out.name}', alt=self.options.get( 'alt', f'd2 diagram: {src.stem}', ), ) if width := self.options.get('width'): img['width'] = width # content-hash the rendered svg so the served # `` carries a `?v=` cache-buster # (sphinx tags css/js this way but not images); # without it an edited diagram keeps showing the # browser's stale copy at the stable # `_images/.svg` url on autobuild reload. if out.exists(): img['d2_stem'] = out.stem img['d2_cachebust'] = hashlib.sha256( out.read_bytes() ).hexdigest()[:8] fig = nodes.figure() fig += img classes: list[str] = ( ['d2-diagram'] + self.options.get('class', []) ) if 'margin' in self.options: # NB: the bare 'margin' class is what # book-style themes key off for # right-margin placement; our custom css # uses 'd2-margin'. classes += [ 'margin', 'd2-margin', ] fig['classes'] += classes if caption_txt := self.options.get('caption'): ( inline_nodes, _msgs, ) = self.state.inline_text( caption_txt, self.lineno, ) caption = nodes.caption( caption_txt, '', *inline_nodes, ) fig += caption self.add_name(fig) return [fig] def _reset_seen( app: Sphinx, ) -> None: ''' Clear the per-build output-collision map at the start of each build. ''' _seen_outputs.clear() def _cachebust_d2_images( app: Sphinx, pagename: str, templatename: str, context: dict, doctree, ) -> None: ''' Append a ``?v=`` query to every d2 diagram ```` in the rendered page ``body`` so a browser re-fetches an *edited* SVG instead of its stale cached copy at the stable ``_images/.svg`` url (sphinx cache-busts css/js this way, but not images). Scoped strictly to d2 images (those carrying the `d2_cachebust` attr set in `D2Diagram.run()`); all other markup is left untouched. ''' if doctree is None or 'body' not in context: return busts: dict[str, str] = {} for img in doctree.findall(nodes.image): if ver := img.get('d2_cachebust'): busts[img['d2_stem']] = ver if not busts: return body: str = context['body'] for stem, ver in busts.items(): body = re.sub( r'(src="[^"]*?' + re.escape(stem) + r'\.svg)(")', rf'\1?v={ver}\2', body, ) context['body'] = body def setup(app: Sphinx) -> dict: app.add_config_value('d2_bin', None, 'env') app.add_config_value('d2_args', [], 'env') app.add_directive('d2', D2Diagram) app.connect('builder-inited', _reset_seen) app.connect('html-page-context', _cachebust_d2_images) return { 'version': '0.1.0', # NB: run() writes the rendered SVG during the # READ phase; the temp-file + `os.replace` swap # keeps that atomic so concurrent renders of the # same diagram (under `sphinx-build -j`) can't # tear the output file. 'parallel_read_safe': True, 'parallel_write_safe': True, }