Product vision
Land the Sphinx-extension head of sphinx-vite-builder — the second of two orthogonal entry points the package was specced around in #28. Phase 1 (PR #29, merged) shipped the PEP 517 backend; this phase moves the working, production-quality gp-sphinx-vite Sphinx extension into sphinx_vite_builder.setup() so that:
# docs/conf.py
extensions = ["sphinx_vite_builder"]
…transparently runs pnpm exec vite build once for sphinx-build (prod) and a long-lived pnpm exec vite build --watch child process for sphinx-autobuild (dev), with graceful SIGTERM→SIGKILL teardown on signal / atexit.
After this lands, the workspace has one package owning vite end-to-end (build-time + docs-build-time), and gp-sphinx-vite is staged for retirement (Phase 3).
Study these first
Action
Open this PR after PR #29 merges. Single-PR scope.
Architecture
The Phase 1 backend already shares _internal/{bus.py, process.py, vite.py, errors.py} with the extension head. Phase 2 adds the extension-specific layers:
packages/sphinx-vite-builder/src/sphinx_vite_builder/
├── __init__.py ← real setup() (replaces the placeholder)
├── build.py ← unchanged from Phase 1
└── _internal/
├── bus.py ← unchanged
├── process.py ← unchanged (AsyncProcess)
├── vite.py ← unchanged (run_vite_build)
├── errors.py ← unchanged
├── config.py ← NEW (port of gp_sphinx_vite/config.py)
└── hooks.py ← NEW (port of gp_sphinx_vite/hooks.py)
_internal/config.py
Port of packages/gp-sphinx-vite/src/gp_sphinx_vite/config.py. Public surface:
Mode enum (DEV, PROD)
detect_mode(config_value, argv=None, env=None, parent_check=None) -> Mode — three-tier autobuild detection: env (SPHINX_AUTOBUILD) → argv (argv[0].endswith("sphinx-autobuild")) → parent process (/proc/<ppid>/cmdline)
resolve_vite_root(explicit) -> Path | None
SphinxViteBuilderConfig frozen dataclass with should_spawn: bool property
Renames: GpSphinxViteConfig → SphinxViteBuilderConfig. All other names stay.
Upstream grounding for the autobuild-detection-is-heuristic premise: ~/study/python/sphinx-autobuild/sphinx_autobuild/build.py:50 — subprocess.run([sys.executable, "-m", "sphinx", "build", ...]). Confirms there is no extension-facing hook protocol in sphinx-autobuild; in-process detection is unavoidable.
_internal/hooks.py
Port of packages/gp-sphinx-vite/src/gp_sphinx_vite/hooks.py. Public surface:
on_builder_inited(app: Sphinx) -> None — idempotent guard checks existing_proc.is_running and returns if already active (existing pattern at gp_sphinx_vite/hooks.py:125-129); auto-installs node_modules/ via pnpm install --frozen-lockfile if missing
on_build_finished(app: Sphinx, exception: BaseException | None) -> None — no-op for watch mode (process must survive the rebuild loop)
teardown(app: Sphinx, *, terminate_timeout: float = 5.0) -> None — idempotent SIGTERM→SIGKILL escalation
_install_teardown_handlers() — weak-ref signal handler registration with previous-handler chaining
Renames: app-private attribute names follow the package: _sphinx_vite_builder_bus, _sphinx_vite_builder_proc, _sphinx_vite_builder_teardown_registered.
Upstream grounding for the teardown design:
~/study/c/cpython/Lib/subprocess.py (start_new_session=True calls setsid() between fork and exec) → child becomes session/process-group leader, pgid == child.pid
~/study/c/cpython/Modules/posixmodule.c:10186-10209 (os.killpg(pgid, signal)) → os.killpg(child.pid, SIGTERM) reaches the entire vite process tree
~/study/c/cpython/Modules/atexitmodule.c:43-99 — atexit handlers run LIFO. Our handler must register early enough that Sphinx's own handlers don't interfere; install in setup() immediately, not lazily in builder-inited.
~/study/c/cpython/Lib/asyncio/unix_events.py:90-133 — signal.set_wakeup_fd() raises ValueError off the main thread. Our bus.py runs the asyncio loop in a daemon thread, so the extension must install signal handlers on the main thread via signal.signal() and dispatch into the bus via bus.call_sync(). (AsyncProcess already runs in _internal/process.py with start_new_session=True per recent commit 1bf54b0.)
__init__.py:setup() — full implementation
Replaces the placeholder. Registers two config values + connects two events. Per upstream Sphinx convention (~/study/python/sphinx/sphinx/application.py add_config_value(name, default, rebuild, types)):
app.add_config_value(
"sphinx_vite_builder_mode",
default="auto",
rebuild="env",
types=[str],
)
app.add_config_value(
"sphinx_vite_builder_root",
default=None,
rebuild="env",
types=[str, type(None)],
)
app.connect("builder-inited", hooks.on_builder_inited)
app.connect("build-finished", hooks.on_build_finished)
return {
"parallel_read_safe": True,
"parallel_write_safe": True,
"version": __version__,
}
Choice of rebuild="env": inherited verbatim from gp-sphinx-vite for behavioral parity. (Strict reading of the upstream contract — ~/study/python/sphinx/sphinx/config.py — would say rebuild="" for "no rebuild needed when toggle changes," but changing this is out of scope; preserve the existing behavior to keep the migration risk-free. Tracking note for a future cleanup.)
Choice of error class for "pnpm not on PATH" at extension-load time: sphinx.errors.ExtensionError(modname="sphinx_vite_builder", ...) — ~/study/python/sphinx/sphinx/errors.py:42-73. (ConfigError is for conf.py validation failures; ExtensionError is the right surface for extension-runtime failures with module attribution.) The PEP 517 backend keeps using its PnpmMissingError family — those are build-time, not Sphinx-runtime.
Double-load guard
Furo raises ConfigError if a user lists Furo in extensions (Sphinx auto-loads it as a theme). Sphinx-vite-builder's situation is different — it IS an extension, not a theme — so no double-load guard is needed. Documented for the record.
Consumer migration sweep
The package-rename touches these call sites; this checklist is the body of the work:
| File |
Change |
packages/gp-sphinx/src/gp_sphinx/config.py:376-381 |
Replace "gp_sphinx_vite" with "sphinx_vite_builder" in the vite_orchestration=True auto-injection block |
packages/gp-sphinx/src/gp_sphinx/config.py:495-497 |
Rename gp_sphinx_vite_root → sphinx_vite_builder_root config-value setter |
packages/gp-sphinx/src/gp_sphinx/config.py:276-277 |
Update docstring: "gp_sphinx_vite" → "sphinx_vite_builder" |
packages/gp-furo-theme/src/gp_furo_theme/__init__.py:420-421 |
Update get_vite_root() docstring example: gp_sphinx_vite_root → sphinx_vite_builder_root |
docs/justfile:43-61 (_assets-build) and lines 64,70,76,82,125,131,137 (the : _assets-build prerequisites) |
Drop entirely — the extension handles it. Remove the _assets-build recipe and remove the prerequisite from html, dirhtml, singlehtml, epub, htmlhelp, qthelp, devhelp |
docs/justfile:118-122 (clean) |
Keep the second rm -rf line (the static dir cleanup) but it now happens before the extension regenerates |
docs/conf.py:99 |
Update comment to reference sphinx_vite_builder |
tests/test_config.py:128-159 |
Rename test names + assertions: gp_sphinx_vite → sphinx_vite_builder |
tests/test_gp_sphinx_vite{,_bus,_hooks,_integration,_process}.py |
Move and rename to tests/test_sphinx_vite_builder_{config,hooks,integration}.py. _bus.py and _process.py tests are already covered by test_sphinx_vite_builder_{process,vite}.py from PR #29 — diff and merge any unique cases (e.g., the autobuild-detection parametrization in _hooks.py) into the new files |
scripts/ci/package_tools.py:531-563,862 |
Rename smoke_gp_sphinx_vite → smoke_sphinx_vite_builder_extension (the existing smoke_sphinx_vite_builder from PR #29 covers the backend; this new one covers the extension) |
docs/packages/gp-sphinx-vite.md |
Replace body with a deprecation notice pointing at sphinx-vite-builder; keep the page so existing inbound links 404-free. Rediraffe entry: docs/packages/gp-sphinx-vite.md docs/packages/sphinx-vite-builder.md |
docs/packages/sphinx-vite-builder.md |
Walk back the Phase-1 placeholder framing in the "Sphinx extension" section; replace with the now-real lifecycle docs (modes, config values, autobuild detection) |
README.md:65, docs/index.md:136, docs/packages/index.md:38, docs/architecture.md:143-144 |
Update package-list entries pointing at gp-sphinx-vite to point at sphinx-vite-builder |
CI workflow simplification
.github/workflows/tests.yml:92-102 (the docs job pnpm/Node setup) — the docs job actually needs to build docs, so the toolchain stays. Keep this section as-is.
.github/workflows/release.yml:53-62 is unchanged — Phase 2 doesn't affect the release path; the AGENTS.md guarantee on those steps still holds.
Required tests
Port-with-rename (existing gp-sphinx-vite tests cover all behaviors):
Out of scope
- Switching the autobuild detection to a public sphinx-autobuild hook protocol — there isn't one (verified at
~/study/python/sphinx-autobuild/sphinx_autobuild/build.py:50).
- Changing
rebuild="env" to rebuild="" for the mode/root config values — out of scope; preserve gp-sphinx-vite's existing behavior.
- Decommissioning the
gp-sphinx-vite package itself — that's Phase 3. This issue renames consumers but leaves packages/gp-sphinx-vite/ in place as an empty deprecation shim.
- Hooking vite's programmatic API (
import { build } from 'vite', returning a RolldownWatcher) — out of scope; subprocess invocation via pnpm exec vite build [--watch] stays. (Reference for a future investigation: ~/study/typescript/vite/packages/vite/src/node/build.ts:545-551,839-864 — vite emits BUNDLE_START/BUNDLE_END/ERROR events; consuming them would require a Node-side helper script.)
Status
Phase 1 = PR #29 (merged). This issue tracks Phase 2. Phase 3 = #31.
🤖 Generated with Claude Code
Product vision
Land the Sphinx-extension head of
sphinx-vite-builder— the second of two orthogonal entry points the package was specced around in #28. Phase 1 (PR #29, merged) shipped the PEP 517 backend; this phase moves the working, production-qualitygp-sphinx-viteSphinx extension intosphinx_vite_builder.setup()so that:…transparently runs
pnpm exec vite buildonce forsphinx-build(prod) and a long-livedpnpm exec vite build --watchchild process forsphinx-autobuild(dev), with graceful SIGTERM→SIGKILL teardown on signal /atexit.After this lands, the workspace has one package owning vite end-to-end (build-time + docs-build-time), and
gp-sphinx-viteis staged for retirement (Phase 3).Study these first
sphinx-vite-builder: PEP 517 backend + Sphinx extension for vite-aware projects #28 — the original spec; this issue inherits its architecture and "two heads, one subprocess core" framing.sphinx-vite-builder: PEP 517 backend + Sphinx extension for vite-aware projects #29 (closes Buildsphinx-vite-builder: PEP 517 backend + Sphinx extension for vite-aware projects #28) — Phase 1, already-merged backend head +_internal/{bus.py,process.py,vite.py,errors.py}.packages/gp-sphinx-vite/— the current working extension; itshooks.py(244 LOC) andconfig.py(191 LOC) are what we port.setup()already connectsbuilder-inited→on_builder_initedandbuild-finished→on_build_finished; the docstring's "subprocess orchestration … lands in subsequent commits" framing is stale — the orchestration is fully implemented and shipping.packages/sphinx-vite-builder/src/sphinx_vite_builder/__init__.py:30-57— the placeholdersetup()we're replacing. Currentlydel app; return {parallel_read_safe, parallel_write_safe, version}.Action
Open this PR after PR #29 merges. Single-PR scope.
Architecture
The Phase 1 backend already shares
_internal/{bus.py, process.py, vite.py, errors.py}with the extension head. Phase 2 adds the extension-specific layers:_internal/config.pyPort of
packages/gp-sphinx-vite/src/gp_sphinx_vite/config.py. Public surface:Modeenum (DEV,PROD)detect_mode(config_value, argv=None, env=None, parent_check=None) -> Mode— three-tier autobuild detection: env (SPHINX_AUTOBUILD) → argv (argv[0].endswith("sphinx-autobuild")) → parent process (/proc/<ppid>/cmdline)resolve_vite_root(explicit) -> Path | NoneSphinxViteBuilderConfigfrozen dataclass withshould_spawn: boolpropertyRenames:
GpSphinxViteConfig→SphinxViteBuilderConfig. All other names stay.Upstream grounding for the autobuild-detection-is-heuristic premise:
~/study/python/sphinx-autobuild/sphinx_autobuild/build.py:50—subprocess.run([sys.executable, "-m", "sphinx", "build", ...]). Confirms there is no extension-facing hook protocol in sphinx-autobuild; in-process detection is unavoidable._internal/hooks.pyPort of
packages/gp-sphinx-vite/src/gp_sphinx_vite/hooks.py. Public surface:on_builder_inited(app: Sphinx) -> None— idempotent guard checksexisting_proc.is_runningand returns if already active (existing pattern atgp_sphinx_vite/hooks.py:125-129); auto-installsnode_modules/viapnpm install --frozen-lockfileif missingon_build_finished(app: Sphinx, exception: BaseException | None) -> None— no-op for watch mode (process must survive the rebuild loop)teardown(app: Sphinx, *, terminate_timeout: float = 5.0) -> None— idempotent SIGTERM→SIGKILL escalation_install_teardown_handlers()— weak-ref signal handler registration with previous-handler chainingRenames: app-private attribute names follow the package:
_sphinx_vite_builder_bus,_sphinx_vite_builder_proc,_sphinx_vite_builder_teardown_registered.Upstream grounding for the teardown design:
~/study/c/cpython/Lib/subprocess.py(start_new_session=Truecallssetsid()between fork and exec) → child becomes session/process-group leader,pgid == child.pid~/study/c/cpython/Modules/posixmodule.c:10186-10209(os.killpg(pgid, signal)) →os.killpg(child.pid, SIGTERM)reaches the entire vite process tree~/study/c/cpython/Modules/atexitmodule.c:43-99— atexit handlers run LIFO. Our handler must register early enough that Sphinx's own handlers don't interfere; install insetup()immediately, not lazily inbuilder-inited.~/study/c/cpython/Lib/asyncio/unix_events.py:90-133—signal.set_wakeup_fd()raisesValueErroroff the main thread. Ourbus.pyruns the asyncio loop in a daemon thread, so the extension must install signal handlers on the main thread viasignal.signal()and dispatch into the bus viabus.call_sync(). (AsyncProcessalready runs in_internal/process.pywithstart_new_session=Trueper recent commit1bf54b0.)__init__.py:setup()— full implementationReplaces the placeholder. Registers two config values + connects two events. Per upstream Sphinx convention (
~/study/python/sphinx/sphinx/application.pyadd_config_value(name, default, rebuild, types)):Choice of
rebuild="env": inherited verbatim from gp-sphinx-vite for behavioral parity. (Strict reading of the upstream contract —~/study/python/sphinx/sphinx/config.py— would sayrebuild=""for "no rebuild needed when toggle changes," but changing this is out of scope; preserve the existing behavior to keep the migration risk-free. Tracking note for a future cleanup.)Choice of error class for "pnpm not on PATH" at extension-load time:
sphinx.errors.ExtensionError(modname="sphinx_vite_builder", ...)—~/study/python/sphinx/sphinx/errors.py:42-73. (ConfigErroris forconf.pyvalidation failures;ExtensionErroris the right surface for extension-runtime failures with module attribution.) The PEP 517 backend keeps using itsPnpmMissingErrorfamily — those are build-time, not Sphinx-runtime.Double-load guard
Furo raises
ConfigErrorif a user lists Furo inextensions(Sphinx auto-loads it as a theme). Sphinx-vite-builder's situation is different — it IS an extension, not a theme — so no double-load guard is needed. Documented for the record.Consumer migration sweep
The package-rename touches these call sites; this checklist is the body of the work:
packages/gp-sphinx/src/gp_sphinx/config.py:376-381"gp_sphinx_vite"with"sphinx_vite_builder"in thevite_orchestration=Trueauto-injection blockpackages/gp-sphinx/src/gp_sphinx/config.py:495-497gp_sphinx_vite_root→sphinx_vite_builder_rootconfig-value setterpackages/gp-sphinx/src/gp_sphinx/config.py:276-277"gp_sphinx_vite"→"sphinx_vite_builder"packages/gp-furo-theme/src/gp_furo_theme/__init__.py:420-421get_vite_root()docstring example:gp_sphinx_vite_root→sphinx_vite_builder_rootdocs/justfile:43-61(_assets-build) and lines 64,70,76,82,125,131,137 (the: _assets-buildprerequisites)_assets-buildrecipe and remove the prerequisite fromhtml,dirhtml,singlehtml,epub,htmlhelp,qthelp,devhelpdocs/justfile:118-122(clean)rm -rfline (the static dir cleanup) but it now happens before the extension regeneratesdocs/conf.py:99sphinx_vite_buildertests/test_config.py:128-159gp_sphinx_vite→sphinx_vite_buildertests/test_gp_sphinx_vite{,_bus,_hooks,_integration,_process}.pytests/test_sphinx_vite_builder_{config,hooks,integration}.py._bus.pyand_process.pytests are already covered bytest_sphinx_vite_builder_{process,vite}.pyfrom PR #29 — diff and merge any unique cases (e.g., the autobuild-detection parametrization in_hooks.py) into the new filesscripts/ci/package_tools.py:531-563,862smoke_gp_sphinx_vite→smoke_sphinx_vite_builder_extension(the existingsmoke_sphinx_vite_builderfrom PR #29 covers the backend; this new one covers the extension)docs/packages/gp-sphinx-vite.mdsphinx-vite-builder; keep the page so existing inbound links 404-free. Rediraffe entry:docs/packages/gp-sphinx-vite.md docs/packages/sphinx-vite-builder.mddocs/packages/sphinx-vite-builder.mdREADME.md:65,docs/index.md:136,docs/packages/index.md:38,docs/architecture.md:143-144gp-sphinx-viteto point atsphinx-vite-builderCI workflow simplification
.github/workflows/tests.yml:92-102(the docs job pnpm/Node setup) — the docs job actually needs to build docs, so the toolchain stays. Keep this section as-is..github/workflows/release.yml:53-62is unchanged — Phase 2 doesn't affect the release path; the AGENTS.md guarantee on those steps still holds.Required tests
Port-with-rename (existing gp-sphinx-vite tests cover all behaviors):
setup(app)registers two config values + connects two events + returns the metadata dicton_builder_initedis idempotent under autobuild rebuild → no second spawn (the existinggp_sphinx_vite_hooks.py:105-117test pattern)automode: detects autobuild via env / argv / parent-process; falls back toprodotherwise (parametrizedModeresolution)devmode: spawnsvite build --watch; survivesbuild-finishedprodmode: runsvite buildonce; blocks until completionExtensionError(modname="sphinx_vite_builder")frombuilder-initedwith the workspace+pnpm hintExtensionErrorwith the build-context erroratexittriggers process cleanupos.killpg(pid, SIGTERM)reaches the whole treepytest.mark.sphinx(already used intests/test_gp_sphinx_vite_integration.py) — fresh build with extension loaded, fake-vite shell script invoker, assert build completesOut of scope
~/study/python/sphinx-autobuild/sphinx_autobuild/build.py:50).rebuild="env"torebuild=""for the mode/root config values — out of scope; preserve gp-sphinx-vite's existing behavior.gp-sphinx-vitepackage itself — that's Phase 3. This issue renames consumers but leavespackages/gp-sphinx-vite/in place as an empty deprecation shim.import { build } from 'vite', returning aRolldownWatcher) — out of scope; subprocess invocation viapnpm exec vite build [--watch]stays. (Reference for a future investigation:~/study/typescript/vite/packages/vite/src/node/build.ts:545-551,839-864— vite emitsBUNDLE_START/BUNDLE_END/ERRORevents; consuming them would require a Node-side helper script.)Status
Phase 1 = PR #29 (merged). This issue tracks Phase 2. Phase 3 = #31.
🤖 Generated with Claude Code