Skip to content

Phase 2: Consolidate gp-sphinx-vite Sphinx extension into sphinx-vite-builder #30

@tony

Description

@tony

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: GpSphinxViteConfigSphinxViteBuilderConfig. All other names stay.

Upstream grounding for the autobuild-detection-is-heuristic premise: ~/study/python/sphinx-autobuild/sphinx_autobuild/build.py:50subprocess.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-133signal.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_rootsphinx_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_rootsphinx_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_vitesphinx_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_vitesmoke_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):

  • setup(app) registers two config values + connects two events + returns the metadata dict
  • on_builder_inited is idempotent under autobuild rebuild → no second spawn (the existing gp_sphinx_vite_hooks.py:105-117 test pattern)
  • auto mode: detects autobuild via env / argv / parent-process; falls back to prod otherwise (parametrized Mode resolution)
  • dev mode: spawns vite build --watch; survives build-finished
  • prod mode: runs vite build once; blocks until completion
  • Missing pnpm: raises ExtensionError(modname="sphinx_vite_builder") from builder-inited with the workspace+pnpm hint
  • Vite spawn failure: raises ExtensionError with the build-context error
  • Teardown: SIGTERM/SIGINT/SIGHUP triggers process cleanup
  • Teardown: atexit triggers process cleanup
  • POSIX: child runs in new session; os.killpg(pid, SIGTERM) reaches the whole tree
  • Sphinx integration: pytest.mark.sphinx (already used in tests/test_gp_sphinx_vite_integration.py) — fresh build with extension loaded, fake-vite shell script invoker, assert build completes

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions