diff --git a/docs/_ext/d2diagrams.py b/docs/_ext/d2diagrams.py new file mode 100644 index 00000000..02e4d7d5 --- /dev/null +++ b/docs/_ext/d2diagrams.py @@ -0,0 +1,235 @@ +# 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. + +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 os +from pathlib import Path +import shlex +import shutil +import subprocess as sp + +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' + + +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, +) -> bool: + ''' + Maybe (re)render `src` -> `out`, returning + `True` when an up-to-date SVG exists after the + call. + + ''' + stale: bool = ( + not out.exists() + or + src.stat().st_mtime > out.stat().st_mtime + ) + if not stale: + return True + d2cmd: list[str]|None = find_d2(app) + if d2cmd is None: + if out.exists(): + log.info( + f'no d2 binary; using committed svg ' + f'for {src.name}' + ) + return True + return False + out.parent.mkdir( + parents=True, + exist_ok=True, + ) + argv: list[str] = ( + d2cmd + + list(app.config.d2_args) + + [str(src), str(out)] + ) + try: + proc = sp.run( + argv, + capture_output=True, + text=True, + timeout=120, + ) + except ( + OSError, + sp.TimeoutExpired, + ) as err: + log.warning( + f'd2 invocation failed for {src.name}: ' + f'{err}' + ) + return out.exists() + if proc.returncode != 0: + log.warning( + f'd2 render error for {src.name}:\n' + f'{proc.stderr}' + ) + return out.exists() + return True + + +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' + ) + if not render_svg(self.env.app, src, out): + # last resort: emit the raw d2 source. + log.warning( + f'no svg available for {relsrc}; ' + f'emitting d2 source as literal block' + ) + literal = nodes.literal_block( + src.read_text(), + src.read_text(), + ) + literal['language'] = 'text' + return [literal] + 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 + 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 setup(app: Sphinx) -> dict: + app.add_config_value('d2_bin', None, 'env') + app.add_config_value('d2_args', [], 'env') + app.add_directive('d2', D2Diagram) + return { + 'version': '0.1.0', + 'parallel_read_safe': True, + 'parallel_write_safe': True, + } diff --git a/docs/_ext/marginalia.py b/docs/_ext/marginalia.py new file mode 100644 index 00000000..987f7264 --- /dev/null +++ b/docs/_ext/marginalia.py @@ -0,0 +1,51 @@ +# tractor: distributed structured concurrency. +''' +``.. margin::`` - prose-anchored, right-margin asides. + +A theme-agnostic vendoring of `sphinx_book_theme`'s +`Margin` directive: a `docutils` `Sidebar` subclass +which tags the node with a ``margin`` class; placement +is then pure CSS (see ``_static/css/custom.css``) +allowing use on any theme incl. our +`pydata_sphinx_theme`. + +Usage, + +.. code:: rst + + .. margin:: An optional title + + Aside content; text, figures, whatever. + +''' +from docutils import nodes +from docutils.parsers.rst.directives.body import ( + Sidebar, +) +from sphinx.application import Sphinx + + +class Margin(Sidebar): + ''' + Notes/figures placed in the right margin, anchored + at the current point in the prose flow. + + ''' + required_arguments = 0 + optional_arguments = 1 + + def run(self) -> list[nodes.Node]: + if not self.arguments: + self.arguments = [''] + out: list[nodes.Node] = super().run() + out[0].attributes['classes'].append('margin') + return out + + +def setup(app: Sphinx) -> dict: + app.add_directive('margin', Margin) + return { + 'version': '0.1.0', + 'parallel_read_safe': True, + 'parallel_write_safe': True, + } diff --git a/docs/_static/css/custom.css b/docs/_static/css/custom.css new file mode 100644 index 00000000..b75c49f7 --- /dev/null +++ b/docs/_static/css/custom.css @@ -0,0 +1,75 @@ +/* tractor docs: a minimal black + white skin over + * `pydata-sphinx-theme` plus RHS-marginalia + d2 + * diagram styling. + */ +html[data-theme="light"] { + --pst-color-primary: #000000; + --pst-color-secondary: #3d3d3d; + --pst-color-accent: #5a5a5a; + --pst-color-link: #000000; + --pst-color-link-hover: #5a5a5a; + --pst-color-inline-code: #1a1a1a; + --pst-color-inline-code-links: #000000; +} +html[data-theme="dark"] { + --pst-color-primary: #ffffff; + --pst-color-secondary: #c9c9c9; + --pst-color-accent: #a8a8a8; + --pst-color-link: #ffffff; + --pst-color-link-hover: #bdbdbd; + --pst-color-inline-code: #e8e8e8; + --pst-color-inline-code-links: #ffffff; +} +/* mono-chrome links: rely on underline for + * affordance instead of color. + */ +.bd-content a:not(.headerlink) { + text-decoration: underline; + text-underline-offset: 0.18em; +} +/* d2 diagram figures */ +figure.d2-diagram { + text-align: center; +} +figure.d2-diagram img { + max-width: 100%; + height: auto; +} +/* keep light-rendered svgs legible in dark mode */ +html[data-theme="dark"] figure.d2-diagram img { + background: #ffffff; + border-radius: 6px; + padding: 6px; +} +/* prose-anchored right-margin asides (tufte-ish): + * float right within the content column on wide + * screens, collapse inline on narrow ones. + */ +@media (min-width: 960px) { + aside.margin, + figure.margin, + div.margin, + figure.d2-margin { + float: right; + clear: right; + width: 44%; + margin: 0.2rem 0 1rem 1.4rem; + font-size: 0.85rem; + } +} +/* strip docutils sidebar chrome from margin asides */ +aside.sidebar.margin { + border: none; + background: transparent; + padding: 0; +} +aside.sidebar.margin > p.sidebar-title { + font-weight: 600; + font-size: 0.9rem; + margin-bottom: 0.3rem; +} +/* landing page hero logo sizing */ +img.hero-logo { + max-width: 360px; + width: 60%; +} diff --git a/docs/conf.py b/docs/conf.py index 6bb0ed16..f1732184 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,105 +1,147 @@ -# Configuration file for the Sphinx documentation builder. -# -# This file only contains a selection of the most common options. For a full -# list see the documentation: -# https://www.sphinx-doc.org/en/master/usage/configuration.html +# tractor: distributed structured concurrency. +''' +Sphinx config for `tractor`'s documentation. -# -- Path setup -------------------------------------------------------------- +Theme-wise we ride the `pydata_sphinx_theme` (per the +research + history in #157) skinned to a minimal +black + white look via `_static/css/custom.css`; see +the local extensions under `_ext/` for our `.. d2::` +diagram and `.. margin::` aside directives. -# If extensions (or modules to document with autodoc) are in another directory, -# add these directories to sys.path here. If the directory is relative to the -# documentation root, use os.path.abspath to make it absolute, like shown here. -# -# import os -# import sys -# sys.path.insert(0, os.path.abspath('.')) +Build locally via, -# Warn about all references to unknown targets -nitpicky = True + uv run --group docs make -C docs html -# The master toctree document. -master_doc = 'index' +''' +from importlib.metadata import version as get_version +from pathlib import Path +import sys -# -- Project information ----------------------------------------------------- +# local sphinx extensions live in `_ext/`: +# - `d2diagrams`: `.. d2::` diagram rendering +# - `marginalia`: `.. margin::` RHS prose-asides +sys.path.insert( + 0, + str((Path(__file__).parent / '_ext').resolve()), +) + +# -- project info --------------------------------- project = 'tractor' -copyright = '2018, Tyler Goodlet' +copyright = '2018-2026, Tyler Goodlet' author = 'Tyler Goodlet' +release: str = get_version('tractor') +version: str = release -# The full version, including alpha/beta/rc tags -release = '0.0.0a0.dev0' +# -- general config ------------------------------- -# -- General configuration --------------------------------------------------- - -# Add any Sphinx extension module names here, as strings. They can be -# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom -# ones. extensions = [ 'sphinx.ext.autodoc', + 'sphinx.ext.autosummary', 'sphinx.ext.intersphinx', + 'sphinx.ext.viewcode', 'sphinx.ext.todo', + 'sphinx_design', + 'sphinx_copybutton', + 'sphinxext.opengraph', + 'sphinx_togglebutton', + 'd2diagrams', + 'marginalia', ] - -# Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] +exclude_patterns = [ + '_build', + # the pypi/gh readme + its standalone generator + # sub-project; NOT doc-tree pages. + 'README.rst', + 'github_readme', + 'Thumbs.db', + '.DS_Store', +] +root_doc = 'index' +# TODO: flip this on + burn down the (many) warnings +# from our informal docstring style; see the autodoc +# readiness notes from the revamp's recon pass. +nitpicky = False -# List of patterns, relative to source directory, that match files and -# directories to ignore when looking for source files. -# This pattern also affects html_static_path and html_extra_path. -exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] +# -- autodoc/autosummary -------------------------- +autodoc_member_order = 'bysource' +autodoc_typehints = 'description' +autosummary_generate = True -# -- Options for HTML output ------------------------------------------------- +# -- intersphinx ---------------------------------- -# The theme to use for HTML and HTML Help pages. See the documentation for -# a list of builtin themes. -# -html_theme = 'sphinx_book_theme' - -pygments_style = 'algol_nu' - -# Theme options are theme-specific and customize the look and feel of a theme -# further. For a list of options available for each theme, see the -# documentation. -html_theme_options = { - # 'logo': 'tractor_logo_side.svg', - # 'description': 'Structured concurrent "actors"', - "repository_url": "https://github.com/goodboy/tractor", - "use_repository_button": True, - "home_page_in_toc": False, - "show_toc_level": 1, - "path_to_docs": "docs", - -} -html_sidebars = { - "**": [ - "sbt-sidebar-nav.html", - # "sidebar-search-bs.html", - # 'localtoc.html', - ], - # 'logo.html', - # 'github.html', - # 'relations.html', - # 'searchbox.html' - # ] +intersphinx_mapping = { + 'python': ( + 'https://docs.python.org/3', + None, + ), + 'trio': ( + 'https://trio.readthedocs.io/en/stable', + None, + ), + # NOTE, msgspec's site doesn't publish an + # `objects.inv` (404s) so no intersphinx for it. + 'pytest': ( + 'https://docs.pytest.org/en/stable', + None, + ), } -# doesn't seem to work? -# extra_navbar = "
nextttt-gennnnn
" +# -- html output ---------------------------------- -html_title = '' +html_theme = 'pydata_sphinx_theme' +html_title = 'tractor' html_logo = '_static/tractor_logo_side.svg' html_favicon = '_static/tractor_logo_side.svg' -# show_navbar_depth = 1 - -# Add any paths that contain custom static files (such as style sheets) here, -# relative to this directory. They are copied after the builtin static files, -# so a file named "default.css" will overwrite the builtin "default.css". html_static_path = ['_static'] - -# Example configuration for intersphinx: refer to the Python standard library. -intersphinx_mapping = { - "python": ("https://docs.python.org/3", None), - "pytest": ("https://docs.pytest.org/en/latest", None), - "setuptools": ("https://setuptools.readthedocs.io/en/latest", None), +html_css_files = ['css/custom.css'] +html_show_sourcelink = False +html_theme_options = { + 'logo': { + 'image_light': '_static/tractor_logo_side.svg', + 'image_dark': '_static/tractor_logo_side.svg', + 'alt_text': 'tractor', + }, + 'github_url': 'https://github.com/goodboy/tractor', + 'navbar_align': 'content', + 'show_toc_level': 2, + 'secondary_sidebar_items': { + '**': ['page-toc'], + 'index': [], + }, + 'use_edit_page_button': True, + 'footer_start': ['copyright'], + 'footer_end': ['theme-version'], + 'pygments_light_style': 'algol_nu', + 'pygments_dark_style': 'github-dark', } +html_context = { + 'github_user': 'goodboy', + 'github_repo': 'tractor', + 'github_version': 'main', + 'doc_path': 'docs', +} + +# -- ext: opengraph ------------------------------- + +ogp_site_url = 'https://goodboy.github.io/tractor/' +ogp_use_first_image = True + +# -- ext: copybutton ------------------------------ + +copybutton_prompt_text = r'>>> |\.\.\. |\$ ' +copybutton_prompt_is_regexp = True + +# -- ext: todo ------------------------------------ + +todo_include_todos = True + +# -- ext: d2diagrams (local) ---------------------- + +# normally resolved from PATH or the `D2_BIN` env +# var; when neither hits, the committed SVGs under +# `_diagrams/` are used as-is. +d2_bin = None +d2_args = []