You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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:
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.
Sphinx extension — extensions = ["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).
The four research dossiers below collect everything we found in the upstream references (maturin, uv, flit, hatchling, sphinx-theme-builder, sphinx, myst-parser, furo, vite, vitest, pnpm) plus a handful of in-house async/subprocess + rich orchestration references (an async dev-server, a batch-orchestration script, and a build/dev pair from a docs pipeline).
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
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.
Absorbed from gp-sphinx-vite/process.py + bus.py. Production patterns we keep:
Asyncio-native subprocess — asyncio.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 isolation — os.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 richConsole
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 teardown — atexit.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
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__ importannotationsimporttypingastimporthatchling.buildas_hatchlingfrom ._internal.viteimportrun_vite_build, VitePhasedefbuild_wheel(wheel_directory, config_settings=None, metadata_directory=None):
run_vite_build(VitePhase.WHEEL)
return_hatchling.build_wheel(wheel_directory, config_settings, metadata_directory)
defbuild_editable(wheel_directory, config_settings=None, metadata_directory=None):
run_vite_build(VitePhase.EDITABLE)
return_hatchling.build_editable(wheel_directory, config_settings, metadata_directory)
defbuild_sdist(sdist_directory, config_settings=None):
run_vite_build(VitePhase.SDIST) # Pre-bake static/ so sdist→wheel works without pnpmreturn_hatchling.build_sdist(sdist_directory, config_settings)
# Optional hooks alias verbatim — no extra logicget_requires_for_build_wheel=_hatchling.get_requires_for_build_wheelget_requires_for_build_sdist=_hatchling.get_requires_for_build_sdistget_requires_for_build_editable=_hatchling.get_requires_for_build_editableprepare_metadata_for_build_wheel=_hatchling.prepare_metadata_for_build_wheelprepare_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:
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>=..."]
Scaffold packages/sphinx-vite-builder/ with pyproject.toml (hatchling-built), LICENSE, README.md
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).
Implement _internal/vite.py — orchestration module, fast-fail on missing pnpm/vite
Implement build.py — PEP 517 backend, ~50 LOC delegating to hatchling
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)
Drop the explicit pnpm exec vite build step from tests.ymldocs job and release.yml (the backend handles it transparently when uv sync / uv build runs)
Tests: tests/test_sphinx_vite_builder_build.py covers each PEP 517 hook + delegation correctness + missing-pnpm/vite paths
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)
Move gp-sphinx-vite's extension code (hooks.py, config.py, setup() in __init__.py) into sphinx_vite_builder/
Add sphinx_vite_builder to consumer conf.py extensions lists; remove gp_sphinx_vite
Deprecate or delete packages/gp-sphinx-vite/
Update docs/ justfile to drop start-docs / build-docs vite steps if Sphinx extension auto-orchestrates them
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
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-inited → builder-inited → env-before-read-docs → source-read → doctree-read → env-updated → write-started → doctree-resolved → build-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 reference — rich.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 event-driven build reference — EventBus 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.
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
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:
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:[build-system].build-backend = "sphinx_vite_builder.build"runspnpm exec vite buildbefore delegating wheel/sdist construction to hatchling. End userspip installfrom PyPI/sdist without needing pnpm; contributorsuv syncand the editable install Just Works.extensions = ["sphinx_vite_builder"]inconf.pyauto-orchestrates vite at docs-build time: one-shotvite buildforsphinx-build,vite build --watchchild process forsphinx-autobuild. No justfile, no Makefile, no manualpnpm exec vite buildstep.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 intosphinx-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), nowsphinx-vite-builder(vite).Study these first
sphinx-vite-builder's extension head replaces. The recipes and rationale there inform the extension's auto-hookup behavior.sphinx-vite-builderextends this with build-time equivalents in both heads.Action
Open a new PR after #27 merges that introduces
packages/sphinx-vite-builder/and consolidates the existinggp-sphinx-viteorchestration 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:
subprocess.rungp-sphinx-vitesphinx-buildandsphinx-autobuildrun vite automatically when extension is loadedArchitecture
Both
__init__.py:setup(Sphinx extension) andbuild.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.create_subprocess_exec()with PIPE'd stdout/stderr; concurrent line-by-line drainers viaasyncio.gather(*drainers, return_exceptions=True)to prevent deadlock (existing pattern atgp-sphinx-vite/process.py:74-187)process.py:terminate()), idempotent across repeated callsos.setsid()so SIGTERM kills the whole vite tree (improvement to add over current code)PYTHONUNBUFFERED=1injected into subprocess env so chained Python tools don't buffer[label]and route to a richConsolecall_sync()usesasyncio.run_coroutine_threadsafeto let sync Sphinx hooks await async coroutines (bus.py:93-121)atexit.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)web/dir alongsidepyproject.toml⇒ vite-managed; absent ⇒ unpacked sdist or non-vite package, no-opshutil.which("pnpm")check up-front; raisePnpmMissingErrorwithcorepack enable+ pnpm.io/installation hintsnode_modules/check; runpnpm install --frozen-lockfileif missingpnpm --filter <package> exec vite build(returns when complete)pnpm --filter <package> exec vite build --watch(long-livedSmartAsyncProcess)ViteFailedErroron non-zero exitLayer 3a — PEP 517 backend (
build.py)Mirrors flit_core's
buildapi.py(85 LOC) shape. Delegates wheel construction to hatchling (verified pure-function inhatchling/src/hatchling/build.py:17-84):run_vite_build()short-circuits whenweb/is absent (the unpacked-sdist case). The sdist contains the pre-bakedstatic/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 atsphinx/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;devif autobuild, elseproddev— spawnvite build --watch(long-lived; survives autobuild rebuilds)prod— runvite buildonce, block until donedisabled— 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 viaatexit+ signal handlerssphinx-autobuildhas no extension-facing hook protocol — it spawnssphinx-buildas a fresh subprocess per rebuild, so each invocation gets a newSphinx()app andbuilder-initedre-fires. The extension'sbuilder-initedhandler 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 existinggp-sphinx-vitepattern).raise sphinx.errors.ConfigError(msg)(defined atsphinx/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.tomldeclaresbuild-backend = "hatchling.build"(orflit_core.buildapi) — sphinx-vite-builder is itself a pure-Python package, no vite needed for its own buildpackages/gp-furo-theme/pyproject.tomldeclaresbuild-backend = "sphinx_vite_builder.build"+backend-path = ["../sphinx-vite-builder"]— pip/uv adds the in-tree directory tosys.pathso the backend resolves without PyPIbackend-pathand userequires = ["sphinx-vite-builder>=..."]Implementation phases
Phase 1 — Backend head + smart subprocess core (PR scope)
packages/sphinx-vite-builder/withpyproject.toml(hatchling-built),LICENSE,README.mdprocess.py+bus.pyfromgp-sphinx-vitetosphinx_vite_builder/_internal/. Addos.setsid()for POSIX process group isolation (improvement over current)._internal/vite.py— orchestration module, fast-fail on missing pnpm/vitebuild.py— PEP 517 backend, ~50 LOC delegating to hatchling_internal/errors.py—PnpmMissingError,ViteFailedError,NodeModulesInstallErrorgp-furo-theme/pyproject.tomlto use the new backend; dropforce-includeblocks (they're rejected by force-include-requires-disk-existence; the backend handles it)pnpm exec vite buildstep fromtests.ymldocsjob andrelease.yml(the backend handles it transparently whenuv sync/uv buildruns)tests/test_sphinx_vite_builder_build.pycovers each PEP 517 hook + delegation correctness + missing-pnpm/vite pathsuv syncfrom clean checkout works;uv build --package gp-furo-themeproduces wheel with assets; CI's deferredsmoke (gp-sphinx)andsmoke (sphinx-gp-theme)failures resolve naturally; no manual vite step needed anywherePhase 2 — Extension head (consolidate gp-sphinx-vite)
gp-sphinx-vite's extension code (hooks.py,config.py,setup()in__init__.py) intosphinx_vite_builder/sphinx_vite_builderto consumerconf.pyextensions lists; removegp_sphinx_vitepackages/gp-sphinx-vite/docs/justfile to dropstart-docs/ build-docs vite steps if Sphinx extension auto-orchestrates themsphinx.testing.util.SphinxTestApp+@pytest.mark.sphinx('html')(existing pattern insphinx/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-builderis 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_wheeldelegates to hatchling.build_wheel after running vite (mock hatchling, assert called)build_editabledelegates to hatchling.build_editablebuild_sdistruns vite + delegatesrun_vite_build()short-circuits whenweb/is absent (sdist-from-temp scenario)PnpmMissingErrorraised with actionable message whenshutil.which("pnpm")is NoneNodeModulesInstallErrorraised whenpnpm installexits non-zeroViteFailedErrorraised whenpnpm exec vite buildexits non-zerostatic/filesstatic/; wheel-from-sdist works without pnpmExtension head
setup(app)registers the two events + config values + returns the metadata dicton_builder_initedis idempotent (re-firing under autobuild rebuild → no second spawn)automode: detects autobuild via env / argv / parent-process; falls back toprodotherwisedevmode: spawnsvite build --watch; survivesbuild-finishedprodmode: runsvite buildonce; blocks until completiondisabledmode: no-opConfigErrorfrombuilder-initedwith the workspace+pnpm hintConfigErrorwith the build-context errorSmart subprocess core
PYTHONUNBUFFERED=1injected into subprocess envResearch 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/__init__.py:158-165) — theget_requires_for_build_wheelauto-install trick for Rust viapuccinialin. We don't replicate (pnpm isn't pip-installable), but the env-var bypass +shutil.which()pattern transfers.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/flit_core/flit_core/buildapi.py, 85 LOC) — the canonical PEP 517 backend module shape. Pure functions, CWD-relativepyproject.toml, hooks aliased where the implementation is identical.hatchling/src/hatchling/build.py:17-84) — verified pure-function delegation surface, no import-time state, readspyproject.tomlfrom CWD with no caller intervention. Wrappable in ~30 lines.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; usesnodeenvfor Node isolation. We delegate to hatchling and use system pnpm via corepack — significantly thinner. Bootstrap pattern (its own pyproject.toml usesflit_core.buildapi) directly informs our workspace bootstrap.Sphinx extension patterns
sphinx/sphinx/events.py) —builder-inited(line 115) andbuild-finished(line 242) are the two events we hook. Lifecycle:config-inited→builder-inited→env-before-read-docs→source-read→doctree-read→env-updated→write-started→doctree-resolved→build-finished.sphinx-buildas a fresh subprocess per rebuild. Extensions detect autobuild heuristically. The existinggp-sphinx-vite/config.py:detect_mode()covers env / argv / parent-process detection — port verbatim.furo/src/furo/__init__.py) — example of theme + extension dual-purpose package; the single-setup()pattern in__init__.pyis the cleanest. Furo refuses to load viaextensionsto avoid double-init; we don't (we ARE an extension).myst-parser/myst_parser/sphinx_ext/__init__.py) — minimal entry-point file; full extension logic in asphinx_ext/submodule. We follow this if__init__.pygrows 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.sphinx/tests/test_application.py) —sphinx.testing.util.SphinxTestApp+@pytest.mark.sphinx('html', testroot=...)is the canonical pattern.Subprocess + UI patterns
packages/gp-sphinx-vite/src/gp_sphinx_vite/) — full asyncio bus + ViteProcess + atexit/signal teardown design. Production-grade; absorb verbatim intosphinx_vite_builder/_internal/. Files:process.py(244 LOC),bus.py(194 LOC),hooks.py(274 LOC),config.py(192 LOC).asyncio.create_subprocess_exec+ line-by-line stream draining via async iterator overprocess.stdout. Reference for async stream consumption without deadlock.rich.live.Live+rich.table.Tablefor live status updates (refresh-per-second config, status table that updates from a manifest). Pattern for the build pipeline status panel.CommandOutputhelper class wrapping rich'sConsole,Panel,Progress,TaskID. Reference for structured CLI output (start/finish messages, multi-step progress bars, panel-styled error summaries).EventBuspub/sub + async subprocess in a builder subsystem; sameCommandOutputreuse. Reference for event-driven build orchestration whereBuildRequestedEvent/BuildCompletedEvent/FileChangedEventdecouple the builder from the watcher and server subsystems.vite/packages/vite/src/node/index.ts:29-39) — exportsbuild(),createServer(),preview(),defineConfig(),resolveConfig(). We shell out viapnpm exec vitefor now (Python→Node bridge is fragile); these exports are documented for if/when we ship a Node helper script.--filter— selects a workspace package by name pattern;pnpm-workspace.yamldefines the package set.pnpm --filter @scope/pkg exec <cmd>runs<cmd>in that package's context.Console,Live,Status,Progress,Panel, styledconsole.print(f"[green]✓[/green] ...").Specs
build_wheel,build_sdist,get_requires_for_build_*,prepare_metadata_for_build_*); also definesbackend-pathfor in-tree backendsbuild_editable,get_requires_for_build_editable,prepare_metadata_for_build_editable[project]table that hatchling readsget_requires_for_build_*returnsOut of scope
sphinx-vite-builderto PyPI — Phase 1 usesbackend-pathfor workspace consumption only. Phase 3 publishes to PyPI if external interest materializes.vite.config.tsproduces stable filenames (furo-tw.css,furo.js); if hashing is ever introduced the backend will need to emit a manifest.sphinx-theme-builderfor upstream Furo / sphinx-book-theme / pydata-sphinx-theme —sphinx-vite-buildercomplements rather than replaces.corepack enable" not "backend bootstraps a Node env" (that's what sphinx-theme-builder'snodeenvdoes, deliberately not adopted because corepack is the modern Node convention).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: