Rebuild sphinx scaffold on `pydata` theme

Full `docs/conf.py` rewrite targeting `pydata-sphinx-theme` 0.18
(3-col layout: nav-index left, content middle, page-toc right)
skinned to a minimal b&w look via `_static/css/custom.css`, keeping
the `algol_nu` pygments style.

Also add 2 local sphinx exts under `docs/_ext/`,

- `d2diagrams.py`: a `.. d2::` directive rendering `d2lang` sources
  at build time when a `d2` bin is found (`D2_BIN` env supports
  argful values eg. `'nix run nixpkgs#d2 --'`), falling back to any
  git-committed SVG, else emitting the raw source as a literal
  block; no pypi ext was usable (all stubs or returncode-swallowing
  per the #157 research),
- `marginalia.py`: a theme-agnostic `.. margin::` directive
  (`sphinx-book-theme` style `Sidebar` subclass) for prose-anchored
  RHS asides; the custom css floats these (and `:margin:` d2 figs)
  into the right gutter on wide screens.

(this patch was generated in some part by [`claude-code`][claude-code-gh])
[claude-code-gh]: https://github.com/anthropics/claude-code
Gud Boi 2026-06-11 15:16:18 -04:00
parent 4e4e7a569b
commit e2ddb5e3fe
4 changed files with 481 additions and 78 deletions

View File

@ -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,
}

View File

@ -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,
}

75
docs/_static/css/custom.css vendored 100644
View File

@ -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%;
}

View File

@ -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 = "<p>nextttt-gennnnn</p>"
# -- 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 = []