Skip to content

Build sphinx-vite-builder: PEP 517 backend + Sphinx extension for vite-aware projects #28

@tony

Description

@tony

Product vision

Build sphinx-vite-builder — a single Python package that any Sphinx theme or docs project using vite can consume. It exposes two orthogonal entry points:

  1. PEP 517 build backend[build-system].build-backend = "sphinx_vite_builder.build" runs pnpm exec vite build before delegating wheel/sdist construction to hatchling. End users pip install from PyPI/sdist without needing pnpm; contributors uv sync and the editable install Just Works.
  2. Sphinx extensionextensions = ["sphinx_vite_builder"] in conf.py auto-orchestrates vite at docs-build time: one-shot vite build for sphinx-build, vite build --watch child process for sphinx-autobuild. No justfile, no Makefile, no manual pnpm exec vite build step.

Both heads share a smart subprocess core built on asyncio + rich, absorbing the production-grade design already shipping in gp-sphinx-vite (this workspace's existing extension that we'll consolidate into sphinx-vite-builder).

The package is generic — designed for any vite-aware Sphinx project, not just gp-furo-theme. It joins the family of tools that own a native toolchain end-to-end: maturin (Rust), sphinx-theme-builder (webpack), now sphinx-vite-builder (vite).

Study these first

Action

Open a new PR after #27 merges that introduces packages/sphinx-vite-builder/ and consolidates the existing gp-sphinx-vite orchestration into it. Migration path detailed below in Implementation phases.

What changed from the original framing

This issue's first version proposed a 580-LOC backend that wraps hatchling. The expanded scope is:

Aspect Original (was) Now
Scope PEP 517 backend only Backend + Sphinx extension (two entry points, one package)
Audience Workspace-internal Generic, publishable from day one
Subprocess management Bare subprocess.run asyncio + rich, modeled on gp-sphinx-vite
Auto-hookup None sphinx-build and sphinx-autobuild run vite automatically when extension is loaded
LOC budget ~580 ~1200 (backend ~150, extension ~250 incl. hooks, subprocess core ~400 absorbed from gp-sphinx-vite, tests ~400)

Architecture

sphinx_vite_builder/                       # The Python package
├── __init__.py                            # Sphinx extension entry: setup(app)
├── build.py                               # PEP 517 backend entry (pure functions)
├── _internal/
│   ├── process.py                         # SmartAsyncProcess (asyncio + rich)
│   ├── bus.py                             # AsyncioBus (sync↔async bridge for Sphinx hooks)
│   ├── vite.py                            # pnpm/vite invocation, pnpm/PATH detection
│   ├── config.py                          # detect_mode() — autobuild/dev/prod heuristic
│   ├── hooks.py                           # builder-inited / build-finished handlers
│   └── errors.py                          # PnpmMissingError, ViteFailedError, etc.
└── _testing/
    └── fixtures.py                        # pytest fixtures for fake-pnpm / fake-vite

Both __init__.py:setup (Sphinx extension) and build.py:build_wheel (PEP 517 backend) consume the same _internal/ core. The two heads never call each other; they share substrate.

Layer 1 — Smart subprocess core (_internal/process.py, _internal/bus.py)

Absorbed from gp-sphinx-vite/process.py + bus.py. Production patterns we keep:

  • Asyncio-native subprocessasyncio.create_subprocess_exec() with PIPE'd stdout/stderr; concurrent line-by-line drainers via asyncio.gather(*drainers, return_exceptions=True) to prevent deadlock (existing pattern at gp-sphinx-vite/process.py:74-187)
  • Graceful SIGTERM → SIGKILL escalation with configurable timeout (process.py:terminate()), idempotent across repeated calls
  • POSIX process group isolationos.setsid() so SIGTERM kills the whole vite tree (improvement to add over current code)
  • PYTHONUNBUFFERED=1 injected into subprocess env so chained Python tools don't buffer
  • Rich-aware logging — drainers prefix each line with [label] and route to a rich Console
  • AsyncioBus — single asyncio loop in a daemon thread; call_sync() uses asyncio.run_coroutine_threadsafe to let sync Sphinx hooks await async coroutines (bus.py:93-121)
  • Idempotent teardownatexit.register() + signal.signal(SIGINT/SIGTERM/SIGHUP) chains with weak refs so long-lived processes don't leak (hooks.py:231-273)

Layer 2 — Vite orchestration (_internal/vite.py)

  • Detect package opt-in: web/ dir alongside pyproject.toml ⇒ vite-managed; absent ⇒ unpacked sdist or non-vite package, no-op
  • shutil.which("pnpm") check up-front; raise PnpmMissingError with corepack enable + pnpm.io/installation hints
  • node_modules/ check; run pnpm install --frozen-lockfile if missing
  • One-shot mode: pnpm --filter <package> exec vite build (returns when complete)
  • Watch mode: pnpm --filter <package> exec vite build --watch (long-lived SmartAsyncProcess)
  • Capture all stderr with build context; surface via ViteFailedError on non-zero exit

Layer 3a — PEP 517 backend (build.py)

Mirrors flit_core's buildapi.py (85 LOC) shape. Delegates wheel construction to hatchling (verified pure-function in hatchling/src/hatchling/build.py:17-84):

# packages/sphinx-vite-builder/sphinx_vite_builder/build.py (sketch, ~50 LOC)
from __future__ import annotations
import typing as t
import hatchling.build as _hatchling
from ._internal.vite import run_vite_build, VitePhase

def build_wheel(wheel_directory, config_settings=None, metadata_directory=None):
    run_vite_build(VitePhase.WHEEL)
    return _hatchling.build_wheel(wheel_directory, config_settings, metadata_directory)

def build_editable(wheel_directory, config_settings=None, metadata_directory=None):
    run_vite_build(VitePhase.EDITABLE)
    return _hatchling.build_editable(wheel_directory, config_settings, metadata_directory)

def build_sdist(sdist_directory, config_settings=None):
    run_vite_build(VitePhase.SDIST)  # Pre-bake static/ so sdist→wheel works without pnpm
    return _hatchling.build_sdist(sdist_directory, config_settings)

# Optional hooks alias verbatim — no extra logic
get_requires_for_build_wheel = _hatchling.get_requires_for_build_wheel
get_requires_for_build_sdist = _hatchling.get_requires_for_build_sdist
get_requires_for_build_editable = _hatchling.get_requires_for_build_editable
prepare_metadata_for_build_wheel = _hatchling.prepare_metadata_for_build_wheel
prepare_metadata_for_build_editable = _hatchling.prepare_metadata_for_build_editable

run_vite_build() short-circuits when web/ is absent (the unpacked-sdist case). The sdist contains the pre-baked static/ tree (not gitignored at sdist-build time because vite ran), so the wheel-from-sdist build skips vite cleanly.

Layer 3b — Sphinx extension (__init__.py)

Consumed via extensions = ["sphinx_vite_builder"] in conf.py. Hooks two events:

  • builder-inited (defined at sphinx/sphinx/events.py:115) — fires after the builder is constructed but before any docs are read. Mode resolution:
    • auto (default) — detect autobuild via env/argv/parent-process; dev if autobuild, else prod
    • dev — spawn vite build --watch (long-lived; survives autobuild rebuilds)
    • prod — run vite build once, block until done
    • disabled — no-op (useful when an external orchestration handles vite)
  • build-finished (same file:242) — no-op for watch mode (process must survive the rebuild loop); teardown is via atexit + signal handlers

sphinx-autobuild has no extension-facing hook protocol — it spawns sphinx-build as a fresh subprocess per rebuild, so each invocation gets a new Sphinx() app and builder-inited re-fires. The extension's builder-inited handler is idempotent: if a vite watch is already running, return early. Detection of autobuild mode is heuristic (config.py:detect_mode() checks env, argv, parent-process; matches the existing gp-sphinx-vite pattern).

raise sphinx.errors.ConfigError(msg) (defined at sphinx/sphinx/errors.py:81-84) for user-fixable failures (missing pnpm, install error, vite spawn failure). ConfigError halts the build with a multi-line actionable message.

Workspace bootstrap (chicken-and-egg)

  • packages/sphinx-vite-builder/pyproject.toml declares build-backend = "hatchling.build" (or flit_core.buildapi) — sphinx-vite-builder is itself a pure-Python package, no vite needed for its own build
  • packages/gp-furo-theme/pyproject.toml declares build-backend = "sphinx_vite_builder.build" + backend-path = ["../sphinx-vite-builder"] — pip/uv adds the in-tree directory to sys.path so the backend resolves without PyPI
  • After publish to PyPI, consumers can drop backend-path and use requires = ["sphinx-vite-builder>=..."]

Implementation phases

Phase 1 — Backend head + smart subprocess core (PR scope)

  1. Scaffold packages/sphinx-vite-builder/ with pyproject.toml (hatchling-built), LICENSE, README.md
  2. Port process.py + bus.py from gp-sphinx-vite to sphinx_vite_builder/_internal/. Add os.setsid() for POSIX process group isolation (improvement over current).
  3. Implement _internal/vite.py — orchestration module, fast-fail on missing pnpm/vite
  4. Implement build.py — PEP 517 backend, ~50 LOC delegating to hatchling
  5. Implement _internal/errors.pyPnpmMissingError, ViteFailedError, NodeModulesInstallError
  6. Wire gp-furo-theme/pyproject.toml to use the new backend; drop force-include blocks (they're rejected by force-include-requires-disk-existence; the backend handles it)
  7. Drop the explicit pnpm exec vite build step from tests.yml docs job and release.yml (the backend handles it transparently when uv sync / uv build runs)
  8. Tests: tests/test_sphinx_vite_builder_build.py covers each PEP 517 hook + delegation correctness + missing-pnpm/vite paths
  9. Verify: uv sync from clean checkout works; uv build --package gp-furo-theme produces wheel with assets; CI's deferred smoke (gp-sphinx) and smoke (sphinx-gp-theme) failures resolve naturally; no manual vite step needed anywhere

Phase 2 — Extension head (consolidate gp-sphinx-vite)

  1. Move gp-sphinx-vite's extension code (hooks.py, config.py, setup() in __init__.py) into sphinx_vite_builder/
  2. Add sphinx_vite_builder to consumer conf.py extensions lists; remove gp_sphinx_vite
  3. Deprecate or delete packages/gp-sphinx-vite/
  4. Update docs/ justfile to drop start-docs / build-docs vite steps if Sphinx extension auto-orchestrates them
  5. Tests: extension-side via sphinx.testing.util.SphinxTestApp + @pytest.mark.sphinx('html') (existing pattern in sphinx/tests/test_application.py)

Phase 2 may land as a follow-up PR after Phase 1 ships.

Phase 3 — Publish to PyPI (optional)

When sphinx-vite-builder is stable, publish from the workspace's release pipeline. External Sphinx-theme projects using vite (outside this workspace) can then adopt it via standard [build-system].requires.

Required tests (fast-fail coverage)

For both heads. Each requirement has at least one corresponding test:

Backend head

  • build_wheel delegates to hatchling.build_wheel after running vite (mock hatchling, assert called)
  • build_editable delegates to hatchling.build_editable
  • build_sdist runs vite + delegates
  • run_vite_build() short-circuits when web/ is absent (sdist-from-temp scenario)
  • PnpmMissingError raised with actionable message when shutil.which("pnpm") is None
  • NodeModulesInstallError raised when pnpm install exits non-zero
  • ViteFailedError raised when pnpm exec vite build exits non-zero
  • ✅ Wheel built from a clean tree contains the vite-built static/ files
  • ✅ Sdist contains pre-baked static/; wheel-from-sdist works without pnpm

Extension head

  • setup(app) registers the two events + config values + returns the metadata dict
  • on_builder_inited is idempotent (re-firing under autobuild rebuild → no second spawn)
  • auto mode: detects autobuild via env / argv / parent-process; falls back to prod otherwise
  • dev mode: spawns vite build --watch; survives build-finished
  • prod mode: runs vite build once; blocks until completion
  • disabled mode: no-op
  • ✅ Missing pnpm: raises ConfigError from builder-inited with the workspace+pnpm hint
  • ✅ Vite spawn failure: raises ConfigError with the build-context error
  • ✅ Teardown: SIGTERM/SIGINT/SIGHUP triggers process cleanup
  • ✅ Teardown: atexit triggers process cleanup
  • ✅ POSIX: child inherits process group so SIGTERM kills the tree

Smart subprocess core

  • ✅ Concurrent stdout/stderr drainers complete without deadlock
  • ✅ SIGTERM → SIGKILL escalation fires after configured timeout
  • PYTHONUNBUFFERED=1 injected into subprocess env
  • ✅ Rich console handles color-detection fallback (no TTY → plain text)

Research summary (what we read)

The full code-level research lives in agent dossiers; this section indexes the upstream sources cited in the architecture above.

Backend patterns

  • maturin (maturin/maturin/__init__.py:158-165) — the get_requires_for_build_wheel auto-install trick for Rust via puccinialin. We don't replicate (pnpm isn't pip-installable), but the env-var bypass + shutil.which() pattern transfers.
  • uv (uv/crates/uv-build/python/uv_build/__init__.py:72-141) — uv_build is a thin Python wrapper around a Rust binary, no hook interface, can't inject custom build steps. Verdict: not suitable as a delegation target. Stick with hatchling. Tracked at astral-sh/uv#11502.
  • flit_core (flit/flit_core/flit_core/buildapi.py, 85 LOC) — the canonical PEP 517 backend module shape. Pure functions, CWD-relative pyproject.toml, hooks aliased where the implementation is identical.
  • hatchling (hatchling/src/hatchling/build.py:17-84) — verified pure-function delegation surface, no import-time state, reads pyproject.toml from CWD with no caller intervention. Wrappable in ~30 lines.
  • sphinx-theme-builder (sphinx-theme-builder/, ~2140 LOC) — the closest analog (webpack-aware backend used by Furo, sphinx-book-theme, pydata-sphinx-theme). Rolls own ZIP packing rather than delegating; uses nodeenv for Node isolation. We delegate to hatchling and use system pnpm via corepack — significantly thinner. Bootstrap pattern (its own pyproject.toml uses flit_core.buildapi) directly informs our workspace bootstrap.

Sphinx extension patterns

  • Sphinx events (sphinx/sphinx/events.py) — builder-inited (line 115) and build-finished (line 242) are the two events we hook. Lifecycle: config-initedbuilder-initedenv-before-read-docssource-readdoctree-readenv-updatedwrite-starteddoctree-resolvedbuild-finished.
  • sphinx-autobuild (readme + main module) — no extension-facing hook protocol; spawns sphinx-build as a fresh subprocess per rebuild. Extensions detect autobuild heuristically. The existing gp-sphinx-vite/config.py:detect_mode() covers env / argv / parent-process detection — port verbatim.
  • Furo (furo/src/furo/__init__.py) — example of theme + extension dual-purpose package; the single-setup() pattern in __init__.py is the cleanest. Furo refuses to load via extensions to avoid double-init; we don't (we ARE an extension).
  • myst-parser (myst-parser/myst_parser/sphinx_ext/__init__.py) — minimal entry-point file; full extension logic in a sphinx_ext/ submodule. We follow this if __init__.py grows past ~100 LOC.
  • sphinx.errors.ConfigError (sphinx/sphinx/errors.py:81-84) — the right exception class for user-fixable failures. Halts build with category-prefixed multi-line message.
  • Test fixtures (sphinx/tests/test_application.py) — sphinx.testing.util.SphinxTestApp + @pytest.mark.sphinx('html', testroot=...) is the canonical pattern.

Subprocess + UI patterns

  • gp-sphinx-vite (packages/gp-sphinx-vite/src/gp_sphinx_vite/) — full asyncio bus + ViteProcess + atexit/signal teardown design. Production-grade; absorb verbatim into sphinx_vite_builder/_internal/. Files: process.py (244 LOC), bus.py (194 LOC), hooks.py (274 LOC), config.py (192 LOC).
  • In-house async dev-server reference — aiohttp + asyncio.create_subprocess_exec + line-by-line stream draining via async iterator over process.stdout. Reference for async stream consumption without deadlock.
  • In-house batch-orchestration referencerich.live.Live + rich.table.Table for live status updates (refresh-per-second config, status table that updates from a manifest). Pattern for the build pipeline status panel.
  • In-house build-orchestration referenceCommandOutput helper class wrapping rich's Console, Panel, Progress, TaskID. Reference for structured CLI output (start/finish messages, multi-step progress bars, panel-styled error summaries).
  • In-house event-driven build referenceEventBus pub/sub + async subprocess in a builder subsystem; same CommandOutput reuse. Reference for event-driven build orchestration where BuildRequestedEvent / BuildCompletedEvent / FileChangedEvent decouple the builder from the watcher and server subsystems.
  • vite programmatic API (vite/packages/vite/src/node/index.ts:29-39) — exports build(), createServer(), preview(), defineConfig(), resolveConfig(). We shell out via pnpm exec vite for now (Python→Node bridge is fragile); these exports are documented for if/when we ship a Node helper script.
  • pnpm --filter — selects a workspace package by name pattern; pnpm-workspace.yaml defines the package set. pnpm --filter @scope/pkg exec <cmd> runs <cmd> in that package's context.
  • rich (per rich.readthedocs.io) — minimum-viable surface: Console, Live, Status, Progress, Panel, styled console.print(f"[green]✓[/green] ...").

Specs

  • PEP 517 (build system contract) — defines the hook surface (build_wheel, build_sdist, get_requires_for_build_*, prepare_metadata_for_build_*); also defines backend-path for in-tree backends
  • PEP 660 (editable installs) — adds build_editable, get_requires_for_build_editable, prepare_metadata_for_build_editable
  • PEP 621 (project metadata) — [project] table that hatchling reads
  • PEP 508 (dependency specifiers) — what get_requires_for_build_* returns

Out of scope

  • Publishing sphinx-vite-builder to PyPI — Phase 1 uses backend-path for workspace consumption only. Phase 3 publishes to PyPI if external interest materializes.
  • Asset hashing / source-map handling — current vite.config.ts produces stable filenames (furo-tw.css, furo.js); if hashing is ever introduced the backend will need to emit a manifest.
  • Replacing sphinx-theme-builder for upstream Furo / sphinx-book-theme / pydata-sphinx-theme — sphinx-vite-builder complements rather than replaces.
  • Auto-installing pnpm — pnpm isn't pip-installable; the failure mode is "user runs corepack enable" not "backend bootstraps a Node env" (that's what sphinx-theme-builder's nodeenv does, deliberately not adopted because corepack is the modern Node convention).
  • Calling vite via Node's programmatic API — Phase 1 shells out via pnpm exec vite build. A future Phase could ship a Node helper script that imports 'vite' directly; not needed for the first iteration.

Status

Phase 1 = the new PR this issue tracks. Opens after PR #27 merges. Two-PR sequence:

  1. PR Fail loud when vite assets are missing or pnpm bootstrap fails #27 (open, ready for review) — runtime fail-loud diagnostics standalone
  2. New PR after Fail loud when vite assets are missing or pnpm bootstrap fails #27 merges — sphinx-vite-builder Phase 1 (backend head + subprocess core); Phase 2 (extension consolidation) and Phase 3 (PyPI publish) follow as separate PRs

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions