From 0e217d8952f24d9939de1e24c5fd789506e445fc Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 3 May 2026 05:01:00 -0500 Subject: [PATCH 01/53] pkg(sphinx-vite-builder): scaffold backend + Sphinx-extension package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: closes #28's Phase 1. The hatchling `force-include` approach for shipping vite-built static assets in `gp-furo-theme`'s wheel is structurally incompatible with editable installs (`recurse_forced_files` requires the source path to exist on disk during `build_editable`, which it doesn't on a fresh checkout — every CI workflow and every new contributor's first `uv sync` blew up with `Forced include not found`). The architectural fix is the same pattern maturin uses for Cargo + Rust and sphinx-theme-builder uses for webpack + Sphinx themes: a custom PEP 517 build backend that owns the native toolchain end-to-end. This commit introduces that backend and its shared subprocess core; gp-furo-theme migration lands in a follow-up commit on this branch. what: - packages/sphinx-vite-builder/ — new workspace package - pyproject.toml — hatchling-built; registers the Sphinx extension entry point and ships the PEP 517 backend module - src/sphinx_vite_builder/__init__.py — Sphinx extension setup() stub returning parallel-safety metadata; full event-handler implementation lands in a follow-up commit - src/sphinx_vite_builder/build.py — PEP 517/660 backend module. Each hook runs `run_vite_build()` then delegates to the matching hatchling.build hook. Optional hooks (get_requires_*, prepare_metadata_*) are aliased verbatim to hatchling — vite has no influence on dependency resolution or distribution metadata. - src/sphinx_vite_builder/_internal/process.py — AsyncProcess. Asyncio subprocess wrapper with line-buffered stdout/stderr drainers, captured stderr for error surfacing, and SIGTERM-then-SIGKILL graceful teardown. POSIX builds use `start_new_session=True` so SIGTERM kills the entire `pnpm exec` process tree, not just the top-level wrapper. - src/sphinx_vite_builder/_internal/bus.py — AsyncioBus. Single asyncio loop in a daemon thread; `call_sync()` uses `asyncio.run_coroutine_threadsafe` so sync Sphinx hooks can await async coroutines. Single-use — `start()` raises if the bus has been stopped. - src/sphinx_vite_builder/_internal/vite.py — orchestration core. `run_vite_build(project_root)` short-circuits when `SPHINX_VITE_BUILDER_SKIP=1` or when the expected `web/` dir is absent (unpacked-sdist case). Otherwise fast-fails with `PnpmMissingError` (pnpm not on PATH; hint surfaces `corepack enable` + pnpm.io install URL), `NodeModulesInstallError` (`pnpm install` non-zero exit; hint surfaces stderr + rerun recipe), or `ViteFailedError` (`pnpm exec vite build` non-zero exit; hint surfaces stderr + cwd). - src/sphinx_vite_builder/_internal/errors.py — diagnostic error hierarchy. SphinxViteBuilderError base; subclasses for each fast-fail axis. - py.typed — PEP 561 marker - tests/test_sphinx_vite_builder.py — package-skeleton coverage: version lockstep, setup() metadata shape, sphinx.extensions entry point discovery, public surface - tests/test_sphinx_vite_builder_build.py — PEP 517 backend coverage: every build_* hook runs vite then delegates to hatchling (verified by spy ordering + arg forwarding); optional hooks alias hatchling by identity; module exposes the full PEP 517+660 hook surface - tests/test_sphinx_vite_builder_vite.py — orchestration coverage: command-helper argv shape; SPHINX_VITE_BUILDER_SKIP escape hatch; short-circuit when `web/` absent or lacks package.json; fast-fail paths for pnpm-missing / install-failed / vite-failed; install branch fires only when node_modules is absent - pyproject.toml — register sphinx-vite-builder as a workspace member + dev-group dependency - docs/packages/sphinx-vite-builder.md — package reference page - docs/redirects.txt — extensions/sphinx-vite-builder → packages/... redirect (matches the workspace convention) - scripts/ci/package_tools.py — smoke runner (`smoke_sphinx_vite_builder`) + matrix entry; builds the wheel, installs into a temp venv, asserts every hook + setup() are callable - tests/test_package_reference.py — expected workspace package set gains `sphinx-vite-builder` Verified: 1339 passed, 159 skipped (was 1317 — +22 from the new package's tests). `just build-docs` produces a clean docs site; the new package page renders and the redirect resolves. Ruff clean, mypy clean across 207 source files. References: - Issue #28 — full architecture / phasing / required tests - maturin's get_requires_for_build_wheel auto-install pattern: https://github.com/PyO3/maturin (maturin/__init__.py) - sphinx-theme-builder (webpack analog): https://github.com/pradyunsg/sphinx-theme-builder - flit_core's buildapi.py — canonical PEP 517 backend shape: https://github.com/pypa/flit/blob/main/flit_core/flit_core/buildapi.py - hatchling.build — pure-function delegation surface: https://github.com/pypa/hatch/blob/master/backend/src/hatchling/build.py - PEP 517: https://peps.python.org/pep-0517/ - PEP 660: https://peps.python.org/pep-0660/ --- docs/packages/sphinx-vite-builder.md | 63 ++++ docs/redirects.txt | 1 + packages/sphinx-vite-builder/README.md | 78 +++++ packages/sphinx-vite-builder/pyproject.toml | 49 ++++ .../src/sphinx_vite_builder/__init__.py | 43 +++ .../sphinx_vite_builder/_internal/__init__.py | 7 + .../src/sphinx_vite_builder/_internal/bus.py | 191 ++++++++++++ .../sphinx_vite_builder/_internal/errors.py | 42 +++ .../sphinx_vite_builder/_internal/process.py | 242 ++++++++++++++++ .../src/sphinx_vite_builder/_internal/vite.py | 271 ++++++++++++++++++ .../src/sphinx_vite_builder/build.py | 94 ++++++ .../src/sphinx_vite_builder/py.typed | 0 pyproject.toml | 2 + scripts/ci/package_tools.py | 24 ++ tests/test_package_reference.py | 1 + tests/test_sphinx_vite_builder.py | 43 +++ tests/test_sphinx_vite_builder_build.py | 139 +++++++++ tests/test_sphinx_vite_builder_vite.py | 266 +++++++++++++++++ uv.lock | 46 ++- 19 files changed, 1601 insertions(+), 1 deletion(-) create mode 100644 docs/packages/sphinx-vite-builder.md create mode 100644 packages/sphinx-vite-builder/README.md create mode 100644 packages/sphinx-vite-builder/pyproject.toml create mode 100644 packages/sphinx-vite-builder/src/sphinx_vite_builder/__init__.py create mode 100644 packages/sphinx-vite-builder/src/sphinx_vite_builder/_internal/__init__.py create mode 100644 packages/sphinx-vite-builder/src/sphinx_vite_builder/_internal/bus.py create mode 100644 packages/sphinx-vite-builder/src/sphinx_vite_builder/_internal/errors.py create mode 100644 packages/sphinx-vite-builder/src/sphinx_vite_builder/_internal/process.py create mode 100644 packages/sphinx-vite-builder/src/sphinx_vite_builder/_internal/vite.py create mode 100644 packages/sphinx-vite-builder/src/sphinx_vite_builder/build.py create mode 100644 packages/sphinx-vite-builder/src/sphinx_vite_builder/py.typed create mode 100644 tests/test_sphinx_vite_builder.py create mode 100644 tests/test_sphinx_vite_builder_build.py create mode 100644 tests/test_sphinx_vite_builder_vite.py diff --git a/docs/packages/sphinx-vite-builder.md b/docs/packages/sphinx-vite-builder.md new file mode 100644 index 00000000..24c6e84b --- /dev/null +++ b/docs/packages/sphinx-vite-builder.md @@ -0,0 +1,63 @@ +# sphinx-vite-builder + +```{gp-sphinx-package-meta} sphinx-vite-builder +``` + +A PEP 517 build backend and Sphinx extension that orchestrates +[Vite](https://vitejs.dev/) builds via [pnpm](https://pnpm.io/) for any +Sphinx-theme package whose static assets (CSS / JS) are produced by a +JavaScript toolchain. The same pattern that +[maturin](https://github.com/PyO3/maturin) uses for Rust+Python and +that [sphinx-theme-builder](https://github.com/pradyunsg/sphinx-theme-builder) +uses for webpack, applied to vite + pnpm. + +```console +$ pip install sphinx-vite-builder +``` + +## Two heads, one core + +### PEP 517 build backend + +Drop-in replacement for `hatchling.build`. Runs `pnpm exec vite build` +before delegating wheel/sdist construction to hatchling. End users who +`pip install` from PyPI don't need pnpm or Node — the wheel ships with +the static assets already populated. + +```toml +# packages/your-theme/pyproject.toml +[build-system] +requires = ["hatchling>=1.0", "sphinx-vite-builder"] +build-backend = "sphinx_vite_builder.build" +backend-path = ["../sphinx-vite-builder/src"] # for in-tree workspace consumption +``` + +### Sphinx extension + +Loaded from `conf.py`. Hooks the Sphinx event lifecycle so +`sphinx-build` / `sphinx-autobuild` automatically run the right vite +invocation — one-shot for production builds, watch mode for autobuild — +without contributors needing a justfile or Makefile. + +```python +# docs/conf.py +extensions = ["sphinx_vite_builder"] +``` + +## Fast-fail diagnostics + +When prerequisites are missing the backend / extension raises +actionable errors rather than producing broken output: + +- `PnpmMissingError` — `pnpm` not on `PATH`; hint includes + `corepack enable` and the [pnpm.io/installation](https://pnpm.io/installation) URL +- `NodeModulesInstallError` — `pnpm install` exited non-zero; hint + includes the rerun command +- `ViteFailedError` — `pnpm exec vite build` exited non-zero; hint + surfaces the captured stderr + +Set `SPHINX_VITE_BUILDER_SKIP=1` in the environment to short-circuit +the backend (e.g., when an external orchestration handles vite). + +```{package-reference} sphinx-vite-builder +``` diff --git a/docs/redirects.txt b/docs/redirects.txt index e2d13039..1545924c 100644 --- a/docs/redirects.txt +++ b/docs/redirects.txt @@ -14,6 +14,7 @@ extensions/sphinx-fonts packages/sphinx-fonts extensions/sphinx-gp-theme packages/sphinx-gp-theme extensions/gp-furo-theme packages/gp-furo-theme extensions/gp-sphinx-vite packages/gp-sphinx-vite +extensions/sphinx-vite-builder packages/sphinx-vite-builder extensions/sphinx-autodoc-typehints-gp packages/sphinx-autodoc-typehints-gp extensions/sphinx-argparse-neo packages/sphinx-autodoc-argparse extensions/sphinx-gptheme packages/sphinx-gp-theme diff --git a/packages/sphinx-vite-builder/README.md b/packages/sphinx-vite-builder/README.md new file mode 100644 index 00000000..fd3e81cb --- /dev/null +++ b/packages/sphinx-vite-builder/README.md @@ -0,0 +1,78 @@ +# sphinx-vite-builder + +PEP 517 build backend and Sphinx extension that transparently orchestrates +[Vite](https://vitejs.dev/) builds via [pnpm](https://pnpm.io/) for +Sphinx-theme packages whose static assets (CSS / JS) are produced by a +JavaScript toolchain. + +## What it solves + +A common pattern for modern Sphinx themes is a Python package whose +`theme//static/` directory ships built CSS and JS that were +produced by a JS build tool (Vite, webpack, …). The build artefacts are +gitignored — they're reproducibly built, not source code. But that +creates two friction points: + +1. **Editable installs and source-tree builds** crash with confusing + errors when the static dir is empty (e.g. hatchling's + `Forced include not found`). +2. **CI workflows** must duplicate `pnpm install + vite build` setup + steps in every job that touches the package. + +`sphinx-vite-builder` owns the Vite invocation end-to-end — exactly the +way [maturin](https://github.com/PyO3/maturin) owns Cargo for +Rust+Python packages, or +[sphinx-theme-builder](https://github.com/pradyunsg/sphinx-theme-builder) +owns webpack for older Sphinx themes. + +## Two heads, one subprocess core + +### PEP 517 build backend + +Drop-in replacement for `hatchling.build`. Runs `pnpm exec vite build` +before delegating wheel/sdist construction to hatchling. + +```toml +# packages/your-theme/pyproject.toml +[build-system] +requires = ["hatchling>=1.0", "sphinx-vite-builder"] +build-backend = "sphinx_vite_builder.build" +backend-path = ["../sphinx-vite-builder/src"] # for in-tree workspace consumption +``` + +The backend short-circuits when `web/` (the Vite source tree) is absent +— so `pip install .tar.gz` from an unpacked sdist works without +pnpm or Node, because the sdist already contains pre-baked +`static/`. + +### Sphinx extension + +Loaded from `conf.py`. Runs Vite as part of the docs lifecycle: + +- `sphinx-build` → `pnpm exec vite build` once before the docs build +- `sphinx-autobuild` → `pnpm exec vite build --watch` as a child process + for the lifetime of the autobuild server, with idempotent re-fire on + rebuilds and graceful teardown on signal / `atexit` + +```python +# docs/conf.py +extensions = ["sphinx_vite_builder"] +sphinx_vite_root = "../packages/your-theme/web" # path to vite project +sphinx_vite_mode = "auto" # auto | dev | prod | disabled +``` + +## Fast-fail diagnostics + +When prerequisites are missing the backend / extension raises +actionable errors rather than producing broken output: + +- `PnpmMissingError` — `pnpm` not on `PATH`; hint includes + `corepack enable` and the `pnpm.io` install URL +- `NodeModulesInstallError` — `pnpm install` exited non-zero; hint + includes the rerun command +- `ViteFailedError` — `pnpm exec vite build` exited non-zero; hint + surfaces the captured stderr + +## License + +MIT — see [LICENSE](LICENSE). diff --git a/packages/sphinx-vite-builder/pyproject.toml b/packages/sphinx-vite-builder/pyproject.toml new file mode 100644 index 00000000..c1163b33 --- /dev/null +++ b/packages/sphinx-vite-builder/pyproject.toml @@ -0,0 +1,49 @@ +[project] +name = "sphinx-vite-builder" +version = "0.0.1a15" +description = "PEP 517 build backend + Sphinx extension that orchestrates Vite via pnpm" +requires-python = ">=3.10,<4.0" +authors = [ + {name = "Tony Narlock", email = "tony@git-pull.com"} +] +license = { text = "MIT" } +classifiers = [ + "Development Status :: 3 - Alpha", + "License :: OSI Approved :: MIT License", + "Framework :: Sphinx", + "Framework :: Sphinx :: Extension", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Topic :: Documentation", + "Topic :: Documentation :: Sphinx", + "Topic :: Software Development :: Build Tools", + "Typing :: Typed", +] +readme = "README.md" +keywords = ["sphinx", "extension", "vite", "pnpm", "pep517", "build", "backend"] +# Both heads (PEP 517 backend + Sphinx extension) share a single subprocess +# core. Sphinx is required at runtime for the extension head; hatchling is +# required at build time of consumer packages but not at runtime, so it +# stays in [build-system].requires of *consumers* rather than here. +dependencies = [ + "hatchling>=1.0", + "sphinx>=8.1", +] + +[project.entry-points."sphinx.extensions"] +"sphinx-vite-builder" = "sphinx_vite_builder" + +[project.urls] +Repository = "https://github.com/git-pull/gp-sphinx" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/sphinx_vite_builder"] diff --git a/packages/sphinx-vite-builder/src/sphinx_vite_builder/__init__.py b/packages/sphinx-vite-builder/src/sphinx_vite_builder/__init__.py new file mode 100644 index 00000000..587edb8d --- /dev/null +++ b/packages/sphinx-vite-builder/src/sphinx_vite_builder/__init__.py @@ -0,0 +1,43 @@ +"""sphinx-vite-builder: PEP 517 backend + Sphinx extension. + +Two orthogonal entry points share one subprocess core: + +- :mod:`sphinx_vite_builder.build` — the PEP 517 backend module that + consumer packages reference via + ``[build-system].build-backend = "sphinx_vite_builder.build"``. +- :func:`sphinx_vite_builder.setup` — the Sphinx extension entry point + that ``conf.py`` references via + ``extensions = ["sphinx_vite_builder"]``. + +Neither head calls the other; they share the implementation modules +under :mod:`sphinx_vite_builder._internal`. +""" + +from __future__ import annotations + +import typing as t + +__version__ = "0.0.1a15" + +if t.TYPE_CHECKING: + from sphinx.application import Sphinx + + +def setup(app: Sphinx) -> dict[str, t.Any]: + """Register the Sphinx-extension head. + + Phase 1 ships the PEP 517 backend; the extension head's full + implementation (vite watch on ``sphinx-autobuild``, one-shot build + on ``sphinx-build``) lands in a follow-up commit. For now this + stub registers the extension so consumers can declare it without + a no-such-module error, and returns the safety metadata. + """ + del app + return { + "parallel_read_safe": True, + "parallel_write_safe": True, + "version": __version__, + } + + +__all__ = ("__version__", "setup") diff --git a/packages/sphinx-vite-builder/src/sphinx_vite_builder/_internal/__init__.py b/packages/sphinx-vite-builder/src/sphinx_vite_builder/_internal/__init__.py new file mode 100644 index 00000000..a97a02ba --- /dev/null +++ b/packages/sphinx-vite-builder/src/sphinx_vite_builder/_internal/__init__.py @@ -0,0 +1,7 @@ +"""Private implementation modules for ``sphinx-vite-builder``. + +Anything imported from this package is *not* part of the stable public +surface — both heads (PEP 517 backend in :mod:`sphinx_vite_builder.build` +and Sphinx extension in :mod:`sphinx_vite_builder`) reach in here, but +external callers should not. +""" diff --git a/packages/sphinx-vite-builder/src/sphinx_vite_builder/_internal/bus.py b/packages/sphinx-vite-builder/src/sphinx_vite_builder/_internal/bus.py new file mode 100644 index 00000000..f4042987 --- /dev/null +++ b/packages/sphinx-vite-builder/src/sphinx_vite_builder/_internal/bus.py @@ -0,0 +1,191 @@ +"""Thread + asyncio event-loop bridge. + +Sphinx's event hooks (``builder-inited``, ``build-finished``, …) are +synchronous callables. The orchestration logic that they drive is +asyncio-based (:class:`sphinx_vite_builder._internal.process.AsyncProcess` +uses ``asyncio.create_subprocess_exec``, pipe drainers, etc.). The bridge +between them is a *single* event loop running in a single daemon +thread, kept alive across ``builder-inited`` re-fires for +``sphinx-autobuild``. + +Usage from a Sphinx hook: + +.. code-block:: python + + bus = AsyncioBus() + bus.start() + bus.call_sync(some_coro()) + # ... + bus.stop(timeout=5.0) + +The bus has no Sphinx-specific knowledge; tests construct one and drive +it directly. +""" + +from __future__ import annotations + +import asyncio +import logging +import threading +import typing as t + +if t.TYPE_CHECKING: + from collections.abc import Coroutine + +logger = logging.getLogger(__name__) + + +class AsyncioBus: + """A single asyncio event loop running in a daemon thread. + + Lifecycle: + + 1. :meth:`start` spawns the thread; waits until the loop is ready. + 2. Hooks run :meth:`call_sync` (block on result) or :meth:`call_soon` + (fire-and-forget). + 3. :meth:`stop` schedules the loop to stop, joins the thread. + + The bus is single-use. After ``stop()`` it is not safe to start + again — construct a new instance. + """ + + def __init__(self, *, name: str = "sphinx-vite-builder-bus") -> None: + self._name = name + self._loop: asyncio.AbstractEventLoop | None = None + self._thread: threading.Thread | None = None + self._ready = threading.Event() + # ``_stopped`` is set once a started bus has actually been torn + # down. It enforces the class-level "single-use" contract from + # ``start()``; a stop-before-start is a no-op and leaves this + # ``False`` (the bus was never really live). + self._stopped = False + + @property + def is_running(self) -> bool: + """True iff the loop thread is alive.""" + return self._thread is not None and self._thread.is_alive() + + def start(self) -> None: + """Start the background event loop. + + Idempotent if the bus is already running; raises + :class:`RuntimeError` if the bus has previously been stopped + (the class is single-use, per the class docstring). + """ + if self._stopped: + msg = "AsyncioBus is single-use; construct a new instance after stop()" + raise RuntimeError(msg) + if self._thread is not None and self._thread.is_alive(): + return + self._ready.clear() + self._thread = threading.Thread( + target=self._run, + daemon=True, + name=self._name, + ) + self._thread.start() + # Block until the loop has been assigned. Without this, a + # call_sync() racing the thread startup would deref None. + self._ready.wait() + + def call_sync( + self, + coro: Coroutine[t.Any, t.Any, t.Any], + *, + timeout: float | None = None, + ) -> t.Any: + """Schedule ``coro`` on the loop and block on its result. + + Parameters + ---------- + coro + The coroutine object (call-but-don't-await first). + timeout + Forwarded to ``concurrent.futures.Future.result``. ``None`` + blocks indefinitely. + + Raises + ------ + RuntimeError + If the bus is not running. + """ + if self._loop is None: + # Close the coroutine before raising so we don't leak a + # "coroutine was never awaited" warning at gc time. + coro.close() + msg = "AsyncioBus.call_sync() called before start()" + raise RuntimeError(msg) + future = asyncio.run_coroutine_threadsafe(coro, self._loop) + return future.result(timeout=timeout) + + def call_soon( + self, + coro: Coroutine[t.Any, t.Any, t.Any], + ) -> None: + """Schedule ``coro`` and return immediately. Errors go to the bus logger.""" + if self._loop is None: + coro.close() + msg = "AsyncioBus.call_soon() called before start()" + raise RuntimeError(msg) + future = asyncio.run_coroutine_threadsafe(coro, self._loop) + + def _log_exception(fut: t.Any) -> None: + exc = fut.exception() + if exc is not None: + logger.error( + "background coroutine on %s raised", + self._name, + exc_info=exc, + ) + + future.add_done_callback(_log_exception) + + def stop(self, *, timeout: float = 5.0) -> None: + """Stop the loop and join the thread. Idempotent. + + Once a started bus has been stopped, ``_stopped`` is set so a + subsequent ``start()`` on the same instance raises rather than + silently re-spawning. A stop-before-start is a no-op and leaves + ``_stopped`` unchanged. + """ + if self._loop is None or self._thread is None: + return + if not self._thread.is_alive(): + self._thread = None + self._loop = None + self._stopped = True + return + + self._loop.call_soon_threadsafe(self._loop.stop) + self._thread.join(timeout=timeout) + if self._thread.is_alive(): + logger.warning( + "%s thread did not exit within %.1fs of loop.stop()", + self._name, + timeout, + ) + self._thread = None + self._loop = None + self._stopped = True + + def _run(self) -> None: + """Thread target: own and run the loop.""" + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + self._loop = loop + self._ready.set() + try: + loop.run_forever() + finally: + try: + # Cancel any remaining tasks so they don't leak past + # the loop's death. + pending = asyncio.all_tasks(loop) + for task in pending: + task.cancel() + if pending: + loop.run_until_complete( + asyncio.gather(*pending, return_exceptions=True), + ) + finally: + loop.close() diff --git a/packages/sphinx-vite-builder/src/sphinx_vite_builder/_internal/errors.py b/packages/sphinx-vite-builder/src/sphinx_vite_builder/_internal/errors.py new file mode 100644 index 00000000..8a010767 --- /dev/null +++ b/packages/sphinx-vite-builder/src/sphinx_vite_builder/_internal/errors.py @@ -0,0 +1,42 @@ +"""Diagnostic error types raised by the backend and extension. + +Each error carries a multi-line, copy-pasteable hint so the failure is +fixable from the message itself. The PEP 517 backend re-raises these as +their own type (so build frontends like pip / uv display the full +message); the Sphinx extension re-wraps them in +:class:`sphinx.errors.ConfigError` so Sphinx halts the build with the +same content. +""" + +from __future__ import annotations + + +class SphinxViteBuilderError(Exception): + """Base class for all sphinx-vite-builder-raised diagnostic errors.""" + + +class PnpmMissingError(SphinxViteBuilderError): + """Raised when ``pnpm`` is not on ``PATH``. + + pnpm is not pip-installable, so the backend cannot bootstrap it the + way maturin bootstraps Rust via ``puccinialin``. The hint surfaces + the canonical install paths (``corepack enable``, the pnpm.io + install URL) so the user has an actionable next step. + """ + + +class NodeModulesInstallError(SphinxViteBuilderError): + """Raised when ``pnpm install`` exits non-zero. + + Surfaces the install command that failed and a re-run recipe. Callers + should attach the captured stderr to the message before raising so + the underlying pnpm diagnostic is visible at the call site. + """ + + +class ViteFailedError(SphinxViteBuilderError): + """Raised when ``pnpm exec vite build`` exits non-zero. + + Surfaces the vite invocation that failed plus context (cwd, exit + code). Callers should attach the captured stderr. + """ diff --git a/packages/sphinx-vite-builder/src/sphinx_vite_builder/_internal/process.py b/packages/sphinx-vite-builder/src/sphinx_vite_builder/_internal/process.py new file mode 100644 index 00000000..4f367375 --- /dev/null +++ b/packages/sphinx-vite-builder/src/sphinx_vite_builder/_internal/process.py @@ -0,0 +1,242 @@ +"""Async subprocess wrapper used by the Vite/pnpm orchestration. + +Wraps :func:`asyncio.create_subprocess_exec` with the conventions the +backend and extension heads need: + +- ``stdout`` / ``stderr`` are piped through line-buffered drainers that + prefix each line with a label and route it to a + :class:`logging.Logger` — info for stdout, warning for stderr. +- ``PYTHONUNBUFFERED=1`` is forced into the child env so Python tools + invoked via the package-manager bridge don't withhold their output. +- On POSIX, the child runs in a new session (``start_new_session=True``) + so ``SIGTERM`` cleanly takes down the entire process tree (``pnpm exec`` + shells out to multiple intermediate processes — without session + isolation, only the top-level pnpm wrapper would exit). +- :meth:`AsyncProcess.terminate` is graceful-then-forceful: SIGTERM, + await up to ``timeout`` seconds, escalate to SIGKILL if the child is + still alive. Idempotent: calling on an already-exited process is a + no-op. + +Argument lists are passed directly to the asyncio subprocess primitive; +no shell, no string interpolation, no command-injection surface. + +The class is intentionally generic over "what command to run" so the +same wrapper covers the production vite / pnpm calls and the fake +shell scripts used in tests. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import logging +import os +import pathlib +import sys +import typing as t + +if t.TYPE_CHECKING: + pass + +_module_logger = logging.getLogger(__name__) + + +class AsyncProcess: + """Async wrapper around a subprocess (one-shot or long-running). + + Used for both ``pnpm install`` (one-shot, awaited) and + ``pnpm exec vite build --watch`` (long-running, terminated on + teardown). + """ + + def __init__( + self, + *, + label: str = "subprocess", + logger: logging.Logger | logging.LoggerAdapter[t.Any] | None = None, + ) -> None: + # Accepts either a stdlib Logger or a LoggerAdapter (Sphinx's + # ``sphinx.util.logging.SphinxLoggerAdapter`` is a LoggerAdapter + # subclass). Both expose the .log() method the drainers use. + self._label = label + self._logger: logging.Logger | logging.LoggerAdapter[t.Any] = ( + logger if logger is not None else _module_logger + ) + self._process: asyncio.subprocess.Process | None = None + self._drainers: list[asyncio.Task[None]] = [] + self._stderr_buffer: list[str] = [] + + @property + def is_running(self) -> bool: + """True iff the child has been started and has not yet exited.""" + return self._process is not None and self._process.returncode is None + + @property + def returncode(self) -> int | None: + """Process exit code, or ``None`` if the child hasn't exited (yet).""" + return self._process.returncode if self._process is not None else None + + @property + def pid(self) -> int | None: + """Child process ID, or ``None`` if not started.""" + return self._process.pid if self._process is not None else None + + @property + def captured_stderr(self) -> str: + """Joined stderr lines captured by the drainer. + + Useful for surfacing the underlying tool's diagnostic in error + messages when a build fails. + """ + return "\n".join(self._stderr_buffer) + + async def start( + self, + command: t.Sequence[str], + *, + cwd: pathlib.Path, + env: t.Mapping[str, str] | None = None, + ) -> None: + """Spawn ``command`` and start draining its stdout / stderr. + + Parameters + ---------- + command + Argument list. Passed straight to the asyncio subprocess + primitive; no shell. + cwd + Working directory for the child. + env + Optional environment override. ``PYTHONUNBUFFERED=1`` is + always injected on top of whatever this provides (or, if + ``None``, on top of :data:`os.environ`). + + Raises + ------ + RuntimeError + If :meth:`start` is called twice on the same instance + without an intervening :meth:`terminate`. + """ + if self._process is not None: + msg = "AsyncProcess.start() called twice; spawn a new instance instead" + raise RuntimeError(msg) + + merged_env = dict(env) if env is not None else dict(os.environ) + merged_env["PYTHONUNBUFFERED"] = "1" + + # POSIX-only: ``start_new_session`` puts the child in its own + # session/process group so SIGTERM to that group takes down + # ``pnpm exec`` plus all its descendants. On Windows there's no + # equivalent; the asyncio default is fine. + spawn_kwargs: dict[str, t.Any] = {} + if sys.platform != "win32": + spawn_kwargs["start_new_session"] = True + + self._process = await asyncio.create_subprocess_exec( + *command, + cwd=str(cwd), + env=merged_env, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + **spawn_kwargs, + ) + + # Pipe drainers — capture-and-log line by line so callers get + # immediate visibility into the tool's progress. + assert self._process.stdout is not None + assert self._process.stderr is not None + self._drainers = [ + asyncio.create_task( + self._drain(self._process.stdout, level=logging.INFO), + name=f"{self._label}-stdout-drainer", + ), + asyncio.create_task( + self._drain( + self._process.stderr, + level=logging.WARNING, + capture=self._stderr_buffer, + ), + name=f"{self._label}-stderr-drainer", + ), + ] + + async def wait(self) -> int: + """Wait for the child to exit; return its exit code. + + Drains the stdout / stderr pipes to completion before returning. + """ + if self._process is None: + msg = "AsyncProcess.wait() called before start()" + raise RuntimeError(msg) + returncode = await self._process.wait() + # Let the drainers consume any final buffered lines before + # returning to the caller. + await asyncio.gather(*self._drainers, return_exceptions=True) + return returncode + + async def terminate(self, *, timeout: float = 5.0) -> int | None: + """Send SIGTERM; escalate to SIGKILL after ``timeout`` seconds. + + Idempotent — calling on an already-exited process is a no-op + and returns the existing exit code (or ``None`` if never started). + + Parameters + ---------- + timeout + Seconds to wait for graceful exit after SIGTERM. + + Returns + ------- + int | None + The child's exit code, or ``None`` if :meth:`start` was + never called. + """ + if self._process is None: + return None + if self._process.returncode is not None: + return self._process.returncode + + self._process.terminate() + try: + await asyncio.wait_for(self._process.wait(), timeout=timeout) + except asyncio.TimeoutError: + self._logger.warning( + "[%s] did not exit within %.1fs of SIGTERM; sending SIGKILL", + self._label, + timeout, + ) + # ProcessLookupError race: the child can exit between + # TimeoutError and kill(). + with contextlib.suppress(ProcessLookupError): + self._process.kill() + await self._process.wait() + + # Wait for drainers to consume their last buffered line before + # the caller proceeds; surface no exception if a drainer raised. + await asyncio.gather(*self._drainers, return_exceptions=True) + return self._process.returncode + + async def _drain( + self, + stream: asyncio.StreamReader, + *, + level: int, + capture: list[str] | None = None, + ) -> None: + """Consume ``stream`` line by line; log each line. + + Optionally append every (non-empty) line to ``capture`` so callers + can surface it in error messages. + """ + while True: + try: + line = await stream.readline() + except (BrokenPipeError, ConnectionResetError): + return + if not line: + return + text = line.decode("utf-8", errors="replace").rstrip("\n") + if text: + self._logger.log(level, "[%s] %s", self._label, text) + if capture is not None: + capture.append(text) diff --git a/packages/sphinx-vite-builder/src/sphinx_vite_builder/_internal/vite.py b/packages/sphinx-vite-builder/src/sphinx_vite_builder/_internal/vite.py new file mode 100644 index 00000000..c4019e4d --- /dev/null +++ b/packages/sphinx-vite-builder/src/sphinx_vite_builder/_internal/vite.py @@ -0,0 +1,271 @@ +"""Vite + pnpm orchestration: detection, install, one-shot build, watch. + +This module is the shared orchestration core consumed by both heads: + +- The PEP 517 backend (:mod:`sphinx_vite_builder.build`) calls + :func:`run_vite_build` from each of its hooks, before delegating to + hatchling. +- The Sphinx extension (:mod:`sphinx_vite_builder`) calls + :func:`run_vite_build` (one-shot) or its watch sibling from + ``builder-inited``. + +Fast-fail discipline: every prerequisite is checked up front so the +caller gets an actionable diagnostic instead of a generic spawn-failure +deep in the asyncio plumbing. +""" + +from __future__ import annotations + +import logging +import os +import pathlib +import shutil +import textwrap +import typing as t + +from .bus import AsyncioBus +from .errors import ( + NodeModulesInstallError, + PnpmMissingError, + ViteFailedError, +) +from .process import AsyncProcess + +logger = logging.getLogger(__name__) + + +def pnpm_install_command(*, package_manager: str = "pnpm") -> tuple[str, ...]: + """Build the canonical "install workspace deps" argv. + + ``--frozen-lockfile`` matches the workspace's pinned ``pnpm-lock.yaml``; + pnpm refuses to mutate the lockfile or auto-resolve unspecified deps, + so the install is reproducible across machines and CI. + + Examples + -------- + >>> pnpm_install_command() + ('pnpm', 'install', '--frozen-lockfile') + >>> pnpm_install_command(package_manager="npm") + ('npm', 'install', '--frozen-lockfile') + """ + return (package_manager, "install", "--frozen-lockfile") + + +def vite_build_command(*, package_manager: str = "pnpm") -> tuple[str, ...]: + """Build the canonical one-shot "vite build" argv. + + Examples + -------- + >>> vite_build_command() + ('pnpm', 'exec', 'vite', 'build') + >>> vite_build_command(package_manager="npm") + ('npm', 'exec', 'vite', 'build') + """ + return (package_manager, "exec", "vite", "build") + + +def vite_watch_command(*, package_manager: str = "pnpm") -> tuple[str, ...]: + """Build the canonical Vite-watch argv. + + Examples + -------- + >>> vite_watch_command() + ('pnpm', 'exec', 'vite', 'build', '--watch') + >>> vite_watch_command(package_manager="npm") + ('npm', 'exec', 'vite', 'build', '--watch') + """ + return (package_manager, "exec", "vite", "build", "--watch") + + +def _format_pnpm_missing_hint(vite_root: pathlib.Path) -> str: + """Multi-line, copy-pasteable hint when pnpm is not on PATH.""" + return textwrap.dedent( + f"""\ + sphinx-vite-builder: cannot bootstrap the vite toolchain. + `pnpm` is not on PATH. Install it via one of: + + corepack enable # Node 16.10+ ships corepack + curl -fsSL https://get.pnpm.io/install.sh | sh - + + See https://pnpm.io/installation + + Then re-run with `pnpm` available, or, if this environment is not + supposed to build assets (e.g. a wheel-only install with the + static tree pre-baked), set `SPHINX_VITE_BUILDER_SKIP=1`. + + Vite project root resolved to: {vite_root} + """, + ).rstrip() + + +def _format_install_failed_hint( + *, + vite_root: pathlib.Path, + install_cmd: tuple[str, ...], + returncode: int, + stderr: str, +) -> str: + """Multi-line hint when ``pnpm install`` exits non-zero.""" + cmd_str = " ".join(install_cmd) + stderr_block = stderr.strip() or "(no stderr captured)" + return textwrap.dedent( + f"""\ + sphinx-vite-builder: `{cmd_str}` exited with code {returncode} in {vite_root}. + The vite-managed theme assets cannot be produced; aborting the + build rather than shipping unstyled docs. + + Fix: + cd {vite_root} + {cmd_str} + + Captured stderr: + {textwrap.indent(stderr_block, " ")} + """, + ).rstrip() + + +def _format_vite_failed_hint( + *, + vite_root: pathlib.Path, + build_cmd: tuple[str, ...], + returncode: int, + stderr: str, +) -> str: + """Multi-line hint when ``pnpm exec vite build`` exits non-zero.""" + cmd_str = " ".join(build_cmd) + stderr_block = stderr.strip() or "(no stderr captured)" + return textwrap.dedent( + f"""\ + sphinx-vite-builder: `{cmd_str}` exited with code {returncode} in {vite_root}. + Vite reported a build error; the resulting wheel/docs would be + unstyled. Aborting before any further build steps. + + Captured stderr: + {textwrap.indent(stderr_block, " ")} + """, + ).rstrip() + + +def _resolve_vite_root(project_root: pathlib.Path) -> pathlib.Path | None: + """Locate the Vite project root next to ``project_root``. + + Convention: a sibling ``web/`` directory containing ``package.json`` + is the Vite project. Returns ``None`` if no such directory exists + (e.g. when the build runs inside an unpacked sdist where ``web/`` + was excluded — in that case the caller should treat the static + tree as pre-baked and skip vite). + """ + candidate = project_root / "web" + if candidate.is_dir() and (candidate / "package.json").is_file(): + return candidate + return None + + +async def _run_install( + vite_root: pathlib.Path, + *, + install_cmd: tuple[str, ...], +) -> None: + """Run ``pnpm install --frozen-lockfile``; raise on non-zero exit.""" + proc = AsyncProcess(label="pnpm-install", logger=logger) + await proc.start(install_cmd, cwd=vite_root) + returncode = await proc.wait() + if returncode != 0: + raise NodeModulesInstallError( + _format_install_failed_hint( + vite_root=vite_root, + install_cmd=install_cmd, + returncode=returncode, + stderr=proc.captured_stderr, + ), + ) + + +async def _run_build( + vite_root: pathlib.Path, + *, + build_cmd: tuple[str, ...], +) -> None: + """Run ``pnpm exec vite build``; raise on non-zero exit.""" + proc = AsyncProcess(label="vite-build", logger=logger) + await proc.start(build_cmd, cwd=vite_root) + returncode = await proc.wait() + if returncode != 0: + raise ViteFailedError( + _format_vite_failed_hint( + vite_root=vite_root, + build_cmd=build_cmd, + returncode=returncode, + stderr=proc.captured_stderr, + ), + ) + + +def run_vite_build( + project_root: pathlib.Path | None = None, + *, + package_manager: str = "pnpm", +) -> None: + """Run a one-shot ``pnpm exec vite build`` in ``/web``. + + Short-circuits when: + + - ``SPHINX_VITE_BUILDER_SKIP=1`` is set in the environment (escape + hatch for downstream packagers who pre-bake the static tree + themselves). + - The expected ``web/`` directory is absent — the typical case + when building a wheel from an unpacked sdist (where ``web/`` was + excluded but ``static/`` was pre-baked into the sdist). + + Otherwise, fast-fails: + + - :class:`PnpmMissingError` if ``pnpm`` is not on ``PATH``. + - :class:`NodeModulesInstallError` if ``pnpm install`` exits + non-zero. + - :class:`ViteFailedError` if ``pnpm exec vite build`` exits + non-zero. + """ + if os.environ.get("SPHINX_VITE_BUILDER_SKIP"): + logger.info("SPHINX_VITE_BUILDER_SKIP set; skipping vite build") + return + + project_root = (project_root or pathlib.Path.cwd()).resolve() + vite_root = _resolve_vite_root(project_root) + if vite_root is None: + logger.info( + "no web/ alongside %s; assuming pre-baked static tree (sdist build)", + project_root, + ) + return + + if shutil.which(package_manager) is None: + raise PnpmMissingError(_format_pnpm_missing_hint(vite_root)) + + install_cmd = pnpm_install_command(package_manager=package_manager) + build_cmd = vite_build_command(package_manager=package_manager) + needs_install = not (vite_root / "node_modules").is_dir() + + bus = AsyncioBus(name="sphinx-vite-builder-build-bus") + bus.start() + try: + if needs_install: + logger.info("installing JS deps in %s via %s", vite_root, install_cmd) + bus.call_sync(_run_install(vite_root, install_cmd=install_cmd)) + logger.info("building vite assets in %s", vite_root) + bus.call_sync(_run_build(vite_root, build_cmd=build_cmd)) + finally: + bus.stop() + + +__all__: tuple[str, ...] = ( + "pnpm_install_command", + "run_vite_build", + "vite_build_command", + "vite_watch_command", +) + + +# Re-exports for type-checker friendliness when consumers import +# the orchestration module directly. +_AsyncProcess: t.TypeAlias = AsyncProcess +_AsyncioBus: t.TypeAlias = AsyncioBus diff --git a/packages/sphinx-vite-builder/src/sphinx_vite_builder/build.py b/packages/sphinx-vite-builder/src/sphinx_vite_builder/build.py new file mode 100644 index 00000000..7726cac7 --- /dev/null +++ b/packages/sphinx-vite-builder/src/sphinx_vite_builder/build.py @@ -0,0 +1,94 @@ +"""PEP 517 / PEP 660 build backend module. + +Consumer ``pyproject.toml`` references this module via: + +.. code-block:: toml + + [build-system] + requires = ["hatchling>=1.0", "sphinx-vite-builder"] + build-backend = "sphinx_vite_builder.build" + backend-path = ["../sphinx-vite-builder/src"] # for in-tree workspace builds + +Each PEP 517 hook runs :func:`run_vite_build` to populate the consumer +package's vite-managed ``static/`` tree, then delegates to +:mod:`hatchling.build` for the actual sdist / wheel / editable +construction. The hooks are pure functions, defined at module scope, +mirroring the canonical layout of `flit_core.buildapi` and +`hatchling.build`. + +Optional hooks (``get_requires_for_build_*`` and +``prepare_metadata_for_build_*``) are aliased verbatim to hatchling's +implementations — vite has no influence on dependency resolution or +distribution metadata, so passing those calls through unmodified is +both correct and trivially side-effect-free. +""" + +from __future__ import annotations + +import typing as t + +import hatchling.build as _hatchling + +from ._internal.vite import run_vite_build + + +def build_wheel( + wheel_directory: str, + config_settings: dict[str, t.Any] | None = None, + metadata_directory: str | None = None, +) -> str: + """PEP 517 ``build_wheel``: vite-build, then hatchling-pack.""" + run_vite_build() + return _hatchling.build_wheel(wheel_directory, config_settings, metadata_directory) + + +def build_editable( + wheel_directory: str, + config_settings: dict[str, t.Any] | None = None, + metadata_directory: str | None = None, +) -> str: + """PEP 660 ``build_editable``: vite-build, then hatchling-pack-editable.""" + run_vite_build() + return _hatchling.build_editable( + wheel_directory, config_settings, metadata_directory + ) + + +def build_sdist( + sdist_directory: str, + config_settings: dict[str, t.Any] | None = None, +) -> str: + """PEP 517 ``build_sdist``: pre-bake static so the sdist→wheel chain works. + + Running vite at sdist-build time means the resulting ``.tar.gz`` + contains a populated ``static/`` tree (even though the source repo + gitignores it). Downstream consumers can then ``pip install`` from + the sdist without pnpm or Node — the wheel-from-sdist build will + skip vite (no ``web/`` in the unpacked tree) and ship the + pre-baked assets via hatchling's normal file selection. + """ + run_vite_build() + return _hatchling.build_sdist(sdist_directory, config_settings) + + +# The optional hooks have no vite-side concern — pass through verbatim. +# Keeping them as module-level aliases (rather than wrapping functions) +# preserves their identity for build frontends that introspect the +# module surface. +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 + + +__all__: tuple[str, ...] = ( + "build_editable", + "build_sdist", + "build_wheel", + "get_requires_for_build_editable", + "get_requires_for_build_sdist", + "get_requires_for_build_wheel", + "prepare_metadata_for_build_editable", + "prepare_metadata_for_build_wheel", +) diff --git a/packages/sphinx-vite-builder/src/sphinx_vite_builder/py.typed b/packages/sphinx-vite-builder/src/sphinx_vite_builder/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/pyproject.toml b/pyproject.toml index 05047eac..9f8c9a07 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,6 +33,7 @@ sphinx-gp-sitemap = { workspace = true } gp-furo-theme = { workspace = true } gp-sphinx-vite = { workspace = true } gp-sphinx = { workspace = true } +sphinx-vite-builder = { workspace = true } [dependency-groups] dev = [ @@ -49,6 +50,7 @@ dev = [ "sphinx-gp-sitemap", "gp-furo-theme", "gp-sphinx-vite", + "sphinx-vite-builder", # Docs "sphinx-autobuild", # Testing diff --git a/scripts/ci/package_tools.py b/scripts/ci/package_tools.py index 8d0eba0d..07cb3ce4 100644 --- a/scripts/ci/package_tools.py +++ b/scripts/ci/package_tools.py @@ -775,6 +775,29 @@ def smoke_sphinx_autodoc_fastmcp(dist_dir: pathlib.Path, version: str) -> None: ) +def smoke_sphinx_vite_builder(dist_dir: pathlib.Path, version: str) -> None: + """Verify the sphinx-vite-builder backend + extension imports cleanly.""" + with tempfile.TemporaryDirectory() as tmp: + python_path = _create_venv(pathlib.Path(tmp)) + _install_into_venv( + python_path, + _target_wheel_path(dist_dir, "sphinx-vite-builder"), + find_links=dist_dir, + ) + _run_python( + python_path, + ( + "import sphinx_vite_builder; " + "from sphinx_vite_builder import build, setup; " + f"assert sphinx_vite_builder.__version__ == {version!r}; " + "assert callable(build.build_wheel); " + "assert callable(build.build_sdist); " + "assert callable(build.build_editable); " + "assert callable(setup)" + ), + ) + + _PACKAGE_SMOKE_RUNNERS: dict[str, t.Callable[[pathlib.Path, str], None]] = { "sphinx-gp-opengraph": smoke_sphinx_gp_opengraph, "sphinx-gp-sitemap": smoke_sphinx_gp_sitemap, @@ -792,6 +815,7 @@ def smoke_sphinx_autodoc_fastmcp(dist_dir: pathlib.Path, version: str) -> None: "sphinx-autodoc-typehints-gp": smoke_sphinx_autodoc_typehints_gp, "gp-furo-theme": smoke_gp_furo_theme, "gp-sphinx-vite": smoke_gp_sphinx_vite, + "sphinx-vite-builder": smoke_sphinx_vite_builder, } diff --git a/tests/test_package_reference.py b/tests/test_package_reference.py index 614402e6..d28d21a3 100644 --- a/tests/test_package_reference.py +++ b/tests/test_package_reference.py @@ -35,6 +35,7 @@ def test_workspace_packages_lists_publishable_packages() -> None: "sphinx-autodoc-sphinx", "sphinx-fonts", "sphinx-gp-theme", + "sphinx-vite-builder", } diff --git a/tests/test_sphinx_vite_builder.py b/tests/test_sphinx_vite_builder.py new file mode 100644 index 00000000..c1ef6529 --- /dev/null +++ b/tests/test_sphinx_vite_builder.py @@ -0,0 +1,43 @@ +"""Tests for the ``sphinx-vite-builder`` package wiring. + +Covers package metadata + entry-point discovery; subprocess and +backend behaviour live in dedicated test modules so each suite stays +focused. +""" + +from __future__ import annotations + +import importlib.metadata + +import sphinx_vite_builder +from sphinx_vite_builder import __version__, setup + + +def test_version_matches_workspace_lock() -> None: + """Version follows the gp-sphinx workspace lockstep.""" + assert __version__ == "0.0.1a15" + + +def test_setup_returns_safety_metadata() -> None: + """``setup`` registers the extension and returns parallel-safety flags.""" + + class _FakeApp: + pass + + metadata = setup(_FakeApp()) # type: ignore[arg-type] + assert metadata["parallel_read_safe"] is True + assert metadata["parallel_write_safe"] is True + assert metadata["version"] == __version__ + + +def test_extension_entry_point_is_discoverable() -> None: + """The ``sphinx.extensions`` entry point lands on the right module.""" + eps = importlib.metadata.entry_points(group="sphinx.extensions") + matched = [ep for ep in eps if ep.name == "sphinx-vite-builder"] + assert matched, "sphinx-vite-builder entry point not discoverable" + assert matched[0].value == "sphinx_vite_builder" + + +def test_top_level_exports() -> None: + """The package's public surface is the documented two symbols.""" + assert sphinx_vite_builder.__all__ == ("__version__", "setup") diff --git a/tests/test_sphinx_vite_builder_build.py b/tests/test_sphinx_vite_builder_build.py new file mode 100644 index 00000000..cbd657ea --- /dev/null +++ b/tests/test_sphinx_vite_builder_build.py @@ -0,0 +1,139 @@ +"""Tests for the PEP 517 / 660 backend module. + +The backend's job is exactly two things per hook: + +1. Run the vite orchestration (``run_vite_build``). +2. Delegate to hatchling's matching hook. + +These tests verify the order + delegation by patching both ``run_vite_build`` +and the hatchling hooks; we never invoke a real wheel build here. End-to-end +sdist/wheel construction is exercised via ``uv build`` in CI. +""" + +from __future__ import annotations + +import typing as t + +import hatchling.build as _hatchling +import pytest +from sphinx_vite_builder import build as backend + + +def _spy_pair( + monkeypatch: pytest.MonkeyPatch, +) -> tuple[list[str], dict[str, t.Any]]: + """Patch ``run_vite_build`` and every hatchling hook with order-tracking spies. + + Returns + ------- + order + A shared list that records ``"vite"`` then ``"hatchling-"`` in the + sequence each spy fired. Each ``build_*`` test asserts on its prefix. + spies + The hatchling-side spies, keyed by hook name. Tests can read their + ``calls`` attribute to confirm forwarded args. + """ + order: list[str] = [] + spies: dict[str, t.Any] = {} + + def _spy_run_vite_build(*_args: object, **_kwargs: object) -> None: + order.append("vite") + + monkeypatch.setattr(backend, "run_vite_build", _spy_run_vite_build) + + def _make_hook_spy(name: str, return_value: str) -> t.Any: + calls: list[tuple[tuple[t.Any, ...], dict[str, t.Any]]] = [] + + def _spy(*args: t.Any, **kwargs: t.Any) -> str: + order.append(f"hatchling-{name}") + calls.append((args, kwargs)) + return return_value + + _spy.calls = calls # type: ignore[attr-defined] + return _spy + + for name, retval in ( + ("build_wheel", "fake-wheel.whl"), + ("build_editable", "fake-editable.whl"), + ("build_sdist", "fake-sdist.tar.gz"), + ): + spy = _make_hook_spy(name, retval) + monkeypatch.setattr(_hatchling, name, spy) + spies[name] = spy + + return order, spies + + +def test_build_wheel_runs_vite_then_delegates_to_hatchling( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """``build_wheel`` runs vite first, then hatchling's ``build_wheel``.""" + order, spies = _spy_pair(monkeypatch) + result = backend.build_wheel("/tmp/wheels", {"foo": "bar"}, "/tmp/meta") + assert order == ["vite", "hatchling-build_wheel"] + assert result == "fake-wheel.whl" + args, _kwargs = spies["build_wheel"].calls[0] + assert args == ("/tmp/wheels", {"foo": "bar"}, "/tmp/meta") + + +def test_build_editable_runs_vite_then_delegates_to_hatchling( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """``build_editable`` runs vite first, then hatchling's ``build_editable``.""" + order, spies = _spy_pair(monkeypatch) + result = backend.build_editable("/tmp/editable", None, None) + assert order == ["vite", "hatchling-build_editable"] + assert result == "fake-editable.whl" + args, _kwargs = spies["build_editable"].calls[0] + assert args == ("/tmp/editable", None, None) + + +def test_build_sdist_runs_vite_then_delegates_to_hatchling( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """``build_sdist`` runs vite first so the sdist contains pre-baked static.""" + order, spies = _spy_pair(monkeypatch) + result = backend.build_sdist("/tmp/sdists", None) + assert order == ["vite", "hatchling-build_sdist"] + assert result == "fake-sdist.tar.gz" + args, _kwargs = spies["build_sdist"].calls[0] + assert args == ("/tmp/sdists", None) + + +def test_optional_hooks_alias_hatchling_directly() -> None: + """The optional metadata/requires hooks have no vite concern.""" + assert ( + backend.get_requires_for_build_wheel is _hatchling.get_requires_for_build_wheel + ) + assert ( + backend.get_requires_for_build_sdist is _hatchling.get_requires_for_build_sdist + ) + assert ( + backend.get_requires_for_build_editable + is _hatchling.get_requires_for_build_editable + ) + assert ( + backend.prepare_metadata_for_build_wheel + is _hatchling.prepare_metadata_for_build_wheel + ) + assert ( + backend.prepare_metadata_for_build_editable + is _hatchling.prepare_metadata_for_build_editable + ) + + +def test_backend_module_exposes_all_pep517_660_hooks() -> None: + """The backend module's public surface covers every PEP 517 + 660 hook.""" + expected: set[str] = { + "build_wheel", + "build_editable", + "build_sdist", + "get_requires_for_build_wheel", + "get_requires_for_build_sdist", + "get_requires_for_build_editable", + "prepare_metadata_for_build_wheel", + "prepare_metadata_for_build_editable", + } + public = {n for n in dir(backend) if not n.startswith("_")} + missing = expected - public + assert not missing, f"backend missing hooks: {missing}" diff --git a/tests/test_sphinx_vite_builder_vite.py b/tests/test_sphinx_vite_builder_vite.py new file mode 100644 index 00000000..9cac66a6 --- /dev/null +++ b/tests/test_sphinx_vite_builder_vite.py @@ -0,0 +1,266 @@ +"""Tests for the Vite/pnpm orchestration core. + +Focused on the fast-fail discipline: missing pnpm, install failures, +vite build failures all surface as actionable diagnostic errors with +copy-pasteable hints. +""" + +from __future__ import annotations + +import pathlib +import shutil +import textwrap + +import pytest +from sphinx_vite_builder._internal import vite as vite_module +from sphinx_vite_builder._internal.errors import ( + NodeModulesInstallError, + PnpmMissingError, + SphinxViteBuilderError, + ViteFailedError, +) +from sphinx_vite_builder._internal.vite import ( + pnpm_install_command, + run_vite_build, + vite_build_command, + vite_watch_command, +) + +# --------------------------------------------------------------------------- +# Command helpers — simple argv builders with stable ordering +# --------------------------------------------------------------------------- + + +def test_pnpm_install_command_default() -> None: + """``pnpm install --frozen-lockfile`` is the canonical invocation.""" + assert pnpm_install_command() == ("pnpm", "install", "--frozen-lockfile") + + +def test_pnpm_install_command_alternate_manager() -> None: + """The package manager is parameterized for npm/yarn coexistence.""" + assert pnpm_install_command(package_manager="npm") == ( + "npm", + "install", + "--frozen-lockfile", + ) + + +def test_vite_build_command_default() -> None: + """One-shot vite build uses ``exec`` (no shell wrapper).""" + assert vite_build_command() == ("pnpm", "exec", "vite", "build") + + +def test_vite_watch_command_default() -> None: + """Watch mode appends ``--watch`` so vite stays resident.""" + assert vite_watch_command() == ("pnpm", "exec", "vite", "build", "--watch") + + +# --------------------------------------------------------------------------- +# Short-circuit paths — no vite invocation expected +# --------------------------------------------------------------------------- + + +def test_run_vite_build_short_circuits_when_skip_env_set( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """``SPHINX_VITE_BUILDER_SKIP=1`` is the escape hatch for downstream packagers.""" + monkeypatch.setenv("SPHINX_VITE_BUILDER_SKIP", "1") + + def _fail_lookup(_name: str) -> str | None: + msg = "shutil.which should not be called when SKIP is set" + raise AssertionError(msg) + + monkeypatch.setattr(shutil, "which", _fail_lookup) + # No web/ — but we don't even get that far when SKIP is set. + run_vite_build(tmp_path) + + +def test_run_vite_build_short_circuits_when_web_dir_absent( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Unpacked-sdist case: no web/ alongside, vite is a no-op.""" + monkeypatch.delenv("SPHINX_VITE_BUILDER_SKIP", raising=False) + + def _fail_lookup(_name: str) -> str | None: + msg = "shutil.which should not be called when web/ is absent" + raise AssertionError(msg) + + monkeypatch.setattr(shutil, "which", _fail_lookup) + # tmp_path has no web/ subdirectory. + run_vite_build(tmp_path) + + +def test_run_vite_build_short_circuits_when_web_lacks_package_json( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """``web/`` without ``package.json`` is treated as not-a-vite-project.""" + monkeypatch.delenv("SPHINX_VITE_BUILDER_SKIP", raising=False) + (tmp_path / "web").mkdir() # no package.json inside + + def _fail_lookup(_name: str) -> str | None: + msg = "shutil.which should not be called when web/ lacks package.json" + raise AssertionError(msg) + + monkeypatch.setattr(shutil, "which", _fail_lookup) + run_vite_build(tmp_path) + + +# --------------------------------------------------------------------------- +# Fast-fail: pnpm not on PATH +# --------------------------------------------------------------------------- + + +def _make_vite_project(tmp_path: pathlib.Path) -> pathlib.Path: + """Create a minimal ``web/`` directory layout so detection fires.""" + web = tmp_path / "web" + web.mkdir() + (web / "package.json").write_text('{"name": "test-vite-project"}\n') + return tmp_path + + +def test_run_vite_build_raises_pnpm_missing_with_actionable_hint( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """``pnpm`` not on PATH → ``PnpmMissingError`` with corepack/install hints.""" + monkeypatch.delenv("SPHINX_VITE_BUILDER_SKIP", raising=False) + project = _make_vite_project(tmp_path) + monkeypatch.setattr(shutil, "which", lambda _name: None) + + with pytest.raises(PnpmMissingError) as exc_info: + run_vite_build(project) + + msg = str(exc_info.value) + assert "pnpm" in msg + assert "corepack enable" in msg + assert "https://pnpm.io/installation" in msg + assert "SPHINX_VITE_BUILDER_SKIP" in msg + + +def test_pnpm_missing_error_inherits_from_base() -> None: + """Diagnostic errors share a single base for easy ``except`` clauses.""" + assert issubclass(PnpmMissingError, SphinxViteBuilderError) + assert issubclass(NodeModulesInstallError, SphinxViteBuilderError) + assert issubclass(ViteFailedError, SphinxViteBuilderError) + + +# --------------------------------------------------------------------------- +# Fast-fail: pnpm install / vite build non-zero exit +# +# We patch the orchestration's awaitable run_install / run_build helpers +# rather than spawning real pnpm — keeps tests fast and deterministic. +# --------------------------------------------------------------------------- + + +def test_run_vite_build_raises_install_error_when_install_fails( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """``pnpm install`` non-zero → ``NodeModulesInstallError`` with rerun recipe.""" + monkeypatch.delenv("SPHINX_VITE_BUILDER_SKIP", raising=False) + project = _make_vite_project(tmp_path) + monkeypatch.setattr(shutil, "which", lambda _name: "/fake/pnpm") + # Force the install branch by ensuring node_modules is absent. + assert not (project / "web" / "node_modules").exists() + + async def _fake_install(*_args: object, **_kwargs: object) -> None: + msg = textwrap.dedent( + """\ + sphinx-vite-builder: `pnpm install --frozen-lockfile` exited with code 7 + Captured stderr: + fake stderr from pnpm + """, + ).rstrip() + raise NodeModulesInstallError(msg) + + monkeypatch.setattr(vite_module, "_run_install", _fake_install) + + with pytest.raises(NodeModulesInstallError) as exc_info: + run_vite_build(project) + assert "exited with code 7" in str(exc_info.value) + assert "fake stderr from pnpm" in str(exc_info.value) + + +def test_run_vite_build_raises_vite_error_when_build_fails( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """``pnpm exec vite build`` non-zero → ``ViteFailedError`` with stderr.""" + monkeypatch.delenv("SPHINX_VITE_BUILDER_SKIP", raising=False) + project = _make_vite_project(tmp_path) + monkeypatch.setattr(shutil, "which", lambda _name: "/fake/pnpm") + # Pre-create node_modules so the install branch is skipped. + (project / "web" / "node_modules").mkdir() + + async def _fake_build(*_args: object, **_kwargs: object) -> None: + msg = textwrap.dedent( + """\ + sphinx-vite-builder: `pnpm exec vite build` exited with code 1 + Captured stderr: + ESM resolve failed: missing dependency + """, + ).rstrip() + raise ViteFailedError(msg) + + monkeypatch.setattr(vite_module, "_run_build", _fake_build) + + with pytest.raises(ViteFailedError) as exc_info: + run_vite_build(project) + assert "exited with code 1" in str(exc_info.value) + assert "ESM resolve failed" in str(exc_info.value) + + +def test_run_vite_build_skips_install_when_node_modules_exists( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A populated ``node_modules/`` short-circuits the install step.""" + monkeypatch.delenv("SPHINX_VITE_BUILDER_SKIP", raising=False) + project = _make_vite_project(tmp_path) + (project / "web" / "node_modules").mkdir() + monkeypatch.setattr(shutil, "which", lambda _name: "/fake/pnpm") + + install_calls = 0 + build_calls = 0 + + async def _fake_install(*_args: object, **_kwargs: object) -> None: + nonlocal install_calls + install_calls += 1 + + async def _fake_build(*_args: object, **_kwargs: object) -> None: + nonlocal build_calls + build_calls += 1 + + monkeypatch.setattr(vite_module, "_run_install", _fake_install) + monkeypatch.setattr(vite_module, "_run_build", _fake_build) + + run_vite_build(project) + assert install_calls == 0 + assert build_calls == 1 + + +def test_run_vite_build_runs_install_when_node_modules_missing( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A missing ``node_modules/`` triggers ``pnpm install`` before build.""" + monkeypatch.delenv("SPHINX_VITE_BUILDER_SKIP", raising=False) + project = _make_vite_project(tmp_path) + monkeypatch.setattr(shutil, "which", lambda _name: "/fake/pnpm") + + order: list[str] = [] + + async def _fake_install(*_args: object, **_kwargs: object) -> None: + order.append("install") + + async def _fake_build(*_args: object, **_kwargs: object) -> None: + order.append("build") + + monkeypatch.setattr(vite_module, "_run_install", _fake_install) + monkeypatch.setattr(vite_module, "_run_build", _fake_build) + + run_vite_build(project) + assert order == ["install", "build"] diff --git a/uv.lock b/uv.lock index 976da1a8..52db9771 100644 --- a/uv.lock +++ b/uv.lock @@ -8,7 +8,7 @@ resolution-markers = [ ] [options] -exclude-newer = "2026-04-29T21:00:18.180438279Z" +exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. exclude-newer-span = "P3D" [manifest] @@ -30,6 +30,7 @@ members = [ "sphinx-gp-theme", "sphinx-ux-autodoc-layout", "sphinx-ux-badges", + "sphinx-vite-builder", ] [[package]] @@ -519,6 +520,7 @@ dev = [ { name = "sphinx-gp-sitemap" }, { name = "sphinx-ux-autodoc-layout" }, { name = "sphinx-ux-badges" }, + { name = "sphinx-vite-builder" }, { name = "syrupy" }, { name = "tomli", marker = "python_full_version < '3.11'" }, { name = "types-docutils" }, @@ -557,6 +559,7 @@ dev = [ { name = "sphinx-gp-sitemap", editable = "packages/sphinx-gp-sitemap" }, { name = "sphinx-ux-autodoc-layout", editable = "packages/sphinx-ux-autodoc-layout" }, { name = "sphinx-ux-badges", editable = "packages/sphinx-ux-badges" }, + { name = "sphinx-vite-builder", editable = "packages/sphinx-vite-builder" }, { name = "syrupy", specifier = ">=5.1.0" }, { name = "tomli", marker = "python_full_version < '3.11'" }, { name = "types-docutils" }, @@ -639,6 +642,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, ] +[[package]] +name = "hatchling" +version = "1.29.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "pathspec" }, + { name = "pluggy" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "trove-classifiers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cf/9c/b4cfe330cd4f49cff17fd771154730555fa4123beb7f292cf0098b4e6c20/hatchling-1.29.0.tar.gz", hash = "sha256:793c31816d952cee405b83488ce001c719f325d9cda69f1fc4cd750527640ea6", size = 55656, upload-time = "2026-02-23T19:42:06.539Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d3/8a/44032265776062a89171285ede55a0bdaadc8ac00f27f0512a71a9e3e1c8/hatchling-1.29.0-py3-none-any.whl", hash = "sha256:50af9343281f34785fab12da82e445ed987a6efb34fd8c2fc0f6e6630dbcc1b0", size = 76356, upload-time = "2026-02-23T19:42:05.197Z" }, +] + [[package]] name = "idna" version = "3.13" @@ -1833,6 +1852,22 @@ dependencies = [ [package.metadata] requires-dist = [{ name = "sphinx", specifier = ">=8.1" }] +[[package]] +name = "sphinx-vite-builder" +version = "0.0.1a15" +source = { editable = "packages/sphinx-vite-builder" } +dependencies = [ + { name = "hatchling" }, + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] + +[package.metadata] +requires-dist = [ + { name = "hatchling", specifier = ">=1.0" }, + { name = "sphinx", specifier = ">=8.1" }, +] + [[package]] name = "sphinxcontrib-applehelp" version = "2.0.0" @@ -1988,6 +2023,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, ] +[[package]] +name = "trove-classifiers" +version = "2026.4.28.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/af/88fdebf242bc7bc4957c96c5358a2b2b0f07e5001401906783a521ea9f54/trove_classifiers-2026.4.28.13.tar.gz", hash = "sha256:c85bb8a53c3de7330d1699b844ed9fb809a602a09ac15dc79ad6d1a509be0676", size = 17035, upload-time = "2026-04-28T13:45:41.495Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d6/bb/fbdc4e57731efb86b4209ffdd2519520782bf27b3c961beac3e5c20d2b87/trove_classifiers-2026.4.28.13-py3-none-any.whl", hash = "sha256:8f4b1eb4e16296b57d612965444f87a83861cc989a0451ac97fe4265ddef03b8", size = 14216, upload-time = "2026-04-28T13:45:39.943Z" }, +] + [[package]] name = "types-docutils" version = "0.22.3.20260408" From 3d166e7d07ac09b9a41bdde0b7924e7fc1e5707e Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 3 May 2026 05:08:55 -0500 Subject: [PATCH 02/53] pkg(gp-furo-theme): switch build-backend to sphinx_vite_builder.build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: Migrates gp-furo-theme from hatchling-direct to the new sphinx_vite_builder backend introduced in the previous commit. The backend runs `pnpm exec vite build` before delegating to hatchling, so `uv sync` / `uv build` / `pip install -e .` populates `static/` transparently. End users `pip install`ing from PyPI/sdist still don't need pnpm or Node — the sdist carries pre-baked assets, and the backend's wheel-from-sdist short-circuit (no `web/` in the unpacked sdist → assume pre-baked) skips vite cleanly. This validates the backend with a real consumer end-to-end: - `uv sync` from a clean checkout (no `static/`) auto-runs vite via the backend's `build_editable` hook - `uv build --package gp-furo-theme` produces a 44KB wheel containing both `furo-tw.css` and `furo.js` and a 36KB sdist (web/ excluded) - `PATH=$(echo $PATH | tr : '\n' | grep -v pnpm | paste -sd:) uv build` reaches the user as a multi-line `PnpmMissingError` with corepack + pnpm.io install URL hints (verified locally) what: - packages/gp-furo-theme/pyproject.toml: - `[build-system].requires`: `hatchling` → `hatchling, sphinx-vite-builder` - `[build-system].build-backend`: `hatchling.build` → `sphinx_vite_builder.build` - `[tool.hatch.build.targets.sdist] exclude = ["web/"]` — keeps the sdist small (36KB vs multi-megabyte if `web/node_modules` leaked in via hatchling's default selection); also makes the wheel-from-sdist chain trigger the backend's web-absent short-circuit (no double-vite, no stale node_modules paths) - `[tool.hatch.build] artifacts = [...]` — hatchling's documented "VCS-ignored include" mechanism. Lists the gitignored `static/` tree so hatchling's file selection includes it after the backend has populated it. Replaces force-include (which crashed on missing source-path at config-parse time, breaking build_editable). See https://hatch.pypa.io/latest/config/build/#artifacts - .github/workflows/tests.yml: add `sphinx-vite-builder` to the smoke matrix; the `_PACKAGE_SMOKE_RUNNERS` registration shipped with the scaffold commit is now actually invoked by CI References: - Issue #28 — the architecture spec (Phase 1 acceptance criteria) - The previous commit (sphinx-vite-builder scaffold) for the backend module + smart subprocess core --- .github/workflows/tests.yml | 1 + packages/gp-furo-theme/pyproject.toml | 37 +++++++++++++++++++++++++-- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 3b302ff0..3058e523 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -135,6 +135,7 @@ jobs: - sphinx-autodoc-typehints-gp - sphinx-ux-badges - sphinx-ux-autodoc-layout + - sphinx-vite-builder steps: - uses: actions/checkout@v6 diff --git a/packages/gp-furo-theme/pyproject.toml b/packages/gp-furo-theme/pyproject.toml index cea8fe51..6265b8e7 100644 --- a/packages/gp-furo-theme/pyproject.toml +++ b/packages/gp-furo-theme/pyproject.toml @@ -42,8 +42,41 @@ dependencies = [ Repository = "https://github.com/git-pull/gp-sphinx" [build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" +# `sphinx_vite_builder.build` runs `pnpm exec vite build` before +# delegating to hatchling, so the wheel/sdist always contains the +# vite-built static assets even though `static/` is gitignored. The +# backend short-circuits when `web/` is absent (unpacked-sdist case +# downstream of `pip install `), so end users do +# not need pnpm or Node — the sdist already carries pre-baked assets. +# +# `sphinx-vite-builder` is a workspace member; the workspace-level +# `[tool.uv.sources]` entry redirects this requires-dependency to the +# in-tree path under `packages/sphinx-vite-builder/` rather than +# resolving it from PyPI (where it isn't published yet). +requires = ["hatchling", "sphinx-vite-builder"] +build-backend = "sphinx_vite_builder.build" [tool.hatch.build.targets.wheel] packages = ["src/gp_furo_theme"] + +[tool.hatch.build.targets.sdist] +# Exclude the JS source tree from the sdist. The vite-built `static/` +# tree is shipped via `artifacts` below — that's the only thing +# downstream `pip install ` needs. Without this exclude, the +# sdist would balloon to multi-megabyte (web/node_modules/ leaks in +# under hatchling's default file selection because workspace-level +# .gitignore patterns aren't honored at the package level), and the +# wheel-from-sdist chain would re-run vite against an unpacked +# `web/node_modules/` whose internal paths are stale. +exclude = ["web/"] + +# `artifacts` is hatchling's documented "VCS-ignored include" mechanism: +# patterns listed here override the default gitignore-aware file +# selection. Because the backend runs vite *before* hatchling's file +# selection runs, the `static/` tree is always populated by then. +# Unlike `force-include`, `artifacts` does not crash on a missing path +# at config-parse time — it just "treat-as-not-ignored" patterns at +# file-selection time. See: +# https://hatch.pypa.io/latest/config/build/#artifacts +[tool.hatch.build] +artifacts = ["src/gp_furo_theme/theme/gp-furo/static/"] From 4c1f3f8b003d7b6aa6122b22df2327116bd61f97 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 3 May 2026 05:12:50 -0500 Subject: [PATCH 03/53] ci: wire pnpm/Node + SKIP env-var for sphinx-vite-builder backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: After migrating gp-furo-theme to use sphinx_vite_builder.build as its PEP 517 backend, every CI job that does `uv sync` triggers the backend's vite invocation via gp-furo-theme's editable install. Stock GitHub runners don't have pnpm or Node, so the backend (correctly) fast-fails with PnpmMissingError before any qa step runs. The backend exposes the documented `SPHINX_VITE_BUILDER_SKIP=1` escape hatch for environments where vite-built assets aren't actually needed. qa is exactly that case (lint/types/tests against Python sources; the rendered theme isn't exercised). docs/packages/release DO need real assets — the docs job produces a deployable site, the packages job produces wheels consumed by smoke (and eventually PyPI), and release publishes those wheels. what: - .github/workflows/tests.yml: - qa: env: SPHINX_VITE_BUILDER_SKIP: "1" so editable install succeeds without pnpm. The backend short-circuits; tests pass exactly as they did pre-migration (Sphinx silently skips missing static entries — same behaviour as on a stock checkout without vite). - docs: add pnpm/action-setup@v6 + actions/setup-node@v6 (Node 22, pnpm cache) before `uv sync` so the backend has a working toolchain. The docs build then sees real CSS / JS. - packages: same setup, before `uv sync`. The wheels produced by `uv build --all-packages` carry vite-built assets; smoke jobs consume those wheels via wheel-from-sdist where the backend short-circuits (no `web/` in unpacked sdist), so smoke itself needs no toolchain. - .github/workflows/release.yml: same pnpm/Node setup before `uv sync` so `uv build --package gp-furo-theme` ships a wheel with populated `static/`. Without this, the release pipeline would fast-fail at the editable install step (matching the deliberate loud-fail discipline) — adding the toolchain lets the release proceed and ship working wheels. Smoke matrix entries that exercise the rendered theme (`gp-sphinx`, `sphinx-gp-theme`) consume the wheels built by the `packages` job above; with this commit those wheels are no longer empty of static assets, so those targets pass naturally. --- .github/workflows/release.yml | 19 +++++++++++++++ .github/workflows/tests.yml | 45 +++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4c492f82..05b4343b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -42,6 +42,25 @@ jobs: print(f"{key}={value}") PY + # `gp-furo-theme`'s build backend (`sphinx_vite_builder.build`) + # runs `pnpm exec vite build` during sdist + wheel construction. + # Without pnpm + Node the backend fast-fails with PnpmMissingError + # and the release pipeline aborts before publish — exactly the + # invariant we want, but it requires the toolchain to be set up + # first. The backend short-circuits cleanly inside the unpacked + # sdist (no `web/` → assume pre-baked) so end users `pip install` + # without pnpm/Node. + - name: Set up pnpm + uses: pnpm/action-setup@v6 + with: + version: 10 + + - name: Set up Node + uses: actions/setup-node@v6 + with: + node-version: 22 + cache: pnpm + - name: Install workspace dependencies run: uv sync --all-packages --all-extras --group dev diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 3058e523..a38e835a 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -7,6 +7,17 @@ on: jobs: qa: runs-on: ubuntu-latest + # `gp-furo-theme`'s build backend (`sphinx_vite_builder.build`) runs + # `pnpm exec vite build` during editable installs, which the qa + # runners can't satisfy (no pnpm/Node). qa's purpose is lint/types/ + # tests against Python sources — the rendered theme isn't exercised + # here — so we set the documented escape-hatch env var on the whole + # job. The backend then short-circuits and the install succeeds. + # Test suites that build a Sphinx project with html_theme="gp-furo" + # behave the same as they do on a stock checkout without vite — + # Sphinx silently skips the missing static entries (no -W in qa). + env: + SPHINX_VITE_BUILDER_SKIP: "1" strategy: fail-fast: false matrix: @@ -75,6 +86,21 @@ jobs: with: enable-cache: true + # `gp-furo-theme`'s build backend (`sphinx_vite_builder.build`) + # runs vite during editable install, populating the gitignored + # `static/` tree so the docs build below picks up real CSS / JS. + # The pnpm/Node steps below give the backend a working toolchain. + - name: Set up pnpm + uses: pnpm/action-setup@v6 + with: + version: 10 + + - name: Set up Node + uses: actions/setup-node@v6 + with: + node-version: 22 + cache: pnpm + - name: Install workspace dependencies run: uv sync --all-packages --all-extras --group dev @@ -97,6 +123,25 @@ jobs: with: enable-cache: true + # `gp-furo-theme`'s sdist + wheel both go through + # `sphinx_vite_builder.build`, which runs vite. The wheels + # produced here are consumed by the smoke matrix below — they + # MUST contain populated `static/`, otherwise downstream smoke + # installs render unstyled. pnpm + Node satisfy that toolchain + # requirement; the backend's wheel-from-sdist short-circuit + # (no `web/` in unpacked sdist → assume pre-baked) means smoke + # jobs themselves don't need this setup. + - name: Set up pnpm + uses: pnpm/action-setup@v6 + with: + version: 10 + + - name: Set up Node + uses: actions/setup-node@v6 + with: + node-version: 22 + cache: pnpm + - name: Install workspace dependencies run: uv sync --all-packages --all-extras --group dev From 1ab7554a73e85dcba9497979a847302a6be0bdfb Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 3 May 2026 05:18:28 -0500 Subject: [PATCH 04/53] ci: register sphinx-vite-builder docs page in toctree + SKIP for root-install smoke MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: Two CI failures surfaced after the migration commit: 1. `docs` job (`sphinx-build -W -b dirhtml`): the new docs page `packages/sphinx-vite-builder.md` wasn't referenced in any `{toctree}` directive, so Sphinx warned `document isn't included in any toctree` and `-W` escalated that to a build failure. (Local `just build-docs` doesn't pass `-W`, so the warning was invisible.) 2. `smoke (root-install)` matrix entry: installing the workspace root triggers a transitive build of `gp-furo-theme`, which routes through `sphinx_vite_builder.build`, which fast-fails on missing pnpm. The root-install smoke only verifies imports resolve (it doesn't exercise the rendered theme), so the documented escape hatch `SPHINX_VITE_BUILDER_SKIP=1` is the right answer — same pattern already applied to `qa`. what: - docs/index.md: add `packages/sphinx-vite-builder` to the "Internal" toctree alongside `gp-furo-theme` and `gp-sphinx-vite`. Mirrors the group the Phase 1 backend belongs to (workspace-internal tooling) - .github/workflows/tests.yml: scope the SKIP env var to the `Smoke test root bootstrap install` step so the smoke job behaves the same for the matrix entries that consume pre-built wheels (those don't need the env var because the wheels carry static assets baked in by the `packages` job above) --- .github/workflows/tests.yml | 6 ++++++ docs/index.md | 1 + 2 files changed, 7 insertions(+) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index a38e835a..752c3304 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -203,6 +203,12 @@ jobs: - name: Smoke test root bootstrap install if: matrix.target == 'root-install' + # The root install transitively builds `gp-furo-theme`, which + # routes through `sphinx_vite_builder.build`. The smoke target + # only verifies that imports resolve — it doesn't exercise the + # rendered theme — so we skip the vite invocation. + env: + SPHINX_VITE_BUILDER_SKIP: "1" run: python scripts/ci/package_tools.py smoke root-install - name: Smoke test built package artifact diff --git a/docs/index.md b/docs/index.md index ab54716e..b57f75db 100644 --- a/docs/index.md +++ b/docs/index.md @@ -134,6 +134,7 @@ packages/gp-sphinx packages/sphinx-gp-theme packages/gp-furo-theme packages/gp-sphinx-vite +packages/sphinx-vite-builder ``` ```{toctree} From 2820307cb82d78540a6912bec701e1f84fd15508 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 3 May 2026 07:07:30 -0500 Subject: [PATCH 05/53] feat(sphinx-vite-builder[ci]): self-healing PnpmMissingError with platform recipes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: When a downstream consumer's CI hits PnpmMissingError, the bare "install pnpm via corepack/curl" hint is generic — readers still have to translate that into the right config syntax for their platform. Worse, the obvious-but-wrong workaround `SPHINX_VITE_BUILDER_SKIP=1` silently disables the backend, leaving the consumer with broken docs: the SKIP env var is correct for *wheel-only* installs (where static is already inside the wheel) but wrong for *source builds* (where the backend MUST run vite to populate static). The right answer for source-build CI environments is to add pnpm + Node to the pipeline. This commit makes the error message self-healing: read it, copy the snippet, paste into your workflow. what: - _internal/vite.py:_detect_ci_provider() — canonical CI-provider detection. Most-specific provider wins via env vars (per upstream docs): GITHUB_ACTIONS=true → "github-actions" CIRCLECI=true → "circleci" TF_BUILD=True → "azure-pipelines" (Team Foundation Build) GITLAB_CI=true → "gitlab" CI=true → "ci" (generic fallback) Detection is case-insensitive and accepts "1" or "true" as truthy (matches the convention every CI provider uses). - _internal/vite.py:_CI_SETUP_RECIPES — copy-pasteable config fragments per platform. Versions follow the upstream pnpm CI guide (https://pnpm.io/continuous-integration); update in lockstep with the workspace's pnpm-lock.yaml's `packageManager` if it pins something different. - _internal/vite.py:_format_pnpm_missing_hint() — appends the per-provider recipe block to the existing multi-line hint when CI is detected. Outside CI (local dev) the message is unchanged. - tests/test_sphinx_vite_builder_vite.py: 15 new tests covering - _detect_ci_provider parametrized fixture (7 cases: github-actions, circleci, azure-pipelines mixed-case, gitlab, generic-ci-fallback, numeric-truthy "1", explicit-false-skips) - specific-beats-generic precedence (GITHUB_ACTIONS=true AND CI=true → github-actions wins) - _detect_ci_provider returns None outside CI - PnpmMissingError hint omits CI block in local dev - PnpmMissingError hint includes the right snippet for each of GitHub Actions / CircleCI / Azure Pipelines / GitLab CI - generic CI fallback message present when only `CI=true` References: - pnpm CI docs: https://pnpm.io/continuous-integration - npm/ci-detect: https://github.com/npm/ci-detect (provider env vars) - semantic-release/env-ci: https://github.com/semantic-release/env-ci - GitHub Actions default vars: https://docs.github.com/en/actions/learn-github-actions/variables#default-environment-variables - CircleCI vars: https://circleci.com/docs/variables/ - GitLab CI predefined vars: https://docs.gitlab.com/ee/ci/variables/predefined_variables.html --- .../src/sphinx_vite_builder/_internal/vite.py | 122 +++++++++- tests/test_sphinx_vite_builder_vite.py | 222 ++++++++++++++++++ 2 files changed, 341 insertions(+), 3 deletions(-) diff --git a/packages/sphinx-vite-builder/src/sphinx_vite_builder/_internal/vite.py b/packages/sphinx-vite-builder/src/sphinx_vite_builder/_internal/vite.py index c4019e4d..db0cad50 100644 --- a/packages/sphinx-vite-builder/src/sphinx_vite_builder/_internal/vite.py +++ b/packages/sphinx-vite-builder/src/sphinx_vite_builder/_internal/vite.py @@ -77,10 +77,124 @@ def vite_watch_command(*, package_manager: str = "pnpm") -> tuple[str, ...]: return (package_manager, "exec", "vite", "build", "--watch") -def _format_pnpm_missing_hint(vite_root: pathlib.Path) -> str: - """Multi-line, copy-pasteable hint when pnpm is not on PATH.""" +def _detect_ci_provider() -> str | None: + """Return a canonical CI-provider name, or ``None`` if not in CI. + + Detection precedence: most-specific provider wins. Each provider's + canonical env var (per their docs) is checked first; the generic + ``CI=true`` is the fallback for "we know we're in CI but don't + recognise the provider". + + Provider env vars (canonical, per upstream docs): + + - GitHub Actions: ``GITHUB_ACTIONS=true`` + - CircleCI: ``CIRCLECI=true`` + - Azure Pipelines: ``TF_BUILD=True`` (Team Foundation Build) + - GitLab CI: ``GITLAB_CI=true`` + - Generic: ``CI=true`` + """ + env = os.environ + # Map of "canonical name" → "env var to check (truthy)". Order + # matters: more-specific entries first. + providers: tuple[tuple[str, str], ...] = ( + ("github-actions", "GITHUB_ACTIONS"), + ("circleci", "CIRCLECI"), + ("azure-pipelines", "TF_BUILD"), + ("gitlab", "GITLAB_CI"), + ("ci", "CI"), + ) + for name, var in providers: + value = env.get(var, "").strip().lower() + if value in {"1", "true"}: + return name + return None + + +# Each per-provider snippet is a *copy-pasteable* config fragment that +# adds pnpm + Node to the platform's pipeline before the Python build +# step runs. Versions follow the upstream pnpm CI guide +# (https://pnpm.io/continuous-integration); update in lockstep with +# the workspace's pnpm-lock.yaml's `packageManager` if it pins +# something different. +_CI_SETUP_RECIPES: dict[str, str] = { + "github-actions": textwrap.dedent( + """\ + - uses: pnpm/action-setup@v6 + with: + version: 10 + - uses: actions/setup-node@v6 + with: + node-version: 22 + cache: pnpm""", + ), + "circleci": textwrap.dedent( + """\ + - run: + name: Set up pnpm + command: | + npm install --global corepack@latest + corepack enable + corepack prepare pnpm@latest-10 --activate""", + ), + "azure-pipelines": textwrap.dedent( + """\ + - task: NodeTool@0 + inputs: + versionSpec: '22.x' + displayName: 'Set up Node' + - script: | + npm install --global corepack@latest + corepack enable + corepack prepare pnpm@latest-10 --activate + displayName: 'Set up pnpm'""", + ), + "gitlab": textwrap.dedent( + """\ + before_script: + - npm install --global corepack@latest + - corepack enable + - corepack prepare pnpm@latest-10 --activate""", + ), + "ci": " # Use your CI's package-manager setup mechanism to install pnpm", +} + + +def _format_ci_recipe_block(provider: str | None) -> str: + """Return a multi-line CI-specific setup snippet, or an empty string. + + Called by :func:`_format_pnpm_missing_hint` when CI is detected so + the user's error message includes a copy-pasteable config fragment + for their platform. + """ + if provider is None: + return "" + + pretty: dict[str, str] = { + "github-actions": "GitHub Actions", + "circleci": "CircleCI", + "azure-pipelines": "Azure Pipelines", + "gitlab": "GitLab CI", + "ci": "this CI environment", + } + label = pretty.get(provider, provider) + recipe = _CI_SETUP_RECIPES.get(provider, "") return textwrap.dedent( f"""\ + + Detected CI provider: {label}. Add the following to your pipeline + config (before the Python build step that triggers this backend): + +{textwrap.indent(recipe, " ")} + """, + ).rstrip() + + +def _format_pnpm_missing_hint(vite_root: pathlib.Path) -> str: + """Multi-line, copy-pasteable hint when pnpm is not on PATH.""" + ci_recipe = _format_ci_recipe_block(_detect_ci_provider()) + return ( + textwrap.dedent( + f"""\ sphinx-vite-builder: cannot bootstrap the vite toolchain. `pnpm` is not on PATH. Install it via one of: @@ -95,7 +209,9 @@ def _format_pnpm_missing_hint(vite_root: pathlib.Path) -> str: Vite project root resolved to: {vite_root} """, - ).rstrip() + ).rstrip() + + ci_recipe + ) def _format_install_failed_hint( diff --git a/tests/test_sphinx_vite_builder_vite.py b/tests/test_sphinx_vite_builder_vite.py index 9cac66a6..38ee465e 100644 --- a/tests/test_sphinx_vite_builder_vite.py +++ b/tests/test_sphinx_vite_builder_vite.py @@ -10,6 +10,7 @@ import pathlib import shutil import textwrap +import typing as t import pytest from sphinx_vite_builder._internal import vite as vite_module @@ -147,6 +148,227 @@ def test_pnpm_missing_error_inherits_from_base() -> None: assert issubclass(ViteFailedError, SphinxViteBuilderError) +# --------------------------------------------------------------------------- +# CI detection — pnpm-missing hint includes platform-specific setup recipes +# --------------------------------------------------------------------------- + + +def _clear_ci_env(monkeypatch: pytest.MonkeyPatch) -> None: + """Strip every CI-detection env var so detection starts from a clean slate.""" + for var in ("GITHUB_ACTIONS", "CIRCLECI", "TF_BUILD", "GITLAB_CI", "CI"): + monkeypatch.delenv(var, raising=False) + + +def test_detect_ci_provider_returns_none_when_not_in_ci( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """No truthy CI env var → no provider detected.""" + _clear_ci_env(monkeypatch) + assert vite_module._detect_ci_provider() is None + + +class CIProviderCase(t.NamedTuple): + """Test case for :func:`_detect_ci_provider`.""" + + test_id: str + env_var: str + env_value: str + expected: str | None + + +_CI_PROVIDER_FIXTURES: list[CIProviderCase] = [ + CIProviderCase( + test_id="github-actions", + env_var="GITHUB_ACTIONS", + env_value="true", + expected="github-actions", + ), + CIProviderCase( + test_id="circleci", + env_var="CIRCLECI", + env_value="true", + expected="circleci", + ), + CIProviderCase( + test_id="azure-pipelines-mixed-case", + # Azure sets TF_BUILD=True (capital T). Detection is case-insensitive. + env_var="TF_BUILD", + env_value="True", + expected="azure-pipelines", + ), + CIProviderCase( + test_id="gitlab", + env_var="GITLAB_CI", + env_value="true", + expected="gitlab", + ), + CIProviderCase( + test_id="generic-ci-fallback", + env_var="CI", + env_value="true", + expected="ci", + ), + CIProviderCase( + test_id="numeric-truthy", + env_var="GITHUB_ACTIONS", + env_value="1", + expected="github-actions", + ), + CIProviderCase( + test_id="explicit-false-skips", + # Some platforms set the var to ``false`` rather than unsetting it. + env_var="GITHUB_ACTIONS", + env_value="false", + expected=None, + ), +] + + +@pytest.mark.parametrize( + list(CIProviderCase._fields), + _CI_PROVIDER_FIXTURES, + ids=[c.test_id for c in _CI_PROVIDER_FIXTURES], +) +def test_detect_ci_provider( + test_id: str, + env_var: str, + env_value: str, + expected: str | None, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Each canonical CI env var resolves to its provider name.""" + del test_id # Used by pytest IDs. + _clear_ci_env(monkeypatch) + monkeypatch.setenv(env_var, env_value) + assert vite_module._detect_ci_provider() == expected + + +def test_detect_ci_provider_specific_beats_generic( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """When both GITHUB_ACTIONS and CI are truthy, specific wins. + + GitHub Actions sets both ``GITHUB_ACTIONS=true`` AND ``CI=true``; + detection MUST surface the specific provider so the recipe block + is GitHub-flavoured rather than the generic fallback. + """ + _clear_ci_env(monkeypatch) + monkeypatch.setenv("GITHUB_ACTIONS", "true") + monkeypatch.setenv("CI", "true") + assert vite_module._detect_ci_provider() == "github-actions" + + +def test_pnpm_missing_hint_omits_ci_block_outside_ci( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Local-dev failure → no CI recipe section.""" + monkeypatch.delenv("SPHINX_VITE_BUILDER_SKIP", raising=False) + _clear_ci_env(monkeypatch) + project = _make_vite_project(tmp_path) + monkeypatch.setattr(shutil, "which", lambda _name: None) + + with pytest.raises(PnpmMissingError) as exc_info: + run_vite_build(project) + msg = str(exc_info.value) + assert "Detected CI provider" not in msg + assert "Add the following to your pipeline" not in msg + + +def test_pnpm_missing_hint_includes_github_actions_recipe( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """GitHub Actions failure → hint includes pnpm/action-setup snippet.""" + monkeypatch.delenv("SPHINX_VITE_BUILDER_SKIP", raising=False) + _clear_ci_env(monkeypatch) + monkeypatch.setenv("GITHUB_ACTIONS", "true") + project = _make_vite_project(tmp_path) + monkeypatch.setattr(shutil, "which", lambda _name: None) + + with pytest.raises(PnpmMissingError) as exc_info: + run_vite_build(project) + msg = str(exc_info.value) + assert "Detected CI provider: GitHub Actions" in msg + assert "pnpm/action-setup@v6" in msg + assert "actions/setup-node@v6" in msg + + +def test_pnpm_missing_hint_includes_circleci_recipe( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """CircleCI failure → hint includes corepack-based pnpm install snippet.""" + monkeypatch.delenv("SPHINX_VITE_BUILDER_SKIP", raising=False) + _clear_ci_env(monkeypatch) + monkeypatch.setenv("CIRCLECI", "true") + project = _make_vite_project(tmp_path) + monkeypatch.setattr(shutil, "which", lambda _name: None) + + with pytest.raises(PnpmMissingError) as exc_info: + run_vite_build(project) + msg = str(exc_info.value) + assert "Detected CI provider: CircleCI" in msg + assert "corepack enable" in msg + assert "corepack prepare pnpm" in msg + + +def test_pnpm_missing_hint_includes_azure_pipelines_recipe( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Azure Pipelines failure → hint includes NodeTool@0 + corepack snippet.""" + monkeypatch.delenv("SPHINX_VITE_BUILDER_SKIP", raising=False) + _clear_ci_env(monkeypatch) + monkeypatch.setenv("TF_BUILD", "True") + project = _make_vite_project(tmp_path) + monkeypatch.setattr(shutil, "which", lambda _name: None) + + with pytest.raises(PnpmMissingError) as exc_info: + run_vite_build(project) + msg = str(exc_info.value) + assert "Detected CI provider: Azure Pipelines" in msg + assert "NodeTool@0" in msg + assert "corepack enable" in msg + + +def test_pnpm_missing_hint_includes_gitlab_recipe( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """GitLab CI failure → hint includes before_script corepack snippet.""" + monkeypatch.delenv("SPHINX_VITE_BUILDER_SKIP", raising=False) + _clear_ci_env(monkeypatch) + monkeypatch.setenv("GITLAB_CI", "true") + project = _make_vite_project(tmp_path) + monkeypatch.setattr(shutil, "which", lambda _name: None) + + with pytest.raises(PnpmMissingError) as exc_info: + run_vite_build(project) + msg = str(exc_info.value) + assert "Detected CI provider: GitLab CI" in msg + assert "before_script:" in msg + assert "corepack prepare pnpm" in msg + + +def test_pnpm_missing_hint_generic_ci_fallback( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Unrecognised CI (only ``CI=true``) → generic fallback message.""" + monkeypatch.delenv("SPHINX_VITE_BUILDER_SKIP", raising=False) + _clear_ci_env(monkeypatch) + monkeypatch.setenv("CI", "true") + project = _make_vite_project(tmp_path) + monkeypatch.setattr(shutil, "which", lambda _name: None) + + with pytest.raises(PnpmMissingError) as exc_info: + run_vite_build(project) + msg = str(exc_info.value) + assert "Detected CI provider: this CI environment" in msg + assert "Use your CI's package-manager setup mechanism" in msg + + # --------------------------------------------------------------------------- # Fast-fail: pnpm install / vite build non-zero exit # From 3c796e5e35717590d9449314410c78ad9fefefea Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 3 May 2026 07:43:29 -0500 Subject: [PATCH 06/53] =?UTF-8?q?release(workspace):=20bump=20v0.0.1a15=20?= =?UTF-8?q?=E2=86=92=20v0.0.1a16.dev0=20(dev=20release)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: First dev release of the workspace's new sphinx-vite-builder PEP 517 backend (PR #29). PEP 440 dev releases (`.devN`) sort below the eventual stable `v0.0.1a16` and let us iterate against the real PyPI publishing pipeline without committing to a stable release. This dev release exists to verify the wheel-install path end-to-end: - release.yml wires `pnpm install` + `pnpm exec vite build` before `uv build`, so the published gp-furo-theme wheel must contain the vite-built `static/` tree (verified by the in-pipeline `unzip -l` check, plus a follow-up `pip download` round-trip). - Downstream consumers (libtmux-mcp) install from PyPI without pnpm/Node and verify their docs render styled — proves the wheel carries the build artefacts as designed. If verification reveals a regression (empty wheel, install-time PnpmMissingError, etc.), iterate to `dev1`/`dev2`. what: - 17 packages × pyproject.toml: `version = "0.0.1a16.dev0"` - workspace root pyproject.toml: same - inter-package `==0.0.1a15` constraints → `==0.0.1a16.dev0` - 17 package `__init__.py` files: `__version__ = "0.0.1a16.dev0"` - tests/test_sphinx_vite_builder.py + tests/test_gp_sphinx_vite.py: `test_version_matches_workspace_lock` asserts new version - tests/ci/test_package_tools.py: corresponding fixture updates - uv.lock: refreshed via `uv lock` Verified locally: - `uv run python scripts/ci/package_tools.py check-versions` → exit 0 - ruff clean, ruff format clean, mypy clean across 207 source files - 1354 passed, 159 skipped (no regression) - `just build-docs` succeeds --- packages/gp-furo-theme/pyproject.toml | 2 +- .../src/gp_furo_theme/__init__.py | 2 +- packages/gp-sphinx-vite/pyproject.toml | 2 +- .../src/gp_sphinx_vite/__init__.py | 2 +- packages/gp-sphinx/pyproject.toml | 14 ++++---- packages/gp-sphinx/src/gp_sphinx/__init__.py | 2 +- .../sphinx-autodoc-api-style/pyproject.toml | 6 ++-- .../sphinx-autodoc-argparse/pyproject.toml | 2 +- .../src/sphinx_autodoc_argparse/__init__.py | 2 +- .../sphinx-autodoc-docutils/pyproject.toml | 8 ++--- .../src/sphinx_autodoc_docutils/__init__.py | 2 +- .../sphinx-autodoc-fastmcp/pyproject.toml | 8 ++--- .../src/sphinx_autodoc_fastmcp/__init__.py | 2 +- .../pyproject.toml | 8 ++--- packages/sphinx-autodoc-sphinx/pyproject.toml | 8 ++--- .../src/sphinx_autodoc_sphinx/__init__.py | 2 +- .../pyproject.toml | 2 +- .../sphinx_autodoc_typehints_gp/extension.py | 2 +- packages/sphinx-fonts/pyproject.toml | 2 +- .../sphinx-fonts/src/sphinx_fonts/__init__.py | 2 +- packages/sphinx-gp-opengraph/pyproject.toml | 2 +- .../src/sphinx_gp_opengraph/__init__.py | 2 +- packages/sphinx-gp-sitemap/pyproject.toml | 2 +- .../src/sphinx_gp_sitemap/__init__.py | 2 +- packages/sphinx-gp-theme/pyproject.toml | 4 +-- .../src/sphinx_gp_theme/__init__.py | 2 +- .../sphinx-ux-autodoc-layout/pyproject.toml | 2 +- packages/sphinx-ux-badges/pyproject.toml | 2 +- .../src/sphinx_ux_badges/__init__.py | 2 +- packages/sphinx-vite-builder/pyproject.toml | 2 +- .../src/sphinx_vite_builder/__init__.py | 2 +- pyproject.toml | 4 +-- tests/ci/test_package_tools.py | 8 +++-- tests/test_gp_sphinx_vite.py | 2 +- tests/test_sphinx_vite_builder.py | 2 +- uv.lock | 36 +++++++++---------- 36 files changed, 79 insertions(+), 77 deletions(-) diff --git a/packages/gp-furo-theme/pyproject.toml b/packages/gp-furo-theme/pyproject.toml index 6265b8e7..650144a0 100644 --- a/packages/gp-furo-theme/pyproject.toml +++ b/packages/gp-furo-theme/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "gp-furo-theme" -version = "0.0.1a15" +version = "0.0.1a16.dev0" description = "Tailwind v4 port of the Furo Sphinx theme for git-pull project documentation" requires-python = ">=3.10,<4.0" authors = [ diff --git a/packages/gp-furo-theme/src/gp_furo_theme/__init__.py b/packages/gp-furo-theme/src/gp_furo_theme/__init__.py index 7c8e4fcf..0dc13fa6 100644 --- a/packages/gp-furo-theme/src/gp_furo_theme/__init__.py +++ b/packages/gp-furo-theme/src/gp_furo_theme/__init__.py @@ -39,7 +39,7 @@ from .navigation import get_navigation_tree -__version__ = "0.0.1a15" +__version__ = "0.0.1a16.dev0" THEME_NAME = "gp-furo" THEME_PATH = (pathlib.Path(__file__).parent / "theme" / THEME_NAME).resolve() diff --git a/packages/gp-sphinx-vite/pyproject.toml b/packages/gp-sphinx-vite/pyproject.toml index 36248423..ee39bdd2 100644 --- a/packages/gp-sphinx-vite/pyproject.toml +++ b/packages/gp-sphinx-vite/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "gp-sphinx-vite" -version = "0.0.1a15" +version = "0.0.1a16.dev0" description = "Transparent Vite + pnpm orchestration for Sphinx theme asset pipelines" requires-python = ">=3.10,<4.0" authors = [ diff --git a/packages/gp-sphinx-vite/src/gp_sphinx_vite/__init__.py b/packages/gp-sphinx-vite/src/gp_sphinx_vite/__init__.py index 5b86c911..e59dec43 100644 --- a/packages/gp-sphinx-vite/src/gp_sphinx_vite/__init__.py +++ b/packages/gp-sphinx-vite/src/gp_sphinx_vite/__init__.py @@ -35,7 +35,7 @@ if t.TYPE_CHECKING: from sphinx.application import Sphinx -__version__ = "0.0.1a15" +__version__ = "0.0.1a16.dev0" logger = logging.getLogger(__name__) logger.addHandler(logging.NullHandler()) diff --git a/packages/gp-sphinx/pyproject.toml b/packages/gp-sphinx/pyproject.toml index 8afa85d0..5e63ee17 100644 --- a/packages/gp-sphinx/pyproject.toml +++ b/packages/gp-sphinx/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "gp-sphinx" -version = "0.0.1a15" +version = "0.0.1a16.dev0" description = "Shared Sphinx documentation platform for git-pull projects" requires-python = ">=3.10,<4.0" authors = [ @@ -26,15 +26,15 @@ readme = "README.md" keywords = ["sphinx", "documentation", "configuration"] dependencies = [ "sphinx>=8.1,<9", - "sphinx-gp-theme==0.0.1a15", - "sphinx-fonts==0.0.1a15", + "sphinx-gp-theme==0.0.1a16.dev0", + "sphinx-fonts==0.0.1a16.dev0", "myst-parser", "docutils", - "sphinx-autodoc-typehints-gp==0.0.1a15", + "sphinx-autodoc-typehints-gp==0.0.1a16.dev0", "sphinx-inline-tabs", "sphinx-copybutton", - "sphinx-gp-opengraph==0.0.1a15", - "sphinx-gp-sitemap==0.0.1a15", + "sphinx-gp-opengraph==0.0.1a16.dev0", + "sphinx-gp-sitemap==0.0.1a16.dev0", "sphinxext-rediraffe", "sphinx-design", "linkify-it-py", @@ -43,7 +43,7 @@ dependencies = [ [project.optional-dependencies] argparse = [ - "sphinx-autodoc-argparse==0.0.1a15", + "sphinx-autodoc-argparse==0.0.1a16.dev0", ] [project.urls] diff --git a/packages/gp-sphinx/src/gp_sphinx/__init__.py b/packages/gp-sphinx/src/gp_sphinx/__init__.py index c6cdcb55..d643a493 100644 --- a/packages/gp-sphinx/src/gp_sphinx/__init__.py +++ b/packages/gp-sphinx/src/gp_sphinx/__init__.py @@ -7,7 +7,7 @@ __title__ = "gp-sphinx" __package_name__ = "gp_sphinx" __description__ = "Shared Sphinx documentation platform for git-pull projects" -__version__ = "0.0.1a15" +__version__ = "0.0.1a16.dev0" __author__ = "Tony Narlock" __github__ = "https://github.com/git-pull/gp-sphinx" __docs__ = "https://gp-sphinx.git-pull.com" diff --git a/packages/sphinx-autodoc-api-style/pyproject.toml b/packages/sphinx-autodoc-api-style/pyproject.toml index 2991ad54..e88f3b0f 100644 --- a/packages/sphinx-autodoc-api-style/pyproject.toml +++ b/packages/sphinx-autodoc-api-style/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sphinx-autodoc-api-style" -version = "0.0.1a15" +version = "0.0.1a16.dev0" description = "Sphinx extension for enhanced autodoc API entry styling (badges and cards)" requires-python = ">=3.10,<4.0" authors = [ @@ -27,8 +27,8 @@ readme = "README.md" keywords = ["sphinx", "autodoc", "documentation", "api", "badges"] dependencies = [ "sphinx>=8.1", - "sphinx-ux-badges==0.0.1a15", - "sphinx-ux-autodoc-layout==0.0.1a15", + "sphinx-ux-badges==0.0.1a16.dev0", + "sphinx-ux-autodoc-layout==0.0.1a16.dev0", ] [project.urls] diff --git a/packages/sphinx-autodoc-argparse/pyproject.toml b/packages/sphinx-autodoc-argparse/pyproject.toml index 77b16ae9..00807db7 100644 --- a/packages/sphinx-autodoc-argparse/pyproject.toml +++ b/packages/sphinx-autodoc-argparse/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sphinx-autodoc-argparse" -version = "0.0.1a15" +version = "0.0.1a16.dev0" description = "Modern Sphinx extension for documenting argparse-based CLI tools" requires-python = ">=3.10,<4.0" authors = [ diff --git a/packages/sphinx-autodoc-argparse/src/sphinx_autodoc_argparse/__init__.py b/packages/sphinx-autodoc-argparse/src/sphinx_autodoc_argparse/__init__.py index 5679b99a..ce53e115 100644 --- a/packages/sphinx-autodoc-argparse/src/sphinx_autodoc_argparse/__init__.py +++ b/packages/sphinx-autodoc-argparse/src/sphinx_autodoc_argparse/__init__.py @@ -44,7 +44,7 @@ if t.TYPE_CHECKING: from sphinx.application import Sphinx -__version__ = "0.0.1a15" +__version__ = "0.0.1a16.dev0" class SetupDict(t.TypedDict): diff --git a/packages/sphinx-autodoc-docutils/pyproject.toml b/packages/sphinx-autodoc-docutils/pyproject.toml index 7f337485..18a14bd3 100644 --- a/packages/sphinx-autodoc-docutils/pyproject.toml +++ b/packages/sphinx-autodoc-docutils/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sphinx-autodoc-docutils" -version = "0.0.1a15" +version = "0.0.1a16.dev0" description = "Sphinx extension for documenting docutils directives and roles as first-class reference entries" requires-python = ">=3.10,<4.0" authors = [ @@ -27,9 +27,9 @@ readme = "README.md" keywords = ["sphinx", "docutils", "directives", "roles", "documentation", "autodoc"] dependencies = [ "sphinx>=8.1", - "sphinx-ux-badges==0.0.1a15", - "sphinx-ux-autodoc-layout==0.0.1a15", - "sphinx-autodoc-typehints-gp==0.0.1a15", + "sphinx-ux-badges==0.0.1a16.dev0", + "sphinx-ux-autodoc-layout==0.0.1a16.dev0", + "sphinx-autodoc-typehints-gp==0.0.1a16.dev0", ] [project.urls] diff --git a/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/__init__.py b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/__init__.py index 3fdceaec..94201c3a 100644 --- a/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/__init__.py +++ b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/__init__.py @@ -77,7 +77,7 @@ def _add_static_path(app: Sphinx) -> None: app.add_css_file("css/sphinx_autodoc_docutils.css") return { - "version": "0.0.1a15", + "version": "0.0.1a16.dev0", "parallel_read_safe": True, "parallel_write_safe": True, } diff --git a/packages/sphinx-autodoc-fastmcp/pyproject.toml b/packages/sphinx-autodoc-fastmcp/pyproject.toml index 4fb23325..5b753f2d 100644 --- a/packages/sphinx-autodoc-fastmcp/pyproject.toml +++ b/packages/sphinx-autodoc-fastmcp/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sphinx-autodoc-fastmcp" -version = "0.0.1a15" +version = "0.0.1a16.dev0" description = "Sphinx extension for documenting FastMCP tools (cards, badges, cross-refs)" requires-python = ">=3.10,<4.0" authors = [ @@ -27,9 +27,9 @@ readme = "README.md" keywords = ["sphinx", "fastmcp", "mcp", "documentation", "badges"] dependencies = [ "sphinx>=8.1", - "sphinx-ux-badges==0.0.1a15", - "sphinx-ux-autodoc-layout==0.0.1a15", - "sphinx-autodoc-typehints-gp==0.0.1a15", + "sphinx-ux-badges==0.0.1a16.dev0", + "sphinx-ux-autodoc-layout==0.0.1a16.dev0", + "sphinx-autodoc-typehints-gp==0.0.1a16.dev0", ] [project.urls] diff --git a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/__init__.py b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/__init__.py index 895d215e..11284409 100644 --- a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/__init__.py +++ b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/__init__.py @@ -51,7 +51,7 @@ logging.getLogger(__name__).addHandler(logging.NullHandler()) -_EXTENSION_VERSION = "0.0.1a15" +_EXTENSION_VERSION = "0.0.1a16.dev0" def setup(app: Sphinx) -> dict[str, t.Any]: diff --git a/packages/sphinx-autodoc-pytest-fixtures/pyproject.toml b/packages/sphinx-autodoc-pytest-fixtures/pyproject.toml index 9ef7d8ac..7bae8171 100644 --- a/packages/sphinx-autodoc-pytest-fixtures/pyproject.toml +++ b/packages/sphinx-autodoc-pytest-fixtures/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sphinx-autodoc-pytest-fixtures" -version = "0.0.1a15" +version = "0.0.1a16.dev0" description = "Sphinx extension for documenting pytest fixtures as first-class objects" requires-python = ">=3.10,<4.0" authors = [ @@ -30,9 +30,9 @@ keywords = ["sphinx", "pytest", "fixtures", "documentation", "autodoc"] dependencies = [ "sphinx>=8.1", "pytest", - "sphinx-ux-badges==0.0.1a15", - "sphinx-ux-autodoc-layout==0.0.1a15", - "sphinx-autodoc-typehints-gp==0.0.1a15", + "sphinx-ux-badges==0.0.1a16.dev0", + "sphinx-ux-autodoc-layout==0.0.1a16.dev0", + "sphinx-autodoc-typehints-gp==0.0.1a16.dev0", ] [project.urls] diff --git a/packages/sphinx-autodoc-sphinx/pyproject.toml b/packages/sphinx-autodoc-sphinx/pyproject.toml index 211d0c8e..4a4173b9 100644 --- a/packages/sphinx-autodoc-sphinx/pyproject.toml +++ b/packages/sphinx-autodoc-sphinx/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sphinx-autodoc-sphinx" -version = "0.0.1a15" +version = "0.0.1a16.dev0" description = "Sphinx extension for documenting extension config values as first-class conf.py reference entries" requires-python = ">=3.10,<4.0" authors = [ @@ -27,9 +27,9 @@ readme = "README.md" keywords = ["sphinx", "configuration", "conf.py", "documentation", "autodoc"] dependencies = [ "sphinx>=8.1", - "sphinx-ux-badges==0.0.1a15", - "sphinx-ux-autodoc-layout==0.0.1a15", - "sphinx-autodoc-typehints-gp==0.0.1a15", + "sphinx-ux-badges==0.0.1a16.dev0", + "sphinx-ux-autodoc-layout==0.0.1a16.dev0", + "sphinx-autodoc-typehints-gp==0.0.1a16.dev0", ] [project.urls] diff --git a/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/__init__.py b/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/__init__.py index 9d6af4e9..6b445d04 100644 --- a/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/__init__.py +++ b/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/__init__.py @@ -61,7 +61,7 @@ def _add_static_path(app: Sphinx) -> None: app.add_css_file("css/sphinx_autodoc_sphinx.css") return { - "version": "0.0.1a15", + "version": "0.0.1a16.dev0", "parallel_read_safe": True, "parallel_write_safe": True, } diff --git a/packages/sphinx-autodoc-typehints-gp/pyproject.toml b/packages/sphinx-autodoc-typehints-gp/pyproject.toml index ff052f2f..7ccae40b 100644 --- a/packages/sphinx-autodoc-typehints-gp/pyproject.toml +++ b/packages/sphinx-autodoc-typehints-gp/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "sphinx-autodoc-typehints-gp" -version = "0.0.1a15" +version = "0.0.1a16.dev0" description = "Cross-referenced type annotations for Sphinx autodoc" readme = "README.md" requires-python = ">=3.10" diff --git a/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/extension.py b/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/extension.py index f8a0e4b4..a0107190 100644 --- a/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/extension.py +++ b/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/extension.py @@ -593,7 +593,7 @@ def setup(app: Sphinx) -> dict[str, t.Any]: # are skipped by the built-in handler. app.connect("object-description-transform", merge_typehints, priority=499) return { - "version": "0.0.1a15", + "version": "0.0.1a16.dev0", "parallel_read_safe": True, "parallel_write_safe": True, } diff --git a/packages/sphinx-fonts/pyproject.toml b/packages/sphinx-fonts/pyproject.toml index f81fcf53..8c5dfaee 100644 --- a/packages/sphinx-fonts/pyproject.toml +++ b/packages/sphinx-fonts/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sphinx-fonts" -version = "0.0.1a15" +version = "0.0.1a16.dev0" description = "Sphinx extension for self-hosted fonts via Fontsource CDN" requires-python = ">=3.10,<4.0" authors = [ diff --git a/packages/sphinx-fonts/src/sphinx_fonts/__init__.py b/packages/sphinx-fonts/src/sphinx_fonts/__init__.py index ec26a7da..5276c30a 100644 --- a/packages/sphinx-fonts/src/sphinx_fonts/__init__.py +++ b/packages/sphinx-fonts/src/sphinx_fonts/__init__.py @@ -25,7 +25,7 @@ logger = logging.getLogger(__name__) logger.addHandler(logging.NullHandler()) -__version__ = "0.0.1a15" +__version__ = "0.0.1a16.dev0" CDN_TEMPLATE = ( "https://cdn.jsdelivr.net/npm/{package}@{version}" diff --git a/packages/sphinx-gp-opengraph/pyproject.toml b/packages/sphinx-gp-opengraph/pyproject.toml index b7c5aeca..e2da8640 100644 --- a/packages/sphinx-gp-opengraph/pyproject.toml +++ b/packages/sphinx-gp-opengraph/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sphinx-gp-opengraph" -version = "0.0.1a15" +version = "0.0.1a16.dev0" description = "OpenGraph and Twitter meta-tag emission for Sphinx — matplotlib-free" requires-python = ">=3.10,<4.0" authors = [ diff --git a/packages/sphinx-gp-opengraph/src/sphinx_gp_opengraph/__init__.py b/packages/sphinx-gp-opengraph/src/sphinx_gp_opengraph/__init__.py index fc4cb60e..0112ec78 100644 --- a/packages/sphinx-gp-opengraph/src/sphinx_gp_opengraph/__init__.py +++ b/packages/sphinx-gp-opengraph/src/sphinx_gp_opengraph/__init__.py @@ -42,7 +42,7 @@ logger = logging.getLogger(__name__) logger.addHandler(logging.NullHandler()) -_EXTENSION_VERSION = "0.0.1a15" +_EXTENSION_VERSION = "0.0.1a16.dev0" DEFAULT_DESCRIPTION_LENGTH = 200 diff --git a/packages/sphinx-gp-sitemap/pyproject.toml b/packages/sphinx-gp-sitemap/pyproject.toml index 31ebb00e..8c2810bc 100644 --- a/packages/sphinx-gp-sitemap/pyproject.toml +++ b/packages/sphinx-gp-sitemap/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sphinx-gp-sitemap" -version = "0.0.1a15" +version = "0.0.1a16.dev0" description = "Sitemap generator for Sphinx — Sphinx 8.1+ idioms, parallel-build safe" requires-python = ">=3.10,<4.0" authors = [ diff --git a/packages/sphinx-gp-sitemap/src/sphinx_gp_sitemap/__init__.py b/packages/sphinx-gp-sitemap/src/sphinx_gp_sitemap/__init__.py index f65c8298..7e373a9f 100644 --- a/packages/sphinx-gp-sitemap/src/sphinx_gp_sitemap/__init__.py +++ b/packages/sphinx-gp-sitemap/src/sphinx_gp_sitemap/__init__.py @@ -47,7 +47,7 @@ from sphinx.util.typing import ExtensionMetadata -_EXTENSION_VERSION = "0.0.1a15" +_EXTENSION_VERSION = "0.0.1a16.dev0" _SITEMAP_NS = "http://www.sitemaps.org/schemas/sitemap/0.9" _XHTML_NS = "http://www.w3.org/1999/xhtml" diff --git a/packages/sphinx-gp-theme/pyproject.toml b/packages/sphinx-gp-theme/pyproject.toml index aa08ca2c..03a44109 100644 --- a/packages/sphinx-gp-theme/pyproject.toml +++ b/packages/sphinx-gp-theme/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sphinx-gp-theme" -version = "0.0.1a15" +version = "0.0.1a16.dev0" description = "Furo child theme for git-pull project documentation" requires-python = ">=3.10,<4.0" authors = [ @@ -27,7 +27,7 @@ readme = "README.md" keywords = ["sphinx", "theme", "furo", "documentation"] dependencies = [ "sphinx>=8.1", - "gp-furo-theme==0.0.1a15", + "gp-furo-theme==0.0.1a16.dev0", ] [project.entry-points."sphinx.html_themes"] diff --git a/packages/sphinx-gp-theme/src/sphinx_gp_theme/__init__.py b/packages/sphinx-gp-theme/src/sphinx_gp_theme/__init__.py index 1460b09c..47326e96 100644 --- a/packages/sphinx-gp-theme/src/sphinx_gp_theme/__init__.py +++ b/packages/sphinx-gp-theme/src/sphinx_gp_theme/__init__.py @@ -23,7 +23,7 @@ if t.TYPE_CHECKING: from sphinx.application import Sphinx -__version__ = "0.0.1a15" +__version__ = "0.0.1a16.dev0" def get_theme_path() -> pathlib.Path: diff --git a/packages/sphinx-ux-autodoc-layout/pyproject.toml b/packages/sphinx-ux-autodoc-layout/pyproject.toml index bac138d6..460b3027 100644 --- a/packages/sphinx-ux-autodoc-layout/pyproject.toml +++ b/packages/sphinx-ux-autodoc-layout/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "sphinx-ux-autodoc-layout" -version = "0.0.1a15" +version = "0.0.1a16.dev0" description = "Componentized layout for Sphinx autodoc output" readme = "README.md" requires-python = ">=3.10" diff --git a/packages/sphinx-ux-badges/pyproject.toml b/packages/sphinx-ux-badges/pyproject.toml index 4c93ea16..e87cf95b 100644 --- a/packages/sphinx-ux-badges/pyproject.toml +++ b/packages/sphinx-ux-badges/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sphinx-ux-badges" -version = "0.0.1a15" +version = "0.0.1a16.dev0" description = "Shared badge node and CSS for Sphinx autodoc extensions" requires-python = ">=3.10,<4.0" authors = [ diff --git a/packages/sphinx-ux-badges/src/sphinx_ux_badges/__init__.py b/packages/sphinx-ux-badges/src/sphinx_ux_badges/__init__.py index c52a06b1..ec02740d 100644 --- a/packages/sphinx-ux-badges/src/sphinx_ux_badges/__init__.py +++ b/packages/sphinx-ux-badges/src/sphinx_ux_badges/__init__.py @@ -48,7 +48,7 @@ logging.getLogger(__name__).addHandler(logging.NullHandler()) -_EXTENSION_VERSION = "0.0.1a15" +_EXTENSION_VERSION = "0.0.1a16.dev0" def setup(app: Sphinx) -> dict[str, t.Any]: diff --git a/packages/sphinx-vite-builder/pyproject.toml b/packages/sphinx-vite-builder/pyproject.toml index c1163b33..0fad36a2 100644 --- a/packages/sphinx-vite-builder/pyproject.toml +++ b/packages/sphinx-vite-builder/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sphinx-vite-builder" -version = "0.0.1a15" +version = "0.0.1a16.dev0" description = "PEP 517 build backend + Sphinx extension that orchestrates Vite via pnpm" requires-python = ">=3.10,<4.0" authors = [ diff --git a/packages/sphinx-vite-builder/src/sphinx_vite_builder/__init__.py b/packages/sphinx-vite-builder/src/sphinx_vite_builder/__init__.py index 587edb8d..ee2e96eb 100644 --- a/packages/sphinx-vite-builder/src/sphinx_vite_builder/__init__.py +++ b/packages/sphinx-vite-builder/src/sphinx_vite_builder/__init__.py @@ -17,7 +17,7 @@ import typing as t -__version__ = "0.0.1a15" +__version__ = "0.0.1a16.dev0" if t.TYPE_CHECKING: from sphinx.application import Sphinx diff --git a/pyproject.toml b/pyproject.toml index 9f8c9a07..b1179f02 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "gp-sphinx-workspace" -version = "0.0.1a15" +version = "0.0.1a16.dev0" description = "Workspace root for gp-sphinx packages" requires-python = ">=3.10,<4.0" authors = [ @@ -9,7 +9,7 @@ authors = [ license = { text = "MIT" } readme = "README.md" dependencies = [ - "gp-sphinx==0.0.1a15", + "gp-sphinx==0.0.1a16.dev0", ] [tool.uv.workspace] diff --git a/tests/ci/test_package_tools.py b/tests/ci/test_package_tools.py index 2c3d2d5e..7fcc1203 100644 --- a/tests/ci/test_package_tools.py +++ b/tests/ci/test_package_tools.py @@ -13,7 +13,7 @@ def test_workspace_version_is_lockstep() -> None: """All workspace packages share the same version.""" - assert package_tools.workspace_version() == "0.0.1a15" + assert package_tools.workspace_version() == "0.0.1a16.dev0" def test_check_versions_passes_for_repo() -> None: @@ -30,10 +30,12 @@ def test_smoke_targets_cover_workspace_packages() -> None: def test_release_metadata_accepts_repo_tag() -> None: """Repo-wide release tags resolve to the shared version.""" - assert package_tools.release_metadata("v0.0.1a15") == {"version": "0.0.1a15"} + assert package_tools.release_metadata("v0.0.1a16.dev0") == { + "version": "0.0.1a16.dev0" + } def test_release_metadata_rejects_package_tag() -> None: """Package-scoped tags are no longer valid release inputs.""" with pytest.raises(SystemExit, match="invalid release tag format"): - package_tools.release_metadata("gp-sphinx@v0.0.1a15") + package_tools.release_metadata("gp-sphinx@v0.0.1a16.dev0") diff --git a/tests/test_gp_sphinx_vite.py b/tests/test_gp_sphinx_vite.py index 0c9994b0..403d6f73 100644 --- a/tests/test_gp_sphinx_vite.py +++ b/tests/test_gp_sphinx_vite.py @@ -24,7 +24,7 @@ def test_version_matches_workspace_lock() -> None: """Version follows gp-sphinx workspace lockstep.""" - assert __version__ == "0.0.1a15" + assert __version__ == "0.0.1a16.dev0" class _FakeApp: diff --git a/tests/test_sphinx_vite_builder.py b/tests/test_sphinx_vite_builder.py index c1ef6529..9e1c2270 100644 --- a/tests/test_sphinx_vite_builder.py +++ b/tests/test_sphinx_vite_builder.py @@ -15,7 +15,7 @@ def test_version_matches_workspace_lock() -> None: """Version follows the gp-sphinx workspace lockstep.""" - assert __version__ == "0.0.1a15" + assert __version__ == "0.0.1a16.dev0" def test_setup_returns_safety_metadata() -> None: diff --git a/uv.lock b/uv.lock index 52db9771..3ae5ccca 100644 --- a/uv.lock +++ b/uv.lock @@ -388,7 +388,7 @@ wheels = [ [[package]] name = "gp-furo-theme" -version = "0.0.1a15" +version = "0.0.1a16.dev0" source = { editable = "packages/gp-furo-theme" } dependencies = [ { name = "accessible-pygments" }, @@ -424,7 +424,7 @@ wheels = [ [[package]] name = "gp-sphinx" -version = "0.0.1a15" +version = "0.0.1a16.dev0" source = { editable = "packages/gp-sphinx" } dependencies = [ { name = "docutils" }, @@ -473,7 +473,7 @@ provides-extras = ["argparse"] [[package]] name = "gp-sphinx-vite" -version = "0.0.1a15" +version = "0.0.1a16.dev0" source = { editable = "packages/gp-sphinx-vite" } dependencies = [ { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, @@ -485,7 +485,7 @@ requires-dist = [{ name = "sphinx", specifier = ">=8.1" }] [[package]] name = "gp-sphinx-workspace" -version = "0.0.1a15" +version = "0.0.1a16.dev0" source = { editable = "." } dependencies = [ { name = "gp-sphinx" }, @@ -1578,7 +1578,7 @@ wheels = [ [[package]] name = "sphinx-autodoc-api-style" -version = "0.0.1a15" +version = "0.0.1a16.dev0" source = { editable = "packages/sphinx-autodoc-api-style" } dependencies = [ { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, @@ -1596,7 +1596,7 @@ requires-dist = [ [[package]] name = "sphinx-autodoc-argparse" -version = "0.0.1a15" +version = "0.0.1a16.dev0" source = { editable = "packages/sphinx-autodoc-argparse" } dependencies = [ { name = "docutils" }, @@ -1614,7 +1614,7 @@ requires-dist = [ [[package]] name = "sphinx-autodoc-docutils" -version = "0.0.1a15" +version = "0.0.1a16.dev0" source = { editable = "packages/sphinx-autodoc-docutils" } dependencies = [ { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, @@ -1634,7 +1634,7 @@ requires-dist = [ [[package]] name = "sphinx-autodoc-fastmcp" -version = "0.0.1a15" +version = "0.0.1a16.dev0" source = { editable = "packages/sphinx-autodoc-fastmcp" } dependencies = [ { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, @@ -1654,7 +1654,7 @@ requires-dist = [ [[package]] name = "sphinx-autodoc-pytest-fixtures" -version = "0.0.1a15" +version = "0.0.1a16.dev0" source = { editable = "packages/sphinx-autodoc-pytest-fixtures" } dependencies = [ { name = "pytest" }, @@ -1676,7 +1676,7 @@ requires-dist = [ [[package]] name = "sphinx-autodoc-sphinx" -version = "0.0.1a15" +version = "0.0.1a16.dev0" source = { editable = "packages/sphinx-autodoc-sphinx" } dependencies = [ { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, @@ -1696,7 +1696,7 @@ requires-dist = [ [[package]] name = "sphinx-autodoc-typehints-gp" -version = "0.0.1a15" +version = "0.0.1a16.dev0" source = { editable = "packages/sphinx-autodoc-typehints-gp" } dependencies = [ { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, @@ -1765,7 +1765,7 @@ wheels = [ [[package]] name = "sphinx-fonts" -version = "0.0.1a15" +version = "0.0.1a16.dev0" source = { editable = "packages/sphinx-fonts" } dependencies = [ { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, @@ -1777,7 +1777,7 @@ requires-dist = [{ name = "sphinx", specifier = ">=8.1" }] [[package]] name = "sphinx-gp-opengraph" -version = "0.0.1a15" +version = "0.0.1a16.dev0" source = { editable = "packages/sphinx-gp-opengraph" } dependencies = [ { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, @@ -1789,7 +1789,7 @@ requires-dist = [{ name = "sphinx", specifier = ">=8.1" }] [[package]] name = "sphinx-gp-sitemap" -version = "0.0.1a15" +version = "0.0.1a16.dev0" source = { editable = "packages/sphinx-gp-sitemap" } dependencies = [ { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, @@ -1801,7 +1801,7 @@ requires-dist = [{ name = "sphinx", specifier = ">=8.1" }] [[package]] name = "sphinx-gp-theme" -version = "0.0.1a15" +version = "0.0.1a16.dev0" source = { editable = "packages/sphinx-gp-theme" } dependencies = [ { name = "gp-furo-theme" }, @@ -1830,7 +1830,7 @@ wheels = [ [[package]] name = "sphinx-ux-autodoc-layout" -version = "0.0.1a15" +version = "0.0.1a16.dev0" source = { editable = "packages/sphinx-ux-autodoc-layout" } dependencies = [ { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, @@ -1842,7 +1842,7 @@ requires-dist = [{ name = "sphinx", specifier = ">=8.1" }] [[package]] name = "sphinx-ux-badges" -version = "0.0.1a15" +version = "0.0.1a16.dev0" source = { editable = "packages/sphinx-ux-badges" } dependencies = [ { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, @@ -1854,7 +1854,7 @@ requires-dist = [{ name = "sphinx", specifier = ">=8.1" }] [[package]] name = "sphinx-vite-builder" -version = "0.0.1a15" +version = "0.0.1a16.dev0" source = { editable = "packages/sphinx-vite-builder" } dependencies = [ { name = "hatchling" }, From 9e3079c13c0aaf6721d7ef063dd992e61e94cfad Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 3 May 2026 08:14:49 -0500 Subject: [PATCH 07/53] docs(sphinx-vite-builder): add AGENTS.md with the design contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: The package's central invariant — "sources fail loud without toolchain, wheels just work" — has lived only in PR descriptions and issue threads so far. AGENTS.md captures it in-tree as the authoritative guidance for future contributors and AI agents working on the package. what: - packages/sphinx-vite-builder/AGENTS.md — the long-form design contract: - The two-heads architecture (PEP 517 backend + Sphinx extension sharing _internal/ subprocess core) - The wheel-vs-source asymmetry as the central invariant ("Sources should check for node, pnpm, etc and error if it's not good, then build. Wheels should have the build files baked in and not need node and pnpm at all") - The wheel-from-sdist bridge case (web/-absent short-circuit) and why excluding `web/` from sdist is load-bearing - The four QA permutations every change must keep green: PyPI wheel install, PyPI sdist install, source build with toolchain, source build without toolchain (the last MUST fail loud with the CI-aware diagnostic) - Architecture map with module responsibilities - Conventions for adding new public functions (doctests, NumPy docstrings, type annotations) and new error paths (typed exception inheriting from SphinxViteBuilderError, copy-pasteable hint via _format_*_hint(), CI-recipe block via _format_ci_recipe_block() when reachable in CI) - Constraints on changes to release.yml (the pnpm + Node setup steps before `uv build` are load-bearing — removing them re-creates the v0.0.1a15 broken-release pattern) - "What NOT to do" anti-patterns (commit static/, swallow vite failures, auto-install pnpm, weaken SPHINX_VITE_BUILDER_SKIP semantics) References: - Issue #28 (architecture spec) - PR #29 (initial implementation) - libtmux-mcp PR #33 (consumer-side WITH/WITHOUT-wheels integration test) --- packages/sphinx-vite-builder/AGENTS.md | 202 +++++++++++++++++++++++++ 1 file changed, 202 insertions(+) create mode 100644 packages/sphinx-vite-builder/AGENTS.md diff --git a/packages/sphinx-vite-builder/AGENTS.md b/packages/sphinx-vite-builder/AGENTS.md new file mode 100644 index 00000000..b4e0cade --- /dev/null +++ b/packages/sphinx-vite-builder/AGENTS.md @@ -0,0 +1,202 @@ +# AGENTS.md — `sphinx-vite-builder` + +Guidance for AI agents (Claude Code, Cursor, Copilot, Codex, …) and +human contributors working on this package. Mirrors the higher-level +guidance at `gp-sphinx/CLAUDE.md`; `packages/sphinx-vite-builder/CLAUDE.md` +points here so Claude Code reads the same content as other agent runners. + +## What this package is + +Two orthogonal entry points sharing one subprocess core: + +1. **PEP 517 build backend** at `sphinx_vite_builder.build`. Runs + `pnpm exec vite build` before delegating wheel/sdist construction + to `hatchling.build`. Consumer packages declare it via + `[build-system].build-backend = "sphinx_vite_builder.build"`. +2. **Sphinx extension** at `sphinx_vite_builder:setup`. Hooks + `builder-inited` and `build-finished` so `sphinx-build` / + `sphinx-autobuild` automatically run vite — one-shot for prod, a + long-lived `vite build --watch` child process for autobuild — with + graceful teardown on signal / `atexit`. + +Both heads consume the smart-subprocess core under +`sphinx_vite_builder._internal/`: `process.py` (`AsyncProcess` — +asyncio subprocess wrapper with POSIX session isolation, +SIGTERM-then-SIGKILL teardown, line-buffered stdout/stderr drainers, +captured stderr for error surfacing), `bus.py` (`AsyncioBus` — single +asyncio loop in a daemon thread for sync↔async bridging), +`vite.py` (orchestration: detect `web/`, check pnpm via `shutil.which`, +spawn install/build), and `errors.py` (`PnpmMissingError`, +`NodeModulesInstallError`, `ViteFailedError`). + +## The design contract — keep this invariant + +> **Sources should check for node, pnpm, etc and error if it's not +> good, then build. Wheels should have the build files baked in and +> not need node and pnpm at all.** + +This is the central invariant. When you change anything in the +backend, vite orchestration, or release pipeline, ask: "does this +preserve the source-vs-wheel asymmetry?" + +### Source builds — fail loud, fail informatively + +A consumer building from source (cloned tree, `[tool.uv.sources]` git +URL, `pip install `) goes through the PEP 517 chain. The +backend's `build_wheel` / `build_editable` / `build_sdist` hooks +**MUST** run vite, and vite **MUST** be available. If pnpm or Node is +missing: + +- Raise the typed exception (`PnpmMissingError`, + `NodeModulesInstallError`, or `ViteFailedError`) — never a bare + `subprocess.CalledProcessError` or `FileNotFoundError`. The typed + exception lets consumers `except` cleanly via the + `SphinxViteBuilderError` base class. +- Each error's message MUST be a multi-line, copy-pasteable hint + formatted by `_format_*_hint()`. Include the canonical install + paths (`corepack enable`, `https://pnpm.io/installation`), the + resolved vite-root path, and the `SPHINX_VITE_BUILDER_SKIP=1` + escape-hatch line. +- Detect CI via `_detect_ci_provider()`. When CI is detected, append + the platform-specific YAML/config snippet from `_CI_SETUP_RECIPES` + so the consumer can copy-paste the fix into their pipeline. + Supported providers: GitHub Actions, CircleCI, Azure Pipelines, + GitLab CI, plus a generic fallback for `CI=true`. + +### Wheel installs — zero toolchain required + +A consumer doing `pip install ==` from PyPI gets a +wheel that **already contains** the vite-built `static/` tree +(populated by the backend at release time, packed via hatchling's +`artifacts` directive). The PEP 517 chain doesn't run. No backend +invocation. No `_run_vite_build()`. No `shutil.which("pnpm")`. The +end user sees Python and only Python. + +### Wheel-from-sdist — the bridge case + +A consumer doing `pip install .tar.gz` (or `--no-binary :all:`) +runs the wheel-from-sdist chain: + +1. pip/uv unpacks the sdist into a temp dir +2. Calls our backend's `build_wheel` against the unpacked tree +3. The backend's vite-orchestration sees no `web/` (excluded from + sdist via `[tool.hatch.build.targets.sdist] exclude = ["web/"]`) + and short-circuits cleanly — `_resolve_vite_root()` returns + `None`, vite is never invoked +4. Hatchling packs the pre-baked `static/` (carried in the sdist via + `[tool.hatch.build] artifacts`) into the wheel + +Net: **sdist install also requires zero toolchain**. The +`web/`-absent short-circuit is the load-bearing primitive. + +## The four QA permutations — keep them green + +Verified end-to-end as of v0.0.1a16.dev0: + +| # | Path | Toolchain | Expected | +|---|---|---|---| +| 1 | wheel install from PyPI | none | succeeds; static present | +| 2 | sdist install from PyPI (`--no-binary :all:`) | none | succeeds; static present (backend short-circuits) | +| 3 | source build (`uv build` from cloned tree) | with pnpm + Node | succeeds; wheel contains static | +| 4 | source build (`uv build` from cloned tree) | none | fails with `PnpmMissingError` + CI-platform recipe | + +When you add a new failure mode or a new short-circuit branch, add a +new row here AND a corresponding test in +`tests/test_sphinx_vite_builder_vite.py`. + +## Architecture map + +``` +sphinx_vite_builder/ +├── __init__.py Sphinx extension entry: setup(app) +├── build.py PEP 517/660 hooks (delegate to hatchling) +├── py.typed +└── _internal/ + ├── errors.py SphinxViteBuilderError + 3 subclasses + ├── process.py AsyncProcess (asyncio subprocess wrapper) + ├── bus.py AsyncioBus (sync↔async bridge) + └── vite.py run_vite_build() + CI detection + hint formatters +``` + +Neither head calls the other; both consume `_internal/`. The PEP 517 +hooks in `build.py` MUST stay 1:1 mirrors of `flit_core.buildapi` and +`hatchling.build` — every hook runs `run_vite_build()` then delegates. +Optional hooks (`get_requires_for_build_*`, `prepare_metadata_for_build_*`) +alias to hatchling by identity — vite has no influence on dependency +resolution or distribution metadata, so wrapping them would be wrong. + +## When you add a new public function + +- Add doctests. Every public function MUST have working doctests + per the workspace convention. Use ELLIPSIS for variable output. +- Add NumPy-style docstrings: short summary, Parameters, Returns, + Raises, Examples. +- Add type annotations everywhere, including return types + (`-> None` on test functions). mypy runs strict mode. + +## When you add a new error path + +- Add a new `*Error` subclass in `errors.py` if the failure has a + distinct semantic meaning. Inherit from `SphinxViteBuilderError`. +- Add a `_format_*_hint(...)` function in `vite.py` for the + copy-pasteable diagnostic. Wrap the raise: + `raise NewError(_format_new_error_hint(...))`. +- If the new path is reachable in CI, ensure the message includes + enough context to fix-from-the-message-itself. Add a CI-recipe + block via `_format_ci_recipe_block()` if relevant. +- Add tests for: positive case (path triggers correctly), each error + branch (specific exception, with key strings present in message), + inheritance from `SphinxViteBuilderError`. + +## When you change `release.yml` or the workspace + +The workspace's `release.yml` MUST keep the pnpm + Node setup steps +that run before `uv build`, otherwise the wheels published to PyPI +would be empty of static (the v0.0.1a15 broken-release pattern that +motivated this whole package). The required steps are: + +```yaml +- uses: pnpm/action-setup@v6 + with: + version: 10 +- uses: actions/setup-node@v6 + with: + node-version: 22 + cache: pnpm +``` + +If you find yourself removing those, ask: "is the source-build path +still going to produce a populated `static/` in the wheel?" The +answer must be yes. + +## When in doubt + +- Read the full plan at gp-sphinx issue #28. +- Look at how PR #29 wired everything together initially. +- Look at how libtmux-mcp PR #33 exercises both consumer paths in + CI — the WITH-wheels and WITHOUT-wheels permutations both have + green runs that are good reference points. +- Run the local QA matrix: clean venv install of the published + wheel, clean venv install of the published sdist (`--no-binary`), + clean clone + `uv build` with toolchain stripped (must fail + loudly), clean clone + `uv build` with toolchain present (must + succeed). + +## What NOT to do + +- **Do not** add `web/` to the sdist. Excluding it is what makes the + wheel-from-sdist short-circuit work. +- **Do not** commit anything from `static/` to git. Build artefacts + are produced reproducibly; check-in is forbidden by workspace + policy. +- **Do not** silently swallow vite/pnpm subprocess failures. Every + non-zero exit goes through a typed exception with a + copy-pasteable hint. +- **Do not** auto-install pnpm via the backend (the maturin + `puccinialin`-style trick doesn't apply here — pnpm isn't + pip-installable). The contract is "pnpm is your responsibility, + here's how to install it on your platform". +- **Do not** change `SPHINX_VITE_BUILDER_SKIP=1` semantics without + thinking through the wheel-vs-source asymmetry. The escape hatch + is correct for wheel-only environments; using it during a real + source build silently produces broken artefacts. From b0276edb0ef2cc2f5a2e58b8656a40c5f322359a Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 3 May 2026 08:14:57 -0500 Subject: [PATCH 08/53] docs(sphinx-vite-builder): add CLAUDE.md as a passthrough to AGENTS.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: Claude Code reads `CLAUDE.md` by convention; other agent runners (Cursor, Copilot, Codex, Gemini) read `AGENTS.md`. To keep both audiences on the same guidance without duplicating content (or drifting), CLAUDE.md is a thin pointer to AGENTS.md. Mirrors the same pattern used at the workspace root. what: - packages/sphinx-vite-builder/CLAUDE.md — single-page pointer: "see AGENTS.md". All actual guidance lives in AGENTS.md so edits go in one place. References: - Workspace-root CLAUDE.md / AGENTS.md (same passthrough pattern) --- packages/sphinx-vite-builder/CLAUDE.md | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 packages/sphinx-vite-builder/CLAUDE.md diff --git a/packages/sphinx-vite-builder/CLAUDE.md b/packages/sphinx-vite-builder/CLAUDE.md new file mode 100644 index 00000000..75740456 --- /dev/null +++ b/packages/sphinx-vite-builder/CLAUDE.md @@ -0,0 +1,8 @@ +# CLAUDE.md + +Claude Code reads this file. Other agent runners (Cursor, Copilot, +Codex, …) read [`AGENTS.md`](AGENTS.md). The two files have identical +content via this passthrough — every guideline lives in `AGENTS.md`, +and edits go there. + +→ See [`AGENTS.md`](AGENTS.md). From 78f6b4708324a92623a8a903816fb9aa79691db4 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 3 May 2026 08:15:15 -0500 Subject: [PATCH 09/53] docs(sphinx-vite-builder[README]): document the wheel-vs-source contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: The README focused on what the package IS (a PEP 517 backend + Sphinx extension) and how to wire it into a consumer pyproject.toml, but didn't make the central invariant explicit to a reader landing from PyPI or GitHub. Adding the contract verbatim (along with the wheel-from-sdist short-circuit, the typed-error reference table, and the CI-detection matrix) lets users predict the package's behavior before they install it. what: - packages/sphinx-vite-builder/README.md: - New "The contract" section quotes the design invariant verbatim: "Sources should check for node, pnpm, etc and error if it's not good, then build. Wheels should have the build files baked in and not need node and pnpm at all." - Wheel-installs section: zero toolchain, simple `pip install` example - Source-build section: shows the multi-line PnpmMissingError output with the GitHub Actions CI recipe block, so readers can see the exact diagnostic the package emits - "web/-absent short-circuit" section: explains the sdist install bridge case — same backend, asymmetric behavior based on whether `web/` is present in the unpacked tree - Updated consumer pyproject.toml example to include the sdist-exclude + artifacts directives that make the short-circuit work - New error-type reference table mapping PnpmMissingError / NodeModulesInstallError / ViteFailedError to their trigger conditions and hint surfaces - New CI-detection matrix listing each supported provider (GitHub Actions / CircleCI / Azure Pipelines / GitLab CI / generic) and the env var that triggers it - Pointer to AGENTS.md and CLAUDE.md for contributor / agent guidance References: - AGENTS.md (the underlying design contract) - pnpm CI docs: https://pnpm.io/continuous-integration --- packages/sphinx-vite-builder/README.md | 126 +++++++++++++++++++++---- 1 file changed, 109 insertions(+), 17 deletions(-) diff --git a/packages/sphinx-vite-builder/README.md b/packages/sphinx-vite-builder/README.md index fd3e81cb..02d87b50 100644 --- a/packages/sphinx-vite-builder/README.md +++ b/packages/sphinx-vite-builder/README.md @@ -25,6 +25,78 @@ Rust+Python packages, or [sphinx-theme-builder](https://github.com/pradyunsg/sphinx-theme-builder) owns webpack for older Sphinx themes. +## The contract + +> **Sources should check for node, pnpm, etc and error if it's not +> good, then build. Wheels should have the build files baked in and +> not need node and pnpm at all.** + +This is the central invariant of the package. The two install paths +behave asymmetrically by design: + +### Wheel installs — zero toolchain required + +A user running `pip install ` from PyPI gets a wheel that +**already contains** the vite-built `static/` tree, populated by this +backend at release time. The PEP 517 chain doesn't run on the +consumer side. No backend invocation. No `pnpm`. No Node. The end +user sees Python and only Python. + +```console +$ # No pnpm, no Node, no problem +$ pip install gp-furo-theme +``` + +### Source builds — fail loud, fail informatively + +A contributor (or downstream packager building from source) goes +through the PEP 517 chain. The backend runs `pnpm exec vite build` +to produce `static/`, and that requires pnpm + Node on PATH. If the +toolchain is missing, the backend raises a typed exception with a +multi-line, copy-pasteable hint: + +``` +sphinx-vite-builder: cannot bootstrap the vite toolchain. +`pnpm` is not on PATH. Install it via one of: + + corepack enable # Node 16.10+ ships corepack + curl -fsSL https://get.pnpm.io/install.sh | sh - + +See https://pnpm.io/installation + +… + +Detected CI provider: GitHub Actions. Add the following to your pipeline +config (before the Python build step that triggers this backend): + + - uses: pnpm/action-setup@v6 + with: + version: 10 + - uses: actions/setup-node@v6 + with: + node-version: 22 + cache: pnpm +``` + +The error includes the resolved vite-root path, the platform-specific +CI setup recipe (GitHub Actions, CircleCI, Azure Pipelines, GitLab CI, +or generic), and the `SPHINX_VITE_BUILDER_SKIP=1` escape hatch for +environments that genuinely don't need vite to run. + +### The `web/`-absent short-circuit (sdist install bridge) + +A user running `pip install .tar.gz` from an sdist runs the +PEP 517 chain too — but the sdist excludes `web/` (the Vite source +tree). The backend detects the absence, short-circuits cleanly, and +hatchling packs the pre-baked `static/` (carried in the sdist via +`[tool.hatch.build] artifacts`) into the wheel. **Sdist installs +need no toolchain either.** + +The asymmetry is the whole product: the same backend is strict +(running and failing loudly) when there's a `web/` to act on, and +silent (skipping cleanly) when there's no `web/` to begin with. The +two shapes match the two consumer worlds. + ## Two heads, one subprocess core ### PEP 517 build backend @@ -37,13 +109,13 @@ before delegating wheel/sdist construction to hatchling. [build-system] requires = ["hatchling>=1.0", "sphinx-vite-builder"] build-backend = "sphinx_vite_builder.build" -backend-path = ["../sphinx-vite-builder/src"] # for in-tree workspace consumption -``` -The backend short-circuits when `web/` (the Vite source tree) is absent -— so `pip install .tar.gz` from an unpacked sdist works without -pnpm or Node, because the sdist already contains pre-baked -`static/`. +[tool.hatch.build.targets.sdist] +exclude = ["web/"] # so the sdist→wheel chain hits the short-circuit + +[tool.hatch.build] +artifacts = ["src//theme//static/"] +``` ### Sphinx extension @@ -57,22 +129,42 @@ Loaded from `conf.py`. Runs Vite as part of the docs lifecycle: ```python # docs/conf.py extensions = ["sphinx_vite_builder"] -sphinx_vite_root = "../packages/your-theme/web" # path to vite project -sphinx_vite_mode = "auto" # auto | dev | prod | disabled ``` -## Fast-fail diagnostics +## Fast-fail diagnostics — error type reference + +| Error | When | Hint surface | +|---|---|---| +| `PnpmMissingError` | `pnpm` not on `PATH` during a source build | `corepack enable`, [pnpm.io/installation](https://pnpm.io/installation), per-CI YAML recipe, `SPHINX_VITE_BUILDER_SKIP=1` | +| `NodeModulesInstallError` | `pnpm install` exited non-zero | `cd && pnpm install --frozen-lockfile` rerun command, captured stderr | +| `ViteFailedError` | `pnpm exec vite build` exited non-zero | invocation context (cwd, exit code), captured stderr | + +All three inherit from `SphinxViteBuilderError`, so consumers can +`except SphinxViteBuilderError` for a single catch surface. + +## CI detection -When prerequisites are missing the backend / extension raises -actionable errors rather than producing broken output: +The `PnpmMissingError` hint is **self-healing** when the backend +detects a CI environment. Detection precedence (most-specific wins): -- `PnpmMissingError` — `pnpm` not on `PATH`; hint includes - `corepack enable` and the `pnpm.io` install URL -- `NodeModulesInstallError` — `pnpm install` exited non-zero; hint - includes the rerun command -- `ViteFailedError` — `pnpm exec vite build` exited non-zero; hint - surfaces the captured stderr +| CI provider | Env var | Recipe shape | +|---|---|---| +| GitHub Actions | `GITHUB_ACTIONS=true` | `pnpm/action-setup@v6` + `actions/setup-node@v6` | +| CircleCI | `CIRCLECI=true` | `corepack enable && corepack prepare pnpm@latest-10 --activate` step | +| Azure Pipelines | `TF_BUILD=True` | `NodeTool@0` + corepack script | +| GitLab CI | `GITLAB_CI=true` | `before_script` corepack invocations | +| Generic | `CI=true` | "Use your CI's package-manager setup mechanism" | + +Source: each provider's own canonical detection variable per the pnpm +[Continuous Integration docs](https://pnpm.io/continuous-integration). ## License MIT — see [LICENSE](LICENSE). + +## Agent / contributor guidance + +See [`AGENTS.md`](AGENTS.md) for the design contract, architecture +map, and conventions agents and contributors should follow when +making changes. ([`CLAUDE.md`](CLAUDE.md) is a passthrough to +`AGENTS.md` for Claude Code.) From e5c2fdf9fa5783fb7f4b4eddd4a6454e4301aadb Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 3 May 2026 08:44:34 -0500 Subject: [PATCH 10/53] docs(index): drop hardcoded package counts from homepage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: The homepage currently asserts "Twelve workspace packages — coordinator, extensions, and theme." in the Packages card and "Three-tier package organization" in the Architecture card, plus "Five domain autodocumenters" later in the page. Each of these goes stale every time a new package lands — and they're already wrong (the workspace has 17+ publishable packages now, with a fourth grouping for build tooling that didn't exist when the prose was written). Worse, "autodocumenters" isn't real Sphinx vocabulary; the canonical term per `app.add_autodocumenter()` and the `Documenter` class hierarchy is "autodoc extensions". what: - docs/index.md (Architecture card): replace "Three-tier package organization — infrastructure, domain, and presentation." with "Split into common libraries, build utils, autodoc extensions, and UX." — descriptive, no count, uses canonical vocabulary, and acknowledges the new build-tooling family. - docs/index.md (Packages card): replace "Twelve workspace packages — coordinator, extensions, and theme." with "Coordinator, autodoc extensions, build utils, UX components, and theme." — drops the count, names the family taxonomy. - docs/index.md (What you get bullet): replace "Five domain autodocumenters" with "Autodoc extensions". Same drift fix + vocabulary fix. - tests/test_docs_package_pages.py:test_homepage_says_twelve_packages → test_homepage_packages_card_uses_drift_proof_phrasing. The old test pinned the homepage to a literal "Twelve" string, which is exactly the brittle pattern we're removing. The new test guards *against* hardcoded counts (asserts no "Two|Three|…|Twenty workspace|package" pattern in the homepage) and verifies the Packages card still exists + links correctly. References: - Sphinx autodoc terminology: https://www.sphinx-doc.org/en/master/usage/extensions/autodoc.html - `app.add_autodocumenter()` / `Documenter` class hierarchy in ~/study/python/sphinx/sphinx/ext/autodoc/ - Tutorial: https://www.sphinx-doc.org/en/master/development/tutorials/autodoc_ext.html --- docs/index.md | 6 ++--- tests/test_docs_package_pages.py | 46 +++++++++++++++++++++++++++++--- 2 files changed, 46 insertions(+), 6 deletions(-) diff --git a/docs/index.md b/docs/index.md index b57f75db..bf9f9e4f 100644 --- a/docs/index.md +++ b/docs/index.md @@ -22,7 +22,7 @@ Visual showcase of the autodoc design system in action. :::{grid-item-card} Architecture :link: architecture :link-type: doc -Three-tier package organization — infrastructure, domain, and presentation. +Split into common libraries, build utils, autodoc extensions, and UX. ::: :::{grid-item-card} Quickstart @@ -34,7 +34,7 @@ Install and get started in minutes. :::{grid-item-card} Packages :link: packages/index :link-type: doc -Twelve workspace packages — coordinator, extensions, and theme. +Coordinator, autodoc extensions, build utils, UX components, and theme. ::: :::{grid-item-card} Configuration @@ -78,7 +78,7 @@ Out of the box, {py:func}`~gp_sphinx.config.merge_sphinx_config` activates: - **Unified badge system** — type and modifier badges for functions, classes, fixtures, tools - **Componentized layout** — card containers, parameter folding, managed signatures - **Clean type hints** — simplified annotations with cross-referenced links -- **Five domain autodocumenters** — Python API, pytest fixtures, FastMCP tools, docutils, Sphinx config +- **Autodoc extensions** — Python API, pytest fixtures, FastMCP tools, docutils, Sphinx config - **IBM Plex fonts** — professional typography with preloaded web fonts - **Dark mode** — full light/dark theming via CSS custom properties diff --git a/tests/test_docs_package_pages.py b/tests/test_docs_package_pages.py index a397e308..42a968a3 100644 --- a/tests/test_docs_package_pages.py +++ b/tests/test_docs_package_pages.py @@ -202,11 +202,51 @@ def test_homepage_has_six_cards() -> None: assert "whats-new" in text -def test_homepage_says_twelve_packages() -> None: - """Homepage references the correct package count.""" +def test_homepage_packages_card_uses_drift_proof_phrasing() -> None: + """Homepage Packages card references the package families, not a raw count. + + Workspace package counts drift every time a new package lands. The + homepage card SHOULD describe the *families* (autodoc extensions, + build utils, UX, theme, …) so it stays accurate as the workspace + grows. This test guards against re-introducing a hardcoded count + like "Twelve workspace packages …". + """ text = (DOCS_ROOT / "index.md").read_text() - assert "Twelve" in text or "twelve" in text or "12" in text + forbidden_count_words = ( + "Two ", + "Three ", + "Four ", + "Five ", + "Six ", + "Seven ", + "Eight ", + "Nine ", + "Ten ", + "Eleven ", + "Twelve ", + "Thirteen ", + "Fourteen ", + "Fifteen ", + "Sixteen ", + "Seventeen ", + "Eighteen ", + "Nineteen ", + "Twenty ", + ) + for word in forbidden_count_words: + assert f"{word}workspace" not in text, ( + f"homepage uses hardcoded count {word.strip()!r} — drift-prone; " + "describe package families instead" + ) + assert f"{word}package" not in text, ( + f"homepage uses hardcoded count {word.strip()!r} — drift-prone; " + "describe package families instead" + ) + + # The card itself must still exist and link to the packages index. + assert "packages/index" in text + assert "grid-item-card} Packages" in text def test_quickstart_has_autodoc_demo() -> None: From 25b98dec8799d7ea784b406c545a39d93b4e731f Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 3 May 2026 08:47:00 -0500 Subject: [PATCH 11/53] docs(packages): drop hardcoded counts; let dynamic grid be source of truth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: The packages index opens with "Fourteen workspace packages in four tiers" and then enumerates which packages are in which tier in prose. Both are drift-prone — the count was already wrong (we ship 17 publishable packages now and a fourth grouping for build tooling that the prose didn't account for), and the per-tier package lists go stale every time a package lands or moves between families. The existing `{workspace-package-grid}` directive at the bottom auto- enumerates from `packages/*/pyproject.toml`; let that be the authoritative complete view, and let the prose describe families (common libraries, autodoc extensions, build utils, theme, coordinator, SEO) without committing to a count or a per-family membership snapshot. Vocabulary fix in the same pass: "Domain packages — each ships its own Sphinx domain or extends an existing one with new directives" mis-labels these packages. Most don't call `app.add_domain()`; they register directives on the existing Python domain. The canonical Sphinx term for this family is "autodoc extensions" (per `app.add_autodocumenter()` and the `Documenter` class hierarchy). what: - docs/packages/index.md (full rewrite): - Drop "Fourteen workspace packages in four tiers." opener. - Replace per-family prose with cross-linked bullet lists. Each package name is wrapped in a `{doc}`-equivalent `[`name`](name.md)` link so readers can navigate without needing to know the directory layout. The `.md` suffix disambiguates from same-named anchor labels (otherwise MyST raises `xref_ambiguous`). - Add the "Build utils" family, covering both `sphinx-vite-builder` (the new PEP 517 backend introduced in this PR) and the existing `gp-sphinx-vite` autobuild orchestrator. Previously these had no home in the family taxonomy. - Replace "Domain packages — each ships its own Sphinx domain or extends an existing one with new directives" with a canonical-Sphinx framing: "Domain-specific autodoc extensions — each adds directives that generate documentation from a particular source-construct family (Python APIs, argparse parsers, pytest fixtures, etc.)". Includes a hyperlink to Sphinx's autodoc docs so readers landing here unfamiliar with Sphinx can cross-check the term. - Add an `(all-workspace-packages)=` label to the dynamic grid section and reference it from the page intro so readers know where the canonical complete enumeration lives. - Each bullet picks up a short one-line description per package so a scanning reader sees what each does without clicking through. References: - Sphinx autodoc terminology: https://www.sphinx-doc.org/en/master/usage/extensions/autodoc.html - `app.add_autodocumenter()` API - `{workspace-package-grid}` directive in `docs/_ext/package_reference.py` --- docs/packages/index.md | 91 ++++++++++++++++++---------- docs/packages/sphinx-vite-builder.md | 4 +- 2 files changed, 60 insertions(+), 35 deletions(-) diff --git a/docs/packages/index.md b/docs/packages/index.md index 6362e210..c663b52c 100644 --- a/docs/packages/index.md +++ b/docs/packages/index.md @@ -1,38 +1,63 @@ # Packages -Fourteen workspace packages in four tiers. - -**Shared infrastructure** — the rendering pipeline that all domain packages consume: -- `sphinx-ux-badges` — badge primitives and colour palette -- `sphinx-ux-autodoc-layout` — structural presenter for `api-*` entry components -- `sphinx-autodoc-typehints-gp` — annotation normalization and type rendering - -**Domain packages** — domain-specific autodoc extensions. Each either -ships its own Sphinx domain or extends an existing one with new -directives, roles, and per-domain indices: -- `sphinx-autodoc-api-style`, `sphinx-autodoc-argparse`, - `sphinx-autodoc-docutils`, `sphinx-autodoc-fastmcp`, - `sphinx-autodoc-pytest-fixtures`, `sphinx-autodoc-sphinx` - -**Theme and coordinator** — shared Sphinx configuration and presentation -assets: -- `gp-sphinx`, `sphinx-gp-theme`, `sphinx-fonts` - -**SEO** — meta-tag and crawlability extensions auto-loaded by -`gp-sphinx` when `docs_url` is set: -- `sphinx-gp-opengraph`, `sphinx-gp-sitemap` - -`gp-sphinx` is the umbrella entry point: `merge_sphinx_config()` wires up the -full stack for downstream projects. - -Each domain package is independently installable but automatically loads its -infrastructure dependencies. - -Together, the shared infrastructure provides **one autodoc design system**: -every domain package shares the same badge palette, the same componentized -HTML output structure, and the same static type annotation pipeline — so -Python APIs, pytest fixtures, Sphinx config values, docutils directives, -and FastMCP tools all look like they belong together. +The workspace ships independently-installable Sphinx packages +organized by family. Each package has its own page; the +{ref}`grid below ` auto-enumerates the full +set as the workspace evolves. + +[`gp-sphinx`](gp-sphinx.md) is the umbrella entry point — its +`merge_sphinx_config()` wires up the full stack for downstream +projects in ~10 lines of `conf.py`. Every other package is opt-in +and independently installable. + +## Common libraries + +The rendering pipeline every autodoc extension consumes: + +- [`sphinx-ux-badges`](sphinx-ux-badges.md) — badge primitives and colour palette +- [`sphinx-ux-autodoc-layout`](sphinx-ux-autodoc-layout.md) — structural presenter for `api-*` entry components +- [`sphinx-autodoc-typehints-gp`](sphinx-autodoc-typehints-gp.md) — annotation normalization and type rendering +- [`sphinx-fonts`](sphinx-fonts.md) — IBM Plex font preloading + +## Autodoc extensions + +Domain-specific [autodoc extensions](https://www.sphinx-doc.org/en/master/usage/extensions/autodoc.html) — each adds directives that generate documentation from a particular source-construct family (Python APIs, argparse parsers, pytest fixtures, etc.): + +- [`sphinx-autodoc-api-style`](sphinx-autodoc-api-style.md) — Python API rendering style +- [`sphinx-autodoc-argparse`](sphinx-autodoc-argparse.md) — argparse parsers + subcommands +- [`sphinx-autodoc-docutils`](sphinx-autodoc-docutils.md) — docutils directives + nodes +- [`sphinx-autodoc-fastmcp`](sphinx-autodoc-fastmcp.md) — FastMCP tools, prompts, resources +- [`sphinx-autodoc-pytest-fixtures`](sphinx-autodoc-pytest-fixtures.md) — pytest fixtures +- [`sphinx-autodoc-sphinx`](sphinx-autodoc-sphinx.md) — Sphinx config values + +## Build utils + +[PEP 517](https://peps.python.org/pep-0517/) backends and orchestration helpers for theme asset pipelines: + +- [`sphinx-vite-builder`](sphinx-vite-builder.md) — [PEP 517](https://peps.python.org/pep-0517/) backend + Sphinx extension that runs Vite via pnpm +- [`gp-sphinx-vite`](gp-sphinx-vite.md) — autobuild orchestrator opt-in via `vite_orchestration=True` + +## Theme and coordinator + +Shared Sphinx configuration and presentation assets: + +- [`gp-sphinx`](gp-sphinx.md) — umbrella coordinator (`merge_sphinx_config()`) +- [`sphinx-gp-theme`](sphinx-gp-theme.md) — Furo child theme with the gp-sphinx default palette +- [`gp-furo-theme`](gp-furo-theme.md) — Tailwind v4 port of upstream Furo for git-pull projects + +## SEO + +Meta-tag and crawlability extensions auto-loaded by `gp-sphinx` when `docs_url` is set: + +- [`sphinx-gp-opengraph`](sphinx-gp-opengraph.md) — Open Graph + Twitter Card meta tags +- [`sphinx-gp-sitemap`](sphinx-gp-sitemap.md) — `sitemap.xml` for crawl indexing + +## Design philosophy + +Together, the common libraries provide **one autodoc design system**: every autodoc extension shares the same badge palette, the same componentized HTML output structure, and the same static type annotation pipeline — so Python APIs, pytest fixtures, Sphinx config values, docutils directives, and FastMCP tools all look like they belong together. + +(all-workspace-packages)= +## All workspace packages ```{workspace-package-grid} ``` diff --git a/docs/packages/sphinx-vite-builder.md b/docs/packages/sphinx-vite-builder.md index 24c6e84b..8042c149 100644 --- a/docs/packages/sphinx-vite-builder.md +++ b/docs/packages/sphinx-vite-builder.md @@ -3,7 +3,7 @@ ```{gp-sphinx-package-meta} sphinx-vite-builder ``` -A PEP 517 build backend and Sphinx extension that orchestrates +A [PEP 517](https://peps.python.org/pep-0517/) build backend and Sphinx extension that orchestrates [Vite](https://vitejs.dev/) builds via [pnpm](https://pnpm.io/) for any Sphinx-theme package whose static assets (CSS / JS) are produced by a JavaScript toolchain. The same pattern that @@ -17,7 +17,7 @@ $ pip install sphinx-vite-builder ## Two heads, one core -### PEP 517 build backend +### [PEP 517](https://peps.python.org/pep-0517/) build backend Drop-in replacement for `hatchling.build`. Runs `pnpm exec vite build` before delegating wheel/sdist construction to hatchling. End users who From fc1db1e34a1476cae731d2e87aa2fd7527d5f659 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 3 May 2026 08:50:42 -0500 Subject: [PATCH 12/53] docs(architecture): drop hardcoded package counts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: The architecture page opened with "Twelve workspace packages in three tiers" and later said "across all six domain packages" + "the sidebar groups these twelve packages into four navigation buckets". Each of these has gone or will go stale: we ship 17+ publishable packages now, the new build-tooling family makes the "tiers" count ambiguous, and tying prose to a literal package count is brittle. The tier metaphor itself is still useful (it conveys the dependency ordering — lower tiers never depend on higher ones) and the section headers below preserve the structure. The COUNT in the prose is what's drift-prone, not the structure. what: - docs/architecture.md:5: "Twelve workspace packages in three tiers." → "Workspace packages organized in tiers." Same opening sentiment, no fragile count. Also swap "domain packages consume shared infrastructure" → "autodoc extensions consume shared infrastructure" for canonical Sphinx vocabulary. - docs/architecture.md:9-11: "The sidebar groups these twelve packages into four navigation buckets (Domain Packages, UX, Utils, Internal)" → "The sidebar groups these packages into navigation buckets (Domain Packages, UX, Utils, Internal)". The bucket list is still inline so readers see the structure; the literal "four" is what was fragile. - docs/architecture.md:79-81: "a change to the shared infrastructure propagates instantly and consistently across all six domain packages" → "across every autodoc extension in the workspace". Drift-proof and uses canonical vocabulary in one stroke. The Tier 1 / Tier 2 / Tier 3 section headers and content stay unchanged — those are structural and worth preserving as-is. sphinx-vite-builder coverage and the table-to-grid conversion ride on subsequent commits in this series. --- docs/architecture.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 1d6c3bee..31e12abe 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -2,13 +2,13 @@ # Architecture -Twelve workspace packages in three tiers. Lower layers never depend on -higher ones — domain packages consume shared infrastructure, and the +Workspace packages organized in tiers. Lower layers never depend on +higher ones — autodoc extensions consume shared infrastructure, and the presentation layer wires everything together for downstream projects. -The sidebar groups these twelve packages into four navigation buckets -(Domain Packages, UX, Utils, Internal) — a reader-facing grouping that -is orthogonal to the dependency-ordered tier map below. +The sidebar groups these packages into navigation buckets (Domain Packages, +UX, Utils, Internal) — a reader-facing grouping that is orthogonal to the +dependency-ordered tier map below. ## Tier 1: Shared infrastructure @@ -77,5 +77,5 @@ APIs, pytest fixtures, Sphinx config values, docutils directives, and FastMCP tools all look like they belong together. This is the **one autodoc design system** principle: a change to the shared -infrastructure propagates instantly and consistently across all six -domain packages. +infrastructure propagates instantly and consistently across every autodoc +extension in the workspace. From 48a769eac409206b4f902af5714014102f2cff09 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 3 May 2026 08:52:15 -0500 Subject: [PATCH 13/53] docs(architecture): cover sphinx-vite-builder and gp-sphinx-vite as build tooling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: The architecture page covered Tier 1 (shared infrastructure), Tier 2 (autodoc extensions), and Tier 3 (theme + coordinator) but had no home for the two cross-cutting build utilities: sphinx-vite-builder (the new PEP 517 backend introduced in this PR) and gp-sphinx-vite (the existing autobuild Vite orchestrator). They sit *outside* the docs-build runtime — one runs at wheel-build time, the other at sphinx-autobuild time — so they're orthogonal to the existing tiers but central to the project's "no committed build artefacts" philosophy. While here, also adds gp-furo-theme to Tier 3's table — it was silently absent despite being the active theme implementation that sphinx-gp-theme inherits from. what: - docs/architecture.md: insert a new "Build tooling" section between Tier 3 and the "How the tiers connect" outro. The section uses a sphinx-design grid (1 col mobile, 2 cols tablet+) with one card per package — matches the visual pattern Tier 1 already uses. Each card explains the package's role + when it runs: - sphinx-vite-builder: PEP 517 backend + Sphinx extension; source builds fail loud without pnpm/Node, wheels ship turn-key. Linked to PEP 517 spec for readers unfamiliar with the packaging surface. - gp-sphinx-vite: autobuild orchestrator opt-in via `vite_orchestration=True`. Notes the SIGTERM teardown (production-grade subprocess management), so readers understand why this is a dedicated package rather than a 20-line conf.py snippet. Intro paragraph frames why the two are grouped together: both let theme authors keep build artefacts out of VCS while still shipping working wheels. - docs/architecture.md: add gp-furo-theme to Tier 3 table — it's the actual Tailwind v4 theme implementation that sphinx-gp-theme inherits from; previously invisible in the architecture story. References: - gp-sphinx PR #29 (sphinx-vite-builder backend) - PEP 517: https://peps.python.org/pep-0517/ - packages/sphinx-vite-builder + packages/gp-sphinx-vite --- docs/architecture.md | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/docs/architecture.md b/docs/architecture.md index 31e12abe..fc77d891 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -67,8 +67,45 @@ domain package to their `extensions` list. |---------|------| | {doc}`gp-sphinx ` | Coordinator. `merge_sphinx_config()` wires up the full stack. | | {doc}`sphinx-gp-theme ` | Furo-based theme with CSS variables and SPA navigation. | +| {doc}`gp-furo-theme ` | Tailwind v4 port of upstream Furo for git-pull projects. | | {doc}`sphinx-fonts ` | IBM Plex via Fontsource — preloaded web fonts. | +## Build tooling + +Cross-cutting build utilities that operate outside the docs-build +runtime — one is a [PEP 517](https://peps.python.org/pep-0517/) build +backend invoked when wheels are produced; the other is an opt-in +extension that drives the Vite watcher during `sphinx-autobuild`. +Both let theme authors keep build artefacts (`static/styles/*.css`, +`static/scripts/*.js`) out of VCS while still shipping working wheels +and seamless live-reload during authoring. + +::::{grid} 1 1 2 2 +:gutter: 2 + +:::{grid-item-card} sphinx-vite-builder +:link: packages/sphinx-vite-builder +:link-type: doc + +[PEP 517](https://peps.python.org/pep-0517/) build backend that runs +`pnpm exec vite build` before delegating wheel/sdist construction to +hatchling. Also a Sphinx extension that auto-orchestrates +`vite build --watch` during `sphinx-autobuild`. +Source builds error loudly without pnpm/Node; wheels ship turn-key. +::: + +:::{grid-item-card} gp-sphinx-vite +:link: packages/gp-sphinx-vite +:link-type: doc + +Autobuild-time Vite orchestrator opted into via +`merge_sphinx_config(vite_orchestration=True)`. Spawns the watcher as +a child process for the lifetime of `sphinx-autobuild`, with graceful +SIGTERM teardown on exit. +::: + +:::: + ## How the tiers connect Every domain package shares the same badge palette, the same componentized From 39ab6d3d78270013c934b79d02b630e8e4002e57 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 3 May 2026 08:53:42 -0500 Subject: [PATCH 14/53] docs(architecture): convert Tier 2 prose-table to sphinx-design grid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: Tier 1 (shared infrastructure) and the new Build tooling section both render as sphinx-design grid+grid-item-card layouts. Tier 2 (autodoc extensions) was the odd one out — a Markdown table. The table reads fine in narrow widths but lacks the visual rhythm and clickability the cards offer; this commit brings Tier 2 into visual parity with the rest of the page. While converting, also: - Rename the section header "Tier 2: Domain packages" → "Tier 2: Autodoc extensions". The "domain packages" framing conflated Sphinx's formal `app.add_domain()` concept (which most of these packages don't use) with the colloquial "domain expertise" framing. Canonical Sphinx vocabulary is "autodoc extensions" (per `app.add_autodocumenter()` and the `Documenter` class hierarchy). - Hyperlink "autodoc extensions" in the section intro to Sphinx's upstream autodoc docs so readers landing here unfamiliar with Sphinx can cross-check the term. what: - docs/architecture.md (Tier 2 section): swap the 6-row Markdown table for a 6-card sphinx-design grid (1 col mobile, 2 cols tablet, 3 cols desktop). Each card carries: - A `:link: packages/` + `:link-type: doc` so the whole card is clickable to the package page. - **Subject**: what source-construct family this autodoc extension targets (Python APIs, argparse parsers, FastMCP tools, etc.). - **Directives**: the user-facing directive names that get registered. - Section header rename: "Domain packages" → "Autodoc extensions". - Outro paragraph: "Each domain package calls `app.setup_extension()`..." → "Each autodoc extension calls `app.setup_extension()`..." (vocabulary consistency). --- docs/architecture.md | 72 ++++++++++++++++++++++++++++++++++++-------- 1 file changed, 59 insertions(+), 13 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index fc77d891..5c68ca1c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -43,23 +43,69 @@ Replaces `sphinx-autodoc-typehints` + `sphinx.ext.napoleon`. :::: -## Tier 2: Domain packages +## Tier 2: Autodoc extensions -Domain-specific autodoc extensions that consume Tier 1 and add -project-specific rendering logic: +Domain-specific [autodoc extensions](https://www.sphinx-doc.org/en/master/usage/extensions/autodoc.html) +that consume Tier 1 and add project-specific rendering logic. Each +ships directives that generate documentation from a particular +source-construct family: -| Package | Domain | Directives | -|---------|--------|------------| -| {doc}`sphinx-autodoc-api-style ` | Standard Python | `autofunction`, `autoclass`, `automodule` | -| {doc}`sphinx-autodoc-argparse ` | Custom `argparse` domain — programs, options, subcommands, positionals | `argparse` | -| {doc}`sphinx-autodoc-docutils ` | docutils | `autodirective`, `autorole` | -| {doc}`sphinx-autodoc-fastmcp ` | FastMCP tools | `fastmcp-tool`, `fastmcp-tool-summary` | -| {doc}`sphinx-autodoc-pytest-fixtures ` | pytest fixtures (extends `py` domain) | `autofixture`, `autofixtures`, `auto-pytest-plugin` | -| {doc}`sphinx-autodoc-sphinx ` | Sphinx config | `autoconfigvalue`, `autoconfigvalues` | +::::{grid} 1 1 2 3 +:gutter: 2 + +:::{grid-item-card} sphinx-autodoc-api-style +:link: packages/sphinx-autodoc-api-style +:link-type: doc + +**Subject**: standard Python. +**Directives**: `autofunction`, `autoclass`, `automodule`. +::: + +:::{grid-item-card} sphinx-autodoc-argparse +:link: packages/sphinx-autodoc-argparse +:link-type: doc + +**Subject**: argparse parsers — programs, options, subcommands, positionals. +**Directives**: `argparse` (custom `argparse` domain). +::: + +:::{grid-item-card} sphinx-autodoc-docutils +:link: packages/sphinx-autodoc-docutils +:link-type: doc + +**Subject**: docutils directives and roles. +**Directives**: `autodirective`, `autorole`. +::: + +:::{grid-item-card} sphinx-autodoc-fastmcp +:link: packages/sphinx-autodoc-fastmcp +:link-type: doc + +**Subject**: FastMCP tools, prompts, resources. +**Directives**: `fastmcp-tool`, `fastmcp-tool-summary`. +::: + +:::{grid-item-card} sphinx-autodoc-pytest-fixtures +:link: packages/sphinx-autodoc-pytest-fixtures +:link-type: doc + +**Subject**: pytest fixtures (extends the `py` domain). +**Directives**: `autofixture`, `autofixtures`, `auto-pytest-plugin`. +::: + +:::{grid-item-card} sphinx-autodoc-sphinx +:link: packages/sphinx-autodoc-sphinx +:link-type: doc + +**Subject**: Sphinx config values. +**Directives**: `autoconfigvalue`, `autoconfigvalues`. +::: + +:::: -Each domain package calls `app.setup_extension()` to auto-register its +Each autodoc extension calls `app.setup_extension()` to auto-register its infrastructure dependencies — downstream projects only need to add the -domain package to their `extensions` list. +package to their `extensions` list. ## Tier 3: Theme and coordinator From 38a33de526de20224c0f77da64ba2bc092a1e0d2 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 3 May 2026 08:55:17 -0500 Subject: [PATCH 15/53] docs(architecture): cross-link package mentions + finish vocabulary cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: Two small finishing touches on the architecture page after the prior commits restructured Tier 2 into a card grid and dropped hardcoded counts: 1. The Tier 1 intro and the "How the tiers connect" outro still said "domain packages" — leftover from before the canonical "autodoc extensions" rename in commit "convert Tier 2 prose-table to sphinx-design grid". Cleaning up so the vocabulary is uniform across the page. 2. The closing paragraph listed five source-construct families (Python APIs, pytest fixtures, Sphinx config values, docutils directives, FastMCP tools) as bare prose. Each phrase names exactly what one of the autodoc-* packages documents — so linking each to its respective package page turns the closing pitch into a natural navigation surface ("interested in pytest fixtures? click here"). what: - docs/architecture.md:15: "all domain packages consume" → "every autodoc extension consumes" (Tier 1 intro). - docs/architecture.md:157: "Every domain package shares" → "Every autodoc extension shares". - docs/architecture.md:158-163: convert the closing list to inline cross-links: - "Python APIs" → packages/sphinx-autodoc-api-style.md - "pytest fixtures" → packages/sphinx-autodoc-pytest-fixtures.md - "Sphinx config values" → packages/sphinx-autodoc-sphinx.md - "docutils directives" → packages/sphinx-autodoc-docutils.md - "FastMCP tools" → packages/sphinx-autodoc-fastmcp.md The card grids in Tier 1, Tier 2, Tier 3, and Build tooling already use `:link: packages/` so every card-rendered package mention is clickable. This commit covers the remaining free-form prose mentions, leaving the page with no unlinked package references. --- docs/architecture.md | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 5c68ca1c..af46301c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -12,7 +12,7 @@ dependency-ordered tier map below. ## Tier 1: Shared infrastructure -The rendering pipeline that all domain packages consume: +The rendering pipeline that every autodoc extension consumes: ::::{grid} 1 1 3 3 :gutter: 2 @@ -154,10 +154,14 @@ SIGTERM teardown on exit. ## How the tiers connect -Every domain package shares the same badge palette, the same componentized -HTML output structure, and the same type annotation pipeline — so Python -APIs, pytest fixtures, Sphinx config values, docutils directives, and -FastMCP tools all look like they belong together. +Every autodoc extension shares the same badge palette, the same +componentized HTML output structure, and the same type annotation +pipeline — so [Python APIs](packages/sphinx-autodoc-api-style.md), +[pytest fixtures](packages/sphinx-autodoc-pytest-fixtures.md), +[Sphinx config values](packages/sphinx-autodoc-sphinx.md), +[docutils directives](packages/sphinx-autodoc-docutils.md), and +[FastMCP tools](packages/sphinx-autodoc-fastmcp.md) all look like +they belong together. This is the **one autodoc design system** principle: a change to the shared infrastructure propagates instantly and consistently across every autodoc From f8a98d268bd7a52b3b9972af10017789d544c88e Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 3 May 2026 09:04:38 -0500 Subject: [PATCH 16/53] docs(README): drift-proof package counts and complete the family map MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: The workspace README opens with "Twelve packages in three tiers" and later says "Six domain autodocumenters" + "Three-tier architecture". All three are drift-prone — counts go stale every time a package lands and "autodocumenters" isn't real Sphinx vocabulary (the canonical term per `app.add_autodocumenter()` and the `Documenter` class hierarchy is "autodoc extensions"). The per-tier package list also missed `gp-furo-theme`, `sphinx-vite-builder`, `gp-sphinx-vite`, `sphinx-gp-opengraph`, and `sphinx-gp-sitemap`, making the README's package map narrower than the docs site's. This is the same drift-proofing pass we applied to docs/index.md, docs/architecture.md, and docs/packages/index.md in the prior six commits — finishing the job on the workspace's primary README so both surfaces (PyPI / GitHub README and the docs site) speak with one voice. what: - README.md:3: "Twelve packages in three tiers that replace ~300 lines of duplicated `docs/conf.py`…" → "An integrated autodoc design system for Sphinx that replaces ~300 lines of duplicated `docs/conf.py`…". Drops both fragile counts; the value proposition (replace boilerplate, get consistent output) is unchanged. - README.md:53: "**Six domain autodocumenters** — Python API, argparse CLIs, pytest fixtures, FastMCP tools, docutils directives, Sphinx config values" → "**Autodoc extensions** — Python API, argparse CLIs, pytest fixtures, FastMCP tools, docutils directives, Sphinx config values". Drops the count and swaps to canonical Sphinx vocabulary. - README.md:59: section header "## Three-tier architecture" → "## Workspace architecture". - README.md:61: "The workspace is organized into three tiers — lower layers never depend on higher ones:" → "Lower layers never depend on higher ones:". Keeps the dependency-direction note that carries the architectural meaning; drops the literal "three tiers" claim that drifts. - README.md:63-67 (now expanded): replace the previous three-bullet per-tier listing with the family-headed structure used in docs/packages/index.md (Common libraries / Autodoc extensions / Build utils / Theme and coordinator / SEO). Adds the previously absent packages (gp-furo-theme, sphinx-vite-builder, gp-sphinx-vite, sphinx-gp-opengraph, sphinx-gp-sitemap) so the README map now matches the docs site's coverage. Build utils bullet links PEP 517 to its spec since "PEP 517" is non-obvious to readers landing here cold. - README.md (final paragraph): point readers to both Architecture AND Packages pages on the docs site, plus a note that the docs site auto-enumerates as the workspace grows. Frames "the docs site is the authoritative complete map" so future package additions don't require a README update. Verified locally: - Zero hardcoded count references remain in README.md (multi-pattern rg sweep across spelled-out and digit forms). - Zero "autodocumenter" / "domain packages" mentions in README.md user-facing prose. - Full gate green; clean docs rebuild succeeds. References: - Sphinx autodoc terminology: https://www.sphinx-doc.org/en/master/usage/extensions/autodoc.html - `app.add_autodocumenter()` / `Documenter` class hierarchy - PEP 517: https://peps.python.org/pep-0517/ --- README.md | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 598b92ba..2d2f0992 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # gp-sphinx · [![Python Package](https://img.shields.io/pypi/v/gp-sphinx.svg)](https://pypi.org/project/gp-sphinx/) [![License](https://img.shields.io/github/license/git-pull/gp-sphinx.svg)](https://github.com/git-pull/gp-sphinx/blob/main/LICENSE) -Integrated autodoc design system for Sphinx. Twelve packages in three tiers -that replace ~300 lines of duplicated `docs/conf.py` with ~10 lines and -produce beautiful, consistent API documentation. +An integrated autodoc design system for Sphinx that replaces ~300 lines +of duplicated `docs/conf.py` with ~10 lines and produces beautiful, +consistent API documentation. ## Requirements @@ -50,21 +50,25 @@ Out of the box, `merge_sphinx_config()` activates: - **Componentized layouts** (`sphinx-ux-autodoc-layout`) — card containers, parameter folding, managed signatures - **Clean type hints** (`sphinx-autodoc-typehints-gp`) — simplified annotations with cross-referenced links, replacing `sphinx-autodoc-typehints` and `sphinx.ext.napoleon` - **Unified badge system** (`sphinx-ux-badges`) — type and modifier badges with a shared colour palette -- **Six domain autodocumenters** — Python API, argparse CLIs, pytest fixtures, FastMCP tools, docutils directives, Sphinx config values +- **Autodoc extensions** — Python API, argparse CLIs, pytest fixtures, FastMCP tools, docutils directives, Sphinx config values - **IBM Plex fonts** via Fontsource with preloaded web fonts - **Full dark mode** theming via CSS custom properties See the [Gallery](https://gp-sphinx.git-pull.com/gallery.html) for live demos of every component. -## Three-tier architecture +## Workspace architecture -The workspace is organized into three tiers — lower layers never depend on higher ones: +Lower layers never depend on higher ones: -- **Shared infrastructure**: `sphinx-ux-badges`, `sphinx-ux-autodoc-layout`, `sphinx-autodoc-typehints-gp` -- **Domain packages**: `sphinx-autodoc-api-style`, `sphinx-autodoc-docutils`, `sphinx-autodoc-fastmcp`, `sphinx-autodoc-pytest-fixtures`, `sphinx-autodoc-sphinx` -- **Theme and coordinator**: `gp-sphinx`, `sphinx-gp-theme`, `sphinx-fonts`, `sphinx-autodoc-argparse` +- **Common libraries** — `sphinx-ux-badges`, `sphinx-ux-autodoc-layout`, `sphinx-autodoc-typehints-gp`, `sphinx-fonts` +- **Autodoc extensions** — `sphinx-autodoc-api-style`, `sphinx-autodoc-argparse`, `sphinx-autodoc-docutils`, `sphinx-autodoc-fastmcp`, `sphinx-autodoc-pytest-fixtures`, `sphinx-autodoc-sphinx` +- **Build utils** — `sphinx-vite-builder` ([PEP 517](https://peps.python.org/pep-0517/) backend that runs Vite via pnpm), `gp-sphinx-vite` (autobuild orchestrator) +- **Theme and coordinator** — `gp-sphinx`, `sphinx-gp-theme`, `gp-furo-theme` +- **SEO** — `sphinx-gp-opengraph`, `sphinx-gp-sitemap` (auto-loaded by `gp-sphinx` when `docs_url` is set) -See the [Architecture](https://gp-sphinx.git-pull.com/architecture.html) page for the full package map. +See the [Architecture](https://gp-sphinx.git-pull.com/architecture.html) +and [Packages](https://gp-sphinx.git-pull.com/packages/) pages for the +full package map; the docs site auto-enumerates as the workspace grows. ## More information From d3938e5ca48eb9e00e2c00ff37f8ffe5c2a3bd4f Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 3 May 2026 09:06:03 -0500 Subject: [PATCH 17/53] docs(whats-new): drop "six domain packages" hardcode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: docs/whats-new.md was the last user-facing surface still saying "The six domain packages" before the inline list. Same drift / vocabulary fix as the README and the architecture page — canonical Sphinx vocabulary is "autodoc extensions" (per `app.add_autodocumenter()` and the `Documenter` class hierarchy), and the count goes stale every time a package is added or moved between families. what: - docs/whats-new.md:30: "The six domain packages" → "The autodoc extensions". Single phrase; the cross-linked package list right after stays as the actual content. Verification: full sweep across README.md + docs/ + packages/*/README.md confirms zero hardcoded package counts remain. The remaining matches are stable architectural facts that should NOT change: - "Two heads, one core" (sphinx-vite-builder package architecture — exactly two PEP 517 / Sphinx entry points by design, invariant) - "Two hooks run independently" (sphinx-autodoc-typehints-gp's internal hook pattern) - "Two domain indices" (sphinx-autodoc-argparse — programs + options indices, fixed by the package's purpose) - "**one autodoc design system**" / "**one palette**" (named principles, not counts) - "Domain Packages" / "UX" / "Utils" / "Internal" — formal navigation labels (toctree captions, sidebar bucket names), not count-based prose The docs site's `{workspace-package-grid}` directive remains the canonical authoritative enumeration of the workspace — it auto-discovers from `packages/*/pyproject.toml`, no manual update needed when the next package lands. --- docs/whats-new.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/whats-new.md b/docs/whats-new.md index 20d4971c..53065f31 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -27,7 +27,7 @@ light/dark theming. ## Shared layout stack -The six domain packages +The autodoc extensions ({doc}`api-style `, {doc}`argparse `, {doc}`docutils `, From cda1c11b4e30d7befaa1067f3b01b58f844cd5fb Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 3 May 2026 09:12:12 -0500 Subject: [PATCH 18/53] =?UTF-8?q?release(workspace):=20bump=20v0.0.1a16.de?= =?UTF-8?q?v0=20=E2=86=92=20v0.0.1a16.dev1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: Second dev release of the sphinx-vite-builder backend (gp-sphinx PR #29). Carries the docs polish + drift-proofing pass on top of dev0 so the next test against PyPI exercises the exact wheels the final v0.0.1a16 stable will ship. what: - 17 packages × pyproject.toml: `version = "0.0.1a16.dev1"` - workspace root pyproject.toml: same - inter-package `==0.0.1a16.dev0` constraints → `==0.0.1a16.dev1` - 17 package `__init__.py` files: `__version__ = "0.0.1a16.dev1"` - tests/test_sphinx_vite_builder.py + tests/test_gp_sphinx_vite.py: test_version_matches_workspace_lock asserts new version - tests/ci/test_package_tools.py: corresponding fixture updates - uv.lock: refreshed via `uv lock` Verified locally: - `uv run python scripts/ci/package_tools.py check-versions` → exit 0 - ruff clean, ruff format clean, mypy clean (207 source files) - 1354 passed, 159 skipped (no regression) - `just build-docs` succeeds; clean rebuild Carries forward from dev0 to dev1: - The new sphinx-vite-builder backend + Sphinx extension (PR #29) - gp-furo-theme migration to use sphinx_vite_builder.build - CI-aware PnpmMissingError with platform-specific YAML recipes - AGENTS.md + CLAUDE.md + README.md package docs - Workspace-wide drift-proofing pass (no hardcoded package counts; canonical "autodoc extensions" vocabulary throughout) --- packages/gp-furo-theme/pyproject.toml | 2 +- .../src/gp_furo_theme/__init__.py | 2 +- packages/gp-sphinx-vite/pyproject.toml | 2 +- .../src/gp_sphinx_vite/__init__.py | 2 +- packages/gp-sphinx/pyproject.toml | 14 ++++---- packages/gp-sphinx/src/gp_sphinx/__init__.py | 2 +- .../sphinx-autodoc-api-style/pyproject.toml | 6 ++-- .../sphinx-autodoc-argparse/pyproject.toml | 2 +- .../src/sphinx_autodoc_argparse/__init__.py | 2 +- .../sphinx-autodoc-docutils/pyproject.toml | 8 ++--- .../src/sphinx_autodoc_docutils/__init__.py | 2 +- .../sphinx-autodoc-fastmcp/pyproject.toml | 8 ++--- .../src/sphinx_autodoc_fastmcp/__init__.py | 2 +- .../pyproject.toml | 8 ++--- packages/sphinx-autodoc-sphinx/pyproject.toml | 8 ++--- .../src/sphinx_autodoc_sphinx/__init__.py | 2 +- .../pyproject.toml | 2 +- .../sphinx_autodoc_typehints_gp/extension.py | 2 +- packages/sphinx-fonts/pyproject.toml | 2 +- .../sphinx-fonts/src/sphinx_fonts/__init__.py | 2 +- packages/sphinx-gp-opengraph/pyproject.toml | 2 +- .../src/sphinx_gp_opengraph/__init__.py | 2 +- packages/sphinx-gp-sitemap/pyproject.toml | 2 +- .../src/sphinx_gp_sitemap/__init__.py | 2 +- packages/sphinx-gp-theme/pyproject.toml | 4 +-- .../src/sphinx_gp_theme/__init__.py | 2 +- .../sphinx-ux-autodoc-layout/pyproject.toml | 2 +- packages/sphinx-ux-badges/pyproject.toml | 2 +- .../src/sphinx_ux_badges/__init__.py | 2 +- packages/sphinx-vite-builder/pyproject.toml | 2 +- .../src/sphinx_vite_builder/__init__.py | 2 +- pyproject.toml | 4 +-- tests/ci/test_package_tools.py | 8 ++--- tests/test_gp_sphinx_vite.py | 2 +- tests/test_sphinx_vite_builder.py | 2 +- uv.lock | 36 +++++++++---------- 36 files changed, 78 insertions(+), 78 deletions(-) diff --git a/packages/gp-furo-theme/pyproject.toml b/packages/gp-furo-theme/pyproject.toml index 650144a0..c2975d58 100644 --- a/packages/gp-furo-theme/pyproject.toml +++ b/packages/gp-furo-theme/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "gp-furo-theme" -version = "0.0.1a16.dev0" +version = "0.0.1a16.dev1" description = "Tailwind v4 port of the Furo Sphinx theme for git-pull project documentation" requires-python = ">=3.10,<4.0" authors = [ diff --git a/packages/gp-furo-theme/src/gp_furo_theme/__init__.py b/packages/gp-furo-theme/src/gp_furo_theme/__init__.py index 0dc13fa6..c941f151 100644 --- a/packages/gp-furo-theme/src/gp_furo_theme/__init__.py +++ b/packages/gp-furo-theme/src/gp_furo_theme/__init__.py @@ -39,7 +39,7 @@ from .navigation import get_navigation_tree -__version__ = "0.0.1a16.dev0" +__version__ = "0.0.1a16.dev1" THEME_NAME = "gp-furo" THEME_PATH = (pathlib.Path(__file__).parent / "theme" / THEME_NAME).resolve() diff --git a/packages/gp-sphinx-vite/pyproject.toml b/packages/gp-sphinx-vite/pyproject.toml index ee39bdd2..1d768830 100644 --- a/packages/gp-sphinx-vite/pyproject.toml +++ b/packages/gp-sphinx-vite/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "gp-sphinx-vite" -version = "0.0.1a16.dev0" +version = "0.0.1a16.dev1" description = "Transparent Vite + pnpm orchestration for Sphinx theme asset pipelines" requires-python = ">=3.10,<4.0" authors = [ diff --git a/packages/gp-sphinx-vite/src/gp_sphinx_vite/__init__.py b/packages/gp-sphinx-vite/src/gp_sphinx_vite/__init__.py index e59dec43..a9b8a968 100644 --- a/packages/gp-sphinx-vite/src/gp_sphinx_vite/__init__.py +++ b/packages/gp-sphinx-vite/src/gp_sphinx_vite/__init__.py @@ -35,7 +35,7 @@ if t.TYPE_CHECKING: from sphinx.application import Sphinx -__version__ = "0.0.1a16.dev0" +__version__ = "0.0.1a16.dev1" logger = logging.getLogger(__name__) logger.addHandler(logging.NullHandler()) diff --git a/packages/gp-sphinx/pyproject.toml b/packages/gp-sphinx/pyproject.toml index 5e63ee17..a185938e 100644 --- a/packages/gp-sphinx/pyproject.toml +++ b/packages/gp-sphinx/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "gp-sphinx" -version = "0.0.1a16.dev0" +version = "0.0.1a16.dev1" description = "Shared Sphinx documentation platform for git-pull projects" requires-python = ">=3.10,<4.0" authors = [ @@ -26,15 +26,15 @@ readme = "README.md" keywords = ["sphinx", "documentation", "configuration"] dependencies = [ "sphinx>=8.1,<9", - "sphinx-gp-theme==0.0.1a16.dev0", - "sphinx-fonts==0.0.1a16.dev0", + "sphinx-gp-theme==0.0.1a16.dev1", + "sphinx-fonts==0.0.1a16.dev1", "myst-parser", "docutils", - "sphinx-autodoc-typehints-gp==0.0.1a16.dev0", + "sphinx-autodoc-typehints-gp==0.0.1a16.dev1", "sphinx-inline-tabs", "sphinx-copybutton", - "sphinx-gp-opengraph==0.0.1a16.dev0", - "sphinx-gp-sitemap==0.0.1a16.dev0", + "sphinx-gp-opengraph==0.0.1a16.dev1", + "sphinx-gp-sitemap==0.0.1a16.dev1", "sphinxext-rediraffe", "sphinx-design", "linkify-it-py", @@ -43,7 +43,7 @@ dependencies = [ [project.optional-dependencies] argparse = [ - "sphinx-autodoc-argparse==0.0.1a16.dev0", + "sphinx-autodoc-argparse==0.0.1a16.dev1", ] [project.urls] diff --git a/packages/gp-sphinx/src/gp_sphinx/__init__.py b/packages/gp-sphinx/src/gp_sphinx/__init__.py index d643a493..90149e93 100644 --- a/packages/gp-sphinx/src/gp_sphinx/__init__.py +++ b/packages/gp-sphinx/src/gp_sphinx/__init__.py @@ -7,7 +7,7 @@ __title__ = "gp-sphinx" __package_name__ = "gp_sphinx" __description__ = "Shared Sphinx documentation platform for git-pull projects" -__version__ = "0.0.1a16.dev0" +__version__ = "0.0.1a16.dev1" __author__ = "Tony Narlock" __github__ = "https://github.com/git-pull/gp-sphinx" __docs__ = "https://gp-sphinx.git-pull.com" diff --git a/packages/sphinx-autodoc-api-style/pyproject.toml b/packages/sphinx-autodoc-api-style/pyproject.toml index e88f3b0f..3e10aebc 100644 --- a/packages/sphinx-autodoc-api-style/pyproject.toml +++ b/packages/sphinx-autodoc-api-style/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sphinx-autodoc-api-style" -version = "0.0.1a16.dev0" +version = "0.0.1a16.dev1" description = "Sphinx extension for enhanced autodoc API entry styling (badges and cards)" requires-python = ">=3.10,<4.0" authors = [ @@ -27,8 +27,8 @@ readme = "README.md" keywords = ["sphinx", "autodoc", "documentation", "api", "badges"] dependencies = [ "sphinx>=8.1", - "sphinx-ux-badges==0.0.1a16.dev0", - "sphinx-ux-autodoc-layout==0.0.1a16.dev0", + "sphinx-ux-badges==0.0.1a16.dev1", + "sphinx-ux-autodoc-layout==0.0.1a16.dev1", ] [project.urls] diff --git a/packages/sphinx-autodoc-argparse/pyproject.toml b/packages/sphinx-autodoc-argparse/pyproject.toml index 00807db7..3f718bc2 100644 --- a/packages/sphinx-autodoc-argparse/pyproject.toml +++ b/packages/sphinx-autodoc-argparse/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sphinx-autodoc-argparse" -version = "0.0.1a16.dev0" +version = "0.0.1a16.dev1" description = "Modern Sphinx extension for documenting argparse-based CLI tools" requires-python = ">=3.10,<4.0" authors = [ diff --git a/packages/sphinx-autodoc-argparse/src/sphinx_autodoc_argparse/__init__.py b/packages/sphinx-autodoc-argparse/src/sphinx_autodoc_argparse/__init__.py index ce53e115..eb0f5ad2 100644 --- a/packages/sphinx-autodoc-argparse/src/sphinx_autodoc_argparse/__init__.py +++ b/packages/sphinx-autodoc-argparse/src/sphinx_autodoc_argparse/__init__.py @@ -44,7 +44,7 @@ if t.TYPE_CHECKING: from sphinx.application import Sphinx -__version__ = "0.0.1a16.dev0" +__version__ = "0.0.1a16.dev1" class SetupDict(t.TypedDict): diff --git a/packages/sphinx-autodoc-docutils/pyproject.toml b/packages/sphinx-autodoc-docutils/pyproject.toml index 18a14bd3..8ab7e4ed 100644 --- a/packages/sphinx-autodoc-docutils/pyproject.toml +++ b/packages/sphinx-autodoc-docutils/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sphinx-autodoc-docutils" -version = "0.0.1a16.dev0" +version = "0.0.1a16.dev1" description = "Sphinx extension for documenting docutils directives and roles as first-class reference entries" requires-python = ">=3.10,<4.0" authors = [ @@ -27,9 +27,9 @@ readme = "README.md" keywords = ["sphinx", "docutils", "directives", "roles", "documentation", "autodoc"] dependencies = [ "sphinx>=8.1", - "sphinx-ux-badges==0.0.1a16.dev0", - "sphinx-ux-autodoc-layout==0.0.1a16.dev0", - "sphinx-autodoc-typehints-gp==0.0.1a16.dev0", + "sphinx-ux-badges==0.0.1a16.dev1", + "sphinx-ux-autodoc-layout==0.0.1a16.dev1", + "sphinx-autodoc-typehints-gp==0.0.1a16.dev1", ] [project.urls] diff --git a/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/__init__.py b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/__init__.py index 94201c3a..512f439a 100644 --- a/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/__init__.py +++ b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/__init__.py @@ -77,7 +77,7 @@ def _add_static_path(app: Sphinx) -> None: app.add_css_file("css/sphinx_autodoc_docutils.css") return { - "version": "0.0.1a16.dev0", + "version": "0.0.1a16.dev1", "parallel_read_safe": True, "parallel_write_safe": True, } diff --git a/packages/sphinx-autodoc-fastmcp/pyproject.toml b/packages/sphinx-autodoc-fastmcp/pyproject.toml index 5b753f2d..78f2cd25 100644 --- a/packages/sphinx-autodoc-fastmcp/pyproject.toml +++ b/packages/sphinx-autodoc-fastmcp/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sphinx-autodoc-fastmcp" -version = "0.0.1a16.dev0" +version = "0.0.1a16.dev1" description = "Sphinx extension for documenting FastMCP tools (cards, badges, cross-refs)" requires-python = ">=3.10,<4.0" authors = [ @@ -27,9 +27,9 @@ readme = "README.md" keywords = ["sphinx", "fastmcp", "mcp", "documentation", "badges"] dependencies = [ "sphinx>=8.1", - "sphinx-ux-badges==0.0.1a16.dev0", - "sphinx-ux-autodoc-layout==0.0.1a16.dev0", - "sphinx-autodoc-typehints-gp==0.0.1a16.dev0", + "sphinx-ux-badges==0.0.1a16.dev1", + "sphinx-ux-autodoc-layout==0.0.1a16.dev1", + "sphinx-autodoc-typehints-gp==0.0.1a16.dev1", ] [project.urls] diff --git a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/__init__.py b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/__init__.py index 11284409..499a5611 100644 --- a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/__init__.py +++ b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/__init__.py @@ -51,7 +51,7 @@ logging.getLogger(__name__).addHandler(logging.NullHandler()) -_EXTENSION_VERSION = "0.0.1a16.dev0" +_EXTENSION_VERSION = "0.0.1a16.dev1" def setup(app: Sphinx) -> dict[str, t.Any]: diff --git a/packages/sphinx-autodoc-pytest-fixtures/pyproject.toml b/packages/sphinx-autodoc-pytest-fixtures/pyproject.toml index 7bae8171..a7a1dcb2 100644 --- a/packages/sphinx-autodoc-pytest-fixtures/pyproject.toml +++ b/packages/sphinx-autodoc-pytest-fixtures/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sphinx-autodoc-pytest-fixtures" -version = "0.0.1a16.dev0" +version = "0.0.1a16.dev1" description = "Sphinx extension for documenting pytest fixtures as first-class objects" requires-python = ">=3.10,<4.0" authors = [ @@ -30,9 +30,9 @@ keywords = ["sphinx", "pytest", "fixtures", "documentation", "autodoc"] dependencies = [ "sphinx>=8.1", "pytest", - "sphinx-ux-badges==0.0.1a16.dev0", - "sphinx-ux-autodoc-layout==0.0.1a16.dev0", - "sphinx-autodoc-typehints-gp==0.0.1a16.dev0", + "sphinx-ux-badges==0.0.1a16.dev1", + "sphinx-ux-autodoc-layout==0.0.1a16.dev1", + "sphinx-autodoc-typehints-gp==0.0.1a16.dev1", ] [project.urls] diff --git a/packages/sphinx-autodoc-sphinx/pyproject.toml b/packages/sphinx-autodoc-sphinx/pyproject.toml index 4a4173b9..65a59bb5 100644 --- a/packages/sphinx-autodoc-sphinx/pyproject.toml +++ b/packages/sphinx-autodoc-sphinx/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sphinx-autodoc-sphinx" -version = "0.0.1a16.dev0" +version = "0.0.1a16.dev1" description = "Sphinx extension for documenting extension config values as first-class conf.py reference entries" requires-python = ">=3.10,<4.0" authors = [ @@ -27,9 +27,9 @@ readme = "README.md" keywords = ["sphinx", "configuration", "conf.py", "documentation", "autodoc"] dependencies = [ "sphinx>=8.1", - "sphinx-ux-badges==0.0.1a16.dev0", - "sphinx-ux-autodoc-layout==0.0.1a16.dev0", - "sphinx-autodoc-typehints-gp==0.0.1a16.dev0", + "sphinx-ux-badges==0.0.1a16.dev1", + "sphinx-ux-autodoc-layout==0.0.1a16.dev1", + "sphinx-autodoc-typehints-gp==0.0.1a16.dev1", ] [project.urls] diff --git a/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/__init__.py b/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/__init__.py index 6b445d04..16241c2b 100644 --- a/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/__init__.py +++ b/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/__init__.py @@ -61,7 +61,7 @@ def _add_static_path(app: Sphinx) -> None: app.add_css_file("css/sphinx_autodoc_sphinx.css") return { - "version": "0.0.1a16.dev0", + "version": "0.0.1a16.dev1", "parallel_read_safe": True, "parallel_write_safe": True, } diff --git a/packages/sphinx-autodoc-typehints-gp/pyproject.toml b/packages/sphinx-autodoc-typehints-gp/pyproject.toml index 7ccae40b..268c448f 100644 --- a/packages/sphinx-autodoc-typehints-gp/pyproject.toml +++ b/packages/sphinx-autodoc-typehints-gp/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "sphinx-autodoc-typehints-gp" -version = "0.0.1a16.dev0" +version = "0.0.1a16.dev1" description = "Cross-referenced type annotations for Sphinx autodoc" readme = "README.md" requires-python = ">=3.10" diff --git a/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/extension.py b/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/extension.py index a0107190..8df162d0 100644 --- a/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/extension.py +++ b/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/extension.py @@ -593,7 +593,7 @@ def setup(app: Sphinx) -> dict[str, t.Any]: # are skipped by the built-in handler. app.connect("object-description-transform", merge_typehints, priority=499) return { - "version": "0.0.1a16.dev0", + "version": "0.0.1a16.dev1", "parallel_read_safe": True, "parallel_write_safe": True, } diff --git a/packages/sphinx-fonts/pyproject.toml b/packages/sphinx-fonts/pyproject.toml index 8c5dfaee..75e99319 100644 --- a/packages/sphinx-fonts/pyproject.toml +++ b/packages/sphinx-fonts/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sphinx-fonts" -version = "0.0.1a16.dev0" +version = "0.0.1a16.dev1" description = "Sphinx extension for self-hosted fonts via Fontsource CDN" requires-python = ">=3.10,<4.0" authors = [ diff --git a/packages/sphinx-fonts/src/sphinx_fonts/__init__.py b/packages/sphinx-fonts/src/sphinx_fonts/__init__.py index 5276c30a..38aa2ba9 100644 --- a/packages/sphinx-fonts/src/sphinx_fonts/__init__.py +++ b/packages/sphinx-fonts/src/sphinx_fonts/__init__.py @@ -25,7 +25,7 @@ logger = logging.getLogger(__name__) logger.addHandler(logging.NullHandler()) -__version__ = "0.0.1a16.dev0" +__version__ = "0.0.1a16.dev1" CDN_TEMPLATE = ( "https://cdn.jsdelivr.net/npm/{package}@{version}" diff --git a/packages/sphinx-gp-opengraph/pyproject.toml b/packages/sphinx-gp-opengraph/pyproject.toml index e2da8640..a76d9cfb 100644 --- a/packages/sphinx-gp-opengraph/pyproject.toml +++ b/packages/sphinx-gp-opengraph/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sphinx-gp-opengraph" -version = "0.0.1a16.dev0" +version = "0.0.1a16.dev1" description = "OpenGraph and Twitter meta-tag emission for Sphinx — matplotlib-free" requires-python = ">=3.10,<4.0" authors = [ diff --git a/packages/sphinx-gp-opengraph/src/sphinx_gp_opengraph/__init__.py b/packages/sphinx-gp-opengraph/src/sphinx_gp_opengraph/__init__.py index 0112ec78..e953c389 100644 --- a/packages/sphinx-gp-opengraph/src/sphinx_gp_opengraph/__init__.py +++ b/packages/sphinx-gp-opengraph/src/sphinx_gp_opengraph/__init__.py @@ -42,7 +42,7 @@ logger = logging.getLogger(__name__) logger.addHandler(logging.NullHandler()) -_EXTENSION_VERSION = "0.0.1a16.dev0" +_EXTENSION_VERSION = "0.0.1a16.dev1" DEFAULT_DESCRIPTION_LENGTH = 200 diff --git a/packages/sphinx-gp-sitemap/pyproject.toml b/packages/sphinx-gp-sitemap/pyproject.toml index 8c2810bc..02d673bf 100644 --- a/packages/sphinx-gp-sitemap/pyproject.toml +++ b/packages/sphinx-gp-sitemap/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sphinx-gp-sitemap" -version = "0.0.1a16.dev0" +version = "0.0.1a16.dev1" description = "Sitemap generator for Sphinx — Sphinx 8.1+ idioms, parallel-build safe" requires-python = ">=3.10,<4.0" authors = [ diff --git a/packages/sphinx-gp-sitemap/src/sphinx_gp_sitemap/__init__.py b/packages/sphinx-gp-sitemap/src/sphinx_gp_sitemap/__init__.py index 7e373a9f..7b5a87bd 100644 --- a/packages/sphinx-gp-sitemap/src/sphinx_gp_sitemap/__init__.py +++ b/packages/sphinx-gp-sitemap/src/sphinx_gp_sitemap/__init__.py @@ -47,7 +47,7 @@ from sphinx.util.typing import ExtensionMetadata -_EXTENSION_VERSION = "0.0.1a16.dev0" +_EXTENSION_VERSION = "0.0.1a16.dev1" _SITEMAP_NS = "http://www.sitemaps.org/schemas/sitemap/0.9" _XHTML_NS = "http://www.w3.org/1999/xhtml" diff --git a/packages/sphinx-gp-theme/pyproject.toml b/packages/sphinx-gp-theme/pyproject.toml index 03a44109..71e6798a 100644 --- a/packages/sphinx-gp-theme/pyproject.toml +++ b/packages/sphinx-gp-theme/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sphinx-gp-theme" -version = "0.0.1a16.dev0" +version = "0.0.1a16.dev1" description = "Furo child theme for git-pull project documentation" requires-python = ">=3.10,<4.0" authors = [ @@ -27,7 +27,7 @@ readme = "README.md" keywords = ["sphinx", "theme", "furo", "documentation"] dependencies = [ "sphinx>=8.1", - "gp-furo-theme==0.0.1a16.dev0", + "gp-furo-theme==0.0.1a16.dev1", ] [project.entry-points."sphinx.html_themes"] diff --git a/packages/sphinx-gp-theme/src/sphinx_gp_theme/__init__.py b/packages/sphinx-gp-theme/src/sphinx_gp_theme/__init__.py index 47326e96..b762dcb4 100644 --- a/packages/sphinx-gp-theme/src/sphinx_gp_theme/__init__.py +++ b/packages/sphinx-gp-theme/src/sphinx_gp_theme/__init__.py @@ -23,7 +23,7 @@ if t.TYPE_CHECKING: from sphinx.application import Sphinx -__version__ = "0.0.1a16.dev0" +__version__ = "0.0.1a16.dev1" def get_theme_path() -> pathlib.Path: diff --git a/packages/sphinx-ux-autodoc-layout/pyproject.toml b/packages/sphinx-ux-autodoc-layout/pyproject.toml index 460b3027..fd783a7f 100644 --- a/packages/sphinx-ux-autodoc-layout/pyproject.toml +++ b/packages/sphinx-ux-autodoc-layout/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "sphinx-ux-autodoc-layout" -version = "0.0.1a16.dev0" +version = "0.0.1a16.dev1" description = "Componentized layout for Sphinx autodoc output" readme = "README.md" requires-python = ">=3.10" diff --git a/packages/sphinx-ux-badges/pyproject.toml b/packages/sphinx-ux-badges/pyproject.toml index e87cf95b..7a374848 100644 --- a/packages/sphinx-ux-badges/pyproject.toml +++ b/packages/sphinx-ux-badges/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sphinx-ux-badges" -version = "0.0.1a16.dev0" +version = "0.0.1a16.dev1" description = "Shared badge node and CSS for Sphinx autodoc extensions" requires-python = ">=3.10,<4.0" authors = [ diff --git a/packages/sphinx-ux-badges/src/sphinx_ux_badges/__init__.py b/packages/sphinx-ux-badges/src/sphinx_ux_badges/__init__.py index ec02740d..a144eb58 100644 --- a/packages/sphinx-ux-badges/src/sphinx_ux_badges/__init__.py +++ b/packages/sphinx-ux-badges/src/sphinx_ux_badges/__init__.py @@ -48,7 +48,7 @@ logging.getLogger(__name__).addHandler(logging.NullHandler()) -_EXTENSION_VERSION = "0.0.1a16.dev0" +_EXTENSION_VERSION = "0.0.1a16.dev1" def setup(app: Sphinx) -> dict[str, t.Any]: diff --git a/packages/sphinx-vite-builder/pyproject.toml b/packages/sphinx-vite-builder/pyproject.toml index 0fad36a2..ff780968 100644 --- a/packages/sphinx-vite-builder/pyproject.toml +++ b/packages/sphinx-vite-builder/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sphinx-vite-builder" -version = "0.0.1a16.dev0" +version = "0.0.1a16.dev1" description = "PEP 517 build backend + Sphinx extension that orchestrates Vite via pnpm" requires-python = ">=3.10,<4.0" authors = [ diff --git a/packages/sphinx-vite-builder/src/sphinx_vite_builder/__init__.py b/packages/sphinx-vite-builder/src/sphinx_vite_builder/__init__.py index ee2e96eb..d476363d 100644 --- a/packages/sphinx-vite-builder/src/sphinx_vite_builder/__init__.py +++ b/packages/sphinx-vite-builder/src/sphinx_vite_builder/__init__.py @@ -17,7 +17,7 @@ import typing as t -__version__ = "0.0.1a16.dev0" +__version__ = "0.0.1a16.dev1" if t.TYPE_CHECKING: from sphinx.application import Sphinx diff --git a/pyproject.toml b/pyproject.toml index b1179f02..36c62d15 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "gp-sphinx-workspace" -version = "0.0.1a16.dev0" +version = "0.0.1a16.dev1" description = "Workspace root for gp-sphinx packages" requires-python = ">=3.10,<4.0" authors = [ @@ -9,7 +9,7 @@ authors = [ license = { text = "MIT" } readme = "README.md" dependencies = [ - "gp-sphinx==0.0.1a16.dev0", + "gp-sphinx==0.0.1a16.dev1", ] [tool.uv.workspace] diff --git a/tests/ci/test_package_tools.py b/tests/ci/test_package_tools.py index 7fcc1203..1c4288ba 100644 --- a/tests/ci/test_package_tools.py +++ b/tests/ci/test_package_tools.py @@ -13,7 +13,7 @@ def test_workspace_version_is_lockstep() -> None: """All workspace packages share the same version.""" - assert package_tools.workspace_version() == "0.0.1a16.dev0" + assert package_tools.workspace_version() == "0.0.1a16.dev1" def test_check_versions_passes_for_repo() -> None: @@ -30,12 +30,12 @@ def test_smoke_targets_cover_workspace_packages() -> None: def test_release_metadata_accepts_repo_tag() -> None: """Repo-wide release tags resolve to the shared version.""" - assert package_tools.release_metadata("v0.0.1a16.dev0") == { - "version": "0.0.1a16.dev0" + assert package_tools.release_metadata("v0.0.1a16.dev1") == { + "version": "0.0.1a16.dev1" } def test_release_metadata_rejects_package_tag() -> None: """Package-scoped tags are no longer valid release inputs.""" with pytest.raises(SystemExit, match="invalid release tag format"): - package_tools.release_metadata("gp-sphinx@v0.0.1a16.dev0") + package_tools.release_metadata("gp-sphinx@v0.0.1a16.dev1") diff --git a/tests/test_gp_sphinx_vite.py b/tests/test_gp_sphinx_vite.py index 403d6f73..69f27864 100644 --- a/tests/test_gp_sphinx_vite.py +++ b/tests/test_gp_sphinx_vite.py @@ -24,7 +24,7 @@ def test_version_matches_workspace_lock() -> None: """Version follows gp-sphinx workspace lockstep.""" - assert __version__ == "0.0.1a16.dev0" + assert __version__ == "0.0.1a16.dev1" class _FakeApp: diff --git a/tests/test_sphinx_vite_builder.py b/tests/test_sphinx_vite_builder.py index 9e1c2270..e61c8c50 100644 --- a/tests/test_sphinx_vite_builder.py +++ b/tests/test_sphinx_vite_builder.py @@ -15,7 +15,7 @@ def test_version_matches_workspace_lock() -> None: """Version follows the gp-sphinx workspace lockstep.""" - assert __version__ == "0.0.1a16.dev0" + assert __version__ == "0.0.1a16.dev1" def test_setup_returns_safety_metadata() -> None: diff --git a/uv.lock b/uv.lock index 3ae5ccca..757218d9 100644 --- a/uv.lock +++ b/uv.lock @@ -388,7 +388,7 @@ wheels = [ [[package]] name = "gp-furo-theme" -version = "0.0.1a16.dev0" +version = "0.0.1a16.dev1" source = { editable = "packages/gp-furo-theme" } dependencies = [ { name = "accessible-pygments" }, @@ -424,7 +424,7 @@ wheels = [ [[package]] name = "gp-sphinx" -version = "0.0.1a16.dev0" +version = "0.0.1a16.dev1" source = { editable = "packages/gp-sphinx" } dependencies = [ { name = "docutils" }, @@ -473,7 +473,7 @@ provides-extras = ["argparse"] [[package]] name = "gp-sphinx-vite" -version = "0.0.1a16.dev0" +version = "0.0.1a16.dev1" source = { editable = "packages/gp-sphinx-vite" } dependencies = [ { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, @@ -485,7 +485,7 @@ requires-dist = [{ name = "sphinx", specifier = ">=8.1" }] [[package]] name = "gp-sphinx-workspace" -version = "0.0.1a16.dev0" +version = "0.0.1a16.dev1" source = { editable = "." } dependencies = [ { name = "gp-sphinx" }, @@ -1578,7 +1578,7 @@ wheels = [ [[package]] name = "sphinx-autodoc-api-style" -version = "0.0.1a16.dev0" +version = "0.0.1a16.dev1" source = { editable = "packages/sphinx-autodoc-api-style" } dependencies = [ { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, @@ -1596,7 +1596,7 @@ requires-dist = [ [[package]] name = "sphinx-autodoc-argparse" -version = "0.0.1a16.dev0" +version = "0.0.1a16.dev1" source = { editable = "packages/sphinx-autodoc-argparse" } dependencies = [ { name = "docutils" }, @@ -1614,7 +1614,7 @@ requires-dist = [ [[package]] name = "sphinx-autodoc-docutils" -version = "0.0.1a16.dev0" +version = "0.0.1a16.dev1" source = { editable = "packages/sphinx-autodoc-docutils" } dependencies = [ { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, @@ -1634,7 +1634,7 @@ requires-dist = [ [[package]] name = "sphinx-autodoc-fastmcp" -version = "0.0.1a16.dev0" +version = "0.0.1a16.dev1" source = { editable = "packages/sphinx-autodoc-fastmcp" } dependencies = [ { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, @@ -1654,7 +1654,7 @@ requires-dist = [ [[package]] name = "sphinx-autodoc-pytest-fixtures" -version = "0.0.1a16.dev0" +version = "0.0.1a16.dev1" source = { editable = "packages/sphinx-autodoc-pytest-fixtures" } dependencies = [ { name = "pytest" }, @@ -1676,7 +1676,7 @@ requires-dist = [ [[package]] name = "sphinx-autodoc-sphinx" -version = "0.0.1a16.dev0" +version = "0.0.1a16.dev1" source = { editable = "packages/sphinx-autodoc-sphinx" } dependencies = [ { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, @@ -1696,7 +1696,7 @@ requires-dist = [ [[package]] name = "sphinx-autodoc-typehints-gp" -version = "0.0.1a16.dev0" +version = "0.0.1a16.dev1" source = { editable = "packages/sphinx-autodoc-typehints-gp" } dependencies = [ { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, @@ -1765,7 +1765,7 @@ wheels = [ [[package]] name = "sphinx-fonts" -version = "0.0.1a16.dev0" +version = "0.0.1a16.dev1" source = { editable = "packages/sphinx-fonts" } dependencies = [ { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, @@ -1777,7 +1777,7 @@ requires-dist = [{ name = "sphinx", specifier = ">=8.1" }] [[package]] name = "sphinx-gp-opengraph" -version = "0.0.1a16.dev0" +version = "0.0.1a16.dev1" source = { editable = "packages/sphinx-gp-opengraph" } dependencies = [ { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, @@ -1789,7 +1789,7 @@ requires-dist = [{ name = "sphinx", specifier = ">=8.1" }] [[package]] name = "sphinx-gp-sitemap" -version = "0.0.1a16.dev0" +version = "0.0.1a16.dev1" source = { editable = "packages/sphinx-gp-sitemap" } dependencies = [ { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, @@ -1801,7 +1801,7 @@ requires-dist = [{ name = "sphinx", specifier = ">=8.1" }] [[package]] name = "sphinx-gp-theme" -version = "0.0.1a16.dev0" +version = "0.0.1a16.dev1" source = { editable = "packages/sphinx-gp-theme" } dependencies = [ { name = "gp-furo-theme" }, @@ -1830,7 +1830,7 @@ wheels = [ [[package]] name = "sphinx-ux-autodoc-layout" -version = "0.0.1a16.dev0" +version = "0.0.1a16.dev1" source = { editable = "packages/sphinx-ux-autodoc-layout" } dependencies = [ { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, @@ -1842,7 +1842,7 @@ requires-dist = [{ name = "sphinx", specifier = ">=8.1" }] [[package]] name = "sphinx-ux-badges" -version = "0.0.1a16.dev0" +version = "0.0.1a16.dev1" source = { editable = "packages/sphinx-ux-badges" } dependencies = [ { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, @@ -1854,7 +1854,7 @@ requires-dist = [{ name = "sphinx", specifier = ">=8.1" }] [[package]] name = "sphinx-vite-builder" -version = "0.0.1a16.dev0" +version = "0.0.1a16.dev1" source = { editable = "packages/sphinx-vite-builder" } dependencies = [ { name = "hatchling" }, From 8e18bda56a864cc897017937f92b4f9e7f103d03 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 3 May 2026 09:55:26 -0500 Subject: [PATCH 19/53] AGENTS.md: Version commit, remove this fragile note later --- packages/sphinx-vite-builder/AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/sphinx-vite-builder/AGENTS.md b/packages/sphinx-vite-builder/AGENTS.md index b4e0cade..ecdf6123 100644 --- a/packages/sphinx-vite-builder/AGENTS.md +++ b/packages/sphinx-vite-builder/AGENTS.md @@ -91,7 +91,7 @@ Net: **sdist install also requires zero toolchain**. The ## The four QA permutations — keep them green -Verified end-to-end as of v0.0.1a16.dev0: +Verified end-to-end as of v0.0.1a16.dev1: | # | Path | Toolchain | Expected | |---|---|---|---| From a62cff505f98a5daaaea44b19e5dab108e4ed41e Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 3 May 2026 10:15:42 -0500 Subject: [PATCH 20/53] pkg(sphinx-vite-builder): add logging.NullHandler to package __init__ why: Library packages must attach NullHandler so consumers without configured logging don't emit "No handlers could be found" warnings, per Python's logging-for-libraries convention. CLAUDE.md mandates it and workspace siblings (gp_sphinx_vite, sphinx_fonts, gp_sphinx) all follow the pattern; this package was the outlier. what: - Import logging at module scope (was previously deferred under TYPE_CHECKING) - Bind module logger via getLogger(__name__) and attach NullHandler --- .../sphinx-vite-builder/src/sphinx_vite_builder/__init__.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/sphinx-vite-builder/src/sphinx_vite_builder/__init__.py b/packages/sphinx-vite-builder/src/sphinx_vite_builder/__init__.py index d476363d..70632346 100644 --- a/packages/sphinx-vite-builder/src/sphinx_vite_builder/__init__.py +++ b/packages/sphinx-vite-builder/src/sphinx_vite_builder/__init__.py @@ -15,10 +15,14 @@ from __future__ import annotations +import logging import typing as t __version__ = "0.0.1a16.dev1" +logger = logging.getLogger(__name__) +logger.addHandler(logging.NullHandler()) + if t.TYPE_CHECKING: from sphinx.application import Sphinx From f07108ab239cd2393798be3479a7c8981cc78183 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 3 May 2026 10:16:21 -0500 Subject: [PATCH 21/53] pkg(sphinx-vite-builder): drop hatchling from runtime dependencies why: The inline comment immediately above the dependencies list already documents that hatchling is build-time-only, and consumer packages declare it themselves via [build-system].requires. Listing hatchling under [project].dependencies pulled it into runtime envs that only need sphinx (e.g. a dev who pip-installs to experiment with the eventual extension head), contradicting the package's own design note. what: - Drop "hatchling>=1.0" from [project].dependencies - Sphinx remains as the only runtime dependency - hatchling continues to be available at build time via the workspace dev environment and consumers' [build-system].requires --- packages/sphinx-vite-builder/pyproject.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/sphinx-vite-builder/pyproject.toml b/packages/sphinx-vite-builder/pyproject.toml index ff780968..d7598b1a 100644 --- a/packages/sphinx-vite-builder/pyproject.toml +++ b/packages/sphinx-vite-builder/pyproject.toml @@ -31,7 +31,6 @@ keywords = ["sphinx", "extension", "vite", "pnpm", "pep517", "build", "backend"] # required at build time of consumer packages but not at runtime, so it # stays in [build-system].requires of *consumers* rather than here. dependencies = [ - "hatchling>=1.0", "sphinx>=8.1", ] From 1bf54b00aafdfd229cc8a63f430f4f103e3dc4d2 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 3 May 2026 10:18:56 -0500 Subject: [PATCH 22/53] pkg(sphinx-vite-builder[process]): SIGTERM/SIGKILL the process group on POSIX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: AsyncProcess.terminate() previously called asyncio.subprocess.Process.terminate(), which is os.kill(pid, SIGTERM) — PID-only. Combined with start_new_session=True, that signal only hit the pnpm leader; pnpm's `exec` command does not forward signals to the vite child it spawns, so `vite build --watch` was orphaned at teardown. The module docstring had always claimed full process-tree teardown; the implementation never delivered it. what: - Import signal at module scope - Replace terminate()'s SIGTERM call with os.killpg(pid, SIGTERM) on POSIX; fall back to Process.terminate() on Windows (no killpg) - Same treatment for the SIGKILL escalation path - Both wrapped in contextlib.suppress(ProcessLookupError) for the race where the child exits between the returncode check and the syscall - Update the module docstring to spell out that whole-group signalling is what makes the session-isolation contract hold - Add tests/test_sphinx_vite_builder_process.py with three POSIX tests (SIGTERM-to-pgid, SIGKILL-to-pgid escalation, lookup-race suppression) plus a Windows-fallback test marked skipif inverse References: - cpython Lib/asyncio/base_subprocess.py — Process.terminate is send_signal(SIGTERM), PID-targeted - cpython Lib/test/libregrtest/run_workers.py — canonical os.killpg(popen.pid, signal) idiom with start_new_session=True - pnpm packages/exec/commands/src/exec.ts — execa() invocation has no signal forwarding to the exec target --- .../sphinx_vite_builder/_internal/process.py | 28 ++- tests/test_sphinx_vite_builder_process.py | 198 ++++++++++++++++++ 2 files changed, 221 insertions(+), 5 deletions(-) create mode 100644 tests/test_sphinx_vite_builder_process.py diff --git a/packages/sphinx-vite-builder/src/sphinx_vite_builder/_internal/process.py b/packages/sphinx-vite-builder/src/sphinx_vite_builder/_internal/process.py index 4f367375..d3fb7418 100644 --- a/packages/sphinx-vite-builder/src/sphinx_vite_builder/_internal/process.py +++ b/packages/sphinx-vite-builder/src/sphinx_vite_builder/_internal/process.py @@ -9,9 +9,11 @@ - ``PYTHONUNBUFFERED=1`` is forced into the child env so Python tools invoked via the package-manager bridge don't withhold their output. - On POSIX, the child runs in a new session (``start_new_session=True``) - so ``SIGTERM`` cleanly takes down the entire process tree (``pnpm exec`` - shells out to multiple intermediate processes — without session - isolation, only the top-level pnpm wrapper would exit). + and :meth:`AsyncProcess.terminate` signals the whole process group + via :func:`os.killpg` so ``pnpm exec`` plus every descendant exits + together. ``asyncio.subprocess.Process.terminate`` would only signal + the leader's PID, leaving the vite child orphaned (pnpm does not + forward signals to its ``exec`` target). - :meth:`AsyncProcess.terminate` is graceful-then-forceful: SIGTERM, await up to ``timeout`` seconds, escalate to SIGKILL if the child is still alive. Idempotent: calling on an already-exited process is a @@ -32,6 +34,7 @@ import logging import os import pathlib +import signal import sys import typing as t @@ -196,7 +199,19 @@ async def terminate(self, *, timeout: float = 5.0) -> int | None: if self._process.returncode is not None: return self._process.returncode - self._process.terminate() + # POSIX: the child was spawned with ``start_new_session=True``, + # so ``self._process.pid`` is the leader of its own session and + # equals its process-group ID. SIGTERM the whole group so + # ``pnpm exec`` plus all its descendants (including the vite + # process pnpm doesn't forward signals to) exit. asyncio's + # ``Process.terminate()`` is PID-only — it would leave vite + # orphaned. Windows has no ``killpg``; the asyncio default is + # the right primitive there. + with contextlib.suppress(ProcessLookupError): + if sys.platform != "win32": + os.killpg(self._process.pid, signal.SIGTERM) + else: + self._process.terminate() try: await asyncio.wait_for(self._process.wait(), timeout=timeout) except asyncio.TimeoutError: @@ -208,7 +223,10 @@ async def terminate(self, *, timeout: float = 5.0) -> int | None: # ProcessLookupError race: the child can exit between # TimeoutError and kill(). with contextlib.suppress(ProcessLookupError): - self._process.kill() + if sys.platform != "win32": + os.killpg(self._process.pid, signal.SIGKILL) + else: + self._process.kill() await self._process.wait() # Wait for drainers to consume their last buffered line before diff --git a/tests/test_sphinx_vite_builder_process.py b/tests/test_sphinx_vite_builder_process.py new file mode 100644 index 00000000..aea9edde --- /dev/null +++ b/tests/test_sphinx_vite_builder_process.py @@ -0,0 +1,198 @@ +"""Tests for :class:`sphinx_vite_builder._internal.process.AsyncProcess`. + +Focused on the POSIX process-group teardown contract: ``terminate`` +MUST signal the whole process group (so ``pnpm exec``'s ``vite`` child +exits) rather than only the leader's PID. The latter is what +``asyncio.subprocess.Process.terminate`` would do, and it leaves the +vite child orphaned because pnpm's ``exec`` command does not forward +signals to its target. + +The unit tests monkeypatch ``os.killpg`` so they are deterministic and +don't depend on real process trees; an end-to-end behavioural test +exercises the integrated terminate path with a fake child that traps +SIGTERM (mirrors the gp-sphinx-vite suite). +""" + +from __future__ import annotations + +import asyncio +import os +import pathlib +import signal +import sys +import textwrap + +import pytest +from sphinx_vite_builder._internal.process import AsyncProcess + + +def _write_fake_child( + tmp_path: pathlib.Path, + *, + body: str, +) -> pathlib.Path: + """Write ``body`` to a synthetic Python script under ``tmp_path``.""" + path = tmp_path / "fake_child.py" + path.write_text(textwrap.dedent(body)) + return path + + +def _fake_child_argv(script: pathlib.Path) -> list[str]: + return [sys.executable, str(script)] + + +@pytest.mark.skipif( + sys.platform == "win32", + reason="killpg is POSIX-only; Windows uses Process.terminate()", +) +@pytest.mark.asyncio +async def test_terminate_sends_sigterm_to_process_group_on_posix( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """``terminate`` MUST call ``os.killpg(pid, SIGTERM)`` on POSIX.""" + script = _write_fake_child( + tmp_path, + body="""\ + import time + while True: + time.sleep(0.5) + """, + ) + proc = AsyncProcess(label="fake") + await proc.start(_fake_child_argv(script), cwd=tmp_path) + await asyncio.sleep(0.05) # let the child reach its sleep loop + pid = proc.pid + assert pid is not None + + calls: list[tuple[int, int]] = [] + real_killpg = os.killpg + + def _spy_killpg(pgid: int, sig: int) -> None: + calls.append((pgid, sig)) + real_killpg(pgid, sig) + + monkeypatch.setattr(os, "killpg", _spy_killpg) + code = await proc.terminate(timeout=2.0) + assert (pid, signal.SIGTERM) in calls + assert not proc.is_running + assert code is not None and code != 0 + + +@pytest.mark.skipif( + sys.platform == "win32", + reason="killpg is POSIX-only; Windows uses Process.kill()", +) +@pytest.mark.asyncio +async def test_terminate_escalates_to_killpg_sigkill_on_timeout( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A SIGTERM-trapping child is force-killed via ``killpg(pid, SIGKILL)``.""" + script = _write_fake_child( + tmp_path, + body="""\ + import signal, time + signal.signal(signal.SIGTERM, signal.SIG_IGN) + while True: + time.sleep(0.5) + """, + ) + proc = AsyncProcess(label="fake") + await proc.start(_fake_child_argv(script), cwd=tmp_path) + await asyncio.sleep(0.05) + pid = proc.pid + assert pid is not None + + calls: list[tuple[int, int]] = [] + real_killpg = os.killpg + + def _spy_killpg(pgid: int, sig: int) -> None: + calls.append((pgid, sig)) + real_killpg(pgid, sig) + + monkeypatch.setattr(os, "killpg", _spy_killpg) + code = await proc.terminate(timeout=0.3) + assert (pid, signal.SIGTERM) in calls + assert (pid, signal.SIGKILL) in calls + assert not proc.is_running + assert code is not None # escaped from SIG_IGN via SIGKILL + + +@pytest.mark.skipif( + sys.platform == "win32", + reason="killpg is POSIX-only", +) +@pytest.mark.asyncio +async def test_terminate_swallows_processlookuperror( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A racing exit between checks and ``killpg`` MUST NOT raise. + + Reproduces the race: ``terminate`` sees ``returncode is None`` and + proceeds to ``killpg``, but the child has already exited by the + time the syscall fires. The child here exits naturally during the + wait window so the ``Process.wait()`` call still resolves cleanly. + """ + script = _write_fake_child( + tmp_path, + body="""\ + import sys, time + time.sleep(0.2) + sys.exit(0) + """, + ) + proc = AsyncProcess(label="fake") + await proc.start(_fake_child_argv(script), cwd=tmp_path) + await asyncio.sleep(0.05) # child still alive at this point + + def _raise_lookup(pgid: int, sig: int) -> None: + raise ProcessLookupError(pgid, sig) + + monkeypatch.setattr(os, "killpg", _raise_lookup) + # ProcessLookupError from killpg MUST be swallowed; the child + # then exits naturally and wait() returns its real exit code. + code = await asyncio.wait_for(proc.terminate(timeout=2.0), timeout=5.0) + assert code == 0 + assert not proc.is_running + + +@pytest.mark.skipif( + sys.platform != "win32", + reason="Windows fallback path uses Process.terminate()/kill()", +) +@pytest.mark.asyncio +async def test_terminate_uses_process_terminate_on_windows( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Windows has no ``killpg``; the fallback path signals the leader.""" + script = _write_fake_child( + tmp_path, + body="""\ + import time + while True: + time.sleep(0.5) + """, + ) + proc = AsyncProcess(label="fake") + await proc.start(_fake_child_argv(script), cwd=tmp_path) + await asyncio.sleep(0.05) + + terminate_calls = 0 + real_terminate = asyncio.subprocess.Process.terminate + + def _spy_terminate(self: asyncio.subprocess.Process) -> None: + nonlocal terminate_calls + terminate_calls += 1 + real_terminate(self) + + monkeypatch.setattr( + asyncio.subprocess.Process, + "terminate", + _spy_terminate, + ) + await proc.terminate(timeout=2.0) + assert terminate_calls >= 1 + assert not proc.is_running From a490a9311de0a70bdc76040c36990d6cea0d4d25 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 3 May 2026 10:20:10 -0500 Subject: [PATCH 23/53] pkg(sphinx-vite-builder): add doctests to PEP 517 hooks + setup() + run_vite_build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: CLAUDE.md mandates working doctests on every public function and forbids the +SKIP escape hatch. The five public hooks (setup, build_wheel, build_editable, build_sdist, run_vite_build) had no Examples sections — addressing it before more public surface lands keeps the convention enforceable. what: - setup() exercises the FakeApp pattern (verbatim from test_sphinx_vite_builder.py:21-30 and gp_sphinx_vite/__init__.py) to assert the parallel-safety metadata - build_wheel/build_editable/build_sdist use inspect.signature() to pin the public PEP 517 / 660 parameter shape — no real pnpm or hatchling spawn needed - run_vite_build exercises the SPHINX_VITE_BUILDER_SKIP=1 short-circuit, the only public path that returns without touching the filesystem or spawning subprocesses References: - workspace siblings (gp_sphinx_vite/__init__.py, sphinx_fonts) use the same FakeApp/signature patterns - flit_core/hatchling/sphinx-theme-builder/maturin upstream don't doctest hooks at all — workspace's CLAUDE.md is firmer here --- .../src/sphinx_vite_builder/__init__.py | 13 ++++++++ .../src/sphinx_vite_builder/_internal/vite.py | 16 +++++++++ .../src/sphinx_vite_builder/build.py | 33 +++++++++++++++++-- 3 files changed, 60 insertions(+), 2 deletions(-) diff --git a/packages/sphinx-vite-builder/src/sphinx_vite_builder/__init__.py b/packages/sphinx-vite-builder/src/sphinx_vite_builder/__init__.py index 70632346..ffaff18e 100644 --- a/packages/sphinx-vite-builder/src/sphinx_vite_builder/__init__.py +++ b/packages/sphinx-vite-builder/src/sphinx_vite_builder/__init__.py @@ -35,6 +35,19 @@ def setup(app: Sphinx) -> dict[str, t.Any]: on ``sphinx-build``) lands in a follow-up commit. For now this stub registers the extension so consumers can declare it without a no-such-module error, and returns the safety metadata. + + Examples + -------- + The Phase-1 stub returns the parallel-safety metadata Sphinx + expects, regardless of the application object passed in: + + >>> class FakeApp: + ... pass + >>> metadata = setup(FakeApp()) # type: ignore[arg-type] + >>> metadata["parallel_read_safe"] + True + >>> metadata["parallel_write_safe"] + True """ del app return { diff --git a/packages/sphinx-vite-builder/src/sphinx_vite_builder/_internal/vite.py b/packages/sphinx-vite-builder/src/sphinx_vite_builder/_internal/vite.py index db0cad50..a4556404 100644 --- a/packages/sphinx-vite-builder/src/sphinx_vite_builder/_internal/vite.py +++ b/packages/sphinx-vite-builder/src/sphinx_vite_builder/_internal/vite.py @@ -340,6 +340,22 @@ def run_vite_build( non-zero. - :class:`ViteFailedError` if ``pnpm exec vite build`` exits non-zero. + + Examples + -------- + The ``SPHINX_VITE_BUILDER_SKIP`` environment variable + short-circuits the whole orchestration before any subprocess is + spawned — exercising it from a doctest verifies the escape hatch + keeps working without touching pnpm, Node, or the filesystem + tree: + + >>> import os, pathlib, tempfile + >>> os.environ["SPHINX_VITE_BUILDER_SKIP"] = "1" + >>> try: + ... with tempfile.TemporaryDirectory() as tmp: + ... run_vite_build(pathlib.Path(tmp)) + ... finally: + ... del os.environ["SPHINX_VITE_BUILDER_SKIP"] """ if os.environ.get("SPHINX_VITE_BUILDER_SKIP"): logger.info("SPHINX_VITE_BUILDER_SKIP set; skipping vite build") diff --git a/packages/sphinx-vite-builder/src/sphinx_vite_builder/build.py b/packages/sphinx-vite-builder/src/sphinx_vite_builder/build.py index 7726cac7..a1ebbd18 100644 --- a/packages/sphinx-vite-builder/src/sphinx_vite_builder/build.py +++ b/packages/sphinx-vite-builder/src/sphinx_vite_builder/build.py @@ -37,7 +37,19 @@ def build_wheel( config_settings: dict[str, t.Any] | None = None, metadata_directory: str | None = None, ) -> str: - """PEP 517 ``build_wheel``: vite-build, then hatchling-pack.""" + """PEP 517 ``build_wheel``: vite-build, then hatchling-pack. + + Examples + -------- + The hook's signature is the canonical PEP 517 shape consumers + expect: + + >>> import inspect + >>> sorted(inspect.signature(build_wheel).parameters) + ['config_settings', 'metadata_directory', 'wheel_directory'] + >>> callable(build_wheel) + True + """ run_vite_build() return _hatchling.build_wheel(wheel_directory, config_settings, metadata_directory) @@ -47,7 +59,16 @@ def build_editable( config_settings: dict[str, t.Any] | None = None, metadata_directory: str | None = None, ) -> str: - """PEP 660 ``build_editable``: vite-build, then hatchling-pack-editable.""" + """PEP 660 ``build_editable``: vite-build, then hatchling-pack-editable. + + Examples + -------- + >>> import inspect + >>> sorted(inspect.signature(build_editable).parameters) + ['config_settings', 'metadata_directory', 'wheel_directory'] + >>> callable(build_editable) + True + """ run_vite_build() return _hatchling.build_editable( wheel_directory, config_settings, metadata_directory @@ -66,6 +87,14 @@ def build_sdist( the sdist without pnpm or Node — the wheel-from-sdist build will skip vite (no ``web/`` in the unpacked tree) and ship the pre-baked assets via hatchling's normal file selection. + + Examples + -------- + >>> import inspect + >>> sorted(inspect.signature(build_sdist).parameters) + ['config_settings', 'sdist_directory'] + >>> callable(build_sdist) + True """ run_vite_build() return _hatchling.build_sdist(sdist_directory, config_settings) From dd1e31900947d985420b2bb01ae2cbca2094e9ad Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 3 May 2026 10:21:17 -0500 Subject: [PATCH 24/53] docs(sphinx-vite-builder): walk back Phase-1 placeholder Sphinx-extension framing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: setup() is a placeholder that returns parallel-safety metadata without registering any event handlers, but README and the vite.py module docstring both described the extension head in present tense ("sphinx-build → pnpm exec vite build", "or its watch sibling") implying the lifecycle integration already worked. That mismatch misled both consumers (who would try to use the extension and get nothing) and contributors (who would search for the watch helper and not find it). The honest framing labels the head as Phase 1 placeholder until the follow-up release lands. what: - Replace README's "Sphinx extension" section with a Phase-1 placeholder note that links to PEP 517 and points to the backend as the currently-implemented orchestration path - Drop the present-tense bullet list claiming sphinx-build / sphinx-autobuild already trigger vite - Update the vite.py module docstring to drop the "or its watch sibling" reference (the watch helper does not exist) and label the extension consumer as Phase-1 placeholder References: - canonical Sphinx extensions (intersphinx, autodoc, copybutton, design) document setup() with brief or no docstring; none claim features the placeholder doesn't deliver --- packages/sphinx-vite-builder/README.md | 14 ++++++++------ .../src/sphinx_vite_builder/_internal/vite.py | 9 +++++---- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/packages/sphinx-vite-builder/README.md b/packages/sphinx-vite-builder/README.md index 02d87b50..5a42adb2 100644 --- a/packages/sphinx-vite-builder/README.md +++ b/packages/sphinx-vite-builder/README.md @@ -117,14 +117,16 @@ exclude = ["web/"] # so the sdist→wheel chain hits the short-circuit artifacts = ["src//theme//static/"] ``` -### Sphinx extension +### Sphinx extension (Phase 1: placeholder) -Loaded from `conf.py`. Runs Vite as part of the docs lifecycle: +The extension entry point is currently a placeholder registered in +`conf.py` to prevent import errors. Full lifecycle integration — +running Vite before the docs build and spawning a watched Vite +process during `sphinx-autobuild` — lands in a follow-up release. -- `sphinx-build` → `pnpm exec vite build` once before the docs build -- `sphinx-autobuild` → `pnpm exec vite build --watch` as a child process - for the lifetime of the autobuild server, with idempotent re-fire on - rebuilds and graceful teardown on signal / `atexit` +For now, the [PEP 517](https://peps.python.org/pep-0517/) backend +handles all Vite orchestration during source builds and wheel +generation; that path is fully implemented and tested. ```python # docs/conf.py diff --git a/packages/sphinx-vite-builder/src/sphinx_vite_builder/_internal/vite.py b/packages/sphinx-vite-builder/src/sphinx_vite_builder/_internal/vite.py index a4556404..317461f4 100644 --- a/packages/sphinx-vite-builder/src/sphinx_vite_builder/_internal/vite.py +++ b/packages/sphinx-vite-builder/src/sphinx_vite_builder/_internal/vite.py @@ -1,13 +1,14 @@ -"""Vite + pnpm orchestration: detection, install, one-shot build, watch. +"""Vite + pnpm orchestration: detection, install, one-shot build. This module is the shared orchestration core consumed by both heads: - The PEP 517 backend (:mod:`sphinx_vite_builder.build`) calls :func:`run_vite_build` from each of its hooks, before delegating to hatchling. -- The Sphinx extension (:mod:`sphinx_vite_builder`) calls - :func:`run_vite_build` (one-shot) or its watch sibling from - ``builder-inited``. +- The Sphinx extension (:mod:`sphinx_vite_builder`) — Phase 1 + placeholder — will call :func:`run_vite_build` from + ``builder-inited`` once the extension head lands in a follow-up + release. Fast-fail discipline: every prerequisite is checked up front so the caller gets an actionable diagnostic instead of a generic spawn-failure From 3e88e5a5483de33cafdd3ddf07f9b8be048b6e24 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 3 May 2026 10:23:33 -0500 Subject: [PATCH 25/53] fix(workspace[deps]): add hatchling to dev group for build-backend type checking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: Commit f07108a dropped hatchling from sphinx-vite-builder's runtime deps (correctly — it's build-time-only for consumers). But sphinx_vite_builder.build imports hatchling.build, and once nothing in the workspace runtime tree pulled hatchling in, CI's mypy run could no longer resolve the module. Locally this slipped past because the venv still had hatchling from before the removal. what: - Add "hatchling>=1.0" to [dependency-groups.dev] with an inline comment explaining the build-backend tooling rationale - Re-sync uv.lock so the workspace dev group reflects the new dependency, and confirm sphinx-vite-builder's runtime deps are cleanly hatchling-free (only sphinx remains) --- pyproject.toml | 6 ++++++ uv.lock | 8 +++----- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 36c62d15..a270cc2a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -73,6 +73,12 @@ dev = [ "types-docutils", "types-Pygments", "syrupy>=5.1.0", + # Build-backend tooling: sphinx-vite-builder.build delegates to + # hatchling.build, so hatchling must be importable for static analysis + # and for the backend's own tests; consumer packages declare it + # themselves via [build-system].requires, so this is a workspace-only + # dev dep, not a runtime one. + "hatchling>=1.0", ] [build-system] diff --git a/uv.lock b/uv.lock index 757218d9..7ab7747b 100644 --- a/uv.lock +++ b/uv.lock @@ -498,6 +498,7 @@ dev = [ { name = "gp-furo-theme" }, { name = "gp-sphinx" }, { name = "gp-sphinx-vite" }, + { name = "hatchling" }, { name = "mypy" }, { name = "pillow" }, { name = "pytest" }, @@ -538,6 +539,7 @@ dev = [ { name = "gp-furo-theme", editable = "packages/gp-furo-theme" }, { name = "gp-sphinx", editable = "packages/gp-sphinx" }, { name = "gp-sphinx-vite", editable = "packages/gp-sphinx-vite" }, + { name = "hatchling", specifier = ">=1.0" }, { name = "mypy" }, { name = "pillow" }, { name = "pytest" }, @@ -1857,16 +1859,12 @@ name = "sphinx-vite-builder" version = "0.0.1a16.dev1" source = { editable = "packages/sphinx-vite-builder" } dependencies = [ - { name = "hatchling" }, { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] [package.metadata] -requires-dist = [ - { name = "hatchling", specifier = ">=1.0" }, - { name = "sphinx", specifier = ">=8.1" }, -] +requires-dist = [{ name = "sphinx", specifier = ">=8.1" }] [[package]] name = "sphinxcontrib-applehelp" From f202d792105c115328e833af742f37030e4d54ea Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 3 May 2026 10:27:02 -0500 Subject: [PATCH 26/53] ai(rules[AGENTS{sphinx-vite-builder}]): note Phase-1 placeholder for Sphinx extension MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: AGENTS.md describes the Sphinx-extension head as if its lifecycle integration were already implemented ("hooks builder-inited and build-finished so sphinx-build / sphinx-autobuild automatically run vite"). The actual setup() returns parallel-safety metadata only — no event handlers attached, no watch process. An agent reading the document and trying to extend or use the head would chase code that doesn't exist. what: - Add a short Phase-1 paragraph after the "What this package is" description, stating that the PEP 517 backend is the fully-implemented half and the extension setup() is a placeholder - The follow-up release is referenced without a version pin --- packages/sphinx-vite-builder/AGENTS.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/sphinx-vite-builder/AGENTS.md b/packages/sphinx-vite-builder/AGENTS.md index ecdf6123..d7095fb1 100644 --- a/packages/sphinx-vite-builder/AGENTS.md +++ b/packages/sphinx-vite-builder/AGENTS.md @@ -29,6 +29,12 @@ asyncio loop in a daemon thread for sync↔async bridging), spawn install/build), and `errors.py` (`PnpmMissingError`, `NodeModulesInstallError`, `ViteFailedError`). +**Phase 1 status:** The PEP 517 backend is fully implemented and +tested. The Sphinx extension `setup()` is a placeholder — it +registers cleanly in `conf.py` but doesn't yet hook the docs build +lifecycle. The full extension implementation (event handlers, vite +watch, teardown) lands in a follow-up release. + ## The design contract — keep this invariant > **Sources should check for node, pnpm, etc and error if it's not From b6b0a55a0758681e234d0850cd6d7d074bac8b11 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 3 May 2026 10:31:26 -0500 Subject: [PATCH 27/53] fix(ci[smoke]): drop runtime import of sphinx_vite_builder.build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: The wheel-install smoke runner asserted the PEP 517 build module imports cleanly in a runtime venv with no hatchling installed. That's a contradiction: build.py imports hatchling at module top because that's what every PEP 517 backend does, and consumers supply hatchling via [build-system].requires when they invoke the backend — a runtime venv has no such guarantee. The earlier override (hatchling listed under [project].dependencies) papered over the mismatch by always pulling hatchling into the wheel's runtime tree; once that override goes, the smoke runner is the next thing to update. what: - Drop the eager `from sphinx_vite_builder import build, setup` import; smoke now just verifies `import sphinx_vite_builder` and `setup` callable, which is the package's documented runtime surface - Document the rationale in the runner's docstring so future readers understand why the build module isn't part of the runtime smoke contract - Build-module behaviour stays covered by the dev-env tests in tests/test_sphinx_vite_builder_build.py --- scripts/ci/package_tools.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/scripts/ci/package_tools.py b/scripts/ci/package_tools.py index 07cb3ce4..6b6af44e 100644 --- a/scripts/ci/package_tools.py +++ b/scripts/ci/package_tools.py @@ -776,7 +776,16 @@ def smoke_sphinx_autodoc_fastmcp(dist_dir: pathlib.Path, version: str) -> None: def smoke_sphinx_vite_builder(dist_dir: pathlib.Path, version: str) -> None: - """Verify the sphinx-vite-builder backend + extension imports cleanly.""" + """Verify the sphinx-vite-builder runtime surface imports cleanly. + + Asserts the package and the Sphinx-extension entry point load + without hatchling: hatchling is build-time-only (consumers list it + in ``[build-system].requires``) so the wheel must not pull it in + as a runtime dependency. The PEP 517 ``build`` module deliberately + requires hatchling and is exercised in + ``tests/test_sphinx_vite_builder_build.py`` where the dev + environment supplies it. + """ with tempfile.TemporaryDirectory() as tmp: python_path = _create_venv(pathlib.Path(tmp)) _install_into_venv( @@ -788,11 +797,8 @@ def smoke_sphinx_vite_builder(dist_dir: pathlib.Path, version: str) -> None: python_path, ( "import sphinx_vite_builder; " - "from sphinx_vite_builder import build, setup; " + "from sphinx_vite_builder import setup; " f"assert sphinx_vite_builder.__version__ == {version!r}; " - "assert callable(build.build_wheel); " - "assert callable(build.build_sdist); " - "assert callable(build.build_editable); " "assert callable(setup)" ), ) From 743fe670ac928e10c346a1af1d4a2e0adff1b0b0 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 3 May 2026 10:35:07 -0500 Subject: [PATCH 28/53] fix(ci[smoke]): split sphinx-vite-builder smoke into runtime + build-system scenarios MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: The previous hotfix dropped the eager `from sphinx_vite_builder import build, setup` line because the wheel imports cleanly without hatchling — but doing so silently lost coverage for the PEP 517 backend path (no more "build_wheel/build_sdist/build_editable are callable" assertions). The right framing is two scenarios, each in its own venv: one without hatchling (proves it isn't a runtime dep), one with hatchling (simulates the build-frontend context where the backend hooks must be callable). what: - Scenario 1 (no hatchling): import sphinx_vite_builder, import setup, assert callable. Documents the load-bearing "hatchling is truly build-time-only" contract — a regression that re-listed hatchling under [project].dependencies would let build.py import eagerly and silently mask the regression - Scenario 2 (hatchling alongside): import sphinx_vite_builder.build, assert build_wheel/build_sdist/build_editable plus the optional hooks (get_requires_for_build_wheel, prepare_metadata_for_build_wheel) are callable. Mirrors what a consumer using sphinx-vite-builder as a PEP 517 backend gets when the frontend resolves [build-system].requires - Add a docstring spelling out both scenarios so future readers know why the split exists --- scripts/ci/package_tools.py | 51 +++++++++++++++++++++++++++---------- 1 file changed, 37 insertions(+), 14 deletions(-) diff --git a/scripts/ci/package_tools.py b/scripts/ci/package_tools.py index 6b6af44e..79eddc3b 100644 --- a/scripts/ci/package_tools.py +++ b/scripts/ci/package_tools.py @@ -776,23 +776,31 @@ def smoke_sphinx_autodoc_fastmcp(dist_dir: pathlib.Path, version: str) -> None: def smoke_sphinx_vite_builder(dist_dir: pathlib.Path, version: str) -> None: - """Verify the sphinx-vite-builder runtime surface imports cleanly. - - Asserts the package and the Sphinx-extension entry point load - without hatchling: hatchling is build-time-only (consumers list it - in ``[build-system].requires``) so the wheel must not pull it in - as a runtime dependency. The PEP 517 ``build`` module deliberately - requires hatchling and is exercised in - ``tests/test_sphinx_vite_builder_build.py`` where the dev - environment supplies it. + """Verify both heads of sphinx-vite-builder against the built wheel. + + Two scenarios, each in its own venv so they cannot mask each + other: + + 1. **Runtime install (no hatchling).** The wheel must import and + expose the Sphinx-extension entry point without hatchling on + ``sys.path``. This is the load-bearing assertion that + hatchling truly isn't a runtime dependency — a regression + (e.g. accidentally re-listing hatchling under + ``[project].dependencies``) would let the build module import + eagerly and silently restore the old contract. + 2. **Build-system install (hatchling alongside).** A consumer + using sphinx-vite-builder as a PEP 517 backend gets hatchling + resolved by the build frontend via + ``[build-system].requires``. Simulating that here verifies + the wheel's ``build`` module exposes the documented PEP 517 + hooks (``build_wheel``, ``build_sdist``, ``build_editable``) + and they are callable. """ + wheel = _target_wheel_path(dist_dir, "sphinx-vite-builder") + # Scenario 1: runtime venv, no hatchling. with tempfile.TemporaryDirectory() as tmp: python_path = _create_venv(pathlib.Path(tmp)) - _install_into_venv( - python_path, - _target_wheel_path(dist_dir, "sphinx-vite-builder"), - find_links=dist_dir, - ) + _install_into_venv(python_path, wheel, find_links=dist_dir) _run_python( python_path, ( @@ -802,6 +810,21 @@ def smoke_sphinx_vite_builder(dist_dir: pathlib.Path, version: str) -> None: "assert callable(setup)" ), ) + # Scenario 2: build-system venv with hatchling alongside. + with tempfile.TemporaryDirectory() as tmp: + python_path = _create_venv(pathlib.Path(tmp)) + _install_into_venv(python_path, wheel, "hatchling>=1.0", find_links=dist_dir) + _run_python( + python_path, + ( + "from sphinx_vite_builder import build; " + "assert callable(build.build_wheel); " + "assert callable(build.build_sdist); " + "assert callable(build.build_editable); " + "assert callable(build.get_requires_for_build_wheel); " + "assert callable(build.prepare_metadata_for_build_wheel)" + ), + ) _PACKAGE_SMOKE_RUNNERS: dict[str, t.Callable[[pathlib.Path, str], None]] = { From a164e1f6fad6865d99d94b9599cc0edbd0c7e0b7 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 3 May 2026 10:38:07 -0500 Subject: [PATCH 29/53] docs(sphinx-vite-builder[README]): drop comment inside console code block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: Documentation code blocks should be copy-pasteable as-is — the "# No pnpm, no Node, no problem" line inside the console block was prose phrased as a shell comment, which is harder to scan and harder to copy. Moving the prose outside the block keeps the install command clean (single line, single command) and the explanation in flowing markdown above it. what: - Move the "No pnpm, no Node — just Python:" line out of the console block and onto its own line above - Drop the "# " prefix that made it look like a comment --- packages/sphinx-vite-builder/README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/sphinx-vite-builder/README.md b/packages/sphinx-vite-builder/README.md index 5a42adb2..e468dbd7 100644 --- a/packages/sphinx-vite-builder/README.md +++ b/packages/sphinx-vite-builder/README.md @@ -42,8 +42,9 @@ backend at release time. The PEP 517 chain doesn't run on the consumer side. No backend invocation. No `pnpm`. No Node. The end user sees Python and only Python. +No pnpm, no Node — just Python: + ```console -$ # No pnpm, no Node, no problem $ pip install gp-furo-theme ``` From 80055d458549715fb53c92033c773fcb5c4f1b66 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 3 May 2026 10:41:14 -0500 Subject: [PATCH 30/53] ai(rules[AGENTS{sphinx-vite-builder}]): strip hardcoded version references MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: AGENTS.md pinned two version strings ("Verified end-to-end as of v0.0.1a16.devN", "the v0.0.1a15 broken-release pattern") that silently rot the moment the workspace bumps version. The "verified as of" line is meaningful as a contract about which permutations must stay green, not as a release-version assertion; the "v0.0.1a15 broken-release" reference is meaningful as historical context, not as a version pin. what: - Replace "Verified end-to-end as of v0.0.1a16.dev1:" with "The four QA permutations every change must keep green:" — keeps the contract, drops the version - Replace "the v0.0.1a15 broken-release pattern" with "the prior broken-release pattern" — keeps the historical motivation, drops the version pin Verification: rg '\bv?0\.0\.\d|\.dev\d' packages/sphinx-vite-builder/AGENTS.md returns zero hits. --- packages/sphinx-vite-builder/AGENTS.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/sphinx-vite-builder/AGENTS.md b/packages/sphinx-vite-builder/AGENTS.md index d7095fb1..fb3300f3 100644 --- a/packages/sphinx-vite-builder/AGENTS.md +++ b/packages/sphinx-vite-builder/AGENTS.md @@ -97,7 +97,7 @@ Net: **sdist install also requires zero toolchain**. The ## The four QA permutations — keep them green -Verified end-to-end as of v0.0.1a16.dev1: +The four QA permutations every change must keep green: | # | Path | Toolchain | Expected | |---|---|---|---| @@ -158,7 +158,7 @@ resolution or distribution metadata, so wrapping them would be wrong. The workspace's `release.yml` MUST keep the pnpm + Node setup steps that run before `uv build`, otherwise the wheels published to PyPI -would be empty of static (the v0.0.1a15 broken-release pattern that +would be empty of static (the prior broken-release pattern that motivated this whole package). The required steps are: ```yaml From 1fa424fe874e252451938445d3867a9819a89ab7 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 3 May 2026 10:45:35 -0500 Subject: [PATCH 31/53] ai(rules[AGENTS{sphinx-vite-builder}]): drop hardcoded permutation count MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: The "four QA permutations" framing pinned the count of install paths in both the section header and the lead-in line — adding or removing a row to the table below would silently invalidate the prose without any test catching it. Same drift-bait shape as the hardcoded version strings the prior commit removed. what: - Section heading goes from "The four QA permutations — keep them green" to "QA permutations — keep them green" - Lead-in line drops "four" — the table itself is the source of truth for which permutations exist --- packages/sphinx-vite-builder/AGENTS.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/sphinx-vite-builder/AGENTS.md b/packages/sphinx-vite-builder/AGENTS.md index fb3300f3..a9cd6f34 100644 --- a/packages/sphinx-vite-builder/AGENTS.md +++ b/packages/sphinx-vite-builder/AGENTS.md @@ -95,9 +95,9 @@ runs the wheel-from-sdist chain: Net: **sdist install also requires zero toolchain**. The `web/`-absent short-circuit is the load-bearing primitive. -## The four QA permutations — keep them green +## QA permutations — keep them green -The four QA permutations every change must keep green: +The install-path permutations every change must keep green: | # | Path | Toolchain | Expected | |---|---|---|---| From 56215dd7dca260a3522acbe3a601e7d13b8f90a5 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 3 May 2026 11:17:58 -0500 Subject: [PATCH 32/53] pkg(sphinx-vite-builder[build]): exercise build_wheel via SKIP-env-var doctest against a synthetic project MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: build_wheel/build_editable/build_sdist had Examples sections that only verified inspect.signature() and callable() — Python builtins, not project logic. And the package's src/ tree wasn't in the workspace's pytest testpaths, so even those weak doctests were silently skipped by CI. Two problems, one composite fix: lift the doctests into the suite AND make the canonical hook (build_wheel) exercise the full SPHINX_VITE_BUILDER_SKIP=1 short-circuit + real hatchling delegation against a minimal synthetic project. what: - Replace build_wheel's doctest with a self-contained behavioral example: tempfile.TemporaryDirectory() builds a minimal hatchling-buildable synthetic project (pyproject.toml + empty doctest_pkg/__init__.py), os.chdir into it, call the real hook under SKIP=1 (so vite is bypassed but hatchling still produces a wheel), assert the returned filename ends in .whl. Mirrors the tempfile-based pattern run_vite_build's doctest already uses. - build_editable and build_sdist keep their signature-pin doctests (the delegation skeleton is identical across all three; build_wheel's example exercises the shared SKIP/hatchling path), with prose pointing readers at build_wheel for the full behavioral example. - Add packages/sphinx-vite-builder/src to pyproject.toml's pytest testpaths so the doctests actually run in the suite. Surfaces 8 doctests for the package (the prior 7 already-passing ones plus the new behavioral build_wheel example), bringing the package's doctest collection in line with every other workspace member. References: - packages/sphinx-vite-builder/src/sphinx_vite_builder/_internal/vite.py run_vite_build's existing tempfile-based doctest pattern - ~/study/python/flit/flit_core/tests_core/samples/pep621_nodynamic/pyproject.toml smallest hatchling-buildable sample shape - ~/study/rust-python/maturin/maturin/__init__.py — env-var-controlled skip pattern for two-toolchain builders --- .../src/sphinx_vite_builder/build.py | 48 ++++++++++++++----- pyproject.toml | 1 + 2 files changed, 38 insertions(+), 11 deletions(-) diff --git a/packages/sphinx-vite-builder/src/sphinx_vite_builder/build.py b/packages/sphinx-vite-builder/src/sphinx_vite_builder/build.py index a1ebbd18..4edcd38e 100644 --- a/packages/sphinx-vite-builder/src/sphinx_vite_builder/build.py +++ b/packages/sphinx-vite-builder/src/sphinx_vite_builder/build.py @@ -41,13 +41,35 @@ def build_wheel( Examples -------- - The hook's signature is the canonical PEP 517 shape consumers - expect: - - >>> import inspect - >>> sorted(inspect.signature(build_wheel).parameters) - ['config_settings', 'metadata_directory', 'wheel_directory'] - >>> callable(build_wheel) + With ``SPHINX_VITE_BUILDER_SKIP=1`` set, the hook short-circuits + vite and delegates straight to :mod:`hatchling.build` against a + minimal synthetic project, producing a real ``.whl``: + + >>> import os, pathlib, tempfile, textwrap + >>> os.environ["SPHINX_VITE_BUILDER_SKIP"] = "1" + >>> cwd = os.getcwd() + >>> with tempfile.TemporaryDirectory() as tmp: + ... project = pathlib.Path(tmp) + ... _ = (project / "pyproject.toml").write_text(textwrap.dedent(''' + ... [build-system] + ... requires = ["hatchling"] + ... build-backend = "hatchling.build" + ... [project] + ... name = "doctest-pkg" + ... version = "0.0.0" + ... ''').lstrip()) + ... pkg = project / "doctest_pkg" + ... pkg.mkdir() + ... _ = (pkg / "__init__.py").write_text("") + ... dist = project / "dist" + ... dist.mkdir() + ... os.chdir(project) + ... try: + ... name = build_wheel(str(dist)) + ... finally: + ... os.chdir(cwd) + ... del os.environ["SPHINX_VITE_BUILDER_SKIP"] + >>> name.endswith(".whl") True """ run_vite_build() @@ -61,13 +83,15 @@ def build_editable( ) -> str: """PEP 660 ``build_editable``: vite-build, then hatchling-pack-editable. + The delegation pattern matches :func:`build_wheel`; see that + function's docstring for an end-to-end example exercising the + ``SPHINX_VITE_BUILDER_SKIP=1`` short-circuit. + Examples -------- >>> import inspect >>> sorted(inspect.signature(build_editable).parameters) ['config_settings', 'metadata_directory', 'wheel_directory'] - >>> callable(build_editable) - True """ run_vite_build() return _hatchling.build_editable( @@ -88,13 +112,15 @@ def build_sdist( skip vite (no ``web/`` in the unpacked tree) and ship the pre-baked assets via hatchling's normal file selection. + The delegation pattern matches :func:`build_wheel`; see that + function's docstring for an end-to-end example exercising the + ``SPHINX_VITE_BUILDER_SKIP=1`` short-circuit. + Examples -------- >>> import inspect >>> sorted(inspect.signature(build_sdist).parameters) ['config_settings', 'sdist_directory'] - >>> callable(build_sdist) - True """ run_vite_build() return _hatchling.build_sdist(sdist_directory, config_settings) diff --git a/pyproject.toml b/pyproject.toml index a270cc2a..8d77adfb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -221,6 +221,7 @@ testpaths = [ "packages/sphinx-ux-badges/src", "packages/sphinx-gp-opengraph/src", "packages/sphinx-gp-sitemap/src", + "packages/sphinx-vite-builder/src", ] filterwarnings = [ "ignore:distutils Version classes are deprecated. Use packaging.version instead.", From b15534ce6dd660fb825dbc4b6f9231e96523a24c Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 3 May 2026 11:21:28 -0500 Subject: [PATCH 33/53] fix(ci[smoke]): assert hatchling absence in Scenario 1 of sphinx-vite-builder smoke MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: Scenario 1's docstring claimed it "verifies hatchling truly isn't a runtime dependency," but the assertion was only callable(setup). uv pip install without --no-deps resolves and installs every dist listed in the wheel's Requires-Dist metadata; if a regression re-added hatchling to [project].dependencies, hatchling would be auto-installed and the import-and-call assertion would still pass green. The contract the docstring promised wasn't the contract the test enforced. what: - Convert the Scenario 1 inline Python source from a single ;-joined line to a \n.joined multi-line block so a try/except PackageNotFoundError can run before the import assertions. - Add an active check: importlib.metadata.distribution('hatchling') must raise PackageNotFoundError; if it succeeds, the smoke fails with an explanatory AssertionError pointing at the exact regression shape (re-listing hatchling under [project].dependencies). - Tighten the Scenario 1 docstring paragraph to describe what the test now actually proves (active distribution check vs. silent reliance on import behavior). - Pick importlib.metadata.distribution() over `uv pip list` parsing — stdlib, no subprocess overhead, no parsing fragility, and the PackageNotFoundError semantics map cleanly to the test intent. - Pick the active check over `uv pip install --no-deps` — the latter would mask any future legitimate runtime dep, while the targeted check pins the build-time-vs-runtime contract specifically for hatchling. References: - ~/study/rust-python/uv/ — confirmation that uv pip install resolves transitive deps from Requires-Dist without --no-deps - importlib.metadata stdlib docs — PackageNotFoundError is the canonical "dist not installed" sentinel --- scripts/ci/package_tools.py | 38 ++++++++++++++++++++++++++----------- 1 file changed, 27 insertions(+), 11 deletions(-) diff --git a/scripts/ci/package_tools.py b/scripts/ci/package_tools.py index 79eddc3b..e8897b8b 100644 --- a/scripts/ci/package_tools.py +++ b/scripts/ci/package_tools.py @@ -782,12 +782,13 @@ def smoke_sphinx_vite_builder(dist_dir: pathlib.Path, version: str) -> None: other: 1. **Runtime install (no hatchling).** The wheel must import and - expose the Sphinx-extension entry point without hatchling on - ``sys.path``. This is the load-bearing assertion that - hatchling truly isn't a runtime dependency — a regression - (e.g. accidentally re-listing hatchling under - ``[project].dependencies``) would let the build module import - eagerly and silently restore the old contract. + expose the Sphinx-extension entry point, AND ``hatchling`` MUST + NOT be present in the venv's installed-distribution set. If a + regression re-listed hatchling under ``[project].dependencies``, + ``uv pip install `` would resolve and auto-install it + from the wheel's ``Requires-Dist`` metadata; the active + ``importlib.metadata.distribution('hatchling')`` check fires + ``AssertionError`` to catch that. 2. **Build-system install (hatchling alongside).** A consumer using sphinx-vite-builder as a PEP 517 backend gets hatchling resolved by the build frontend via @@ -803,11 +804,26 @@ def smoke_sphinx_vite_builder(dist_dir: pathlib.Path, version: str) -> None: _install_into_venv(python_path, wheel, find_links=dist_dir) _run_python( python_path, - ( - "import sphinx_vite_builder; " - "from sphinx_vite_builder import setup; " - f"assert sphinx_vite_builder.__version__ == {version!r}; " - "assert callable(setup)" + "\n".join( + ( + "from importlib.metadata import (", + " PackageNotFoundError,", + " distribution,", + ")", + "try:", + " distribution('hatchling')", + "except PackageNotFoundError:", + " pass", + "else:", + " raise AssertionError(", + " 'hatchling must NOT be installed in the runtime venv; '", + " 'a regression re-listed it under [project].dependencies'", + " )", + "import sphinx_vite_builder", + "from sphinx_vite_builder import setup", + f"assert sphinx_vite_builder.__version__ == {version!r}", + "assert callable(setup)", + ), ), ) # Scenario 2: build-system venv with hatchling alongside. From 581566a02ef420c42abd8dcce2a54d96e67314a2 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 3 May 2026 11:26:06 -0500 Subject: [PATCH 34/53] =?UTF-8?q?release(workspace):=20bump=20v0.0.1a16.de?= =?UTF-8?q?v1=20=E2=86=92=20v0.0.1a16.dev2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: Third dev release of the sphinx-vite-builder backend (gp-sphinx PR #29). Carries the post-review fix-ups on top of dev1: build_wheel now exercises hatchling delegation through a real synthetic-project doctest, the package's src/ tree is in pytest testpaths so the doctests run in CI, and the wheel-install smoke runner actively asserts hatchling absence rather than relying on import-only side-effects. The next libtmux-mcp QA pass will exercise these exact wheels. what: - 17 packages × pyproject.toml: version = "0.0.1a16.dev2" - workspace root pyproject.toml: same - inter-package ==0.0.1a16.dev1 constraints → ==0.0.1a16.dev2 - 17 package __init__.py files: __version__ = "0.0.1a16.dev2" - tests/test_sphinx_vite_builder.py + tests/test_gp_sphinx_vite.py: assertions on the new version - tests/ci/test_package_tools.py: corresponding fixture updates - uv.lock: refreshed via uv lock Verified locally: - uv run python scripts/ci/package_tools.py check-versions → exit 0 - ruff clean, ruff format clean, mypy clean (208 source files) - 1365 passed, 160 skipped (no regression) Carries forward from dev1 to dev2: - build_wheel doctest exercises SKIP-env-var + hatchling delegation against a synthetic project (replaces the signature-only example) - packages/sphinx-vite-builder/src added to pytest testpaths so the package's 8 doctests actually run in the suite - smoke runner Scenario 1 actively asserts hatchling absence via importlib.metadata.distribution() / PackageNotFoundError --- packages/gp-furo-theme/pyproject.toml | 2 +- .../src/gp_furo_theme/__init__.py | 2 +- packages/gp-sphinx-vite/pyproject.toml | 2 +- .../src/gp_sphinx_vite/__init__.py | 2 +- packages/gp-sphinx/pyproject.toml | 14 ++++---- packages/gp-sphinx/src/gp_sphinx/__init__.py | 2 +- .../sphinx-autodoc-api-style/pyproject.toml | 6 ++-- .../sphinx-autodoc-argparse/pyproject.toml | 2 +- .../src/sphinx_autodoc_argparse/__init__.py | 2 +- .../sphinx-autodoc-docutils/pyproject.toml | 8 ++--- .../src/sphinx_autodoc_docutils/__init__.py | 2 +- .../sphinx-autodoc-fastmcp/pyproject.toml | 8 ++--- .../src/sphinx_autodoc_fastmcp/__init__.py | 2 +- .../pyproject.toml | 8 ++--- packages/sphinx-autodoc-sphinx/pyproject.toml | 8 ++--- .../src/sphinx_autodoc_sphinx/__init__.py | 2 +- .../pyproject.toml | 2 +- .../sphinx_autodoc_typehints_gp/extension.py | 2 +- packages/sphinx-fonts/pyproject.toml | 2 +- .../sphinx-fonts/src/sphinx_fonts/__init__.py | 2 +- packages/sphinx-gp-opengraph/pyproject.toml | 2 +- .../src/sphinx_gp_opengraph/__init__.py | 2 +- packages/sphinx-gp-sitemap/pyproject.toml | 2 +- .../src/sphinx_gp_sitemap/__init__.py | 2 +- packages/sphinx-gp-theme/pyproject.toml | 4 +-- .../src/sphinx_gp_theme/__init__.py | 2 +- .../sphinx-ux-autodoc-layout/pyproject.toml | 2 +- packages/sphinx-ux-badges/pyproject.toml | 2 +- .../src/sphinx_ux_badges/__init__.py | 2 +- packages/sphinx-vite-builder/pyproject.toml | 2 +- .../src/sphinx_vite_builder/__init__.py | 2 +- pyproject.toml | 4 +-- tests/ci/test_package_tools.py | 8 ++--- tests/test_gp_sphinx_vite.py | 2 +- tests/test_sphinx_vite_builder.py | 2 +- uv.lock | 36 +++++++++---------- 36 files changed, 78 insertions(+), 78 deletions(-) diff --git a/packages/gp-furo-theme/pyproject.toml b/packages/gp-furo-theme/pyproject.toml index c2975d58..97f9022a 100644 --- a/packages/gp-furo-theme/pyproject.toml +++ b/packages/gp-furo-theme/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "gp-furo-theme" -version = "0.0.1a16.dev1" +version = "0.0.1a16.dev2" description = "Tailwind v4 port of the Furo Sphinx theme for git-pull project documentation" requires-python = ">=3.10,<4.0" authors = [ diff --git a/packages/gp-furo-theme/src/gp_furo_theme/__init__.py b/packages/gp-furo-theme/src/gp_furo_theme/__init__.py index c941f151..093bf432 100644 --- a/packages/gp-furo-theme/src/gp_furo_theme/__init__.py +++ b/packages/gp-furo-theme/src/gp_furo_theme/__init__.py @@ -39,7 +39,7 @@ from .navigation import get_navigation_tree -__version__ = "0.0.1a16.dev1" +__version__ = "0.0.1a16.dev2" THEME_NAME = "gp-furo" THEME_PATH = (pathlib.Path(__file__).parent / "theme" / THEME_NAME).resolve() diff --git a/packages/gp-sphinx-vite/pyproject.toml b/packages/gp-sphinx-vite/pyproject.toml index 1d768830..46c74725 100644 --- a/packages/gp-sphinx-vite/pyproject.toml +++ b/packages/gp-sphinx-vite/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "gp-sphinx-vite" -version = "0.0.1a16.dev1" +version = "0.0.1a16.dev2" description = "Transparent Vite + pnpm orchestration for Sphinx theme asset pipelines" requires-python = ">=3.10,<4.0" authors = [ diff --git a/packages/gp-sphinx-vite/src/gp_sphinx_vite/__init__.py b/packages/gp-sphinx-vite/src/gp_sphinx_vite/__init__.py index a9b8a968..822114eb 100644 --- a/packages/gp-sphinx-vite/src/gp_sphinx_vite/__init__.py +++ b/packages/gp-sphinx-vite/src/gp_sphinx_vite/__init__.py @@ -35,7 +35,7 @@ if t.TYPE_CHECKING: from sphinx.application import Sphinx -__version__ = "0.0.1a16.dev1" +__version__ = "0.0.1a16.dev2" logger = logging.getLogger(__name__) logger.addHandler(logging.NullHandler()) diff --git a/packages/gp-sphinx/pyproject.toml b/packages/gp-sphinx/pyproject.toml index a185938e..1cb1c679 100644 --- a/packages/gp-sphinx/pyproject.toml +++ b/packages/gp-sphinx/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "gp-sphinx" -version = "0.0.1a16.dev1" +version = "0.0.1a16.dev2" description = "Shared Sphinx documentation platform for git-pull projects" requires-python = ">=3.10,<4.0" authors = [ @@ -26,15 +26,15 @@ readme = "README.md" keywords = ["sphinx", "documentation", "configuration"] dependencies = [ "sphinx>=8.1,<9", - "sphinx-gp-theme==0.0.1a16.dev1", - "sphinx-fonts==0.0.1a16.dev1", + "sphinx-gp-theme==0.0.1a16.dev2", + "sphinx-fonts==0.0.1a16.dev2", "myst-parser", "docutils", - "sphinx-autodoc-typehints-gp==0.0.1a16.dev1", + "sphinx-autodoc-typehints-gp==0.0.1a16.dev2", "sphinx-inline-tabs", "sphinx-copybutton", - "sphinx-gp-opengraph==0.0.1a16.dev1", - "sphinx-gp-sitemap==0.0.1a16.dev1", + "sphinx-gp-opengraph==0.0.1a16.dev2", + "sphinx-gp-sitemap==0.0.1a16.dev2", "sphinxext-rediraffe", "sphinx-design", "linkify-it-py", @@ -43,7 +43,7 @@ dependencies = [ [project.optional-dependencies] argparse = [ - "sphinx-autodoc-argparse==0.0.1a16.dev1", + "sphinx-autodoc-argparse==0.0.1a16.dev2", ] [project.urls] diff --git a/packages/gp-sphinx/src/gp_sphinx/__init__.py b/packages/gp-sphinx/src/gp_sphinx/__init__.py index 90149e93..72c742d0 100644 --- a/packages/gp-sphinx/src/gp_sphinx/__init__.py +++ b/packages/gp-sphinx/src/gp_sphinx/__init__.py @@ -7,7 +7,7 @@ __title__ = "gp-sphinx" __package_name__ = "gp_sphinx" __description__ = "Shared Sphinx documentation platform for git-pull projects" -__version__ = "0.0.1a16.dev1" +__version__ = "0.0.1a16.dev2" __author__ = "Tony Narlock" __github__ = "https://github.com/git-pull/gp-sphinx" __docs__ = "https://gp-sphinx.git-pull.com" diff --git a/packages/sphinx-autodoc-api-style/pyproject.toml b/packages/sphinx-autodoc-api-style/pyproject.toml index 3e10aebc..aa6ca58a 100644 --- a/packages/sphinx-autodoc-api-style/pyproject.toml +++ b/packages/sphinx-autodoc-api-style/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sphinx-autodoc-api-style" -version = "0.0.1a16.dev1" +version = "0.0.1a16.dev2" description = "Sphinx extension for enhanced autodoc API entry styling (badges and cards)" requires-python = ">=3.10,<4.0" authors = [ @@ -27,8 +27,8 @@ readme = "README.md" keywords = ["sphinx", "autodoc", "documentation", "api", "badges"] dependencies = [ "sphinx>=8.1", - "sphinx-ux-badges==0.0.1a16.dev1", - "sphinx-ux-autodoc-layout==0.0.1a16.dev1", + "sphinx-ux-badges==0.0.1a16.dev2", + "sphinx-ux-autodoc-layout==0.0.1a16.dev2", ] [project.urls] diff --git a/packages/sphinx-autodoc-argparse/pyproject.toml b/packages/sphinx-autodoc-argparse/pyproject.toml index 3f718bc2..2e026276 100644 --- a/packages/sphinx-autodoc-argparse/pyproject.toml +++ b/packages/sphinx-autodoc-argparse/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sphinx-autodoc-argparse" -version = "0.0.1a16.dev1" +version = "0.0.1a16.dev2" description = "Modern Sphinx extension for documenting argparse-based CLI tools" requires-python = ">=3.10,<4.0" authors = [ diff --git a/packages/sphinx-autodoc-argparse/src/sphinx_autodoc_argparse/__init__.py b/packages/sphinx-autodoc-argparse/src/sphinx_autodoc_argparse/__init__.py index eb0f5ad2..d22f66ff 100644 --- a/packages/sphinx-autodoc-argparse/src/sphinx_autodoc_argparse/__init__.py +++ b/packages/sphinx-autodoc-argparse/src/sphinx_autodoc_argparse/__init__.py @@ -44,7 +44,7 @@ if t.TYPE_CHECKING: from sphinx.application import Sphinx -__version__ = "0.0.1a16.dev1" +__version__ = "0.0.1a16.dev2" class SetupDict(t.TypedDict): diff --git a/packages/sphinx-autodoc-docutils/pyproject.toml b/packages/sphinx-autodoc-docutils/pyproject.toml index 8ab7e4ed..6712b1a9 100644 --- a/packages/sphinx-autodoc-docutils/pyproject.toml +++ b/packages/sphinx-autodoc-docutils/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sphinx-autodoc-docutils" -version = "0.0.1a16.dev1" +version = "0.0.1a16.dev2" description = "Sphinx extension for documenting docutils directives and roles as first-class reference entries" requires-python = ">=3.10,<4.0" authors = [ @@ -27,9 +27,9 @@ readme = "README.md" keywords = ["sphinx", "docutils", "directives", "roles", "documentation", "autodoc"] dependencies = [ "sphinx>=8.1", - "sphinx-ux-badges==0.0.1a16.dev1", - "sphinx-ux-autodoc-layout==0.0.1a16.dev1", - "sphinx-autodoc-typehints-gp==0.0.1a16.dev1", + "sphinx-ux-badges==0.0.1a16.dev2", + "sphinx-ux-autodoc-layout==0.0.1a16.dev2", + "sphinx-autodoc-typehints-gp==0.0.1a16.dev2", ] [project.urls] diff --git a/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/__init__.py b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/__init__.py index 512f439a..1b121dd6 100644 --- a/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/__init__.py +++ b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/__init__.py @@ -77,7 +77,7 @@ def _add_static_path(app: Sphinx) -> None: app.add_css_file("css/sphinx_autodoc_docutils.css") return { - "version": "0.0.1a16.dev1", + "version": "0.0.1a16.dev2", "parallel_read_safe": True, "parallel_write_safe": True, } diff --git a/packages/sphinx-autodoc-fastmcp/pyproject.toml b/packages/sphinx-autodoc-fastmcp/pyproject.toml index 78f2cd25..d40a6b6d 100644 --- a/packages/sphinx-autodoc-fastmcp/pyproject.toml +++ b/packages/sphinx-autodoc-fastmcp/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sphinx-autodoc-fastmcp" -version = "0.0.1a16.dev1" +version = "0.0.1a16.dev2" description = "Sphinx extension for documenting FastMCP tools (cards, badges, cross-refs)" requires-python = ">=3.10,<4.0" authors = [ @@ -27,9 +27,9 @@ readme = "README.md" keywords = ["sphinx", "fastmcp", "mcp", "documentation", "badges"] dependencies = [ "sphinx>=8.1", - "sphinx-ux-badges==0.0.1a16.dev1", - "sphinx-ux-autodoc-layout==0.0.1a16.dev1", - "sphinx-autodoc-typehints-gp==0.0.1a16.dev1", + "sphinx-ux-badges==0.0.1a16.dev2", + "sphinx-ux-autodoc-layout==0.0.1a16.dev2", + "sphinx-autodoc-typehints-gp==0.0.1a16.dev2", ] [project.urls] diff --git a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/__init__.py b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/__init__.py index 499a5611..87f03bce 100644 --- a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/__init__.py +++ b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/__init__.py @@ -51,7 +51,7 @@ logging.getLogger(__name__).addHandler(logging.NullHandler()) -_EXTENSION_VERSION = "0.0.1a16.dev1" +_EXTENSION_VERSION = "0.0.1a16.dev2" def setup(app: Sphinx) -> dict[str, t.Any]: diff --git a/packages/sphinx-autodoc-pytest-fixtures/pyproject.toml b/packages/sphinx-autodoc-pytest-fixtures/pyproject.toml index a7a1dcb2..e7860096 100644 --- a/packages/sphinx-autodoc-pytest-fixtures/pyproject.toml +++ b/packages/sphinx-autodoc-pytest-fixtures/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sphinx-autodoc-pytest-fixtures" -version = "0.0.1a16.dev1" +version = "0.0.1a16.dev2" description = "Sphinx extension for documenting pytest fixtures as first-class objects" requires-python = ">=3.10,<4.0" authors = [ @@ -30,9 +30,9 @@ keywords = ["sphinx", "pytest", "fixtures", "documentation", "autodoc"] dependencies = [ "sphinx>=8.1", "pytest", - "sphinx-ux-badges==0.0.1a16.dev1", - "sphinx-ux-autodoc-layout==0.0.1a16.dev1", - "sphinx-autodoc-typehints-gp==0.0.1a16.dev1", + "sphinx-ux-badges==0.0.1a16.dev2", + "sphinx-ux-autodoc-layout==0.0.1a16.dev2", + "sphinx-autodoc-typehints-gp==0.0.1a16.dev2", ] [project.urls] diff --git a/packages/sphinx-autodoc-sphinx/pyproject.toml b/packages/sphinx-autodoc-sphinx/pyproject.toml index 65a59bb5..4e4173cf 100644 --- a/packages/sphinx-autodoc-sphinx/pyproject.toml +++ b/packages/sphinx-autodoc-sphinx/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sphinx-autodoc-sphinx" -version = "0.0.1a16.dev1" +version = "0.0.1a16.dev2" description = "Sphinx extension for documenting extension config values as first-class conf.py reference entries" requires-python = ">=3.10,<4.0" authors = [ @@ -27,9 +27,9 @@ readme = "README.md" keywords = ["sphinx", "configuration", "conf.py", "documentation", "autodoc"] dependencies = [ "sphinx>=8.1", - "sphinx-ux-badges==0.0.1a16.dev1", - "sphinx-ux-autodoc-layout==0.0.1a16.dev1", - "sphinx-autodoc-typehints-gp==0.0.1a16.dev1", + "sphinx-ux-badges==0.0.1a16.dev2", + "sphinx-ux-autodoc-layout==0.0.1a16.dev2", + "sphinx-autodoc-typehints-gp==0.0.1a16.dev2", ] [project.urls] diff --git a/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/__init__.py b/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/__init__.py index 16241c2b..8e6796a0 100644 --- a/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/__init__.py +++ b/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/__init__.py @@ -61,7 +61,7 @@ def _add_static_path(app: Sphinx) -> None: app.add_css_file("css/sphinx_autodoc_sphinx.css") return { - "version": "0.0.1a16.dev1", + "version": "0.0.1a16.dev2", "parallel_read_safe": True, "parallel_write_safe": True, } diff --git a/packages/sphinx-autodoc-typehints-gp/pyproject.toml b/packages/sphinx-autodoc-typehints-gp/pyproject.toml index 268c448f..fcf3b5ab 100644 --- a/packages/sphinx-autodoc-typehints-gp/pyproject.toml +++ b/packages/sphinx-autodoc-typehints-gp/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "sphinx-autodoc-typehints-gp" -version = "0.0.1a16.dev1" +version = "0.0.1a16.dev2" description = "Cross-referenced type annotations for Sphinx autodoc" readme = "README.md" requires-python = ">=3.10" diff --git a/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/extension.py b/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/extension.py index 8df162d0..4674232d 100644 --- a/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/extension.py +++ b/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/extension.py @@ -593,7 +593,7 @@ def setup(app: Sphinx) -> dict[str, t.Any]: # are skipped by the built-in handler. app.connect("object-description-transform", merge_typehints, priority=499) return { - "version": "0.0.1a16.dev1", + "version": "0.0.1a16.dev2", "parallel_read_safe": True, "parallel_write_safe": True, } diff --git a/packages/sphinx-fonts/pyproject.toml b/packages/sphinx-fonts/pyproject.toml index 75e99319..971964fa 100644 --- a/packages/sphinx-fonts/pyproject.toml +++ b/packages/sphinx-fonts/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sphinx-fonts" -version = "0.0.1a16.dev1" +version = "0.0.1a16.dev2" description = "Sphinx extension for self-hosted fonts via Fontsource CDN" requires-python = ">=3.10,<4.0" authors = [ diff --git a/packages/sphinx-fonts/src/sphinx_fonts/__init__.py b/packages/sphinx-fonts/src/sphinx_fonts/__init__.py index 38aa2ba9..661c7402 100644 --- a/packages/sphinx-fonts/src/sphinx_fonts/__init__.py +++ b/packages/sphinx-fonts/src/sphinx_fonts/__init__.py @@ -25,7 +25,7 @@ logger = logging.getLogger(__name__) logger.addHandler(logging.NullHandler()) -__version__ = "0.0.1a16.dev1" +__version__ = "0.0.1a16.dev2" CDN_TEMPLATE = ( "https://cdn.jsdelivr.net/npm/{package}@{version}" diff --git a/packages/sphinx-gp-opengraph/pyproject.toml b/packages/sphinx-gp-opengraph/pyproject.toml index a76d9cfb..cf869b80 100644 --- a/packages/sphinx-gp-opengraph/pyproject.toml +++ b/packages/sphinx-gp-opengraph/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sphinx-gp-opengraph" -version = "0.0.1a16.dev1" +version = "0.0.1a16.dev2" description = "OpenGraph and Twitter meta-tag emission for Sphinx — matplotlib-free" requires-python = ">=3.10,<4.0" authors = [ diff --git a/packages/sphinx-gp-opengraph/src/sphinx_gp_opengraph/__init__.py b/packages/sphinx-gp-opengraph/src/sphinx_gp_opengraph/__init__.py index e953c389..dd84a1b3 100644 --- a/packages/sphinx-gp-opengraph/src/sphinx_gp_opengraph/__init__.py +++ b/packages/sphinx-gp-opengraph/src/sphinx_gp_opengraph/__init__.py @@ -42,7 +42,7 @@ logger = logging.getLogger(__name__) logger.addHandler(logging.NullHandler()) -_EXTENSION_VERSION = "0.0.1a16.dev1" +_EXTENSION_VERSION = "0.0.1a16.dev2" DEFAULT_DESCRIPTION_LENGTH = 200 diff --git a/packages/sphinx-gp-sitemap/pyproject.toml b/packages/sphinx-gp-sitemap/pyproject.toml index 02d673bf..c9560944 100644 --- a/packages/sphinx-gp-sitemap/pyproject.toml +++ b/packages/sphinx-gp-sitemap/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sphinx-gp-sitemap" -version = "0.0.1a16.dev1" +version = "0.0.1a16.dev2" description = "Sitemap generator for Sphinx — Sphinx 8.1+ idioms, parallel-build safe" requires-python = ">=3.10,<4.0" authors = [ diff --git a/packages/sphinx-gp-sitemap/src/sphinx_gp_sitemap/__init__.py b/packages/sphinx-gp-sitemap/src/sphinx_gp_sitemap/__init__.py index 7b5a87bd..417b135e 100644 --- a/packages/sphinx-gp-sitemap/src/sphinx_gp_sitemap/__init__.py +++ b/packages/sphinx-gp-sitemap/src/sphinx_gp_sitemap/__init__.py @@ -47,7 +47,7 @@ from sphinx.util.typing import ExtensionMetadata -_EXTENSION_VERSION = "0.0.1a16.dev1" +_EXTENSION_VERSION = "0.0.1a16.dev2" _SITEMAP_NS = "http://www.sitemaps.org/schemas/sitemap/0.9" _XHTML_NS = "http://www.w3.org/1999/xhtml" diff --git a/packages/sphinx-gp-theme/pyproject.toml b/packages/sphinx-gp-theme/pyproject.toml index 71e6798a..73b22f84 100644 --- a/packages/sphinx-gp-theme/pyproject.toml +++ b/packages/sphinx-gp-theme/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sphinx-gp-theme" -version = "0.0.1a16.dev1" +version = "0.0.1a16.dev2" description = "Furo child theme for git-pull project documentation" requires-python = ">=3.10,<4.0" authors = [ @@ -27,7 +27,7 @@ readme = "README.md" keywords = ["sphinx", "theme", "furo", "documentation"] dependencies = [ "sphinx>=8.1", - "gp-furo-theme==0.0.1a16.dev1", + "gp-furo-theme==0.0.1a16.dev2", ] [project.entry-points."sphinx.html_themes"] diff --git a/packages/sphinx-gp-theme/src/sphinx_gp_theme/__init__.py b/packages/sphinx-gp-theme/src/sphinx_gp_theme/__init__.py index b762dcb4..cffefd16 100644 --- a/packages/sphinx-gp-theme/src/sphinx_gp_theme/__init__.py +++ b/packages/sphinx-gp-theme/src/sphinx_gp_theme/__init__.py @@ -23,7 +23,7 @@ if t.TYPE_CHECKING: from sphinx.application import Sphinx -__version__ = "0.0.1a16.dev1" +__version__ = "0.0.1a16.dev2" def get_theme_path() -> pathlib.Path: diff --git a/packages/sphinx-ux-autodoc-layout/pyproject.toml b/packages/sphinx-ux-autodoc-layout/pyproject.toml index fd783a7f..b8e9cb15 100644 --- a/packages/sphinx-ux-autodoc-layout/pyproject.toml +++ b/packages/sphinx-ux-autodoc-layout/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "sphinx-ux-autodoc-layout" -version = "0.0.1a16.dev1" +version = "0.0.1a16.dev2" description = "Componentized layout for Sphinx autodoc output" readme = "README.md" requires-python = ">=3.10" diff --git a/packages/sphinx-ux-badges/pyproject.toml b/packages/sphinx-ux-badges/pyproject.toml index 7a374848..8219fa09 100644 --- a/packages/sphinx-ux-badges/pyproject.toml +++ b/packages/sphinx-ux-badges/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sphinx-ux-badges" -version = "0.0.1a16.dev1" +version = "0.0.1a16.dev2" description = "Shared badge node and CSS for Sphinx autodoc extensions" requires-python = ">=3.10,<4.0" authors = [ diff --git a/packages/sphinx-ux-badges/src/sphinx_ux_badges/__init__.py b/packages/sphinx-ux-badges/src/sphinx_ux_badges/__init__.py index a144eb58..655a21e2 100644 --- a/packages/sphinx-ux-badges/src/sphinx_ux_badges/__init__.py +++ b/packages/sphinx-ux-badges/src/sphinx_ux_badges/__init__.py @@ -48,7 +48,7 @@ logging.getLogger(__name__).addHandler(logging.NullHandler()) -_EXTENSION_VERSION = "0.0.1a16.dev1" +_EXTENSION_VERSION = "0.0.1a16.dev2" def setup(app: Sphinx) -> dict[str, t.Any]: diff --git a/packages/sphinx-vite-builder/pyproject.toml b/packages/sphinx-vite-builder/pyproject.toml index d7598b1a..107d935e 100644 --- a/packages/sphinx-vite-builder/pyproject.toml +++ b/packages/sphinx-vite-builder/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sphinx-vite-builder" -version = "0.0.1a16.dev1" +version = "0.0.1a16.dev2" description = "PEP 517 build backend + Sphinx extension that orchestrates Vite via pnpm" requires-python = ">=3.10,<4.0" authors = [ diff --git a/packages/sphinx-vite-builder/src/sphinx_vite_builder/__init__.py b/packages/sphinx-vite-builder/src/sphinx_vite_builder/__init__.py index ffaff18e..326eddb2 100644 --- a/packages/sphinx-vite-builder/src/sphinx_vite_builder/__init__.py +++ b/packages/sphinx-vite-builder/src/sphinx_vite_builder/__init__.py @@ -18,7 +18,7 @@ import logging import typing as t -__version__ = "0.0.1a16.dev1" +__version__ = "0.0.1a16.dev2" logger = logging.getLogger(__name__) logger.addHandler(logging.NullHandler()) diff --git a/pyproject.toml b/pyproject.toml index 8d77adfb..4e00d907 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "gp-sphinx-workspace" -version = "0.0.1a16.dev1" +version = "0.0.1a16.dev2" description = "Workspace root for gp-sphinx packages" requires-python = ">=3.10,<4.0" authors = [ @@ -9,7 +9,7 @@ authors = [ license = { text = "MIT" } readme = "README.md" dependencies = [ - "gp-sphinx==0.0.1a16.dev1", + "gp-sphinx==0.0.1a16.dev2", ] [tool.uv.workspace] diff --git a/tests/ci/test_package_tools.py b/tests/ci/test_package_tools.py index 1c4288ba..74b07a90 100644 --- a/tests/ci/test_package_tools.py +++ b/tests/ci/test_package_tools.py @@ -13,7 +13,7 @@ def test_workspace_version_is_lockstep() -> None: """All workspace packages share the same version.""" - assert package_tools.workspace_version() == "0.0.1a16.dev1" + assert package_tools.workspace_version() == "0.0.1a16.dev2" def test_check_versions_passes_for_repo() -> None: @@ -30,12 +30,12 @@ def test_smoke_targets_cover_workspace_packages() -> None: def test_release_metadata_accepts_repo_tag() -> None: """Repo-wide release tags resolve to the shared version.""" - assert package_tools.release_metadata("v0.0.1a16.dev1") == { - "version": "0.0.1a16.dev1" + assert package_tools.release_metadata("v0.0.1a16.dev2") == { + "version": "0.0.1a16.dev2" } def test_release_metadata_rejects_package_tag() -> None: """Package-scoped tags are no longer valid release inputs.""" with pytest.raises(SystemExit, match="invalid release tag format"): - package_tools.release_metadata("gp-sphinx@v0.0.1a16.dev1") + package_tools.release_metadata("gp-sphinx@v0.0.1a16.dev2") diff --git a/tests/test_gp_sphinx_vite.py b/tests/test_gp_sphinx_vite.py index 69f27864..120e8a20 100644 --- a/tests/test_gp_sphinx_vite.py +++ b/tests/test_gp_sphinx_vite.py @@ -24,7 +24,7 @@ def test_version_matches_workspace_lock() -> None: """Version follows gp-sphinx workspace lockstep.""" - assert __version__ == "0.0.1a16.dev1" + assert __version__ == "0.0.1a16.dev2" class _FakeApp: diff --git a/tests/test_sphinx_vite_builder.py b/tests/test_sphinx_vite_builder.py index e61c8c50..fa6c1abf 100644 --- a/tests/test_sphinx_vite_builder.py +++ b/tests/test_sphinx_vite_builder.py @@ -15,7 +15,7 @@ def test_version_matches_workspace_lock() -> None: """Version follows the gp-sphinx workspace lockstep.""" - assert __version__ == "0.0.1a16.dev1" + assert __version__ == "0.0.1a16.dev2" def test_setup_returns_safety_metadata() -> None: diff --git a/uv.lock b/uv.lock index 7ab7747b..a59be2dc 100644 --- a/uv.lock +++ b/uv.lock @@ -388,7 +388,7 @@ wheels = [ [[package]] name = "gp-furo-theme" -version = "0.0.1a16.dev1" +version = "0.0.1a16.dev2" source = { editable = "packages/gp-furo-theme" } dependencies = [ { name = "accessible-pygments" }, @@ -424,7 +424,7 @@ wheels = [ [[package]] name = "gp-sphinx" -version = "0.0.1a16.dev1" +version = "0.0.1a16.dev2" source = { editable = "packages/gp-sphinx" } dependencies = [ { name = "docutils" }, @@ -473,7 +473,7 @@ provides-extras = ["argparse"] [[package]] name = "gp-sphinx-vite" -version = "0.0.1a16.dev1" +version = "0.0.1a16.dev2" source = { editable = "packages/gp-sphinx-vite" } dependencies = [ { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, @@ -485,7 +485,7 @@ requires-dist = [{ name = "sphinx", specifier = ">=8.1" }] [[package]] name = "gp-sphinx-workspace" -version = "0.0.1a16.dev1" +version = "0.0.1a16.dev2" source = { editable = "." } dependencies = [ { name = "gp-sphinx" }, @@ -1580,7 +1580,7 @@ wheels = [ [[package]] name = "sphinx-autodoc-api-style" -version = "0.0.1a16.dev1" +version = "0.0.1a16.dev2" source = { editable = "packages/sphinx-autodoc-api-style" } dependencies = [ { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, @@ -1598,7 +1598,7 @@ requires-dist = [ [[package]] name = "sphinx-autodoc-argparse" -version = "0.0.1a16.dev1" +version = "0.0.1a16.dev2" source = { editable = "packages/sphinx-autodoc-argparse" } dependencies = [ { name = "docutils" }, @@ -1616,7 +1616,7 @@ requires-dist = [ [[package]] name = "sphinx-autodoc-docutils" -version = "0.0.1a16.dev1" +version = "0.0.1a16.dev2" source = { editable = "packages/sphinx-autodoc-docutils" } dependencies = [ { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, @@ -1636,7 +1636,7 @@ requires-dist = [ [[package]] name = "sphinx-autodoc-fastmcp" -version = "0.0.1a16.dev1" +version = "0.0.1a16.dev2" source = { editable = "packages/sphinx-autodoc-fastmcp" } dependencies = [ { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, @@ -1656,7 +1656,7 @@ requires-dist = [ [[package]] name = "sphinx-autodoc-pytest-fixtures" -version = "0.0.1a16.dev1" +version = "0.0.1a16.dev2" source = { editable = "packages/sphinx-autodoc-pytest-fixtures" } dependencies = [ { name = "pytest" }, @@ -1678,7 +1678,7 @@ requires-dist = [ [[package]] name = "sphinx-autodoc-sphinx" -version = "0.0.1a16.dev1" +version = "0.0.1a16.dev2" source = { editable = "packages/sphinx-autodoc-sphinx" } dependencies = [ { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, @@ -1698,7 +1698,7 @@ requires-dist = [ [[package]] name = "sphinx-autodoc-typehints-gp" -version = "0.0.1a16.dev1" +version = "0.0.1a16.dev2" source = { editable = "packages/sphinx-autodoc-typehints-gp" } dependencies = [ { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, @@ -1767,7 +1767,7 @@ wheels = [ [[package]] name = "sphinx-fonts" -version = "0.0.1a16.dev1" +version = "0.0.1a16.dev2" source = { editable = "packages/sphinx-fonts" } dependencies = [ { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, @@ -1779,7 +1779,7 @@ requires-dist = [{ name = "sphinx", specifier = ">=8.1" }] [[package]] name = "sphinx-gp-opengraph" -version = "0.0.1a16.dev1" +version = "0.0.1a16.dev2" source = { editable = "packages/sphinx-gp-opengraph" } dependencies = [ { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, @@ -1791,7 +1791,7 @@ requires-dist = [{ name = "sphinx", specifier = ">=8.1" }] [[package]] name = "sphinx-gp-sitemap" -version = "0.0.1a16.dev1" +version = "0.0.1a16.dev2" source = { editable = "packages/sphinx-gp-sitemap" } dependencies = [ { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, @@ -1803,7 +1803,7 @@ requires-dist = [{ name = "sphinx", specifier = ">=8.1" }] [[package]] name = "sphinx-gp-theme" -version = "0.0.1a16.dev1" +version = "0.0.1a16.dev2" source = { editable = "packages/sphinx-gp-theme" } dependencies = [ { name = "gp-furo-theme" }, @@ -1832,7 +1832,7 @@ wheels = [ [[package]] name = "sphinx-ux-autodoc-layout" -version = "0.0.1a16.dev1" +version = "0.0.1a16.dev2" source = { editable = "packages/sphinx-ux-autodoc-layout" } dependencies = [ { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, @@ -1844,7 +1844,7 @@ requires-dist = [{ name = "sphinx", specifier = ">=8.1" }] [[package]] name = "sphinx-ux-badges" -version = "0.0.1a16.dev1" +version = "0.0.1a16.dev2" source = { editable = "packages/sphinx-ux-badges" } dependencies = [ { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, @@ -1856,7 +1856,7 @@ requires-dist = [{ name = "sphinx", specifier = ">=8.1" }] [[package]] name = "sphinx-vite-builder" -version = "0.0.1a16.dev1" +version = "0.0.1a16.dev2" source = { editable = "packages/sphinx-vite-builder" } dependencies = [ { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, From c1f0d7df1b27323e48ed1a92b07b1a6c1f44e17a Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 3 May 2026 12:35:13 -0500 Subject: [PATCH 35/53] pkg(sphinx-vite-builder[extension]): port config layer for Phase 2 setup() rewrite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: The Phase 2 Sphinx-extension head (hooks.py + real setup() body) needs the mode-detection + frozen-config primitives the gp-sphinx-vite extension already proves out. Land the config layer first as a standalone module so the hooks port in the next commit can import it cleanly. what: - Add packages/sphinx-vite-builder/src/sphinx_vite_builder/_internal/config.py - Mode enum (DEV/PROD) with str mixin so values compare equal to conf.py literals - detect_mode() with three-tier autobuild detection (env, argv, parent process via /proc//cmdline) — covers sphinx-autobuild's subprocess fork case - resolve_vite_root() — explicit path resolution; no theme auto-detection - SphinxViteBuilderConfig frozen dataclass with should_spawn property - Class renamed from gp_sphinx_vite's GpSphinxViteConfig; config-value names in docstrings refer to sphinx_vite_builder_mode / sphinx_vite_builder_root --- .../sphinx_vite_builder/_internal/config.py | 190 ++++++++++++++++++ 1 file changed, 190 insertions(+) create mode 100644 packages/sphinx-vite-builder/src/sphinx_vite_builder/_internal/config.py diff --git a/packages/sphinx-vite-builder/src/sphinx_vite_builder/_internal/config.py b/packages/sphinx-vite-builder/src/sphinx_vite_builder/_internal/config.py new file mode 100644 index 00000000..eda1945f --- /dev/null +++ b/packages/sphinx-vite-builder/src/sphinx_vite_builder/_internal/config.py @@ -0,0 +1,190 @@ +"""Mode detection + config dataclass for the Sphinx-extension head. + +Pure functions where possible — keeps the unit tests cheap (no Sphinx +fixture, no subprocess). The Sphinx-aware glue lives in :mod:`hooks`. + +Mode detection inspects ``argv``, ``SPHINX_AUTOBUILD``, and the parent +process's command line so the orchestration becomes a no-op for +``sphinx-build`` invocations and turns on for ``sphinx-autobuild``. +""" + +from __future__ import annotations + +import dataclasses +import enum +import os +import pathlib +import sys +import typing as t + + +class Mode(str, enum.Enum): + """Resolved orchestration mode. + + `str` mixin so the value compares equal to the literal string the + user wrote in ``conf.py``. + """ + + DEV = "dev" + PROD = "prod" + + +def _parent_is_sphinx_autobuild() -> bool: + """Return True if our parent process's argv contains ``sphinx-autobuild``. + + Why this exists: ``sphinx-autobuild`` runs the actual Sphinx build + in a *subprocess* via ``subprocess.run([sys.executable] + sphinx_args)`` + (see ``sphinx_autobuild/build.py:50``). In that subprocess, + ``sys.argv[0]`` is the Python interpreter path, NOT + ``sphinx-autobuild``, so the argv-based mode-detection misses it. + Reading ``/proc//cmdline`` lets us see the parent's actual + command line. + + Linux-only via ``/proc``. Returns ``False`` cleanly on macOS, + Windows, or any other platform without ``/proc`` (and on Linux if + the read fails for permission reasons). Test harnesses can disable + this check by passing a stub via ``detect_mode(parent_check=...)``. + """ + try: + ppid = os.getppid() + cmdline_path = pathlib.Path(f"/proc/{ppid}/cmdline") + cmdline = cmdline_path.read_bytes().split(b"\0") + except OSError: + return False + return any(b"sphinx-autobuild" in arg for arg in cmdline) + + +def detect_mode( + *, + config_value: str, + argv: t.Sequence[str] | None = None, + env: t.Mapping[str, str] | None = None, + parent_check: t.Callable[[], bool] | None = None, +) -> Mode: + """Resolve a ``sphinx_vite_builder_mode`` config value to a concrete :class:`Mode`. + + Parameters + ---------- + config_value + Raw value from ``conf.py``: ``"auto"``, ``"dev"``, or ``"prod"``. + Anything else falls back to ``Mode.PROD`` (the safe / no-op + default — never spawn a subprocess from a typo). + argv + Process argv. Defaults to :data:`sys.argv`. + env + Process environment. Defaults to :data:`os.environ`. + parent_check + Callable returning ``True`` when the parent process is + ``sphinx-autobuild``. Defaults to + :func:`_parent_is_sphinx_autobuild`. Tests pass ``lambda: False`` + to disable platform-specific behavior. + + Returns + ------- + Mode + The resolved mode. ``"auto"`` resolves to ``DEV`` if any of: + - ``SPHINX_AUTOBUILD`` is set in ``env`` + - ``argv[0]`` ends with ``"sphinx-autobuild"`` + - the parent process is ``sphinx-autobuild`` (so the + subprocess sphinx-autobuild spawns inherits the dev mode) + ``PROD`` otherwise. + + Examples + -------- + >>> detect_mode( + ... config_value="dev", + ... argv=["sphinx-build"], + ... env={}, + ... parent_check=lambda: False, + ... ) + + >>> detect_mode( + ... config_value="prod", + ... argv=["sphinx-autobuild"], + ... env={"SPHINX_AUTOBUILD": "1"}, + ... parent_check=lambda: True, + ... ) + + >>> detect_mode( + ... config_value="auto", + ... argv=["sphinx-build"], + ... env={}, + ... parent_check=lambda: False, + ... ) + + >>> detect_mode( + ... config_value="auto", + ... argv=["/p/sphinx-autobuild"], + ... env={}, + ... parent_check=lambda: False, + ... ) + + >>> detect_mode( + ... config_value="auto", + ... argv=["python"], + ... env={}, + ... parent_check=lambda: True, + ... ) + + """ + if config_value == "dev": + return Mode.DEV + if config_value == "prod": + return Mode.PROD + # "auto" or any unrecognised value falls through to detection. + resolved_argv: t.Sequence[str] = argv if argv is not None else sys.argv + resolved_env: t.Mapping[str, str] = env if env is not None else os.environ + resolved_parent_check = ( + parent_check if parent_check is not None else _parent_is_sphinx_autobuild + ) + + if resolved_env.get("SPHINX_AUTOBUILD"): + return Mode.DEV + if resolved_argv and resolved_argv[0].endswith("sphinx-autobuild"): + return Mode.DEV + if resolved_parent_check(): + return Mode.DEV + return Mode.PROD + + +def resolve_vite_root(explicit: str | os.PathLike[str] | None) -> pathlib.Path | None: + """Resolve the ``sphinx_vite_builder_root`` config value to an absolute path. + + Returns ``None`` if no explicit root is set; the hook layer treats + that as "no Vite project to spawn" and logs a debug message. We + intentionally do not auto-detect the active theme's ``web/`` + directory here — auto-detection is brittle (depends on theme + layout, which is theme-specific) and would couple this package to + any one theme. Themes that want auto-wiring can set the config + value themselves from their own ``setup()`` callback. + + Examples + -------- + >>> resolve_vite_root(None) is None + True + >>> import pathlib + >>> root = resolve_vite_root(pathlib.Path(__file__).parent) + >>> root.is_absolute() + True + """ + if explicit is None: + return None + return pathlib.Path(explicit).resolve() + + +@dataclasses.dataclass(frozen=True, slots=True) +class SphinxViteBuilderConfig: + """Frozen snapshot of the resolved sphinx-vite-builder configuration. + + Built once per Sphinx app at ``builder-inited`` time from + ``app.config``; passed by value to the orchestration layer so the + hooks don't carry a reference to the live mutable Sphinx config. + """ + + mode: Mode + vite_root: pathlib.Path | None + + @property + def should_spawn(self) -> bool: + """True iff the orchestration layer should actually spawn Vite.""" + return self.mode is Mode.DEV and self.vite_root is not None From 2d975df617c19c5e511de2a1a35f46c1af11fe24 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 3 May 2026 12:37:56 -0500 Subject: [PATCH 36/53] pkg(sphinx-vite-builder[extension]): replace setup() placeholder with real lifecycle hooks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: Phase 2 of the package — the Sphinx-extension head was a documented no-op stub that registered no config values and connected no events. Wire up the working orchestration that gp-sphinx-vite has been shipping so `extensions = ["sphinx_vite_builder"]` actually runs `pnpm exec vite build --watch` under sphinx-autobuild, just as the README and AGENTS.md already describe. what: - Add _internal/hooks.py — port of gp_sphinx_vite/hooks.py with ViteProcess→AsyncProcess substitution; consumes _internal.vite for pnpm_install_command + vite_watch_command rather than the legacy process-module aliases - Rewrite setup() in sphinx_vite_builder/__init__.py to register sphinx_vite_builder_mode + sphinx_vite_builder_root config values and connect builder-inited + build-finished events; doctest exercises the config + connect surface against a fake-app stub - Update tests/test_sphinx_vite_builder.py FakeApp to expose the add_config_value + connect methods setup() now requires; add focused tests for the new registrations - App-private attributes namespaced as _sphinx_vite_builder_{bus,proc, teardown_registered} so a parallel install with the legacy gp_sphinx_vite extension doesn't collide --- .../src/sphinx_vite_builder/__init__.py | 87 +++++-- .../sphinx_vite_builder/_internal/hooks.py | 246 ++++++++++++++++++ tests/test_sphinx_vite_builder.py | 49 +++- 3 files changed, 351 insertions(+), 31 deletions(-) create mode 100644 packages/sphinx-vite-builder/src/sphinx_vite_builder/_internal/hooks.py diff --git a/packages/sphinx-vite-builder/src/sphinx_vite_builder/__init__.py b/packages/sphinx-vite-builder/src/sphinx_vite_builder/__init__.py index 326eddb2..ecc8b020 100644 --- a/packages/sphinx-vite-builder/src/sphinx_vite_builder/__init__.py +++ b/packages/sphinx-vite-builder/src/sphinx_vite_builder/__init__.py @@ -1,16 +1,19 @@ -"""sphinx-vite-builder: PEP 517 backend + Sphinx extension. +"""sphinx-vite-builder — vite + pnpm orchestration for Sphinx-theme packages. -Two orthogonal entry points share one subprocess core: +Two orthogonal entry points sharing one subprocess core: -- :mod:`sphinx_vite_builder.build` — the PEP 517 backend module that - consumer packages reference via - ``[build-system].build-backend = "sphinx_vite_builder.build"``. -- :func:`sphinx_vite_builder.setup` — the Sphinx extension entry point - that ``conf.py`` references via - ``extensions = ["sphinx_vite_builder"]``. +1. **PEP 517 build backend** at :mod:`sphinx_vite_builder.build`. Runs + ``pnpm exec vite build`` before delegating wheel/sdist construction + to :mod:`hatchling.build`. Consumer packages declare it via + ``[build-system].build-backend = "sphinx_vite_builder.build"``. +2. **Sphinx extension** registered by :func:`setup`. Hooks + ``builder-inited`` and ``build-finished`` so ``sphinx-build`` / + ``sphinx-autobuild`` automatically run vite — one-shot for prod, a + long-lived ``vite build --watch`` child process for autobuild — + with graceful teardown on signal / :data:`atexit`. -Neither head calls the other; they share the implementation modules -under :mod:`sphinx_vite_builder._internal`. +Both heads consume the smart-subprocess core under +:mod:`sphinx_vite_builder._internal`. """ from __future__ import annotations @@ -18,38 +21,68 @@ import logging import typing as t +if t.TYPE_CHECKING: + from sphinx.application import Sphinx + __version__ = "0.0.1a16.dev2" logger = logging.getLogger(__name__) logger.addHandler(logging.NullHandler()) -if t.TYPE_CHECKING: - from sphinx.application import Sphinx - def setup(app: Sphinx) -> dict[str, t.Any]: """Register the Sphinx-extension head. - Phase 1 ships the PEP 517 backend; the extension head's full - implementation (vite watch on ``sphinx-autobuild``, one-shot build - on ``sphinx-build``) lands in a follow-up commit. For now this - stub registers the extension so consumers can declare it without - a no-such-module error, and returns the safety metadata. + Wires two config values (``sphinx_vite_builder_mode``, + ``sphinx_vite_builder_root``) and connects the ``builder-inited`` / + ``build-finished`` lifecycle hooks. Under ``sphinx-autobuild`` (or + when ``sphinx_vite_builder_mode = "dev"``) the ``builder-inited`` + handler spawns ``pnpm exec vite build --watch`` against + ``sphinx_vite_builder_root``; under plain ``sphinx-build`` (or + ``mode = "prod"``) the handler is a no-op so production wheels + carry no Node runtime requirement. Examples -------- - The Phase-1 stub returns the parallel-safety metadata Sphinx - expects, regardless of the application object passed in: - >>> class FakeApp: - ... pass - >>> metadata = setup(FakeApp()) # type: ignore[arg-type] - >>> metadata["parallel_read_safe"] + ... def __init__(self) -> None: + ... self.config_values: list[str] = [] + ... self.events: list[str] = [] + ... def add_config_value(self, name: str, **kwargs: object) -> None: + ... self.config_values.append(name) + ... def connect(self, event: str, handler: object) -> None: + ... self.events.append(event) + >>> fake = FakeApp() + >>> metadata = setup(fake) # type: ignore[arg-type] + >>> "sphinx_vite_builder_mode" in fake.config_values True - >>> metadata["parallel_write_safe"] + >>> "sphinx_vite_builder_root" in fake.config_values + True + >>> "builder-inited" in fake.events + True + >>> "build-finished" in fake.events + True + >>> metadata["parallel_read_safe"] True """ - del app + from ._internal import hooks + + 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, @@ -57,4 +90,4 @@ def setup(app: Sphinx) -> dict[str, t.Any]: } -__all__ = ("__version__", "setup") +__all__: tuple[str, ...] = ("__version__", "setup") diff --git a/packages/sphinx-vite-builder/src/sphinx_vite_builder/_internal/hooks.py b/packages/sphinx-vite-builder/src/sphinx_vite_builder/_internal/hooks.py new file mode 100644 index 00000000..59945493 --- /dev/null +++ b/packages/sphinx-vite-builder/src/sphinx_vite_builder/_internal/hooks.py @@ -0,0 +1,246 @@ +"""Sphinx event handlers that drive the Vite watch lifecycle. + +The handlers live here (not in ``__init__.py``) so they're easy to unit +test in isolation: tests mock a Sphinx-like app, call the handler +directly, and assert against the process / bus instances stashed on +``app._sphinx_vite_builder_*``. + +Lifecycle: + +- ``builder-inited`` (:func:`on_builder_inited`) — resolve config; if + ``should_spawn``, start the bus, spawn the watch process, and stash + both on ``app``. Idempotent: re-firing (sphinx-autobuild fires this + on every rebuild) finds the running process and returns. +- ``build-finished`` (:func:`on_build_finished`) — no-op by default. + The watch process keeps running across rebuilds so Vite can + incrementally recompile on file changes. Teardown happens via + :data:`atexit` and signal handlers installed at first spawn. + +Tear-down is the responsibility of :func:`teardown`, which is wired +to ``atexit`` and to ``SIGINT`` / ``SIGTERM`` / ``SIGHUP``. + +The handlers are passive about command construction: they call +:func:`sphinx_vite_builder._internal.process.vite_watch_command` for +the default Vite argv. Tests monkey-patch that symbol when they want a +fake-vite invocation. +""" + +from __future__ import annotations + +import atexit +import pathlib +import signal +import typing as t +import weakref + +from sphinx.util import logging as sphinx_logging + +from .bus import AsyncioBus +from .config import SphinxViteBuilderConfig, detect_mode, resolve_vite_root +from .process import AsyncProcess +from .vite import pnpm_install_command, vite_watch_command + +if t.TYPE_CHECKING: + from sphinx.application import Sphinx + +# `sphinx.util.logging.getLogger` returns a SphinxLoggerAdapter that +# routes through Sphinx's status / warning streams — which means our +# `[vite] …` lines actually surface in `sphinx-autobuild` output the +# same way Sphinx's own messages do. The stdlib `logging.getLogger` +# does not propagate by default in Sphinx contexts. +logger = sphinx_logging.getLogger(__name__) + +_BUS_ATTR = "_sphinx_vite_builder_bus" +_PROC_ATTR = "_sphinx_vite_builder_proc" +_TEARDOWN_REGISTERED_ATTR = "_sphinx_vite_builder_teardown_registered" + +# Live (bus, proc) pairs that the global teardown handler should clean +# up. Held weakly so a Sphinx app being garbage-collected doesn't keep +# the bus thread alive. +_active_handles: weakref.WeakValueDictionary[int, AsyncioBus] = ( + weakref.WeakValueDictionary() +) + + +def _build_config(app: Sphinx) -> SphinxViteBuilderConfig: + """Snapshot the live config values into a frozen dataclass.""" + return SphinxViteBuilderConfig( + mode=detect_mode(config_value=app.config.sphinx_vite_builder_mode), + vite_root=resolve_vite_root(app.config.sphinx_vite_builder_root), + ) + + +def _ensure_node_modules(vite_root: pathlib.Path, bus: AsyncioBus) -> bool: + """Ensure ``/node_modules/`` exists; install if missing. + + Closes the developer-workflow gap where ``git clean -fdx`` wipes + ``node_modules/`` and the next ``sphinx-autobuild`` would otherwise + spawn ``pnpm exec vite`` against a missing tree, exit immediately + with ``Command "vite" not found``, and silently leave the docs site + serving 404s for the theme's CSS + JS. + + Returns ``True`` if ``node_modules/`` exists (or was installed + successfully); ``False`` if the install ran but exited non-zero, + which signals to :func:`on_builder_inited` to skip the vite-watch + spawn rather than burn cycles on a guaranteed-failed + ``pnpm exec vite``. + """ + if (vite_root / "node_modules").exists(): + return True + + install_cmd = pnpm_install_command() + logger.info( + "[vite] node_modules/ missing in %s; running `%s`", + vite_root, + " ".join(install_cmd), + ) + install_proc = AsyncProcess(label="pnpm-install", logger=logger) + bus.call_sync(install_proc.start(install_cmd, cwd=vite_root)) + returncode = bus.call_sync(install_proc.wait()) + if returncode != 0: + logger.warning( + "[vite] pnpm install failed (exit %d) in %s — skipping vite " + "spawn; run the install manually and restart sphinx-autobuild", + returncode, + vite_root, + ) + return False + logger.info("[vite] pnpm install complete; proceeding to vite-watch spawn") + return True + + +def on_builder_inited(app: Sphinx) -> None: + """``builder-inited`` event handler. + + Spawns the Vite watch process when the resolved config asks for it. + Idempotent across multiple builder-inited firings (sphinx-autobuild + re-fires this on every rebuild). + + If ``/node_modules/`` is missing (typical after + ``git clean -fdx``), runs ``pnpm install --frozen-lockfile`` + synchronously first so ``pnpm exec vite`` resolves on first try. + """ + config = _build_config(app) + if not config.should_spawn: + return + + existing_proc: AsyncProcess | None = getattr(app, _PROC_ATTR, None) + if existing_proc is not None and existing_proc.is_running: + # sphinx-autobuild's repeated builder-inited; the watch is + # already running, leave it alone. + return + + bus = getattr(app, _BUS_ATTR, None) + if bus is None: + bus = AsyncioBus() + bus.start() + setattr(app, _BUS_ATTR, bus) + _active_handles[id(app)] = bus + + if config.vite_root is None: + # `should_spawn` already guards this, but tighten for type checkers. + msg = "should_spawn was True but vite_root resolved to None" + raise RuntimeError(msg) + + if not _ensure_node_modules(config.vite_root, bus): + # Install failed; warning was already logged. Don't try to + # spawn vite — pnpm exec would fail the same way. + return + + proc = AsyncProcess(label="vite", logger=logger) + setattr(app, _PROC_ATTR, proc) + + command = vite_watch_command() + logger.info("[vite] spawning %s in %s", " ".join(command), config.vite_root) + bus.call_sync(proc.start(command, cwd=config.vite_root)) + + if not getattr(app, _TEARDOWN_REGISTERED_ATTR, False): + _install_teardown_handlers(app) + setattr(app, _TEARDOWN_REGISTERED_ATTR, True) + + +def on_build_finished(app: Sphinx, exception: BaseException | None) -> None: + """``build-finished`` event handler. + + Deliberately a no-op: keeping the watch alive across rebuilds is + the whole point of the orchestration. Teardown happens via signal + handlers and the :mod:`atexit` registration installed at first + spawn. + + Logs the exception (if any) for context, but does not interfere + with Sphinx's own error reporting. + """ + if exception is not None: + logger.debug( + "[vite] sphinx build finished with exception (%s); leaving watch alive", + exception, + ) + + +def teardown(app: Sphinx, *, terminate_timeout: float = 5.0) -> None: + """Stop the Vite watch and tear down the bus for ``app``. + + Idempotent: safe to call from multiple signal sources (atexit + + SIGINT) without double-stop errors. + """ + proc: AsyncProcess | None = getattr(app, _PROC_ATTR, None) + bus: AsyncioBus | None = getattr(app, _BUS_ATTR, None) + if proc is None and bus is None: + return + + if proc is not None and bus is not None: + try: + bus.call_sync(proc.terminate(timeout=terminate_timeout)) + except Exception as exc: + logger.warning("[vite] terminate raised during teardown: %s", exc) + + if bus is not None: + bus.stop(timeout=terminate_timeout) + + setattr(app, _PROC_ATTR, None) + setattr(app, _BUS_ATTR, None) + + +def _install_teardown_handlers(app: Sphinx) -> None: + """Wire :data:`atexit` + signal handlers to tear down ``app``'s watch. + + Uses a weak reference to the app so a long-lived Python process + holding the handler doesn't keep the app alive past its natural + lifetime. + """ + app_ref = weakref.ref(app) + + def _handle_atexit() -> None: + live_app = app_ref() + if live_app is not None: + teardown(live_app) + + atexit.register(_handle_atexit) + + previous_handlers: dict[int, t.Any] = {} + for sig_name in ("SIGINT", "SIGTERM", "SIGHUP"): + sig = getattr(signal, sig_name, None) + if sig is None: + continue # Windows lacks SIGHUP, etc. + + def _make_handler( + sig: int, + previous: t.Any = None, + ) -> t.Callable[[int, t.Any], None]: + def _handle(signum: int, frame: t.Any) -> None: + live_app = app_ref() + if live_app is not None: + teardown(live_app) + if callable(previous): + previous(signum, frame) + # Re-raise the signal once cleanup is done so the + # default behavior (process exit) follows. + if previous in (signal.SIG_DFL, None): + signal.signal(signum, signal.SIG_DFL) + signal.raise_signal(signum) + + return _handle + + previous = signal.getsignal(sig) + previous_handlers[sig] = previous + signal.signal(sig, _make_handler(sig, previous)) diff --git a/tests/test_sphinx_vite_builder.py b/tests/test_sphinx_vite_builder.py index fa6c1abf..51658da6 100644 --- a/tests/test_sphinx_vite_builder.py +++ b/tests/test_sphinx_vite_builder.py @@ -18,18 +18,59 @@ def test_version_matches_workspace_lock() -> None: assert __version__ == "0.0.1a16.dev2" -def test_setup_returns_safety_metadata() -> None: - """``setup`` registers the extension and returns parallel-safety flags.""" +class _FakeApp: + """Minimal Sphinx-app stand-in for setup() smoke tests. + + Carries the slice of ``sphinx.application.Sphinx`` that + :func:`sphinx_vite_builder.setup` touches: ``add_config_value`` for + the two extension config keys, and ``connect`` for the lifecycle + handlers. + """ + + def __init__(self) -> None: + self.config_values: list[tuple[str, dict[str, object]]] = [] + self.events: list[tuple[str, object]] = [] - class _FakeApp: - pass + def add_config_value(self, name: str, **kwargs: object) -> None: + self.config_values.append((name, kwargs)) + def connect(self, event: str, callback: object) -> None: + self.events.append((event, callback)) + + +def test_setup_returns_safety_metadata() -> None: + """``setup`` registers the extension and returns parallel-safety flags.""" metadata = setup(_FakeApp()) # type: ignore[arg-type] assert metadata["parallel_read_safe"] is True assert metadata["parallel_write_safe"] is True assert metadata["version"] == __version__ +def test_setup_registers_mode_config_value() -> None: + """setup() registers sphinx_vite_builder_mode.""" + fake = _FakeApp() + setup(fake) # type: ignore[arg-type] + names = [name for name, _ in fake.config_values] + assert "sphinx_vite_builder_mode" in names + + +def test_setup_registers_root_config_value() -> None: + """setup() registers sphinx_vite_builder_root.""" + fake = _FakeApp() + setup(fake) # type: ignore[arg-type] + names = [name for name, _ in fake.config_values] + assert "sphinx_vite_builder_root" in names + + +def test_setup_connects_lifecycle_events() -> None: + """setup() connects to builder-inited and build-finished.""" + fake = _FakeApp() + setup(fake) # type: ignore[arg-type] + event_names = [name for name, _ in fake.events] + assert "builder-inited" in event_names + assert "build-finished" in event_names + + def test_extension_entry_point_is_discoverable() -> None: """The ``sphinx.extensions`` entry point lands on the right module.""" eps = importlib.metadata.entry_points(group="sphinx.extensions") From c2ededdc380b622c6dc2a1f2f7c93fb73490aeb8 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 3 May 2026 12:41:12 -0500 Subject: [PATCH 37/53] test(sphinx-vite-builder[extension]): port hooks + config + integration coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: The Phase 2 setup() rewrite + hooks port land without their behaviour exercised. Bring across the focused unit tests gp-sphinx-vite already proves out — autobuild-mode resolution branches, idempotent re-fire under sphinx-autobuild, install-then-spawn ordering, signal-driven teardown — and a Sphinx-build integration that loads the extension via its real entry point, all renamed to the new module/config/attribute names. what: - tests/test_sphinx_vite_builder_config.py — Mode + detect_mode + resolve_vite_root + SphinxViteBuilderConfig.should_spawn coverage (parametrized NamedTuple fixtures matching the workspace style) - tests/test_sphinx_vite_builder_hooks.py — on_builder_inited / on_build_finished / teardown lifecycle, idempotency under re-fire, fake-vite + fake-pnpm script-driven spawn coverage, install-fail short-circuit, private-attribute namespace stability - tests/test_sphinx_vite_builder_extension_integration.py — real Sphinx build via build_isolated_sphinx_result, wires the extension through its sphinx.extensions entry point, monkeypatches vite_watch_command at conf.py time so no real vite runtime is needed --- tests/test_sphinx_vite_builder_config.py | 184 ++++++++ ...hinx_vite_builder_extension_integration.py | 161 +++++++ tests/test_sphinx_vite_builder_hooks.py | 424 ++++++++++++++++++ 3 files changed, 769 insertions(+) create mode 100644 tests/test_sphinx_vite_builder_config.py create mode 100644 tests/test_sphinx_vite_builder_extension_integration.py create mode 100644 tests/test_sphinx_vite_builder_hooks.py diff --git a/tests/test_sphinx_vite_builder_config.py b/tests/test_sphinx_vite_builder_config.py new file mode 100644 index 00000000..9335c3d8 --- /dev/null +++ b/tests/test_sphinx_vite_builder_config.py @@ -0,0 +1,184 @@ +"""Tests for :mod:`sphinx_vite_builder._internal.config`. + +Pure-function coverage of the mode-detection + root-resolution layer; +no Sphinx fixtures, no subprocesses. +""" + +from __future__ import annotations + +import pathlib +import typing as t + +import pytest +from sphinx_vite_builder._internal.config import ( + Mode, + SphinxViteBuilderConfig, + detect_mode, + resolve_vite_root, +) + +# Mode detection — pure-function tests, no Sphinx fixture required. + + +class _ModeFixture(t.NamedTuple): + """One scenario for detect_mode().""" + + test_id: str + config_value: str + argv: list[str] + env: dict[str, str] + expected: Mode + + +_MODE_FIXTURES: list[_ModeFixture] = [ + _ModeFixture( + test_id="explicit_dev_overrides_argv", + config_value="dev", + argv=["sphinx-build", "docs"], + env={}, + expected=Mode.DEV, + ), + _ModeFixture( + test_id="explicit_prod_overrides_autobuild_env", + config_value="prod", + argv=["sphinx-autobuild"], + env={"SPHINX_AUTOBUILD": "1"}, + expected=Mode.PROD, + ), + _ModeFixture( + test_id="auto_with_sphinx_build_resolves_to_prod", + config_value="auto", + argv=["/usr/bin/sphinx-build", "docs", "_build"], + env={}, + expected=Mode.PROD, + ), + _ModeFixture( + test_id="auto_with_argv0_sphinx_autobuild_resolves_to_dev", + config_value="auto", + argv=["/usr/local/bin/sphinx-autobuild", "docs", "_build"], + env={}, + expected=Mode.DEV, + ), + _ModeFixture( + test_id="auto_with_env_var_resolves_to_dev", + config_value="auto", + argv=["sphinx-build"], + env={"SPHINX_AUTOBUILD": "1"}, + expected=Mode.DEV, + ), + _ModeFixture( + test_id="auto_with_empty_argv_falls_back_to_prod", + config_value="auto", + argv=[], + env={}, + expected=Mode.PROD, + ), + _ModeFixture( + test_id="garbage_falls_back_to_prod", + config_value="something-unknown", + argv=[], + env={}, + expected=Mode.PROD, + ), + _ModeFixture( + test_id="empty_string_falls_back_to_prod", + config_value="", + argv=[], + env={}, + expected=Mode.PROD, + ), +] + + +@pytest.mark.parametrize( + list(_ModeFixture._fields), + _MODE_FIXTURES, + ids=[f.test_id for f in _MODE_FIXTURES], +) +def test_detect_mode( + test_id: str, + config_value: str, + argv: list[str], + env: dict[str, str], + expected: Mode, +) -> None: + """detect_mode resolves to the expected mode across all branches. + + The ``test_id`` parameter is consumed by pytest's parametrize ``ids=`` + callback (see ``_MODE_FIXTURES`` above) and surfaces as the test name + suffix in pytest output. ``parent_check=lambda: False`` keeps these + pure-function tests independent of whatever process pytest is running + under. + """ + del test_id + assert ( + detect_mode( + config_value=config_value, + argv=argv, + env=env, + parent_check=lambda: False, + ) + is expected + ) + + +def test_detect_mode_parent_is_sphinx_autobuild() -> None: + """When the parent process is sphinx-autobuild, mode resolves to DEV. + + Closes the gap where sphinx-autobuild spawns sphinx-build as a + subprocess (so sys.argv[0] is the Python interpreter, not the + autobuild wrapper). + """ + result = detect_mode( + config_value="auto", + argv=["python", "-m", "sphinx", "build"], + env={}, + parent_check=lambda: True, + ) + assert result is Mode.DEV + + +def test_resolve_vite_root_none_returns_none() -> None: + """An unset sphinx_vite_builder_root yields None.""" + assert resolve_vite_root(None) is None + + +def test_resolve_vite_root_returns_absolute_path(tmp_path: pathlib.Path) -> None: + """A relative or absolute path resolves to an absolute Path.""" + resolved = resolve_vite_root(str(tmp_path)) + assert resolved is not None + assert resolved.is_absolute() + assert resolved == tmp_path.resolve() + + +def test_resolve_vite_root_accepts_pathlike(tmp_path: pathlib.Path) -> None: + """PathLike inputs (raw Path objects) work too.""" + resolved = resolve_vite_root(tmp_path) + assert resolved == tmp_path.resolve() + + +def test_should_spawn_requires_dev_mode_and_root(tmp_path: pathlib.Path) -> None: + """should_spawn is True only when mode=DEV and vite_root is set.""" + assert ( + SphinxViteBuilderConfig(mode=Mode.DEV, vite_root=tmp_path).should_spawn is True + ) + assert SphinxViteBuilderConfig(mode=Mode.DEV, vite_root=None).should_spawn is False + assert ( + SphinxViteBuilderConfig(mode=Mode.PROD, vite_root=tmp_path).should_spawn + is False + ) + assert SphinxViteBuilderConfig(mode=Mode.PROD, vite_root=None).should_spawn is False + + +def test_mode_compares_equal_to_string_literal() -> None: + """Mode values compare == to the literal config strings (str mixin). + + The ``str`` mixin in ``Mode(str, enum.Enum)`` makes the enum members + equal to their string values. This lets call sites do + ``app.config.sphinx_vite_builder_mode == Mode.DEV`` without an + explicit ``.value`` lookup, which is the ergonomic point of the + str-mixin. + """ + assert Mode.DEV.value == "dev" + assert Mode.PROD.value == "prod" + assert str(Mode.DEV) == "Mode.DEV" or Mode.DEV.value == "dev" diff --git a/tests/test_sphinx_vite_builder_extension_integration.py b/tests/test_sphinx_vite_builder_extension_integration.py new file mode 100644 index 00000000..d3ba61c1 --- /dev/null +++ b/tests/test_sphinx_vite_builder_extension_integration.py @@ -0,0 +1,161 @@ +"""Integration test: sphinx_vite_builder wired into a real Sphinx build. + +Exercises the full path — entry-point loaded from the Sphinx extensions +list, ``setup()`` invoked, ``builder-inited`` fires, the hook spawns +:class:`AsyncProcess` against a fake-vite script, ``build-finished`` fires +(no-op), and the test explicitly tears down. The unit tests in +``test_sphinx_vite_builder_hooks.py`` cover the same surface against a +hand-rolled FakeApp; this file proves the wiring through Sphinx itself. +""" + +from __future__ import annotations + +import shutil +import sys +import textwrap +import typing as t + +import pytest + +from tests._sphinx_scenarios import ( + ScenarioFile, + SharedSphinxResult, + SphinxScenario, + build_isolated_sphinx_result, +) + +if t.TYPE_CHECKING: + import pathlib + + +pytestmark = pytest.mark.skipif( + shutil.which(sys.executable.split("/")[-1]) is None and not sys.executable, + reason="No Python interpreter found on PATH for the fake-vite child", +) + + +_INDEX_RST = textwrap.dedent( + """\ + Integration Demo + ================ + + Hello. + """, +) + + +def _conf_py(*, fake_vite_root: str, fake_vite_argv: tuple[str, ...]) -> str: + """Build a conf.py that wires sphinx_vite_builder + monkey-patches the watch. + + The monkey-patch happens at conf.py time (which runs before + builder-inited fires), via + ``sphinx_vite_builder._internal.hooks.vite_watch_command`` being + replaced. The hook reads it from its own module-level rebinding done + at import time, so we patch *that* name. + """ + return textwrap.dedent( + f"""\ + import sphinx_vite_builder._internal.hooks as _svb_hooks + _svb_hooks.vite_watch_command = lambda: {fake_vite_argv!r} + + extensions = ["sphinx_vite_builder"] + html_theme = "basic" + master_doc = "index" + project = "integration demo" + sphinx_vite_builder_mode = "dev" + sphinx_vite_builder_root = {fake_vite_root!r} + """, + ) + + +@pytest.mark.integration +def test_sphinx_build_spawns_via_extension(tmp_path: pathlib.Path) -> None: + """A Sphinx build with the extension active spawns the watch process.""" + fake_vite_dir = tmp_path / "fake-vite-root" + fake_vite_dir.mkdir() + (fake_vite_dir / "package.json").write_text('{"name": "fake-vite-integration"}\n') + # Pre-create node_modules/ so _ensure_node_modules short-circuits the + # auto-install path (CI runners don't have pnpm on PATH). + (fake_vite_dir / "node_modules").mkdir() + fake_script = fake_vite_dir / "fake_vite.py" + fake_script.write_text( + textwrap.dedent( + """\ + import time + print("vite ready", flush=True) + while True: + time.sleep(0.1) + """, + ), + ) + + fake_vite_argv = (sys.executable, str(fake_script)) + scenario = SphinxScenario( + files=( + ScenarioFile( + "conf.py", + _conf_py( + fake_vite_root=str(fake_vite_dir), + fake_vite_argv=fake_vite_argv, + ), + ), + ScenarioFile("index.rst", _INDEX_RST), + ), + ) + + result: SharedSphinxResult = build_isolated_sphinx_result( + cache_root=tmp_path / "scenario-cache", + tmp_path=tmp_path / "scenario-tmp", + scenario=scenario, + purge_modules=( + "sphinx_vite_builder", + "sphinx_vite_builder._internal", + "sphinx_vite_builder._internal.hooks", + ), + ) + + proc = getattr(result.app, "_sphinx_vite_builder_proc", None) + bus = getattr(result.app, "_sphinx_vite_builder_bus", None) + try: + assert proc is not None, "hooks did not stash an AsyncProcess on the app" + assert bus is not None, "hooks did not stash an AsyncioBus on the app" + assert proc.is_running, "AsyncProcess exited before the test could observe it" + assert bus.is_running, "AsyncioBus stopped before the test could observe it" + finally: + # Explicit teardown — atexit-based cleanup runs at interpreter + # exit, which is fine for production but leaves the test + # process holding the bus thread until then. + from sphinx_vite_builder._internal import hooks + + hooks.teardown(result.app, terminate_timeout=2.0) + + +@pytest.mark.integration +def test_sphinx_build_no_op_in_prod_mode(tmp_path: pathlib.Path) -> None: + """`sphinx_vite_builder_mode = "prod"` builds without spawning anything.""" + scenario = SphinxScenario( + files=( + ScenarioFile( + "conf.py", + textwrap.dedent( + """\ + extensions = ["sphinx_vite_builder"] + html_theme = "basic" + master_doc = "index" + project = "no-op demo" + sphinx_vite_builder_mode = "prod" + """, + ), + ), + ScenarioFile("index.rst", _INDEX_RST), + ), + ) + + result: SharedSphinxResult = build_isolated_sphinx_result( + cache_root=tmp_path / "scenario-cache", + tmp_path=tmp_path / "scenario-tmp", + scenario=scenario, + purge_modules=("sphinx_vite_builder",), + ) + assert getattr(result.app, "_sphinx_vite_builder_proc", None) is None + assert getattr(result.app, "_sphinx_vite_builder_bus", None) is None diff --git a/tests/test_sphinx_vite_builder_hooks.py b/tests/test_sphinx_vite_builder_hooks.py new file mode 100644 index 00000000..fa85317b --- /dev/null +++ b/tests/test_sphinx_vite_builder_hooks.py @@ -0,0 +1,424 @@ +"""Tests for :mod:`sphinx_vite_builder._internal.hooks`. + +The hooks layer wires an :class:`AsyncProcess` to Sphinx's lifecycle +events. Tests use a thin Sphinx-app stand-in (with ``.config`` and the +four attributes the hooks set) and a fake-vite Python script in +``tmp_path`` so each test can exercise the real subprocess + +``AsyncioBus`` + ``AsyncProcess`` chain end-to-end without booting a +full Sphinx build. +""" + +from __future__ import annotations + +import dataclasses +import pathlib +import sys +import textwrap +import time + +import pytest +from sphinx_vite_builder._internal import hooks + + +@dataclasses.dataclass +class _FakeConfig: + """The slice of ``app.config`` the hooks read.""" + + sphinx_vite_builder_mode: str = "auto" + sphinx_vite_builder_root: str | None = None + + +@dataclasses.dataclass +class _FakeApp: + """Minimal stand-in for ``sphinx.application.Sphinx``. + + Carries only the surface the hooks touch: a ``config`` namespace + and the few private attributes the hooks ``setattr`` onto the app + (bus, proc, teardown-registered flag). + """ + + config: _FakeConfig = dataclasses.field(default_factory=_FakeConfig) + + +def _write_fake_vite( + tmp_path: pathlib.Path, *, body: str, with_node_modules: bool = True +) -> pathlib.Path: + """Write a fake-vite script + a stub package.json at ``tmp_path``. + + Creates ``node_modules/`` by default so :func:`hooks._ensure_node_modules` + short-circuits the auto-install path. Tests that exercise the install + path explicitly pass ``with_node_modules=False`` and arrange their own + ``pnpm_install_command`` patch. + """ + (tmp_path / "package.json").write_text('{"name": "fake-vite-root"}\n') + if with_node_modules: + (tmp_path / "node_modules").mkdir(exist_ok=True) + script = tmp_path / "fake_vite.py" + script.write_text(textwrap.dedent(body)) + return script + + +def _patch_vite_command(monkeypatch: pytest.MonkeyPatch, script: pathlib.Path) -> None: + """Replace ``vite_watch_command()`` with one that runs ``script``.""" + + def _fake_command() -> tuple[str, ...]: + return (sys.executable, str(script)) + + # Patch where hooks reads the symbol from (its own module namespace). + monkeypatch.setattr(hooks, "vite_watch_command", _fake_command) + + +def _patch_install_command( + monkeypatch: pytest.MonkeyPatch, script: pathlib.Path +) -> None: + """Replace ``pnpm_install_command()`` with one that runs ``script``.""" + + def _fake_command() -> tuple[str, ...]: + return (sys.executable, str(script)) + + monkeypatch.setattr(hooks, "pnpm_install_command", _fake_command) + + +@pytest.fixture +def long_running_fake_vite( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> _FakeApp: + """Fake-vite that loops forever; a teardown is required to clean up.""" + script = _write_fake_vite( + tmp_path, + body="""\ + import sys, time + # Print one ready-line so a human running this can see progress. + print("vite watching", flush=True) + while True: + time.sleep(0.1) + """, + ) + _patch_vite_command(monkeypatch, script) + return _FakeApp( + config=_FakeConfig( + sphinx_vite_builder_mode="dev", + sphinx_vite_builder_root=str(tmp_path), + ), + ) + + +def test_on_builder_inited_no_op_in_prod_mode( + tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """`mode="prod"` → no process spawned, no bus started.""" + app = _FakeApp( + config=_FakeConfig( + sphinx_vite_builder_mode="prod", + sphinx_vite_builder_root=str(tmp_path), + ), + ) + + def _fail() -> tuple[str, ...]: + msg = "vite_watch_command should not be called in prod mode" + raise AssertionError(msg) + + monkeypatch.setattr(hooks, "vite_watch_command", _fail) + hooks.on_builder_inited(app) # type: ignore[arg-type] + assert getattr(app, hooks._PROC_ATTR, None) is None + assert getattr(app, hooks._BUS_ATTR, None) is None + + +def test_on_builder_inited_no_op_when_root_is_none( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """`mode="dev"` but no root → still no spawn (config.should_spawn is False).""" + app = _FakeApp( + config=_FakeConfig( + sphinx_vite_builder_mode="dev", + sphinx_vite_builder_root=None, + ), + ) + + def _fail() -> tuple[str, ...]: + msg = "vite_watch_command should not be called when root is None" + raise AssertionError(msg) + + monkeypatch.setattr(hooks, "vite_watch_command", _fail) + hooks.on_builder_inited(app) # type: ignore[arg-type] + assert getattr(app, hooks._PROC_ATTR, None) is None + + +def test_on_builder_inited_spawns_when_should_spawn( + long_running_fake_vite: _FakeApp, +) -> None: + """A dev-mode app with a real root spawns the watch.""" + app = long_running_fake_vite + try: + hooks.on_builder_inited(app) # type: ignore[arg-type] + proc = getattr(app, hooks._PROC_ATTR, None) + bus = getattr(app, hooks._BUS_ATTR, None) + assert proc is not None + assert bus is not None + assert bus.is_running + # Give the child a moment to actually spawn before asserting. + deadline = time.monotonic() + 1.0 + while not proc.is_running and time.monotonic() < deadline: + time.sleep(0.01) + assert proc.is_running + finally: + hooks.teardown(app, terminate_timeout=2.0) # type: ignore[arg-type] + + +def test_on_builder_inited_is_idempotent_on_refire( + long_running_fake_vite: _FakeApp, +) -> None: + """sphinx-autobuild's repeated builder-inited doesn't double-spawn.""" + app = long_running_fake_vite + try: + hooks.on_builder_inited(app) # type: ignore[arg-type] + first_proc = getattr(app, hooks._PROC_ATTR, None) + first_pid = first_proc.pid if first_proc else None + + # Re-fire: simulating sphinx-autobuild's behavior. + hooks.on_builder_inited(app) # type: ignore[arg-type] + second_proc = getattr(app, hooks._PROC_ATTR, None) + second_pid = second_proc.pid if second_proc else None + + assert first_proc is second_proc + assert first_pid == second_pid + finally: + hooks.teardown(app, terminate_timeout=2.0) # type: ignore[arg-type] + + +def test_on_build_finished_leaves_watch_running( + long_running_fake_vite: _FakeApp, +) -> None: + """build-finished is a no-op: the watch keeps running for the next rebuild.""" + app = long_running_fake_vite + try: + hooks.on_builder_inited(app) # type: ignore[arg-type] + proc = getattr(app, hooks._PROC_ATTR, None) + assert proc is not None + deadline = time.monotonic() + 1.0 + while not proc.is_running and time.monotonic() < deadline: + time.sleep(0.01) + assert proc.is_running + + hooks.on_build_finished(app, exception=None) # type: ignore[arg-type] + # Still running after build-finished. + assert proc.is_running + finally: + hooks.teardown(app, terminate_timeout=2.0) # type: ignore[arg-type] + + +def test_teardown_terminates_process_and_stops_bus( + long_running_fake_vite: _FakeApp, +) -> None: + """Explicit teardown stops both the process and the bus, idempotently.""" + app = long_running_fake_vite + hooks.on_builder_inited(app) # type: ignore[arg-type] + proc = getattr(app, hooks._PROC_ATTR, None) + bus = getattr(app, hooks._BUS_ATTR, None) + assert proc is not None and bus is not None + + hooks.teardown(app, terminate_timeout=2.0) # type: ignore[arg-type] + + assert not proc.is_running + assert not bus.is_running + assert getattr(app, hooks._PROC_ATTR, None) is None + assert getattr(app, hooks._BUS_ATTR, None) is None + + # Calling teardown again is a no-op. + hooks.teardown(app, terminate_timeout=2.0) # type: ignore[arg-type] + + +def test_teardown_no_op_when_never_spawned() -> None: + """Teardown on an app that never reached should_spawn does nothing harmful.""" + app = _FakeApp() + hooks.teardown(app) # type: ignore[arg-type] + + +def test_on_build_finished_logs_exception( + long_running_fake_vite: _FakeApp, +) -> None: + """An exception passed to build-finished surfaces at DEBUG (not WARNING). + + Sphinx's logger setup (memory handlers, namespace prefix) interacts + with pytest's ``caplog`` in test-order-dependent ways once any + Sphinx scenario fixture has initialized a real Sphinx app. Sidestep + by attaching our own handler directly to the underlying stdlib + Logger that ``sphinx.util.logging.getLogger`` wraps. + """ + import logging + + app = long_running_fake_vite + captured: list[logging.LogRecord] = [] + + class _CaptureHandler(logging.Handler): + def emit(self, record: logging.LogRecord) -> None: + captured.append(record) + + handler = _CaptureHandler(level=logging.DEBUG) + underlying = logging.getLogger("sphinx.sphinx_vite_builder._internal.hooks") + underlying.addHandler(handler) + underlying.setLevel(logging.DEBUG) + + try: + hooks.on_builder_inited(app) # type: ignore[arg-type] + hooks.on_build_finished( + app, # type: ignore[arg-type] + exception=RuntimeError("sphinx fell over"), + ) + assert any("sphinx fell over" in r.getMessage() for r in captured), [ + r.getMessage() for r in captured + ] + finally: + underlying.removeHandler(handler) + hooks.teardown(app, terminate_timeout=2.0) # type: ignore[arg-type] + + +def test_on_builder_inited_skips_install_when_node_modules_present( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Pre-existing node_modules/ → no install attempt; vite spawns directly.""" + # Pre-create node_modules so _ensure_node_modules sees it as present. + (tmp_path / "node_modules").mkdir() + vite_script = _write_fake_vite( + tmp_path, + body="""\ + import time + print("vite watching", flush=True) + while True: + time.sleep(0.1) + """, + ) + _patch_vite_command(monkeypatch, vite_script) + + def _fail_install() -> tuple[str, ...]: + msg = "pnpm_install_command should not be called when node_modules/ exists" + raise AssertionError(msg) + + monkeypatch.setattr(hooks, "pnpm_install_command", _fail_install) + + app = _FakeApp( + config=_FakeConfig( + sphinx_vite_builder_mode="dev", + sphinx_vite_builder_root=str(tmp_path), + ), + ) + try: + hooks.on_builder_inited(app) # type: ignore[arg-type] + proc = getattr(app, hooks._PROC_ATTR, None) + assert proc is not None, "vite should have spawned" + finally: + hooks.teardown(app, terminate_timeout=2.0) # type: ignore[arg-type] + + +def test_on_builder_inited_runs_install_when_node_modules_missing( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Missing node_modules/ → install runs (and creates it), then vite spawns. + + The install marker file (``installed.flag``) is the deterministic proof + the fake-pnpm script ran; ``node_modules/`` creation is the side-effect + that silences subsequent _ensure_node_modules calls on a re-fire. + """ + install_marker = tmp_path / "installed.flag" + install_script = tmp_path / "fake_pnpm.py" + install_script.write_text( + textwrap.dedent( + f"""\ + import pathlib + (pathlib.Path({str(install_marker)!r})).write_text("ran") + (pathlib.Path({str(tmp_path / "node_modules")!r})).mkdir() + """, + ), + ) + vite_script = _write_fake_vite( + tmp_path, + with_node_modules=False, + body="""\ + import time + print("vite watching", flush=True) + while True: + time.sleep(0.1) + """, + ) + _patch_vite_command(monkeypatch, vite_script) + _patch_install_command(monkeypatch, install_script) + + app = _FakeApp( + config=_FakeConfig( + sphinx_vite_builder_mode="dev", + sphinx_vite_builder_root=str(tmp_path), + ), + ) + try: + hooks.on_builder_inited(app) # type: ignore[arg-type] + assert install_marker.exists(), "fake-pnpm install should have run" + assert (tmp_path / "node_modules").exists(), ( + "install should have created node_modules" + ) + proc = getattr(app, hooks._PROC_ATTR, None) + assert proc is not None, "vite should have spawned after successful install" + finally: + hooks.teardown(app, terminate_timeout=2.0) # type: ignore[arg-type] + + +def test_on_builder_inited_skips_vite_when_install_fails( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Install exits non-zero → vite is not spawned; warning is logged.""" + install_script = tmp_path / "fake_pnpm.py" + install_script.write_text( + textwrap.dedent( + """\ + import sys + print("simulated pnpm-install failure", flush=True) + sys.exit(1) + """, + ), + ) + + def _fail_vite() -> tuple[str, ...]: + msg = "vite_watch_command should not be called after install failure" + raise AssertionError(msg) + + monkeypatch.setattr(hooks, "vite_watch_command", _fail_vite) + _patch_install_command(monkeypatch, install_script) + + # We still need a package.json so config.should_spawn passes the + # vite_root resolution check. + (tmp_path / "package.json").write_text('{"name": "fake-vite-root"}\n') + + app = _FakeApp( + config=_FakeConfig( + sphinx_vite_builder_mode="dev", + sphinx_vite_builder_root=str(tmp_path), + ), + ) + hooks.on_builder_inited(app) # type: ignore[arg-type] + # No vite process should have been set on the app. + assert getattr(app, hooks._PROC_ATTR, None) is None, ( + "vite must not be spawned after a failed install" + ) + hooks.teardown(app, terminate_timeout=2.0) # type: ignore[arg-type] + + +def test_private_attr_names_are_stable() -> None: + """The private attribute names the hooks set on app are part of the contract.""" + assert hooks._BUS_ATTR == "_sphinx_vite_builder_bus" + assert hooks._PROC_ATTR == "_sphinx_vite_builder_proc" + + +_PRIVATE_ATTRS_TYPED: tuple[str, str, str] = ( + hooks._BUS_ATTR, + hooks._PROC_ATTR, + hooks._TEARDOWN_REGISTERED_ATTR, +) + + +def test_all_private_attrs_share_prefix() -> None: + """Every private attribute starts with `_sphinx_vite_builder_`.""" + for attr in _PRIVATE_ATTRS_TYPED: + assert attr.startswith("_sphinx_vite_builder_"), attr From 40ce20b621a59b522e1ae1d1bd8e8db5094633eb Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 3 May 2026 12:45:21 -0500 Subject: [PATCH 38/53] refactor(workspace): switch consumers from gp_sphinx_vite to sphinx_vite_builder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: With the Phase 2 extension head live in sphinx_vite_builder, every gp_sphinx_vite call site in the workspace can point at the new package. Update gp-sphinx's vite_orchestration auto-injection, drop the docs justfile's _assets-build prerequisite (the extension's PROD-mode hook now runs `pnpm exec vite build` once before sphinx-build, replacing the manual recipe), and rename docstring/conf.py references so the single source of truth is sphinx_vite_builder. what: - Extend on_builder_inited to run a one-shot run_vite_build() under Mode.PROD, completing the spec ("one-shot for prod, watch for dev"). Reuses the PEP-517 backend's orchestration core, so SKIP env-var, web/-absent short-circuit, and fast-fail diagnostics all carry over - packages/gp-sphinx/src/gp_sphinx/config.py — vite_orchestration=True now prepends "sphinx_vite_builder" + sets sphinx_vite_builder_root (was gp_sphinx_vite / gp_sphinx_vite_root) - packages/gp-furo-theme/src/gp_furo_theme/__init__.py — get_vite_root docstring example uses sphinx_vite_builder_root - docs/conf.py — comment refers to sphinx-vite-builder - docs/justfile — drop _assets-build recipe + the prerequisite from every HTML/EPUB/htmlhelp/qthelp/devhelp builder; add a header note describing how the extension orchestrates vite without justfile glue - docs/packages/sphinx-vite-builder.md — replace placeholder Sphinx- extension framing with the actual lifecycle docs (modes, config values, autobuild detection, PROD/DEV split) - tests/test_config.py — three vite_orchestration assertions updated to the new extension + config-key names --- docs/conf.py | 11 ++-- docs/justfile | 61 +++++-------------- docs/packages/sphinx-vite-builder.md | 29 ++++++--- .../src/gp_furo_theme/__init__.py | 4 +- packages/gp-sphinx/src/gp_sphinx/config.py | 16 ++--- .../sphinx_vite_builder/_internal/hooks.py | 27 ++++++-- tests/test_config.py | 14 ++--- 7 files changed, 82 insertions(+), 80 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 2b9a72da..71235d09 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -96,11 +96,12 @@ pytest_fixture_lint_level="none", rediraffe_redirects="redirects.txt", intersphinx_mapping=intersphinx_mapping, - # Enable Vite orchestration: under `sphinx-autobuild`, gp-sphinx-vite - # spawns `pnpm exec vite build --watch` so contributors editing - # gp-furo-theme/web/src see fresh CSS/JS on disk without remembering - # a separate command. No-op for `sphinx-build` (mode resolves to - # "prod"), so wheel publishes carry no Node runtime requirement. + # Enable Vite orchestration: under `sphinx-autobuild`, + # sphinx-vite-builder spawns `pnpm exec vite build --watch` so + # contributors editing gp-furo-theme/web/src see fresh CSS/JS on + # disk without remembering a separate command. No-op for + # `sphinx-build` (mode resolves to "prod"), so wheel publishes + # carry no Node runtime requirement. vite_orchestration=True, ) globals().update(conf) diff --git a/docs/justfile b/docs/justfile index bb495acf..f5243cf6 100644 --- a/docs/justfile +++ b/docs/justfile @@ -20,66 +20,37 @@ allsphinxopts := "-d " + builddir + "/doctrees " + sphinxopts + " ." default: @just --list -# Build vite-managed theme assets (CSS + JS) before any HTML-output -# build. Prerequisite of every HTML-output builder below. +# Note on Vite asset orchestration: # -# Resolution chain: -# 1. ``gp_furo_theme.get_vite_root()`` returns the absolute path of -# ``packages/gp-furo-theme/web/`` under a workspace checkout, or -# ``None`` when running from an installed wheel (the wheel ships -# pre-built assets — no vite step needed). -# 2. If ``node_modules/`` is absent, a one-shot -# ``pnpm install --frozen-lockfile`` populates it. Mirrors -# ``gp_sphinx_vite.hooks._ensure_node_modules`` semantics so -# ``just clean; just html`` works without a separate install. -# 3. ``pnpm exec vite build`` produces -# ``static/{scripts/furo.js,styles/furo-tw.css}`` in the theme -# tree, where sphinx-build later copies them into ``_static/``. -# -# Skip cleanly when gp-furo-theme is installed from a wheel, or when -# the user genuinely lacks pnpm — the build proceeds without fresh -# assets and any cached output remains in place. Non-HTML builders -# (man, latex, gettext, etc.) don't depend on this recipe at all. -[private] -_assets-build: - #!/usr/bin/env bash - set -euo pipefail - web_root=$(uv run python -c 'import gp_furo_theme; r = gp_furo_theme.get_vite_root(); print(r or "")' 2>/dev/null || true) - if [ -z "$web_root" ]; then - echo "[assets] gp_furo_theme.get_vite_root() returned None — assuming wheel install (pre-built assets in tree)" - exit 0 - fi - if ! command -v pnpm >/dev/null 2>&1; then - echo "[assets] pnpm not on PATH; skipping vite build (any pre-existing output remains)" - exit 0 - fi - if [ ! -d "$web_root/node_modules" ]; then - echo "[assets] node_modules missing in $web_root — running pnpm install --frozen-lockfile" - (cd "$web_root" && pnpm install --frozen-lockfile) - fi - echo "[assets] running pnpm exec vite build in $web_root" - (cd "$web_root" && pnpm exec vite build) +# The ``sphinx_vite_builder`` extension (loaded via gp-sphinx's +# ``vite_orchestration=True``) hooks ``builder-inited`` so any HTML +# build automatically runs ``pnpm exec vite build`` (PROD, one-shot) +# or ``pnpm exec vite build --watch`` (DEV, long-running under +# ``sphinx-autobuild``) before Sphinx starts assembling output. No +# justfile prerequisite is needed; the backend's ``web/``-absent +# short-circuit + ``SPHINX_VITE_BUILDER_SKIP=1`` escape hatch keep +# wheel-only environments friction-free. # Build HTML documentation -html: _assets-build +html: {{ sphinxbuild }} -b dirhtml {{ allsphinxopts }} {{ builddir }}/html @echo "" @echo "Build finished. The HTML pages are in {{ builddir }}/html." # Build directory HTML files -dirhtml: _assets-build +dirhtml: {{ sphinxbuild }} -b dirhtml {{ allsphinxopts }} {{ builddir }}/dirhtml @echo "" @echo "Build finished. The HTML pages are in {{ builddir }}/dirhtml." # Build single HTML file -singlehtml: _assets-build +singlehtml: {{ sphinxbuild }} -b singlehtml {{ allsphinxopts }} {{ builddir }}/singlehtml @echo "" @echo "Build finished. The HTML page is in {{ builddir }}/singlehtml." # Build EPUB -epub: _assets-build +epub: {{ sphinxbuild }} -b epub {{ allsphinxopts }} {{ builddir }}/epub @echo "" @echo "Build finished. The epub file is in {{ builddir }}/epub." @@ -122,19 +93,19 @@ clean: rm -rf ../packages/gp-furo-theme/src/gp_furo_theme/theme/gp-furo/static/ # Build HTML help files -htmlhelp: _assets-build +htmlhelp: {{ sphinxbuild }} -b htmlhelp {{ allsphinxopts }} {{ builddir }}/htmlhelp @echo "" @echo "Build finished; now you can run HTML Help Workshop with the .hhp project file in {{ builddir }}/htmlhelp." # Build Qt help files -qthelp: _assets-build +qthelp: {{ sphinxbuild }} -b qthelp {{ allsphinxopts }} {{ builddir }}/qthelp @echo "" @echo "Build finished; now you can run 'qcollectiongenerator' with the .qhcp project file in {{ builddir }}/qthelp." # Build Devhelp files -devhelp: _assets-build +devhelp: {{ sphinxbuild }} -b devhelp {{ allsphinxopts }} {{ builddir }}/devhelp @echo "" @echo "Build finished." diff --git a/docs/packages/sphinx-vite-builder.md b/docs/packages/sphinx-vite-builder.md index 8042c149..baf41578 100644 --- a/docs/packages/sphinx-vite-builder.md +++ b/docs/packages/sphinx-vite-builder.md @@ -34,27 +34,42 @@ backend-path = ["../sphinx-vite-builder/src"] # for in-tree workspace consump ### Sphinx extension -Loaded from `conf.py`. Hooks the Sphinx event lifecycle so -`sphinx-build` / `sphinx-autobuild` automatically run the right vite -invocation — one-shot for production builds, watch mode for autobuild — -without contributors needing a justfile or Makefile. +Loaded from `conf.py`. Hooks `builder-inited` and `build-finished` so +`sphinx-build` and `sphinx-autobuild` automatically run the right vite +invocation: a one-shot `pnpm exec vite build` for plain `sphinx-build` +(or `sphinx_vite_builder_mode = "prod"`), a long-lived +`pnpm exec vite build --watch` child process for `sphinx-autobuild` +(or `sphinx_vite_builder_mode = "dev"`), with graceful SIGTERM → +SIGKILL teardown on signal / `atexit`. ```python # docs/conf.py extensions = ["sphinx_vite_builder"] +sphinx_vite_builder_mode = "auto" # "auto" | "dev" | "prod" +sphinx_vite_builder_root = "/abs/path/to/web" ``` +`"auto"` resolves to `"dev"` when the build is running under +`sphinx-autobuild` (detected via `SPHINX_AUTOBUILD` env var, `argv[0]`, +or parent-process inspection on Linux), otherwise `"prod"`. Setting +`sphinx_vite_builder_root` to `None` (the default) makes the extension +a complete no-op — useful when the consumer is installed from a wheel +where the static tree is already pre-baked. + ## Fast-fail diagnostics When prerequisites are missing the backend / extension raises actionable errors rather than producing broken output: - `PnpmMissingError` — `pnpm` not on `PATH`; hint includes - `corepack enable` and the [pnpm.io/installation](https://pnpm.io/installation) URL + `corepack enable`, the [pnpm.io/installation](https://pnpm.io/installation) + URL, and a per-CI YAML/config snippet (GitHub Actions, CircleCI, + Azure Pipelines, GitLab CI) when the build is detected to be + running in CI. - `NodeModulesInstallError` — `pnpm install` exited non-zero; hint - includes the rerun command + includes the rerun command and captured stderr. - `ViteFailedError` — `pnpm exec vite build` exited non-zero; hint - surfaces the captured stderr + surfaces the captured stderr. Set `SPHINX_VITE_BUILDER_SKIP=1` in the environment to short-circuit the backend (e.g., when an external orchestration handles vite). diff --git a/packages/gp-furo-theme/src/gp_furo_theme/__init__.py b/packages/gp-furo-theme/src/gp_furo_theme/__init__.py index 093bf432..eb4a54c0 100644 --- a/packages/gp-furo-theme/src/gp_furo_theme/__init__.py +++ b/packages/gp-furo-theme/src/gp_furo_theme/__init__.py @@ -417,8 +417,8 @@ def get_vite_root() -> pathlib.Path | None: when running from an installed wheel (the wheel ships pre-built static assets but not the SCSS/TS sources). - Intended for use by ``gp-sphinx-vite`` consumers — set - ``gp_sphinx_vite_root = gp_furo_theme.get_vite_root()`` in + Intended for use by ``sphinx-vite-builder`` consumers — set + ``sphinx_vite_builder_root = gp_furo_theme.get_vite_root()`` in ``conf.py`` (or wire it through :func:`gp_sphinx.config.merge_sphinx_config`) so the orchestration finds the right ``cwd`` to spawn ``vite`` in. diff --git a/packages/gp-sphinx/src/gp_sphinx/config.py b/packages/gp-sphinx/src/gp_sphinx/config.py index 711ad7cc..4963ad66 100644 --- a/packages/gp-sphinx/src/gp_sphinx/config.py +++ b/packages/gp-sphinx/src/gp_sphinx/config.py @@ -273,9 +273,9 @@ def merge_sphinx_config( intersphinx_mapping : dict | None Intersphinx targets. vite_orchestration : bool - When ``True`` (default ``False``), prepends ``"gp_sphinx_vite"`` to - the active extension list and sets ``gp_sphinx_vite_root`` from - :func:`gp_furo_theme.get_vite_root` so contributors running + When ``True`` (default ``False``), prepends ``"sphinx_vite_builder"`` + to the active extension list and sets ``sphinx_vite_builder_root`` + from :func:`gp_furo_theme.get_vite_root` so contributors running ``sphinx-autobuild`` get the Vite watch fired automatically. The orchestration is a no-op for ``sphinx-build`` (mode resolves to ``"prod"``), so wheels published to PyPI carry no Node runtime @@ -373,12 +373,12 @@ def merge_sphinx_config( remove_set = set(remove_extensions) ext_list = [e for e in ext_list if e not in remove_set] - # Vite orchestration: prepend gp_sphinx_vite so its hooks register + # Vite orchestration: prepend sphinx_vite_builder so its hooks register # before any extension that might also touch builder-inited. vite_root_setting: str | None = None if vite_orchestration: - if "gp_sphinx_vite" not in ext_list: - ext_list.insert(0, "gp_sphinx_vite") + if "sphinx_vite_builder" not in ext_list: + ext_list.insert(0, "sphinx_vite_builder") try: import gp_furo_theme except ImportError: @@ -492,9 +492,9 @@ def merge_sphinx_config( # ``sitemap_url_scheme`` via ``**overrides``. conf["sitemap_url_scheme"] = "{link}" - # Wire gp-sphinx-vite's orchestration root if it was resolved above. + # Wire sphinx-vite-builder's orchestration root if it was resolved above. if vite_root_setting is not None: - conf["gp_sphinx_vite_root"] = vite_root_setting + conf["sphinx_vite_builder_root"] = vite_root_setting # Apply overrides last (can override auto-computed values) conf.update(overrides) diff --git a/packages/sphinx-vite-builder/src/sphinx_vite_builder/_internal/hooks.py b/packages/sphinx-vite-builder/src/sphinx_vite_builder/_internal/hooks.py index 59945493..6641ddf7 100644 --- a/packages/sphinx-vite-builder/src/sphinx_vite_builder/_internal/hooks.py +++ b/packages/sphinx-vite-builder/src/sphinx_vite_builder/_internal/hooks.py @@ -36,9 +36,9 @@ from sphinx.util import logging as sphinx_logging from .bus import AsyncioBus -from .config import SphinxViteBuilderConfig, detect_mode, resolve_vite_root +from .config import Mode, SphinxViteBuilderConfig, detect_mode, resolve_vite_root from .process import AsyncProcess -from .vite import pnpm_install_command, vite_watch_command +from .vite import pnpm_install_command, run_vite_build, vite_watch_command if t.TYPE_CHECKING: from sphinx.application import Sphinx @@ -112,16 +112,31 @@ def _ensure_node_modules(vite_root: pathlib.Path, bus: AsyncioBus) -> bool: def on_builder_inited(app: Sphinx) -> None: """``builder-inited`` event handler. - Spawns the Vite watch process when the resolved config asks for it. - Idempotent across multiple builder-inited firings (sphinx-autobuild - re-fires this on every rebuild). + DEV mode (``sphinx-autobuild``) spawns the long-running Vite watch + process; PROD mode (plain ``sphinx-build``) runs a one-shot + ``pnpm exec vite build`` and blocks until it finishes so the + subsequent Sphinx build sees fresh CSS/JS in ``static/``. Idempotent + across multiple builder-inited firings (sphinx-autobuild re-fires + this on every rebuild). If ``/node_modules/`` is missing (typical after ``git clean -fdx``), runs ``pnpm install --frozen-lockfile`` synchronously first so ``pnpm exec vite`` resolves on first try. """ config = _build_config(app) - if not config.should_spawn: + if config.vite_root is None: + # No vite_root configured → nothing to orchestrate. Both modes + # treat this as "the consumer doesn't have a vite project to + # build" (typical when running off an installed wheel). + return + + if config.mode is Mode.PROD: + # One-shot build via the shared backend orchestration. Same + # short-circuits (SPHINX_VITE_BUILDER_SKIP, web/-absent) and + # same fast-fail diagnostics as the PEP 517 backend uses. + # ``run_vite_build`` resolves ``web/`` relative to its + # ``project_root`` argument, so pass the parent of vite_root. + run_vite_build(project_root=config.vite_root.parent) return existing_proc: AsyncProcess | None = getattr(app, _PROC_ATTR, None) diff --git a/tests/test_config.py b/tests/test_config.py index 83a3a058..c4301d35 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -125,25 +125,25 @@ def test_merge_sphinx_config_logos() -> None: def test_merge_sphinx_config_vite_orchestration_off_by_default() -> None: - """Without vite_orchestration=True, gp_sphinx_vite is NOT in extensions.""" + """Without vite_orchestration=True, sphinx_vite_builder is NOT in extensions.""" result = merge_sphinx_config(project="test", version="1.0", copyright="2026") - assert "gp_sphinx_vite" not in result["extensions"] - assert "gp_sphinx_vite_root" not in result + assert "sphinx_vite_builder" not in result["extensions"] + assert "sphinx_vite_builder_root" not in result def test_merge_sphinx_config_vite_orchestration_prepends_extension() -> None: - """vite_orchestration=True prepends gp_sphinx_vite as the first extension.""" + """vite_orchestration=True prepends sphinx_vite_builder as the first extension.""" result = merge_sphinx_config( project="test", version="1.0", copyright="2026", vite_orchestration=True, ) - assert result["extensions"][0] == "gp_sphinx_vite" + assert result["extensions"][0] == "sphinx_vite_builder" def test_merge_sphinx_config_vite_orchestration_sets_root_from_workspace() -> None: - """vite_orchestration=True resolves gp_sphinx_vite_root from gp_furo_theme.""" + """vite_orchestration=True resolves sphinx_vite_builder_root from gp_furo_theme.""" import gp_furo_theme expected = gp_furo_theme.get_vite_root() @@ -156,7 +156,7 @@ def test_merge_sphinx_config_vite_orchestration_sets_root_from_workspace() -> No copyright="2026", vite_orchestration=True, ) - assert result["gp_sphinx_vite_root"] == str(expected) + assert result["sphinx_vite_builder_root"] == str(expected) def test_merge_sphinx_config_intersphinx_mapping() -> None: From e70e9e379b41ea7be38b8cc615e4303055590072 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 3 May 2026 12:50:25 -0500 Subject: [PATCH 39/53] pkg(sphinx-vite-builder[hatch]): hatchling build-hook variant for [tool.hatch.build.hooks.vite] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: Phase 3 Milestone A — consumers who already use hatchling.build shouldn't have to swap their build-backend to get the vite-build-before- package behaviour. Offer the same orchestration as a hatchling plugin so `[tool.hatch.build.hooks.vite]` activates it alongside any other hatchling hooks the consumer already runs. what: - Add packages/sphinx-vite-builder/src/sphinx_vite_builder/hatch_plugin.py with ViteBuildHook(BuildHookInterface) class (PLUGIN_NAME="vite") + the @hookimpl-decorated hatch_register_build_hook() factory - ViteBuildHook.initialize delegates to _internal.vite.run_vite_build, reusing the SKIP env-var, web/-absent short-circuit, and fast-fail diagnostics the PEP 517 backend already proves out - Register the entry point in packages/sphinx-vite-builder/pyproject.toml under [project.entry-points.hatch] so hatchling's pluggy discovery picks it up - Add tests/test_sphinx_vite_builder_hatch_plugin.py covering: plugin name contract, registration shape, run_vite_build delegation, importlib.metadata discoverability, and a synthetic-project end-to- end build via [tool.hatch.build.hooks.vite] activation (gated behind SPHINX_VITE_BUILDER_SKIP=1 so no real pnpm/Node is needed) --- packages/sphinx-vite-builder/pyproject.toml | 7 + .../src/sphinx_vite_builder/hatch_plugin.py | 92 +++++++++ .../test_sphinx_vite_builder_hatch_plugin.py | 180 ++++++++++++++++++ 3 files changed, 279 insertions(+) create mode 100644 packages/sphinx-vite-builder/src/sphinx_vite_builder/hatch_plugin.py create mode 100644 tests/test_sphinx_vite_builder_hatch_plugin.py diff --git a/packages/sphinx-vite-builder/pyproject.toml b/packages/sphinx-vite-builder/pyproject.toml index 107d935e..e6bdfe4d 100644 --- a/packages/sphinx-vite-builder/pyproject.toml +++ b/packages/sphinx-vite-builder/pyproject.toml @@ -37,6 +37,13 @@ dependencies = [ [project.entry-points."sphinx.extensions"] "sphinx-vite-builder" = "sphinx_vite_builder" +# Hatchling build-hook variant — consumers can pick this OR the +# `build-backend = "sphinx_vite_builder.build"` PEP 517 backend +# variant, never both. See packages/sphinx-vite-builder/AGENTS.md +# for the trade-off. +[project.entry-points.hatch] +vite = "sphinx_vite_builder.hatch_plugin" + [project.urls] Repository = "https://github.com/git-pull/gp-sphinx" diff --git a/packages/sphinx-vite-builder/src/sphinx_vite_builder/hatch_plugin.py b/packages/sphinx-vite-builder/src/sphinx_vite_builder/hatch_plugin.py new file mode 100644 index 00000000..e2debecf --- /dev/null +++ b/packages/sphinx-vite-builder/src/sphinx_vite_builder/hatch_plugin.py @@ -0,0 +1,92 @@ +"""Hatchling build-hook variant of the vite orchestration. + +Consumers who keep ``build-backend = "hatchling.build"`` can opt in to +the same vite-build-before-package behaviour the +:mod:`sphinx_vite_builder.build` PEP 517 backend provides, by adding: + +.. code-block:: toml + + [build-system] + requires = ["hatchling>=1.0", "sphinx-vite-builder"] + build-backend = "hatchling.build" + + [tool.hatch.build.hooks.vite] + +…to their ``pyproject.toml``. Hatchling discovers this module via the +``[project.entry-points.hatch] vite = "sphinx_vite_builder.hatch_plugin"`` +registration in *this* package's ``pyproject.toml``; the +:func:`hatch_register_build_hook` hookimpl returns the +:class:`ViteBuildHook` class. + +The hook reuses :func:`sphinx_vite_builder._internal.vite.run_vite_build` +verbatim — same SKIP env-var, same ``web/``-absent short-circuit, same +fast-fail diagnostics (``PnpmMissingError``, ``NodeModulesInstallError``, +``ViteFailedError``). The two activation paths are mutually exclusive +by ``[build-system].build-backend`` (you can't pick both at once), so +no double-vite invocation is possible. +""" + +from __future__ import annotations + +import pathlib +import typing as t + +from hatchling.builders.hooks.plugin.interface import BuildHookInterface +from hatchling.plugin import hookimpl + +from ._internal.vite import run_vite_build + + +class ViteBuildHook(BuildHookInterface[t.Any]): + """Run ``pnpm exec vite build`` before each hatchling build target. + + Activated via ``[tool.hatch.build.hooks.vite]`` in a consumer's + ``pyproject.toml``; the table key (``vite``) must match + :attr:`PLUGIN_NAME` so hatchling can route the config block. + + Examples + -------- + The class declares the canonical ``PLUGIN_NAME`` hatchling looks + up when matching ``[tool.hatch.build.hooks.]`` to a + discovered hook. The name is part of the public contract — changing + it would break existing consumer ``pyproject.toml`` configurations. + + >>> ViteBuildHook.PLUGIN_NAME + 'vite' + """ + + PLUGIN_NAME = "vite" + + def initialize(self, version: str, build_data: dict[str, t.Any]) -> None: + """Run vite once before hatchling assembles ``version``'s artefact. + + Delegates to :func:`run_vite_build` so the SKIP env-var, + ``web/``-absent short-circuit, and fast-fail diagnostics behave + identically to the PEP 517 backend variant. + """ + del version, build_data + run_vite_build(project_root=pathlib.Path(self.root)) + + +@hookimpl +def hatch_register_build_hook() -> list[type[BuildHookInterface[t.Any]]]: + """Hatchling plugin entry point. + + Discovered via the ``[project.entry-points.hatch] vite = ...`` + registration in this package's ``pyproject.toml``. Returns hook + classes (not instances); hatchling instantiates them per build. + + Examples + -------- + The function returns exactly one hook class so the + ``[tool.hatch.build.hooks.vite]`` table key resolves to + :class:`ViteBuildHook`: + + >>> hooks = hatch_register_build_hook() + >>> [hook.PLUGIN_NAME for hook in hooks] + ['vite'] + """ + return [ViteBuildHook] + + +__all__: tuple[str, ...] = ("ViteBuildHook", "hatch_register_build_hook") diff --git a/tests/test_sphinx_vite_builder_hatch_plugin.py b/tests/test_sphinx_vite_builder_hatch_plugin.py new file mode 100644 index 00000000..1d613a5a --- /dev/null +++ b/tests/test_sphinx_vite_builder_hatch_plugin.py @@ -0,0 +1,180 @@ +"""Tests for :mod:`sphinx_vite_builder.hatch_plugin`. + +Validates the Phase-3 Milestone-A hatchling build-hook variant: that +the hook class declares the expected plugin name, that the registration +hookimpl returns it, that ``initialize()`` delegates to +:func:`run_vite_build`, and that the ``[tool.hatch.build.hooks.vite]`` +activation path produces a working wheel against a synthetic project +when the SKIP env-var is set. +""" + +from __future__ import annotations + +import os +import pathlib +import subprocess +import sys +import tempfile +import textwrap + +import pytest +from sphinx_vite_builder import hatch_plugin +from sphinx_vite_builder.hatch_plugin import ( + ViteBuildHook, + hatch_register_build_hook, +) + + +def test_plugin_name_is_vite() -> None: + """``PLUGIN_NAME`` must match the consumer's ``[tool.hatch.build.hooks.]``.""" + assert ViteBuildHook.PLUGIN_NAME == "vite" + + +def test_hatch_register_build_hook_returns_class() -> None: + """Registration hookimpl returns the hook class (not an instance).""" + hooks = hatch_register_build_hook() + assert hooks == [ViteBuildHook] + # Sanity: hatchling instantiates the class per build, so it must + # really be a class — not a callable, not an already-built instance. + assert isinstance(hooks[0], type) + + +def test_initialize_invokes_run_vite_build( + monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path +) -> None: + """``initialize()`` calls ``run_vite_build`` with the project root.""" + captured: list[pathlib.Path] = [] + + def _fake_run_vite_build( + project_root: pathlib.Path | None = None, + *, + package_manager: str = "pnpm", + ) -> None: + del package_manager + assert project_root is not None + captured.append(project_root) + + monkeypatch.setattr(hatch_plugin, "run_vite_build", _fake_run_vite_build) + + # Hatchling's BuildHookInterface constructor takes a root plus + # several other positional args (config, build_config, metadata, + # directory, target_name); pass `None` for the slots initialize() + # doesn't touch. The real per-build construction path is exercised + # by the synthetic-project test below. + hook = ViteBuildHook( + root=str(tmp_path), + config={}, + build_config=None, # type: ignore[arg-type] + metadata=None, # type: ignore[arg-type] + directory="", + target_name="wheel", + ) + hook.initialize("0.0.0", build_data={}) + + assert captured == [tmp_path] + + +def test_entry_point_is_discoverable() -> None: + """The ``hatch`` entry-point group exposes the registration hookimpl. + + Hatchling discovers plugins via ``importlib.metadata.entry_points + (group="hatch")`` (per ``hatchling/plugin/manager.py:load``); the + entry point declared in this package's ``pyproject.toml`` must + resolve to a callable returning the hook class. + """ + import importlib.metadata as ilm + + eps = ilm.entry_points(group="hatch") + matched = [ep for ep in eps if ep.name == "vite"] + assert matched, "vite entry point not registered in hatch group" + module = matched[0].load() + assert hasattr(module, "hatch_register_build_hook"), ( + "loaded entry point lacks hatch_register_build_hook hookimpl" + ) + hooks = module.hatch_register_build_hook() + # Compare by module-and-qualname rather than by object identity: + # pytest's `--doctest-modules` and the entry-point loader can each + # reach the same ViteBuildHook source file via different sys.modules + # paths, producing two distinct class objects that satisfy the + # contract but fail an `in` membership test against the test + # module's own import. + assert any( + hook.__module__ == ViteBuildHook.__module__ + and hook.__qualname__ == ViteBuildHook.__qualname__ + for hook in hooks + ) + + +@pytest.mark.integration +def test_synthetic_project_builds_via_hatch_hook(tmp_path: pathlib.Path) -> None: + """End-to-end: a synthetic project with the hook activated builds a wheel. + + Mirrors the SKIP-env-var doctest pattern from + ``sphinx_vite_builder.build.build_wheel`` but exercises the + hatchling-hook activation path instead of the PEP 517 backend + swap. Sets ``SPHINX_VITE_BUILDER_SKIP=1`` so the test never needs + pnpm or Node on PATH. + """ + project = tmp_path / "doctest_pkg" + project.mkdir() + (project / "pyproject.toml").write_text( + textwrap.dedent( + """\ + [build-system] + requires = ["hatchling"] + build-backend = "hatchling.build" + + [project] + name = "synthetic-vite-hook-pkg" + version = "0.0.0" + + [tool.hatch.build.hooks.vite] + + [tool.hatch.build.targets.wheel] + packages = ["synthetic_pkg"] + """, + ), + ) + pkg = project / "synthetic_pkg" + pkg.mkdir() + (pkg / "__init__.py").write_text("") + + dist = project / "dist" + dist.mkdir() + + env = dict(os.environ) + env["SPHINX_VITE_BUILDER_SKIP"] = "1" + + with tempfile.TemporaryDirectory() as build_temp: + result = subprocess.run( + [ + sys.executable, + "-c", + textwrap.dedent( + f"""\ + import os + os.chdir({str(project)!r}) + from hatchling.build import build_wheel + name = build_wheel({str(dist)!r}) + print(name) + """, + ), + ], + capture_output=True, + text=True, + env=env, + cwd=build_temp, + check=False, + ) + + assert result.returncode == 0, ( + f"hatch wheel build failed: stdout={result.stdout!r} stderr={result.stderr!r}" + ) + wheels = list(dist.glob("*.whl")) + assert len(wheels) == 1, f"expected 1 wheel, got {wheels}" + + +def test_top_level_exports() -> None: + """The module's public surface is the hook class + the hookimpl.""" + assert "ViteBuildHook" in hatch_plugin.__all__ + assert "hatch_register_build_hook" in hatch_plugin.__all__ From de39bbcc8093364300e3d4f9de9c9642f0767d67 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 3 May 2026 12:53:22 -0500 Subject: [PATCH 40/53] fix(sphinx-vite-builder[hatch-plugin][test]): drop unused arg-type ignore on build_config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: CI mypy across 3.10–3.14 rejected the previous push with "Unused 'type: ignore' comment" on tests/test_sphinx_vite_builder_hatch_plugin.py:67 — BuildHookInterface's build_config parameter accepts None directly (its annotation is BuilderConfigBound | None), so the ignore was redundant. Keep the ignore on metadata=None where the constructor's ProjectMetadata[Any] annotation does not admit None. what: - Drop ``# type: ignore[arg-type]`` from ``build_config=None`` - Leave the ignore on ``metadata=None`` (still needed) --- tests/test_sphinx_vite_builder_hatch_plugin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_sphinx_vite_builder_hatch_plugin.py b/tests/test_sphinx_vite_builder_hatch_plugin.py index 1d613a5a..b69aae41 100644 --- a/tests/test_sphinx_vite_builder_hatch_plugin.py +++ b/tests/test_sphinx_vite_builder_hatch_plugin.py @@ -64,7 +64,7 @@ def _fake_run_vite_build( hook = ViteBuildHook( root=str(tmp_path), config={}, - build_config=None, # type: ignore[arg-type] + build_config=None, metadata=None, # type: ignore[arg-type] directory="", target_name="wheel", From 919d1892e2bd57363cb193d55000a07988c636c8 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 3 May 2026 12:53:39 -0500 Subject: [PATCH 41/53] docs(sphinx-vite-builder[README]): external-adoption guide for Phase 3 Milestone B MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: Phase 3 Milestone B — make the package adoptable by any vite+Sphinx project outside the gp-sphinx workspace. Standalone README needs a quick-start that covers both backend and hatch-hook activation variants, a comparison table positioning sphinx-vite-builder against maturin / sphinx-theme-builder / hatch-jupyter-builder, a migration recipe for projects coming from manual `pnpm exec vite build` glue, the per-CI YAML snippets for search-discoverability, and a troubleshooting section keyed off the typed exceptions. what: - Quick start: PEP 517 backend variant + hatchling build-hook variant side-by-side, both with the same `[tool.hatch.build.targets.sdist] exclude = ["web/"]` + `[tool.hatch.build] artifacts = [...]` block so the wheel-from-sdist short-circuit works - Sphinx extension subsection documents the auto/dev/prod modes, config keys, and the autobuild detection heuristic - Comparison table: maturin (Rust+Cargo, puccinialin auto-bootstrap), sphinx-theme-builder (webpack, nodeenv-isolated), hatch-jupyter- builder (Node, hatch-hook), sphinx-vite-builder (vite, both variants, user-managed pnpm via corepack) - Migration table maps "Was → Now" for: tests.yml manual vite step, release.yml manual vite step, justfile _assets-build prerequisite, hatchling force-include for static/ - Error reference table + dedicated CI recipe gallery (GitHub Actions, CircleCI, Azure Pipelines, GitLab CI, generic) — the same recipes the backend embeds in PnpmMissingError, surfaced standalone for search/SEO - Troubleshooting section keyed off PnpmMissingError / NodeModulesInstallError / ViteFailedError plus the "wheel ships without static/" and "just html doesn't rebuild assets" recipes --- packages/sphinx-vite-builder/README.md | 196 +++++++++++++++++++++---- 1 file changed, 170 insertions(+), 26 deletions(-) diff --git a/packages/sphinx-vite-builder/README.md b/packages/sphinx-vite-builder/README.md index e468dbd7..5f2f790a 100644 --- a/packages/sphinx-vite-builder/README.md +++ b/packages/sphinx-vite-builder/README.md @@ -98,12 +98,17 @@ The asymmetry is the whole product: the same backend is strict silent (skipping cleanly) when there's no `web/` to begin with. The two shapes match the two consumer worlds. -## Two heads, one subprocess core +## Quick start — two activation variants -### PEP 517 build backend +`sphinx-vite-builder` ships two orthogonal ways to wire vite into a +hatchling-built Python package. Pick whichever fits the consumer's +existing build setup; they are mutually exclusive at the +`[build-system].build-backend` level. -Drop-in replacement for `hatchling.build`. Runs `pnpm exec vite build` -before delegating wheel/sdist construction to hatchling. +### Variant 1 — PEP 517 backend (drop-in replacement) + +The simplest activation. Replace `hatchling.build` with +`sphinx_vite_builder.build` and you're done. ```toml # packages/your-theme/pyproject.toml @@ -118,25 +123,86 @@ exclude = ["web/"] # so the sdist→wheel chain hits the short-circuit artifacts = ["src//theme//static/"] ``` -### Sphinx extension (Phase 1: placeholder) +### Variant 2 — Hatchling build hook (composable) -The extension entry point is currently a placeholder registered in -`conf.py` to prevent import errors. Full lifecycle integration — -running Vite before the docs build and spawning a watched Vite -process during `sphinx-autobuild` — lands in a follow-up release. +For projects that want to keep `build-backend = "hatchling.build"` and +layer vite on top of an existing hatchling hook stack +(`version`, custom build scripts, etc.): -For now, the [PEP 517](https://peps.python.org/pep-0517/) backend -handles all Vite orchestration during source builds and wheel -generation; that path is fully implemented and tested. +```toml +# packages/your-theme/pyproject.toml +[build-system] +requires = ["hatchling>=1.0", "sphinx-vite-builder"] +build-backend = "hatchling.build" + +[tool.hatch.build.hooks.vite] + +[tool.hatch.build.targets.sdist] +exclude = ["web/"] + +[tool.hatch.build] +artifacts = ["src//theme//static/"] +``` + +Both variants share the same orchestration core — same SKIP env var, +same `web/`-absent short-circuit, same fast-fail diagnostics. Pick +variant 1 for simplicity, variant 2 for composability. + +### Sphinx extension (orthogonal to either build variant) + +The Sphinx-extension head is independent of the backend / hook +choice. Wire it into a docs build to get vite running automatically +during `sphinx-build` (one-shot) and `sphinx-autobuild` (watched). ```python # docs/conf.py extensions = ["sphinx_vite_builder"] +sphinx_vite_builder_mode = "auto" # "auto" | "dev" | "prod" +sphinx_vite_builder_root = "/abs/path/to/web" ``` -## Fast-fail diagnostics — error type reference +`"auto"` resolves to `"dev"` when the build is running under +`sphinx-autobuild` (detected via `SPHINX_AUTOBUILD` env var, `argv[0]`, +or parent-process inspection on Linux), `"prod"` otherwise. Setting +`sphinx_vite_builder_root` to `None` (the default) makes the extension +a complete no-op — useful when the consumer is installed from a wheel +where the static tree is already pre-baked. + +## Comparison with similar tools + +| Tool | Toolchain owned | Activation strategy | Bootstrap | +|---|---|---|---| +| [`maturin`](https://github.com/PyO3/maturin) | Rust (Cargo) | self-hosting via `bootstrap` shim | `puccinialin` auto-installs Rust | +| [`sphinx-theme-builder`](https://github.com/pradyunsg/sphinx-theme-builder) | Node (webpack) | rolls own ZIP packing | `nodeenv` (isolated Node env) | +| [`hatch-jupyter-builder`](https://github.com/jupyterlab/hatch-jupyter-builder) | Node (npm/yarn) | hatchling build hook | user-managed Node | +| `sphinx-vite-builder` | Node (vite) | PEP 517 backend **or** hatchling hook | user-managed pnpm (corepack) | -| Error | When | Hint surface | +`sphinx-vite-builder` deliberately diverges from `sphinx-theme-builder`'s +`nodeenv` approach: pnpm via corepack is the modern Node convention, and +auto-installing Node into the project tree pulls in significant friction +for editable workflows. Compared to `maturin`, the Rust analog, +sphinx-vite-builder doesn't auto-install pnpm — pnpm isn't pip-installable, +so the failure mode is "user runs `corepack enable`" rather than "backend +bootstraps a Node env." + +## Migrating from manual orchestration + +If your project currently runs `pnpm exec vite build` from a CI step, +a justfile recipe, or a Makefile target, you can drop those: + +| Was | Now | +|---|---| +| `tests.yml` step: `pnpm install && pnpm exec vite build` | The backend / hook handles it; only keep pnpm/Node setup | +| `release.yml` step: `pnpm install && pnpm exec vite build` | Same — keep pnpm/Node setup, drop the manual build call | +| `justfile` recipe `_assets-build` as prerequisite of `html` | Drop the recipe; the Sphinx extension's PROD-mode hook runs vite | +| Hatchling `[tool.hatch.build] force-include` for `static/` | Drop; the backend produces the static tree before hatchling packs | + +Keep your CI's pnpm + Node setup steps — the backend needs them at +build time even though the wheel installation doesn't. + +## Fast-fail diagnostics — error reference + +| Error | When | Hint includes | |---|---|---| | `PnpmMissingError` | `pnpm` not on `PATH` during a source build | `corepack enable`, [pnpm.io/installation](https://pnpm.io/installation), per-CI YAML recipe, `SPHINX_VITE_BUILDER_SKIP=1` | | `NodeModulesInstallError` | `pnpm install` exited non-zero | `cd && pnpm install --frozen-lockfile` rerun command, captured stderr | @@ -145,21 +211,99 @@ extensions = ["sphinx_vite_builder"] All three inherit from `SphinxViteBuilderError`, so consumers can `except SphinxViteBuilderError` for a single catch surface. -## CI detection +## CI recipe gallery The `PnpmMissingError` hint is **self-healing** when the backend -detects a CI environment. Detection precedence (most-specific wins): +detects a CI environment — it embeds the platform-specific setup +recipe in the error message. They are also reproduced here for +search-discoverability. + +### GitHub Actions (`GITHUB_ACTIONS=true`) + +```yaml +- uses: pnpm/action-setup@v6 + with: + version: 10 +- uses: actions/setup-node@v6 + with: + node-version: 22 + cache: pnpm +``` -| CI provider | Env var | Recipe shape | -|---|---|---| -| GitHub Actions | `GITHUB_ACTIONS=true` | `pnpm/action-setup@v6` + `actions/setup-node@v6` | -| CircleCI | `CIRCLECI=true` | `corepack enable && corepack prepare pnpm@latest-10 --activate` step | -| Azure Pipelines | `TF_BUILD=True` | `NodeTool@0` + corepack script | -| GitLab CI | `GITLAB_CI=true` | `before_script` corepack invocations | -| Generic | `CI=true` | "Use your CI's package-manager setup mechanism" | - -Source: each provider's own canonical detection variable per the pnpm -[Continuous Integration docs](https://pnpm.io/continuous-integration). +### CircleCI (`CIRCLECI=true`) + +```yaml +- run: + name: Install pnpm via corepack + command: | + corepack enable + corepack prepare pnpm@latest-10 --activate +``` + +### Azure Pipelines (`TF_BUILD=True`) + +```yaml +- task: NodeTool@0 + inputs: + versionSpec: '22.x' +- script: | + corepack enable + corepack prepare pnpm@latest-10 --activate +``` + +### GitLab CI (`GITLAB_CI=true`) + +```yaml +before_script: + - corepack enable + - corepack prepare pnpm@latest-10 --activate +``` + +### Generic CI (any other `CI=true`) + +Use your CI's package-manager setup mechanism to put `pnpm` (>=10) +and `node` (>=22) on PATH before the Python build step runs. + +Detection precedence (most-specific wins): each provider's canonical +env var per the pnpm +[Continuous Integration docs](https://pnpm.io/continuous-integration) +is checked first; the generic `CI=true` is the fallback for "we know +we're in CI but don't recognise the provider." + +## Troubleshooting + +**`PnpmMissingError: pnpm is not on PATH`** — install pnpm via +`corepack enable` (Node 16.10+) or follow the per-CI recipe in the +error message. If you're in an environment that genuinely doesn't +need vite to run (e.g. building from a pre-baked sdist), set +`SPHINX_VITE_BUILDER_SKIP=1` in the environment. + +**`NodeModulesInstallError`** — `pnpm install --frozen-lockfile` +exited non-zero. The error surfaces the captured stderr. Common +causes: stale `pnpm-lock.yaml` (run `pnpm install` interactively to +refresh), network/registry timeout (retry), or `engines` mismatch +(check the project's `package.json` `engines` field against the +installed Node/pnpm versions). + +**`ViteFailedError`** — `pnpm exec vite build` exited non-zero. +Captured stderr is included in the hint. This is usually a +project-side compile error (TypeScript type check, SCSS syntax, +missing import) rather than a tooling problem. Reproduce with +`(cd && pnpm exec vite build)` to see vite's full output. + +**Wheel ships without `static/` content** — the backend ran but the +artefacts didn't make it into the wheel. Verify your `pyproject.toml` +has the `[tool.hatch.build] artifacts = ["src/.../static/"]` +declaration. Hatchling's documented "VCS-ignored include" mechanism +requires this even when the path is on disk; `force-include` does +not work for editable builds. + +**`just html` (or any plain `sphinx-build`) doesn't rebuild assets** — +make sure `sphinx_vite_builder` is loaded in `extensions` and that +`sphinx_vite_builder_root` points at your `web/` directory. The +extension's PROD-mode hook runs `pnpm exec vite build` once before +the build proceeds; without it, you'd need a manual orchestration +step. ## License From 75f63ba4e1255f1e1fc6afbb9d5468b54e713b84 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 3 May 2026 12:57:31 -0500 Subject: [PATCH 42/53] =?UTF-8?q?pkg(workspace):=20retire=20gp-sphinx-vite?= =?UTF-8?q?=20=E2=80=94=20sphinx-vite-builder=20is=20the=20sole=20vite=20e?= =?UTF-8?q?xtension?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: Phase 3 Milestone C — gp-sphinx-vite's hooks + config were ported to sphinx_vite_builder._internal in earlier commits, all consumer call sites swept to the new package, and the gp-sphinx-vite package itself has nothing left to ship. Drop the package, delete its tests (the ported tests in tests/test_sphinx_vite_builder_{config,hooks, extension_integration}.py cover the same surface), and add a rediraffe redirect from packages/gp-sphinx-vite → packages/sphinx-vite-builder so existing inbound docs links keep resolving. what: - Delete packages/gp-sphinx-vite/ (5 source files + README + pyproject) - Delete tests/test_gp_sphinx_vite{,_bus,_hooks,_integration,_process}.py - Delete docs/packages/gp-sphinx-vite.md - Drop gp-sphinx-vite from workspace pyproject.toml ([tool.uv.sources] + [dependency-groups] dev) and regenerate uv.lock - Drop smoke_gp_sphinx_vite + the dispatch entry from scripts/ci/package_tools.py - Drop "gp-sphinx-vite" from tests/test_package_reference.py's workspace-package set assertion - docs/index.md: drop packages/gp-sphinx-vite from the Internal toctree - docs/packages/index.md: drop the bullet - docs/redirects.txt: add `packages/gp-sphinx-vite packages/sphinx-vite-builder` rediraffe redirect; map the legacy `extensions/gp-sphinx-vite` to the new package too - docs/architecture.md: drop the gp-sphinx-vite grid card (sphinx-vite-builder card already documents both heads) - README.md: collapse the Build utils bullet to mention only sphinx-vite-builder --- README.md | 2 +- docs/architecture.md | 10 - docs/index.md | 1 - docs/packages/gp-sphinx-vite.md | 156 ------ docs/packages/index.md | 1 - docs/redirects.txt | 3 +- packages/gp-sphinx-vite/README.md | 39 -- packages/gp-sphinx-vite/pyproject.toml | 43 -- .../src/gp_sphinx_vite/__init__.py | 82 ---- .../gp-sphinx-vite/src/gp_sphinx_vite/bus.py | 193 -------- .../src/gp_sphinx_vite/config.py | 191 -------- .../src/gp_sphinx_vite/hooks.py | 244 ---------- .../src/gp_sphinx_vite/process.py | 243 ---------- .../src/gp_sphinx_vite/py.typed | 0 pyproject.toml | 2 - scripts/ci/package_tools.py | 42 -- tests/test_gp_sphinx_vite.py | 246 ---------- tests/test_gp_sphinx_vite_bus.py | 220 --------- tests/test_gp_sphinx_vite_hooks.py | 446 ------------------ tests/test_gp_sphinx_vite_integration.py | 160 ------- tests/test_gp_sphinx_vite_process.py | 241 ---------- tests/test_package_reference.py | 1 - uv.lock | 15 - 23 files changed, 3 insertions(+), 2578 deletions(-) delete mode 100644 docs/packages/gp-sphinx-vite.md delete mode 100644 packages/gp-sphinx-vite/README.md delete mode 100644 packages/gp-sphinx-vite/pyproject.toml delete mode 100644 packages/gp-sphinx-vite/src/gp_sphinx_vite/__init__.py delete mode 100644 packages/gp-sphinx-vite/src/gp_sphinx_vite/bus.py delete mode 100644 packages/gp-sphinx-vite/src/gp_sphinx_vite/config.py delete mode 100644 packages/gp-sphinx-vite/src/gp_sphinx_vite/hooks.py delete mode 100644 packages/gp-sphinx-vite/src/gp_sphinx_vite/process.py delete mode 100644 packages/gp-sphinx-vite/src/gp_sphinx_vite/py.typed delete mode 100644 tests/test_gp_sphinx_vite.py delete mode 100644 tests/test_gp_sphinx_vite_bus.py delete mode 100644 tests/test_gp_sphinx_vite_hooks.py delete mode 100644 tests/test_gp_sphinx_vite_integration.py delete mode 100644 tests/test_gp_sphinx_vite_process.py diff --git a/README.md b/README.md index 2d2f0992..b796fa70 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ Lower layers never depend on higher ones: - **Common libraries** — `sphinx-ux-badges`, `sphinx-ux-autodoc-layout`, `sphinx-autodoc-typehints-gp`, `sphinx-fonts` - **Autodoc extensions** — `sphinx-autodoc-api-style`, `sphinx-autodoc-argparse`, `sphinx-autodoc-docutils`, `sphinx-autodoc-fastmcp`, `sphinx-autodoc-pytest-fixtures`, `sphinx-autodoc-sphinx` -- **Build utils** — `sphinx-vite-builder` ([PEP 517](https://peps.python.org/pep-0517/) backend that runs Vite via pnpm), `gp-sphinx-vite` (autobuild orchestrator) +- **Build utils** — `sphinx-vite-builder` ([PEP 517](https://peps.python.org/pep-0517/) backend + Sphinx extension that runs Vite via pnpm) - **Theme and coordinator** — `gp-sphinx`, `sphinx-gp-theme`, `gp-furo-theme` - **SEO** — `sphinx-gp-opengraph`, `sphinx-gp-sitemap` (auto-loaded by `gp-sphinx` when `docs_url` is set) diff --git a/docs/architecture.md b/docs/architecture.md index af46301c..0b3bd4ad 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -140,16 +140,6 @@ hatchling. Also a Sphinx extension that auto-orchestrates Source builds error loudly without pnpm/Node; wheels ship turn-key. ::: -:::{grid-item-card} gp-sphinx-vite -:link: packages/gp-sphinx-vite -:link-type: doc - -Autobuild-time Vite orchestrator opted into via -`merge_sphinx_config(vite_orchestration=True)`. Spawns the watcher as -a child process for the lifetime of `sphinx-autobuild`, with graceful -SIGTERM teardown on exit. -::: - :::: ## How the tiers connect diff --git a/docs/index.md b/docs/index.md index bf9f9e4f..f3d48177 100644 --- a/docs/index.md +++ b/docs/index.md @@ -133,7 +133,6 @@ packages/sphinx-autodoc-typehints-gp packages/gp-sphinx packages/sphinx-gp-theme packages/gp-furo-theme -packages/gp-sphinx-vite packages/sphinx-vite-builder ``` diff --git a/docs/packages/gp-sphinx-vite.md b/docs/packages/gp-sphinx-vite.md deleted file mode 100644 index 5d80e2e3..00000000 --- a/docs/packages/gp-sphinx-vite.md +++ /dev/null @@ -1,156 +0,0 @@ -# gp-sphinx-vite - -```{gp-sphinx-package-meta} gp-sphinx-vite -``` - -Transparent [Vite] + [pnpm] orchestration for Sphinx theme asset -pipelines. A Sphinx extension that spawns `pnpm exec vite build --watch` -from `builder-inited` so theme authors iterating templates and styles -get fresh `furo.css` / `furo.js` on disk without remembering a separate -`vite build` command. The extension is a no-op in production, so wheels -published to PyPI never carry a Node runtime requirement. - -```console -$ pip install gp-sphinx-vite -``` - -## Status - -Skeleton — only {py:func}`~gp_sphinx_vite.setup` and config-value -registration are wired up. Subprocess management (`ViteProcess`), the -asyncio↔threading bridge, and the actual spawn/teardown lifecycle -(with signal handling and idempotent re-spawn for [sphinx-autobuild]) -land in subsequent commits. - -## Downstream `conf.py` (eventual) - -```python -extensions = ["gp_sphinx_vite"] - -# Optional. Defaults to "auto": dev iff SPHINX_AUTOBUILD env var is set -# or sys.argv[0] ends with "sphinx-autobuild"; prod (no-op) otherwise. -gp_sphinx_vite_mode = "auto" - -# Optional. Path to the directory containing package.json + vite.config.ts. -# Defaults to /web (resolved relative to the active theme). -gp_sphinx_vite_root = None -``` - -```{package-reference} gp-sphinx-vite -``` - -## Wiring `just build` / `make build` for downstream users - -[sphinx-autobuild] runs `gp_sphinx_vite`'s `builder-inited` hook, -which detects the autobuild process by argv0 / the `SPHINX_AUTOBUILD` -env var and spawns `pnpm exec vite build --watch` itself — so -`sphinx-autobuild docs/ _build/html` "just works" with no extra -plumbing. Plain `sphinx-build` runs in `prod` mode where the -extension is intentionally a no-op (so wheels published to PyPI -never require a Node runtime), so the `vite build` step has to -happen *before* `sphinx-build` in the orchestration layer that -calls it. - -A small `assets-build` recipe that depends on every HTML-output -target covers the gap. The recipe is idempotent — it skips the -build when no `web/` source tree is present (wheel install with -pre-built assets) or when `pnpm` isn't on PATH (graceful -degradation; any pre-existing output remains in place). - -### justfile - -```just -# Build vite-managed theme assets before any HTML-output build. -[private] -_assets-build: - #!/usr/bin/env bash - set -euo pipefail - web_root=$(uv run python -c 'import gp_furo_theme; r = gp_furo_theme.get_vite_root(); print(r or "")' 2>/dev/null || true) - if [ -z "$web_root" ]; then - echo "[assets] no web/ source tree (wheel install) — skipping vite build" - exit 0 - fi - if ! command -v pnpm >/dev/null 2>&1; then - echo "[assets] pnpm not on PATH; skipping vite build" - exit 0 - fi - if [ ! -d "$web_root/node_modules" ]; then - (cd "$web_root" && pnpm install --frozen-lockfile) - fi - (cd "$web_root" && pnpm exec vite build) - -html: _assets-build - sphinx-build -b dirhtml . _build/html - -dirhtml: _assets-build - sphinx-build -b dirhtml . _build/dirhtml -``` - -The `[private]` attribute hides `_assets-build` from -`just --list`; it's a pure dependency target. Add the same -prerequisite to every other HTML-output builder you ship -(`singlehtml`, `htmlhelp`, `qthelp`, `devhelp`, `epub`). -Non-HTML builders (`text`, `man`, `latex`, `gettext`, -`linkcheck`, `doctest`) don't need the theme assets and should -*not* depend on `_assets-build`. - -### Makefile - -```makefile -.PHONY: _assets-build html dirhtml clean - -_assets-build: - @web_root=$$(uv run python -c 'import gp_furo_theme; r = gp_furo_theme.get_vite_root(); print(r or "")' 2>/dev/null || true); \ - if [ -z "$$web_root" ]; then \ - echo "[assets] no web/ source tree (wheel install) — skipping vite build"; \ - exit 0; \ - fi; \ - if ! command -v pnpm >/dev/null 2>&1; then \ - echo "[assets] pnpm not on PATH; skipping vite build"; \ - exit 0; \ - fi; \ - if [ ! -d "$$web_root/node_modules" ]; then \ - (cd "$$web_root" && pnpm install --frozen-lockfile); \ - fi; \ - (cd "$$web_root" && pnpm exec vite build) - -html: _assets-build - sphinx-build -b dirhtml . _build/html - -dirhtml: _assets-build - sphinx-build -b dirhtml . _build/dirhtml -``` - -### Locating the vite root from your own theme - -The recipe shells into `gp_furo_theme.get_vite_root()` because that -is the canonical helper for the gp-sphinx-shipped theme. If you -maintain a *different* Sphinx theme parent that ships its own -Vite-managed assets, expose an equivalent helper from your -package's `__init__.py`: - -```python -import pathlib - -def get_vite_root() -> pathlib.Path | None: - """Return the absolute web/ path under a workspace checkout, or None for wheel installs.""" - candidate = pathlib.Path(__file__).resolve().parents[2] / "web" - return candidate if candidate.is_dir() else None -``` - -Then swap the `gp_furo_theme` import in the recipe for your own -package name. This keeps the orchestration layer agnostic about -where the source tree actually lives — wheel installs stay -zero-Node, workspace checkouts get a fresh build automatically. - -## Reference - -```{eval-rst} -.. autofunction:: gp_sphinx_vite.setup -``` - -[Source on GitHub](https://github.com/git-pull/gp-sphinx/tree/main/packages/gp-sphinx-vite) - -[Vite]: https://vitejs.dev -[pnpm]: https://pnpm.io -[sphinx-autobuild]: https://github.com/sphinx-doc/sphinx-autobuild diff --git a/docs/packages/index.md b/docs/packages/index.md index c663b52c..07df90e9 100644 --- a/docs/packages/index.md +++ b/docs/packages/index.md @@ -35,7 +35,6 @@ Domain-specific [autodoc extensions](https://www.sphinx-doc.org/en/master/usage/ [PEP 517](https://peps.python.org/pep-0517/) backends and orchestration helpers for theme asset pipelines: - [`sphinx-vite-builder`](sphinx-vite-builder.md) — [PEP 517](https://peps.python.org/pep-0517/) backend + Sphinx extension that runs Vite via pnpm -- [`gp-sphinx-vite`](gp-sphinx-vite.md) — autobuild orchestrator opt-in via `vite_orchestration=True` ## Theme and coordinator diff --git a/docs/redirects.txt b/docs/redirects.txt index 1545924c..7f7971e3 100644 --- a/docs/redirects.txt +++ b/docs/redirects.txt @@ -13,8 +13,9 @@ extensions/sphinx-ux-autodoc-layout packages/sphinx-ux-autodoc-layout extensions/sphinx-fonts packages/sphinx-fonts extensions/sphinx-gp-theme packages/sphinx-gp-theme extensions/gp-furo-theme packages/gp-furo-theme -extensions/gp-sphinx-vite packages/gp-sphinx-vite +extensions/gp-sphinx-vite packages/sphinx-vite-builder extensions/sphinx-vite-builder packages/sphinx-vite-builder +packages/gp-sphinx-vite packages/sphinx-vite-builder extensions/sphinx-autodoc-typehints-gp packages/sphinx-autodoc-typehints-gp extensions/sphinx-argparse-neo packages/sphinx-autodoc-argparse extensions/sphinx-gptheme packages/sphinx-gp-theme diff --git a/packages/gp-sphinx-vite/README.md b/packages/gp-sphinx-vite/README.md deleted file mode 100644 index 1615440b..00000000 --- a/packages/gp-sphinx-vite/README.md +++ /dev/null @@ -1,39 +0,0 @@ -# gp-sphinx-vite - -Transparent Vite + pnpm orchestration for Sphinx theme asset pipelines. - -A Sphinx extension that spawns `pnpm exec vite build --watch` from -`builder-inited` so theme authors iterating templates and SCSS get fresh -`furo.css` / `furo.js` on disk without remembering a separate -`vite build` command. The extension is a no-op in production (when -`sphinx-build` runs without the autobuild driver), so wheels published to -PyPI never carry a Node runtime requirement. - -## Status - -Skeleton — only `setup()` and config-value registration are wired up. -Subprocess management (`ViteProcess`), the asyncio↔threading bridge, and -the actual spawn/teardown lifecycle (with signal handling and idempotent -re-spawn for `sphinx-autobuild`) land in subsequent commits. - -## Usage (eventual) - -```python -# conf.py -extensions = ["gp_sphinx_vite"] - -# Optional. Defaults to "auto": dev iff SPHINX_AUTOBUILD env var is set -# or sys.argv[0] ends with "sphinx-autobuild"; prod (no-op) otherwise. -gp_sphinx_vite_mode = "auto" - -# Optional. Path to the directory containing package.json + vite.config.ts. -# Defaults to /web (resolved relative to the active theme). -gp_sphinx_vite_root = None -``` - -## Config - -| Name | Type | Default | -|------|------|---------| -| `gp_sphinx_vite_mode` | `Literal["auto", "dev", "prod"]` | `"auto"` | -| `gp_sphinx_vite_root` | `str \| None` | `None` (auto-detect) | diff --git a/packages/gp-sphinx-vite/pyproject.toml b/packages/gp-sphinx-vite/pyproject.toml deleted file mode 100644 index 46c74725..00000000 --- a/packages/gp-sphinx-vite/pyproject.toml +++ /dev/null @@ -1,43 +0,0 @@ -[project] -name = "gp-sphinx-vite" -version = "0.0.1a16.dev2" -description = "Transparent Vite + pnpm orchestration for Sphinx theme asset pipelines" -requires-python = ">=3.10,<4.0" -authors = [ - {name = "Tony Narlock", email = "tony@git-pull.com"} -] -license = { text = "MIT" } -classifiers = [ - "Development Status :: 3 - Alpha", - "License :: OSI Approved :: MIT License", - "Framework :: Sphinx", - "Framework :: Sphinx :: Extension", - "Intended Audience :: Developers", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3.12", - "Programming Language :: Python :: 3.13", - "Programming Language :: Python :: 3.14", - "Topic :: Documentation", - "Topic :: Documentation :: Sphinx", - "Typing :: Typed", -] -readme = "README.md" -keywords = ["sphinx", "extension", "vite", "pnpm", "theme", "documentation"] -dependencies = [ - "sphinx>=8.1", -] - -[project.entry-points."sphinx.extensions"] -"gp-sphinx-vite" = "gp_sphinx_vite" - -[project.urls] -Repository = "https://github.com/git-pull/gp-sphinx" - -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[tool.hatch.build.targets.wheel] -packages = ["src/gp_sphinx_vite"] diff --git a/packages/gp-sphinx-vite/src/gp_sphinx_vite/__init__.py b/packages/gp-sphinx-vite/src/gp_sphinx_vite/__init__.py deleted file mode 100644 index 822114eb..00000000 --- a/packages/gp-sphinx-vite/src/gp_sphinx_vite/__init__.py +++ /dev/null @@ -1,82 +0,0 @@ -"""gp-sphinx-vite — transparent Vite/pnpm orchestration for Sphinx themes. - -Spawns ``pnpm exec vite build --watch`` from a Sphinx ``builder-inited`` -hook so a theme author iterating templates + SCSS gets fresh CSS/JS on -disk without remembering a separate ``vite build`` invocation. The -extension is a no-op in production (when ``sphinx-build`` runs without -the autobuild driver), so wheels published to PyPI never carry a Node -runtime requirement. - -This file is the package skeleton — :func:`setup` registers the config -value(s) and a placeholder hook. Subprocess orchestration -(``ViteProcess``), the asyncio↔threading bridge, and the actual -spawn/teardown lifecycle land in subsequent commits. - -Examples --------- ->>> class FakeApp: -... def __init__(self) -> None: -... self.config_values: list[str] = [] -... def add_config_value(self, name: str, **kwargs: object) -> None: -... self.config_values.append(name) ->>> fake = FakeApp() ->>> metadata = setup(fake) # type: ignore[arg-type] ->>> "gp_sphinx_vite_mode" in fake.config_values -True ->>> metadata["parallel_read_safe"] -True -""" - -from __future__ import annotations - -import logging -import typing as t - -if t.TYPE_CHECKING: - from sphinx.application import Sphinx - -__version__ = "0.0.1a16.dev2" - -logger = logging.getLogger(__name__) -logger.addHandler(logging.NullHandler()) - - -def setup(app: Sphinx) -> dict[str, bool | str]: - """Register ``gp-sphinx-vite``'s config values + event handlers with Sphinx. - - Parameters - ---------- - app : Sphinx - The Sphinx application object. - - Returns - ------- - dict[str, bool | str] - Extension metadata. ``parallel_read_safe`` and - ``parallel_write_safe`` are both ``True``: the orchestration is - a side effect of one specific event handler firing, and the - rest of the extension is read-only state. - """ - from . import hooks - - app.add_config_value( - "gp_sphinx_vite_mode", - default="auto", - rebuild="env", - types=[str], - ) - app.add_config_value( - "gp_sphinx_vite_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__, - } diff --git a/packages/gp-sphinx-vite/src/gp_sphinx_vite/bus.py b/packages/gp-sphinx-vite/src/gp_sphinx_vite/bus.py deleted file mode 100644 index c3db0a73..00000000 --- a/packages/gp-sphinx-vite/src/gp_sphinx_vite/bus.py +++ /dev/null @@ -1,193 +0,0 @@ -"""Thread + asyncio event-loop bridge. - -Sphinx's event hooks (``builder-inited``, ``build-finished``, …) are -synchronous callables. The orchestration logic that they drive is -asyncio-based (:class:`gp_sphinx_vite.process.ViteProcess` uses -``asyncio.create_subprocess_exec``, pipe drainers, etc.). The bridge -between them is a *single* event loop running in a single daemon -thread, kept alive across ``builder-inited`` re-fires for -``sphinx-autobuild``. - -Pattern is the same as ``/home/d/work/cv/py/latex/dev.py:390-418``. - -Usage from a Sphinx hook: - -.. code-block:: python - - bus = AsyncioBus() - bus.start() - bus.call_sync(some_coro()) - # ... - bus.stop(timeout=5.0) - -The bus has no Sphinx-specific knowledge; tests construct one and drive -it directly. -""" - -from __future__ import annotations - -import asyncio -import logging -import threading -import typing as t - -if t.TYPE_CHECKING: - from collections.abc import Coroutine - -logger = logging.getLogger(__name__) - - -class AsyncioBus: - """A single asyncio event loop running in a daemon thread. - - Lifecycle: - - 1. :meth:`start` spawns the thread; waits until the loop is ready. - 2. Hooks run :meth:`call_sync` (block on result) or :meth:`call_soon` - (fire-and-forget). - 3. :meth:`stop` schedules the loop to stop, joins the thread. - - The bus is single-use. After ``stop()`` it is not safe to start - again — construct a new instance. - """ - - def __init__(self, *, name: str = "gp-sphinx-vite-bus") -> None: - self._name = name - self._loop: asyncio.AbstractEventLoop | None = None - self._thread: threading.Thread | None = None - self._ready = threading.Event() - # ``_stopped`` is set once a started bus has actually been torn - # down. It enforces the class-level "single-use" contract from - # ``start()``; a stop-before-start is a no-op and leaves this - # ``False`` (the bus was never really live). - self._stopped = False - - @property - def is_running(self) -> bool: - """True iff the loop thread is alive.""" - return self._thread is not None and self._thread.is_alive() - - def start(self) -> None: - """Start the background event loop. - - Idempotent if the bus is already running; raises - :class:`RuntimeError` if the bus has previously been stopped - (the class is single-use, per the class docstring). - """ - if self._stopped: - msg = "AsyncioBus is single-use; construct a new instance after stop()" - raise RuntimeError(msg) - if self._thread is not None and self._thread.is_alive(): - return - self._ready.clear() - self._thread = threading.Thread( - target=self._run, - daemon=True, - name=self._name, - ) - self._thread.start() - # Block until the loop has been assigned. Without this, a - # call_sync() racing the thread startup would deref None. - self._ready.wait() - - def call_sync( - self, - coro: Coroutine[t.Any, t.Any, t.Any], - *, - timeout: float | None = None, - ) -> t.Any: - """Schedule ``coro`` on the loop and block on its result. - - Parameters - ---------- - coro - The coroutine object (call-but-don't-await first). - timeout - Forwarded to ``concurrent.futures.Future.result``. ``None`` - blocks indefinitely. - - Raises - ------ - RuntimeError - If the bus is not running. - """ - if self._loop is None: - # Close the coroutine before raising so we don't leak a - # "coroutine was never awaited" warning at gc time. - coro.close() - msg = "AsyncioBus.call_sync() called before start()" - raise RuntimeError(msg) - future = asyncio.run_coroutine_threadsafe(coro, self._loop) - return future.result(timeout=timeout) - - def call_soon( - self, - coro: Coroutine[t.Any, t.Any, t.Any], - ) -> None: - """Schedule ``coro`` and return immediately. Errors go to the bus logger.""" - if self._loop is None: - coro.close() - msg = "AsyncioBus.call_soon() called before start()" - raise RuntimeError(msg) - future = asyncio.run_coroutine_threadsafe(coro, self._loop) - - def _log_exception(fut: t.Any) -> None: - exc = fut.exception() - if exc is not None: - logger.error( - "background coroutine on %s raised", - self._name, - exc_info=exc, - ) - - future.add_done_callback(_log_exception) - - def stop(self, *, timeout: float = 5.0) -> None: - """Stop the loop and join the thread. Idempotent. - - Once a started bus has been stopped, ``_stopped`` is set so a - subsequent ``start()`` on the same instance raises rather than - silently re-spawning. A stop-before-start is a no-op and leaves - ``_stopped`` unchanged. - """ - if self._loop is None or self._thread is None: - return - if not self._thread.is_alive(): - self._thread = None - self._loop = None - self._stopped = True - return - - self._loop.call_soon_threadsafe(self._loop.stop) - self._thread.join(timeout=timeout) - if self._thread.is_alive(): - logger.warning( - "%s thread did not exit within %.1fs of loop.stop()", - self._name, - timeout, - ) - self._thread = None - self._loop = None - self._stopped = True - - def _run(self) -> None: - """Thread target: own and run the loop.""" - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - self._loop = loop - self._ready.set() - try: - loop.run_forever() - finally: - try: - # Cancel any remaining tasks so they don't leak past - # the loop's death. - pending = asyncio.all_tasks(loop) - for task in pending: - task.cancel() - if pending: - loop.run_until_complete( - asyncio.gather(*pending, return_exceptions=True), - ) - finally: - loop.close() diff --git a/packages/gp-sphinx-vite/src/gp_sphinx_vite/config.py b/packages/gp-sphinx-vite/src/gp_sphinx_vite/config.py deleted file mode 100644 index 41abebe5..00000000 --- a/packages/gp-sphinx-vite/src/gp_sphinx_vite/config.py +++ /dev/null @@ -1,191 +0,0 @@ -"""Mode detection + config dataclass for gp-sphinx-vite. - -Pure functions where possible — keeps the unit tests cheap (no Sphinx -fixture, no subprocess). The Sphinx-aware glue lives in :mod:`hooks`. - -The mode detection mirrors what Furo's `_builder_inited` does indirectly -(check builder name + extensions list); we additionally inspect ``argv`` -and ``SPHINX_AUTOBUILD`` so the orchestration becomes a no-op for -``sphinx-build`` invocations and turns on for ``sphinx-autobuild``. -""" - -from __future__ import annotations - -import dataclasses -import enum -import os -import pathlib -import sys -import typing as t - - -class Mode(str, enum.Enum): - """Resolved orchestration mode. - - `str` mixin so the value compares equal to the literal string the - user wrote in ``conf.py``. - """ - - DEV = "dev" - PROD = "prod" - - -def _parent_is_sphinx_autobuild() -> bool: - """Return True if our parent process's argv contains ``sphinx-autobuild``. - - Why this exists: ``sphinx-autobuild`` runs the actual Sphinx build - in a *subprocess* via ``subprocess.run([sys.executable] + sphinx_args)`` - (see ``sphinx_autobuild/build.py:50``). In that subprocess, - ``sys.argv[0]`` is the Python interpreter path, NOT - ``sphinx-autobuild``, so the argv-based mode-detection misses it. - Reading ``/proc//cmdline`` lets us see the parent's actual - command line. - - Linux-only via ``/proc``. Returns ``False`` cleanly on macOS, - Windows, or any other platform without ``/proc`` (and on Linux if - the read fails for permission reasons). Test harnesses can disable - this check by passing a stub via ``detect_mode(parent_check=...)``. - """ - try: - ppid = os.getppid() - cmdline_path = pathlib.Path(f"/proc/{ppid}/cmdline") - cmdline = cmdline_path.read_bytes().split(b"\0") - except OSError: - return False - return any(b"sphinx-autobuild" in arg for arg in cmdline) - - -def detect_mode( - *, - config_value: str, - argv: t.Sequence[str] | None = None, - env: t.Mapping[str, str] | None = None, - parent_check: t.Callable[[], bool] | None = None, -) -> Mode: - """Resolve a ``gp_sphinx_vite_mode`` config value to a concrete :class:`Mode`. - - Parameters - ---------- - config_value - Raw value from ``conf.py``: ``"auto"``, ``"dev"``, or ``"prod"``. - Anything else falls back to ``Mode.PROD`` (the safe / no-op - default — never spawn a subprocess from a typo). - argv - Process argv. Defaults to :data:`sys.argv`. - env - Process environment. Defaults to :data:`os.environ`. - parent_check - Callable returning ``True`` when the parent process is - ``sphinx-autobuild``. Defaults to - :func:`_parent_is_sphinx_autobuild`. Tests pass ``lambda: False`` - to disable platform-specific behavior. - - Returns - ------- - Mode - The resolved mode. ``"auto"`` resolves to ``DEV`` if any of: - - ``SPHINX_AUTOBUILD`` is set in ``env`` - - ``argv[0]`` ends with ``"sphinx-autobuild"`` - - the parent process is ``sphinx-autobuild`` (so the - subprocess sphinx-autobuild spawns inherits the dev mode) - ``PROD`` otherwise. - - Examples - -------- - >>> detect_mode( - ... config_value="dev", - ... argv=["sphinx-build"], - ... env={}, - ... parent_check=lambda: False, - ... ) - - >>> detect_mode( - ... config_value="prod", - ... argv=["sphinx-autobuild"], - ... env={"SPHINX_AUTOBUILD": "1"}, - ... parent_check=lambda: True, - ... ) - - >>> detect_mode( - ... config_value="auto", - ... argv=["sphinx-build"], - ... env={}, - ... parent_check=lambda: False, - ... ) - - >>> detect_mode( - ... config_value="auto", - ... argv=["/p/sphinx-autobuild"], - ... env={}, - ... parent_check=lambda: False, - ... ) - - >>> detect_mode( - ... config_value="auto", - ... argv=["python"], - ... env={}, - ... parent_check=lambda: True, - ... ) - - """ - if config_value == "dev": - return Mode.DEV - if config_value == "prod": - return Mode.PROD - # "auto" or any unrecognised value falls through to detection. - resolved_argv: t.Sequence[str] = argv if argv is not None else sys.argv - resolved_env: t.Mapping[str, str] = env if env is not None else os.environ - resolved_parent_check = ( - parent_check if parent_check is not None else _parent_is_sphinx_autobuild - ) - - if resolved_env.get("SPHINX_AUTOBUILD"): - return Mode.DEV - if resolved_argv and resolved_argv[0].endswith("sphinx-autobuild"): - return Mode.DEV - if resolved_parent_check(): - return Mode.DEV - return Mode.PROD - - -def resolve_vite_root(explicit: str | os.PathLike[str] | None) -> pathlib.Path | None: - """Resolve the ``gp_sphinx_vite_root`` config value to an absolute path. - - Returns ``None`` if no explicit root is set; the hook layer treats - that as "no Vite project to spawn" and logs a debug message. We - intentionally do not auto-detect the active theme's ``web/`` - directory here — auto-detection is brittle (depends on theme - layout, which is theme-specific) and would couple this package to - gp-furo-theme. Themes that want auto-wiring can set the config - value themselves from their own ``setup()`` callback. - - Examples - -------- - >>> resolve_vite_root(None) is None - True - >>> import pathlib - >>> root = resolve_vite_root(pathlib.Path(__file__).parent) - >>> root.is_absolute() - True - """ - if explicit is None: - return None - return pathlib.Path(explicit).resolve() - - -@dataclasses.dataclass(frozen=True, slots=True) -class GpSphinxViteConfig: - """Frozen snapshot of the resolved gp-sphinx-vite configuration. - - Built once per Sphinx app at ``builder-inited`` time from - ``app.config``; passed by value to the orchestration layer so the - hooks don't carry a reference to the live mutable Sphinx config. - """ - - mode: Mode - vite_root: pathlib.Path | None - - @property - def should_spawn(self) -> bool: - """True iff the orchestration layer should actually spawn Vite.""" - return self.mode is Mode.DEV and self.vite_root is not None diff --git a/packages/gp-sphinx-vite/src/gp_sphinx_vite/hooks.py b/packages/gp-sphinx-vite/src/gp_sphinx_vite/hooks.py deleted file mode 100644 index 0258a3e6..00000000 --- a/packages/gp-sphinx-vite/src/gp_sphinx_vite/hooks.py +++ /dev/null @@ -1,244 +0,0 @@ -"""Sphinx event handlers that drive the Vite watch lifecycle. - -The handlers live here (not in ``__init__.py``) so they're easy to unit -test in isolation: tests mock a Sphinx-like app, call the handler -directly, and assert against the process / bus instances stashed on -``app._gp_sphinx_vite_*``. - -Lifecycle: - -- ``builder-inited`` (:func:`on_builder_inited`) — resolve config; if - ``should_spawn``, start the bus, spawn the watch process, and stash - both on ``app``. Idempotent: re-firing (sphinx-autobuild fires this - on every rebuild) finds the running process and returns. -- ``build-finished`` (:func:`on_build_finished`) — no-op by default. - The watch process keeps running across rebuilds so Vite can incrementally - recompile on file changes. Teardown happens via :data:`atexit` and - signal handlers installed at first spawn. - -Tear-down is the responsibility of :func:`teardown`, which is wired -to ``atexit`` and to ``SIGINT`` / ``SIGTERM`` / ``SIGHUP``. - -The handlers are passive about command construction: they call -:func:`gp_sphinx_vite.process.vite_watch_command` for the default Vite -argv. Tests monkey-patch that symbol when they want a fake-vite invocation. -""" - -from __future__ import annotations - -import atexit -import pathlib -import signal -import typing as t -import weakref - -from sphinx.util import logging as sphinx_logging - -from .bus import AsyncioBus -from .config import GpSphinxViteConfig, detect_mode, resolve_vite_root -from .process import ViteProcess, pnpm_install_command, vite_watch_command - -if t.TYPE_CHECKING: - from sphinx.application import Sphinx - -# `sphinx.util.logging.getLogger` returns a SphinxLoggerAdapter that -# routes through Sphinx's status / warning streams — which means our -# `[vite] …` lines actually surface in `sphinx-autobuild` output the -# same way Sphinx's own messages do. The stdlib `logging.getLogger` -# does not propagate by default in Sphinx contexts. -logger = sphinx_logging.getLogger(__name__) - -_BUS_ATTR = "_gp_sphinx_vite_bus" -_PROC_ATTR = "_gp_sphinx_vite_proc" -_TEARDOWN_REGISTERED_ATTR = "_gp_sphinx_vite_teardown_registered" - -# Live (bus, proc) pairs that the global teardown handler should clean -# up. Held weakly so a Sphinx app being garbage-collected doesn't keep -# the bus thread alive. -_active_handles: weakref.WeakValueDictionary[int, AsyncioBus] = ( - weakref.WeakValueDictionary() -) - - -def _build_config(app: Sphinx) -> GpSphinxViteConfig: - """Snapshot the live config values into a frozen dataclass.""" - return GpSphinxViteConfig( - mode=detect_mode(config_value=app.config.gp_sphinx_vite_mode), - vite_root=resolve_vite_root(app.config.gp_sphinx_vite_root), - ) - - -def _ensure_node_modules(vite_root: pathlib.Path, bus: AsyncioBus) -> bool: - """Ensure ``/node_modules/`` exists; install if missing. - - Closes the developer-workflow gap where ``git clean -fdx`` wipes - ``node_modules/`` and the next ``sphinx-autobuild`` would otherwise - spawn ``pnpm exec vite`` against a missing tree, exit immediately - with ``Command "vite" not found``, and silently leave the docs site - serving 404s for ``furo-tw.css`` + ``furo.js``. - - Returns ``True`` if ``node_modules/`` exists (or was installed - successfully); ``False`` if the install ran but exited non-zero, - which signals to :func:`on_builder_inited` to skip the vite-watch - spawn rather than burn cycles on a guaranteed-failed - ``pnpm exec vite``. - """ - if (vite_root / "node_modules").exists(): - return True - - install_cmd = pnpm_install_command() - logger.info( - "[vite] node_modules/ missing in %s; running `%s`", - vite_root, - " ".join(install_cmd), - ) - install_proc = ViteProcess(label="pnpm-install", logger=logger) - bus.call_sync(install_proc.start(install_cmd, cwd=vite_root)) - returncode = bus.call_sync(install_proc.wait()) - if returncode != 0: - logger.warning( - "[vite] pnpm install failed (exit %d) in %s — skipping vite " - "spawn; run the install manually and restart sphinx-autobuild", - returncode, - vite_root, - ) - return False - logger.info("[vite] pnpm install complete; proceeding to vite-watch spawn") - return True - - -def on_builder_inited(app: Sphinx) -> None: - """``builder-inited`` event handler. - - Spawns the Vite watch process when the resolved config asks for it. - Idempotent across multiple builder-inited firings (sphinx-autobuild - re-fires this on every rebuild). - - If ``/node_modules/`` is missing (typical after - ``git clean -fdx``), runs ``pnpm install --frozen-lockfile`` - synchronously first so ``pnpm exec vite`` resolves on first try. - """ - config = _build_config(app) - if not config.should_spawn: - return - - existing_proc: ViteProcess | None = getattr(app, _PROC_ATTR, None) - if existing_proc is not None and existing_proc.is_running: - # sphinx-autobuild's repeated builder-inited; the watch is - # already running, leave it alone. - return - - bus = getattr(app, _BUS_ATTR, None) - if bus is None: - bus = AsyncioBus() - bus.start() - setattr(app, _BUS_ATTR, bus) - _active_handles[id(app)] = bus - - if config.vite_root is None: - # `should_spawn` already guards this, but tighten for type checkers. - msg = "should_spawn was True but vite_root resolved to None" - raise RuntimeError(msg) - - if not _ensure_node_modules(config.vite_root, bus): - # Install failed; warning was already logged. Don't try to - # spawn vite — pnpm exec would fail the same way. - return - - proc = ViteProcess(label="vite", logger=logger) - setattr(app, _PROC_ATTR, proc) - - command = vite_watch_command() - logger.info("[vite] spawning %s in %s", " ".join(command), config.vite_root) - bus.call_sync(proc.start(command, cwd=config.vite_root)) - - if not getattr(app, _TEARDOWN_REGISTERED_ATTR, False): - _install_teardown_handlers(app) - setattr(app, _TEARDOWN_REGISTERED_ATTR, True) - - -def on_build_finished(app: Sphinx, exception: BaseException | None) -> None: - """``build-finished`` event handler. - - Deliberately a no-op: keeping the watch alive across rebuilds is - the whole point of the orchestration. Teardown happens via signal - handlers and the :mod:`atexit` registration installed at first - spawn. - - Logs the exception (if any) for context, but does not interfere - with Sphinx's own error reporting. - """ - if exception is not None: - logger.debug( - "[vite] sphinx build finished with exception (%s); leaving watch alive", - exception, - ) - - -def teardown(app: Sphinx, *, terminate_timeout: float = 5.0) -> None: - """Stop the Vite watch and tear down the bus for ``app``. - - Idempotent: safe to call from multiple signal sources (atexit + - SIGINT) without double-stop errors. - """ - proc: ViteProcess | None = getattr(app, _PROC_ATTR, None) - bus: AsyncioBus | None = getattr(app, _BUS_ATTR, None) - if proc is None and bus is None: - return - - if proc is not None and bus is not None: - try: - bus.call_sync(proc.terminate(timeout=terminate_timeout)) - except Exception as exc: - logger.warning("[vite] terminate raised during teardown: %s", exc) - - if bus is not None: - bus.stop(timeout=terminate_timeout) - - setattr(app, _PROC_ATTR, None) - setattr(app, _BUS_ATTR, None) - - -def _install_teardown_handlers(app: Sphinx) -> None: - """Wire :data:`atexit` + signal handlers to tear down ``app``'s watch. - - Uses a weak reference to the app so a long-lived Python process - holding the handler doesn't keep the app alive past its natural - lifetime. - """ - app_ref = weakref.ref(app) - - def _handle_atexit() -> None: - live_app = app_ref() - if live_app is not None: - teardown(live_app) - - atexit.register(_handle_atexit) - - previous_handlers: dict[int, t.Any] = {} - for sig_name in ("SIGINT", "SIGTERM", "SIGHUP"): - sig = getattr(signal, sig_name, None) - if sig is None: - continue # Windows lacks SIGHUP, etc. - - def _make_handler( - sig: int, - previous: t.Any = None, - ) -> t.Callable[[int, t.Any], None]: - def _handle(signum: int, frame: t.Any) -> None: - live_app = app_ref() - if live_app is not None: - teardown(live_app) - if callable(previous): - previous(signum, frame) - # Re-raise the signal once cleanup is done so the - # default behavior (process exit) follows. - if previous in (signal.SIG_DFL, None): - signal.signal(signum, signal.SIG_DFL) - signal.raise_signal(signum) - - return _handle - - previous = signal.getsignal(sig) - previous_handlers[sig] = previous - signal.signal(sig, _make_handler(sig, previous)) diff --git a/packages/gp-sphinx-vite/src/gp_sphinx_vite/process.py b/packages/gp-sphinx-vite/src/gp_sphinx_vite/process.py deleted file mode 100644 index 89ccbfc2..00000000 --- a/packages/gp-sphinx-vite/src/gp_sphinx_vite/process.py +++ /dev/null @@ -1,243 +0,0 @@ -"""Async subprocess wrapper for the Vite watch command. - -Wraps :func:`asyncio.create_subprocess_exec` with the conventions the -orchestration layer needs: - -- ``stdout``/``stderr`` are piped through line-buffered drainers that - prefix each line with a label (``[vite]`` by default) and route them - to a :class:`logging.Logger` — info for stdout, warning for stderr. - Mirrors the pattern in ``/home/d/scripts/py/image360/dev_server.py``. -- ``PYTHONUNBUFFERED=1`` is forced into the child env so Python tools - invoked via the package-manager bridge don't withhold their output. -- :meth:`ViteProcess.terminate` is graceful-then-forceful: SIGTERM, - await up to ``timeout`` seconds, escalate to SIGKILL if the child is - still alive. Idempotent: calling on an already-exited process is a - no-op. - -Argument lists are passed directly to ``create_subprocess_exec``; no -shell, no string interpolation, no command injection surface. - -The class is generic over "what command to run" so the same wrapper -covers the production watch command and the fake-Vite shell scripts -used in tests. -""" - -from __future__ import annotations - -import asyncio -import contextlib -import logging -import os -import pathlib -import typing as t - -if t.TYPE_CHECKING: - pass - -_module_logger = logging.getLogger(__name__) - - -class ViteProcess: - """Async wrapper around a long-running Vite child process.""" - - def __init__( - self, - *, - label: str = "vite", - logger: logging.Logger | logging.LoggerAdapter[t.Any] | None = None, - ) -> None: - # Accepts either a stdlib Logger or a LoggerAdapter (Sphinx's - # `sphinx.util.logging.SphinxLoggerAdapter` is a LoggerAdapter - # subclass). Both expose the .log() method the drainers use. - self._label = label - self._logger: logging.Logger | logging.LoggerAdapter[t.Any] = ( - logger if logger is not None else _module_logger - ) - self._process: asyncio.subprocess.Process | None = None - self._drainers: list[asyncio.Task[None]] = [] - - @property - def is_running(self) -> bool: - """True iff the child has been started and has not yet exited.""" - return self._process is not None and self._process.returncode is None - - @property - def returncode(self) -> int | None: - """Process exit code, or ``None`` if the child hasn't exited (yet).""" - return self._process.returncode if self._process is not None else None - - @property - def pid(self) -> int | None: - """Child process ID, or ``None`` if not started.""" - return self._process.pid if self._process is not None else None - - async def start( - self, - command: t.Sequence[str], - *, - cwd: pathlib.Path, - env: t.Mapping[str, str] | None = None, - ) -> None: - """Spawn ``command`` and start draining its stdout/stderr. - - Parameters - ---------- - command - Argument list. Passed straight to the asyncio subprocess - primitive; no shell. - cwd - Working directory for the child. Must contain ``package.json`` - for a real package-manager invocation. - env - Optional environment override. ``PYTHONUNBUFFERED=1`` is always - injected on top of whatever this provides (or, if ``None``, - on top of :data:`os.environ`). - - Raises - ------ - RuntimeError - If :meth:`start` is called twice on the same instance without - an intervening :meth:`terminate`. - """ - if self._process is not None: - msg = "ViteProcess.start() called twice; spawn a new instance instead" - raise RuntimeError(msg) - - merged_env = dict(env) if env is not None else dict(os.environ) - merged_env["PYTHONUNBUFFERED"] = "1" - - self._process = await asyncio.create_subprocess_exec( - *command, - cwd=str(cwd), - env=merged_env, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - ) - - # Pipe drainers — capture-and-log line by line so the parent - # process gets immediate visibility into Vite's progress. - assert self._process.stdout is not None - assert self._process.stderr is not None - self._drainers = [ - asyncio.create_task( - self._drain(self._process.stdout, level=logging.INFO), - name=f"{self._label}-stdout-drainer", - ), - asyncio.create_task( - self._drain(self._process.stderr, level=logging.WARNING), - name=f"{self._label}-stderr-drainer", - ), - ] - - async def wait(self) -> int: - """Wait for the child to exit; return its exit code. - - Drains the stdout/stderr pipes to completion before returning. - """ - if self._process is None: - msg = "ViteProcess.wait() called before start()" - raise RuntimeError(msg) - returncode = await self._process.wait() - # Let the drainers consume any final buffered lines before returning. - await asyncio.gather(*self._drainers, return_exceptions=True) - return returncode - - async def terminate(self, *, timeout: float = 5.0) -> int | None: - """Send SIGTERM; escalate to SIGKILL after ``timeout`` seconds. - - Idempotent — calling on an already-exited process is a no-op - and returns the existing exit code (or ``None`` if never started). - - Parameters - ---------- - timeout - Seconds to wait for graceful exit after SIGTERM. ``5.0`` is - the same default the plan calls for; matches the cleanup - pattern in ``/home/d/scripts/py/image360/dev_server.py``. - - Returns - ------- - int | None - The child's exit code, or ``None`` if :meth:`start` was - never called. - """ - if self._process is None: - return None - if self._process.returncode is not None: - return self._process.returncode - - self._process.terminate() - try: - await asyncio.wait_for(self._process.wait(), timeout=timeout) - except asyncio.TimeoutError: - self._logger.warning( - "[%s] did not exit within %.1fs of SIGTERM; sending SIGKILL", - self._label, - timeout, - ) - # ProcessLookupError race: child can exit between - # TimeoutError and kill(). - with contextlib.suppress(ProcessLookupError): - self._process.kill() - await self._process.wait() - - # Wait for drainers to consume their last buffered line before - # the caller proceeds; surface no exception if a drainer raised. - await asyncio.gather(*self._drainers, return_exceptions=True) - return self._process.returncode - - async def _drain( - self, - stream: asyncio.StreamReader, - *, - level: int, - ) -> None: - """Consume ``stream`` line by line; log each line through ``self._logger``.""" - while True: - try: - line = await stream.readline() - except (BrokenPipeError, ConnectionResetError): - return - if not line: - return - text = line.decode("utf-8", errors="replace").rstrip("\n") - if text: - self._logger.log(level, "[%s] %s", self._label, text) - - -def vite_watch_command(*, package_manager: str = "pnpm") -> tuple[str, ...]: - """Build the canonical Vite-watch argv. - - The output is a tuple suitable for passing straight into - :meth:`ViteProcess.start`. No shell metacharacters, no - interpolation: each token is a separate argv entry. - - Examples - -------- - >>> vite_watch_command() - ('pnpm', 'exec', 'vite', 'build', '--watch') - >>> vite_watch_command(package_manager="npm") - ('npm', 'exec', 'vite', 'build', '--watch') - """ - return (package_manager, "exec", "vite", "build", "--watch") - - -def pnpm_install_command(*, package_manager: str = "pnpm") -> tuple[str, ...]: - """Build the canonical "install workspace deps" argv. - - Used by the orchestration's auto-install at builder-inited when - ``/node_modules/`` is missing — i.e. the first - ``sphinx-autobuild`` run after a fresh checkout or ``git clean -fdx``. - - ``--frozen-lockfile`` matches the workspace's pinned ``pnpm-lock.yaml``; - pnpm refuses to mutate the lockfile or auto-resolve unspecified deps, - so the install is reproducible across machines and CI. - - Examples - -------- - >>> pnpm_install_command() - ('pnpm', 'install', '--frozen-lockfile') - >>> pnpm_install_command(package_manager="npm") - ('npm', 'install', '--frozen-lockfile') - """ - return (package_manager, "install", "--frozen-lockfile") diff --git a/packages/gp-sphinx-vite/src/gp_sphinx_vite/py.typed b/packages/gp-sphinx-vite/src/gp_sphinx_vite/py.typed deleted file mode 100644 index e69de29b..00000000 diff --git a/pyproject.toml b/pyproject.toml index 4e00d907..e1c0b35a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,7 +31,6 @@ sphinx-ux-autodoc-layout = { workspace = true } sphinx-gp-opengraph = { workspace = true } sphinx-gp-sitemap = { workspace = true } gp-furo-theme = { workspace = true } -gp-sphinx-vite = { workspace = true } gp-sphinx = { workspace = true } sphinx-vite-builder = { workspace = true } @@ -49,7 +48,6 @@ dev = [ "sphinx-gp-opengraph", "sphinx-gp-sitemap", "gp-furo-theme", - "gp-sphinx-vite", "sphinx-vite-builder", # Docs "sphinx-autobuild", diff --git a/scripts/ci/package_tools.py b/scripts/ci/package_tools.py index e8897b8b..00d7b0f7 100644 --- a/scripts/ci/package_tools.py +++ b/scripts/ci/package_tools.py @@ -528,47 +528,6 @@ def smoke_gp_furo_theme(dist_dir: pathlib.Path, version: str) -> None: _run_sphinx_build(python_path, docs_dir, tmpdir / "_build") -def smoke_gp_sphinx_vite(dist_dir: pathlib.Path, version: str) -> None: - """Verify the orchestration package installs cleanly, registers config values. - - Skeleton-stage: subprocess orchestration is not yet wired, so the smoke - test imports the module, calls ``setup()`` against a fake app, and - asserts the two config values are registered. Full orchestration tests - live alongside the implementation in ``tests/test_gp_sphinx_vite.py``. - """ - with tempfile.TemporaryDirectory() as tmp: - python_path = _create_venv(pathlib.Path(tmp)) - _install_into_venv( - python_path, - *_workspace_wheel_requirements(dist_dir), - ) - # Statements live on their own logical lines: Python's grammar - # forbids compound statements (``class``, ``def``, ``if``, …) after - # ``;``, so the previous semicolon-joined form raised - # ``SyntaxError: invalid syntax`` on ``calls = []; class FakeApp:``. - _run_python( - python_path, - ( - "import gp_sphinx_vite\n" - f"assert gp_sphinx_vite.__version__ == {version!r}\n" - "assert callable(gp_sphinx_vite.setup)\n" - "config_values = []\n" - "events = []\n" - "class FakeApp:\n" - " def add_config_value(self, name, **kwargs):\n" - " config_values.append(name)\n" - " def connect(self, event, handler):\n" - " events.append(event)\n" - "metadata = gp_sphinx_vite.setup(FakeApp())\n" - "assert 'gp_sphinx_vite_mode' in config_values\n" - "assert 'gp_sphinx_vite_root' in config_values\n" - "assert 'builder-inited' in events\n" - "assert 'build-finished' in events\n" - "assert metadata['parallel_read_safe'] is True\n" - ), - ) - - def smoke_sphinx_fonts(dist_dir: pathlib.Path, version: str) -> None: """Verify the standalone extension installs and imports cleanly.""" with tempfile.TemporaryDirectory() as tmp: @@ -859,7 +818,6 @@ def smoke_sphinx_vite_builder(dist_dir: pathlib.Path, version: str) -> None: "sphinx-gp-theme": smoke_sphinx_gp_theme, "sphinx-autodoc-typehints-gp": smoke_sphinx_autodoc_typehints_gp, "gp-furo-theme": smoke_gp_furo_theme, - "gp-sphinx-vite": smoke_gp_sphinx_vite, "sphinx-vite-builder": smoke_sphinx_vite_builder, } diff --git a/tests/test_gp_sphinx_vite.py b/tests/test_gp_sphinx_vite.py deleted file mode 100644 index 120e8a20..00000000 --- a/tests/test_gp_sphinx_vite.py +++ /dev/null @@ -1,246 +0,0 @@ -"""Tests for gp_sphinx_vite package skeleton + config layer. - -Subprocess orchestration tests (spawn / teardown / idempotence / -SIGINT / SIGHUP) land alongside the implementation in subsequent -commits. This file covers the package wiring and the pure -config-layer functions (mode detection + root resolution). -""" - -from __future__ import annotations - -import importlib.metadata -import pathlib -import typing as t - -import pytest -from gp_sphinx_vite import __version__, setup -from gp_sphinx_vite.config import ( - GpSphinxViteConfig, - Mode, - detect_mode, - resolve_vite_root, -) - - -def test_version_matches_workspace_lock() -> None: - """Version follows gp-sphinx workspace lockstep.""" - assert __version__ == "0.0.1a16.dev2" - - -class _FakeApp: - """Minimal Sphinx-app stand-in for setup() smoke tests.""" - - def __init__(self) -> None: - self.config_values: list[tuple[str, dict[str, object]]] = [] - self.events: list[tuple[str, object]] = [] - - def add_config_value(self, name: str, **kwargs: object) -> None: - self.config_values.append((name, kwargs)) - - def connect(self, event: str, callback: object) -> None: - self.events.append((event, callback)) - - -def test_setup_registers_mode_config_value() -> None: - """setup() registers gp_sphinx_vite_mode.""" - fake = _FakeApp() - setup(fake) # type: ignore[arg-type] - names = [name for name, _ in fake.config_values] - assert "gp_sphinx_vite_mode" in names - - -def test_setup_registers_root_config_value() -> None: - """setup() registers gp_sphinx_vite_root.""" - fake = _FakeApp() - setup(fake) # type: ignore[arg-type] - names = [name for name, _ in fake.config_values] - assert "gp_sphinx_vite_root" in names - - -def test_setup_connects_lifecycle_events() -> None: - """setup() connects to builder-inited and build-finished.""" - fake = _FakeApp() - setup(fake) # type: ignore[arg-type] - event_names = [name for name, _ in fake.events] - assert "builder-inited" in event_names - assert "build-finished" in event_names - - -def test_setup_returns_parallel_safe_metadata() -> None: - """Both parallel-safe flags are True (no shared mutable state).""" - metadata = setup(_FakeApp()) # type: ignore[arg-type] - assert metadata["parallel_read_safe"] is True - assert metadata["parallel_write_safe"] is True - assert metadata["version"] == __version__ - - -def test_entry_point_is_discoverable() -> None: - """The sphinx.extensions entry point is registered for gp-sphinx-vite.""" - eps = importlib.metadata.entry_points(group="sphinx.extensions") - matched = [ep for ep in eps if ep.name == "gp-sphinx-vite"] - assert matched, "gp-sphinx-vite entry point not discoverable" - assert matched[0].value == "gp_sphinx_vite" - - -# Mode detection — pure-function tests, no Sphinx fixture required. - - -class _ModeFixture(t.NamedTuple): - """One scenario for detect_mode().""" - - test_id: str - config_value: str - argv: list[str] - env: dict[str, str] - expected: Mode - - -_MODE_FIXTURES: list[_ModeFixture] = [ - _ModeFixture( - test_id="explicit_dev_overrides_argv", - config_value="dev", - argv=["sphinx-build", "docs"], - env={}, - expected=Mode.DEV, - ), - _ModeFixture( - test_id="explicit_prod_overrides_autobuild_env", - config_value="prod", - argv=["sphinx-autobuild"], - env={"SPHINX_AUTOBUILD": "1"}, - expected=Mode.PROD, - ), - _ModeFixture( - test_id="auto_with_sphinx_build_resolves_to_prod", - config_value="auto", - argv=["/usr/bin/sphinx-build", "docs", "_build"], - env={}, - expected=Mode.PROD, - ), - _ModeFixture( - test_id="auto_with_argv0_sphinx_autobuild_resolves_to_dev", - config_value="auto", - argv=["/usr/local/bin/sphinx-autobuild", "docs", "_build"], - env={}, - expected=Mode.DEV, - ), - _ModeFixture( - test_id="auto_with_env_var_resolves_to_dev", - config_value="auto", - argv=["sphinx-build"], - env={"SPHINX_AUTOBUILD": "1"}, - expected=Mode.DEV, - ), - _ModeFixture( - test_id="auto_with_empty_argv_falls_back_to_prod", - config_value="auto", - argv=[], - env={}, - expected=Mode.PROD, - ), - _ModeFixture( - test_id="garbage_falls_back_to_prod", - config_value="something-unknown", - argv=[], - env={}, - expected=Mode.PROD, - ), - _ModeFixture( - test_id="empty_string_falls_back_to_prod", - config_value="", - argv=[], - env={}, - expected=Mode.PROD, - ), -] - - -@pytest.mark.parametrize( - list(_ModeFixture._fields), - _MODE_FIXTURES, - ids=[f.test_id for f in _MODE_FIXTURES], -) -def test_detect_mode( - test_id: str, - config_value: str, - argv: list[str], - env: dict[str, str], - expected: Mode, -) -> None: - """detect_mode resolves to the expected mode across all branches. - - The ``test_id`` parameter is consumed by pytest's parametrize ``ids=`` - callback (see ``_MODE_FIXTURES`` above) and surfaces as the test name - suffix in pytest output. ``parent_check=lambda: False`` keeps these - pure-function tests independent of whatever process pytest is running - under. - """ - del test_id - assert ( - detect_mode( - config_value=config_value, - argv=argv, - env=env, - parent_check=lambda: False, - ) - is expected - ) - - -def test_detect_mode_parent_is_sphinx_autobuild() -> None: - """When the parent process is sphinx-autobuild, mode resolves to DEV. - - Closes the gap where sphinx-autobuild spawns sphinx-build as a - subprocess (so sys.argv[0] is the Python interpreter, not the - autobuild wrapper). - """ - result = detect_mode( - config_value="auto", - argv=["python", "-m", "sphinx", "build"], - env={}, - parent_check=lambda: True, - ) - assert result is Mode.DEV - - -def test_resolve_vite_root_none_returns_none() -> None: - """An unset gp_sphinx_vite_root yields None.""" - assert resolve_vite_root(None) is None - - -def test_resolve_vite_root_returns_absolute_path(tmp_path: pathlib.Path) -> None: - """A relative or absolute path resolves to an absolute Path.""" - resolved = resolve_vite_root(str(tmp_path)) - assert resolved is not None - assert resolved.is_absolute() - assert resolved == tmp_path.resolve() - - -def test_resolve_vite_root_accepts_pathlike(tmp_path: pathlib.Path) -> None: - """PathLike inputs (raw Path objects) work too.""" - resolved = resolve_vite_root(tmp_path) - assert resolved == tmp_path.resolve() - - -def test_should_spawn_requires_dev_mode_and_root(tmp_path: pathlib.Path) -> None: - """should_spawn is True only when mode=DEV and vite_root is set.""" - assert GpSphinxViteConfig(mode=Mode.DEV, vite_root=tmp_path).should_spawn is True - assert GpSphinxViteConfig(mode=Mode.DEV, vite_root=None).should_spawn is False - assert GpSphinxViteConfig(mode=Mode.PROD, vite_root=tmp_path).should_spawn is False - assert GpSphinxViteConfig(mode=Mode.PROD, vite_root=None).should_spawn is False - - -def test_mode_compares_equal_to_string_literal() -> None: - """Mode values compare == to the literal config strings (str mixin). - - The ``str`` mixin in ``Mode(str, enum.Enum)`` makes the enum members - equal to their string values. This lets call sites do - ``app.config.gp_sphinx_vite_mode == Mode.DEV`` without an explicit - `.value` lookup, which is the ergonomic point of the str-mixin. - """ - assert Mode.DEV.value == "dev" - assert Mode.PROD.value == "prod" - # The str-mixin makes the enum directly comparable to a string. - # mypy can't see this through the literal-vs-enum overlap analysis, - # but the behavior is the documented public contract. - assert str(Mode.DEV) == "Mode.DEV" or Mode.DEV.value == "dev" diff --git a/tests/test_gp_sphinx_vite_bus.py b/tests/test_gp_sphinx_vite_bus.py deleted file mode 100644 index ff4a85b5..00000000 --- a/tests/test_gp_sphinx_vite_bus.py +++ /dev/null @@ -1,220 +0,0 @@ -"""Tests for :class:`gp_sphinx_vite.bus.AsyncioBus`. - -Bus is the thread + event-loop bridge that lets Sphinx's sync hooks -drive ``ViteProcess``. Tests cover the lifecycle (start/stop/restart), -the two scheduling primitives (``call_sync`` / ``call_soon``), and a -few edge cases (call before start, double start, exceptions in -fire-and-forget coroutines). - -Tests are intentionally synchronous (no ``@pytest.mark.asyncio``) — the -whole point of the bus is to run async code from a sync caller. -""" - -from __future__ import annotations - -import asyncio -import logging -import threading -import time - -import pytest -from gp_sphinx_vite.bus import AsyncioBus - - -def test_start_makes_bus_running() -> None: - """start() leaves the bus in a running state.""" - bus = AsyncioBus() - try: - assert not bus.is_running - bus.start() - assert bus.is_running - finally: - bus.stop() - - -def test_double_start_is_idempotent() -> None: - """A second start() against an already-running bus is a no-op.""" - bus = AsyncioBus() - try: - bus.start() - bus.start() # must not raise / spawn a second thread - assert bus.is_running - finally: - bus.stop() - - -def test_stop_before_start_is_idempotent() -> None: - """stop() on a bus that was never started is a no-op.""" - bus = AsyncioBus() - bus.stop() # must not raise - assert not bus.is_running - - -def test_call_sync_executes_coroutine_and_returns_result() -> None: - """call_sync schedules a coroutine and blocks on its result.""" - - async def _add(a: int, b: int) -> int: - return a + b - - bus = AsyncioBus() - try: - bus.start() - result = bus.call_sync(_add(2, 3)) - finally: - bus.stop() - assert result == 5 - - -def test_call_sync_propagates_exceptions() -> None: - """An exception in the coroutine surfaces in the caller's frame.""" - - async def _boom() -> None: - msg = "intentional" - raise RuntimeError(msg) - - bus = AsyncioBus() - try: - bus.start() - with pytest.raises(RuntimeError, match=r"intentional"): - bus.call_sync(_boom()) - finally: - bus.stop() - - -def test_call_sync_before_start_raises() -> None: - """call_sync against an unstarted bus is a programming error.""" - bus = AsyncioBus() - - async def _noop() -> None: - return None - - with pytest.raises(RuntimeError, match=r"call_sync.*before start"): - bus.call_sync(_noop()) - - -def test_call_soon_runs_coroutine_in_background() -> None: - """call_soon schedules without blocking; the side effect lands eventually.""" - flag = threading.Event() - - async def _set_flag() -> None: - flag.set() - - bus = AsyncioBus() - try: - bus.start() - bus.call_soon(_set_flag()) - assert flag.wait(timeout=1.0) - finally: - bus.stop() - - -def test_call_soon_logs_exception_without_raising( - caplog: pytest.LogCaptureFixture, -) -> None: - """A background failure logs at ERROR but does not crash the bus.""" - - async def _boom() -> None: - msg = "background failure" - raise RuntimeError(msg) - - flag = threading.Event() - - async def _flag_after() -> None: - flag.set() - - bus = AsyncioBus() - try: - with caplog.at_level(logging.ERROR, logger="gp_sphinx_vite.bus"): - bus.start() - bus.call_soon(_boom()) - # Schedule a follow-up so we know the bus is still alive. - bus.call_soon(_flag_after()) - assert flag.wait(timeout=1.0) - finally: - bus.stop() - - errors = [r for r in caplog.records if r.levelno == logging.ERROR] - assert errors, "expected an ERROR log record from the failing coroutine" - assert any("background coroutine" in r.getMessage() for r in errors) - # The original exception is attached via exc_info — verify it. - assert any( - r.exc_info is not None - and r.exc_info[1] is not None - and "background failure" in str(r.exc_info[1]) - for r in errors - ) - - -def test_stop_cancels_pending_tasks() -> None: - """In-flight long-running tasks are cancelled cleanly on stop().""" - started = threading.Event() - - async def _hang() -> None: - started.set() - await asyncio.sleep(60) # would outlast the test - - bus = AsyncioBus() - bus.start() - bus.call_soon(_hang()) - started.wait(timeout=1.0) - - t0 = time.monotonic() - bus.stop(timeout=2.0) - elapsed = time.monotonic() - t0 - assert not bus.is_running - # Should return well under the 60-second sleep. - assert elapsed < 2.0 - - -def test_can_construct_a_new_bus_after_stop() -> None: - """After stop(), a fresh AsyncioBus instance starts cleanly.""" - - async def _ping() -> str: - return "pong" - - first = AsyncioBus(name="first") - first.start() - first.stop() - - second = AsyncioBus(name="second") - try: - second.start() - assert second.call_sync(_ping()) == "pong" - finally: - second.stop() - - -def test_start_after_stop_raises_runtime_error() -> None: - """start() on a previously stopped instance enforces "single-use". - - The class docstring promises "After ``stop()`` it is not safe to - start again — construct a new instance." Without enforcement, - ``stop()`` resets ``_thread`` / ``_loop`` to ``None``, which makes - the idempotency guard in ``start()`` pass through and silently - spawn a fresh thread + loop — silently violating the contract. - - The ``_stopped`` flag enforces the documented behaviour: a raise - on misuse rather than a confusing zombie restart. - """ - bus = AsyncioBus() - bus.start() - bus.stop() - with pytest.raises(RuntimeError, match=r"single-use"): - bus.start() - - -def test_stop_before_start_does_not_lock_out_subsequent_start() -> None: - """A no-op stop() (no prior start()) leaves the bus startable. - - Stop-before-start is documented as idempotent. Marking the bus - "stopped" in that case would conflate "never lived" with - "lifecycle complete" — a fresh-from-the-constructor bus would - refuse to start after a defensive ``stop()`` in a finally block. - """ - bus = AsyncioBus() - bus.stop() # no-op - try: - bus.start() - assert bus.is_running - finally: - bus.stop() diff --git a/tests/test_gp_sphinx_vite_hooks.py b/tests/test_gp_sphinx_vite_hooks.py deleted file mode 100644 index 94c61822..00000000 --- a/tests/test_gp_sphinx_vite_hooks.py +++ /dev/null @@ -1,446 +0,0 @@ -"""Tests for :mod:`gp_sphinx_vite.hooks`. - -The hooks layer wires a ``ViteProcess`` to Sphinx's lifecycle events. -Tests use a thin Sphinx-app stand-in (with `.config` and the four -attributes the hooks set) and a fake-vite Python script in -``tmp_path`` so each test can exercise the real subprocess + -``AsyncioBus`` + ``ViteProcess`` chain end-to-end without booting a -full Sphinx build. -""" - -from __future__ import annotations - -import dataclasses -import pathlib -import sys -import textwrap -import time - -import pytest -from gp_sphinx_vite import hooks - - -@dataclasses.dataclass -class _FakeConfig: - """The slice of ``app.config`` the hooks read.""" - - gp_sphinx_vite_mode: str = "auto" - gp_sphinx_vite_root: str | None = None - - -@dataclasses.dataclass -class _FakeApp: - """Minimal stand-in for ``sphinx.application.Sphinx``. - - Carries only the surface the hooks touch: a ``config`` namespace - and the few private attributes the hooks ``setattr`` onto the app - (bus, proc, teardown-registered flag). - """ - - config: _FakeConfig = dataclasses.field(default_factory=_FakeConfig) - - -def _write_fake_vite( - tmp_path: pathlib.Path, *, body: str, with_node_modules: bool = True -) -> pathlib.Path: - """Write a fake-vite script + a stub package.json at ``tmp_path``. - - Creates ``node_modules/`` by default so :func:`hooks._ensure_node_modules` - short-circuits the auto-install path. Tests that exercise the install - path explicitly pass ``with_node_modules=False`` and arrange their own - ``pnpm_install_command`` patch. - """ - (tmp_path / "package.json").write_text('{"name": "fake-vite-root"}\n') - if with_node_modules: - (tmp_path / "node_modules").mkdir(exist_ok=True) - script = tmp_path / "fake_vite.py" - script.write_text(textwrap.dedent(body)) - return script - - -def _patch_vite_command(monkeypatch: pytest.MonkeyPatch, script: pathlib.Path) -> None: - """Replace ``vite_watch_command()`` with one that runs ``script``.""" - - def _fake_command() -> tuple[str, ...]: - return (sys.executable, str(script)) - - # Patch where hooks reads the symbol from (its own module namespace). - monkeypatch.setattr(hooks, "vite_watch_command", _fake_command) - - -def _patch_install_command( - monkeypatch: pytest.MonkeyPatch, script: pathlib.Path -) -> None: - """Replace ``pnpm_install_command()`` with one that runs ``script``. - - Mirrors :func:`_patch_vite_command` — keeps the auto-install tests - fast (no real pnpm invocation) and deterministic across machines. - """ - - def _fake_command() -> tuple[str, ...]: - return (sys.executable, str(script)) - - monkeypatch.setattr(hooks, "pnpm_install_command", _fake_command) - - -@pytest.fixture -def long_running_fake_vite( - tmp_path: pathlib.Path, - monkeypatch: pytest.MonkeyPatch, -) -> _FakeApp: - """Fake-vite that loops forever; a teardown is required to clean up.""" - script = _write_fake_vite( - tmp_path, - body="""\ - import sys, time - # Print one ready-line so a human running this can see progress. - print("vite watching", flush=True) - while True: - time.sleep(0.1) - """, - ) - _patch_vite_command(monkeypatch, script) - return _FakeApp( - config=_FakeConfig( - gp_sphinx_vite_mode="dev", - gp_sphinx_vite_root=str(tmp_path), - ), - ) - - -def test_on_builder_inited_no_op_in_prod_mode( - tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch -) -> None: - """`mode="prod"` → no process spawned, no bus started.""" - app = _FakeApp( - config=_FakeConfig( - gp_sphinx_vite_mode="prod", - gp_sphinx_vite_root=str(tmp_path), - ), - ) - - def _fail() -> tuple[str, ...]: - msg = "vite_watch_command should not be called in prod mode" - raise AssertionError(msg) - - monkeypatch.setattr(hooks, "vite_watch_command", _fail) - hooks.on_builder_inited(app) # type: ignore[arg-type] - assert getattr(app, hooks._PROC_ATTR, None) is None - assert getattr(app, hooks._BUS_ATTR, None) is None - - -def test_on_builder_inited_no_op_when_root_is_none( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """`mode="dev"` but no root → still no spawn (config.should_spawn is False).""" - app = _FakeApp( - config=_FakeConfig( - gp_sphinx_vite_mode="dev", - gp_sphinx_vite_root=None, - ), - ) - - def _fail() -> tuple[str, ...]: - msg = "vite_watch_command should not be called when root is None" - raise AssertionError(msg) - - monkeypatch.setattr(hooks, "vite_watch_command", _fail) - hooks.on_builder_inited(app) # type: ignore[arg-type] - assert getattr(app, hooks._PROC_ATTR, None) is None - - -def test_on_builder_inited_spawns_when_should_spawn( - long_running_fake_vite: _FakeApp, -) -> None: - """A dev-mode app with a real root spawns the watch.""" - app = long_running_fake_vite - try: - hooks.on_builder_inited(app) # type: ignore[arg-type] - proc = getattr(app, hooks._PROC_ATTR, None) - bus = getattr(app, hooks._BUS_ATTR, None) - assert proc is not None - assert bus is not None - assert bus.is_running - # Give the child a moment to actually spawn before asserting. - deadline = time.monotonic() + 1.0 - while not proc.is_running and time.monotonic() < deadline: - time.sleep(0.01) - assert proc.is_running - finally: - hooks.teardown(app, terminate_timeout=2.0) # type: ignore[arg-type] - - -def test_on_builder_inited_is_idempotent_on_refire( - long_running_fake_vite: _FakeApp, -) -> None: - """sphinx-autobuild's repeated builder-inited doesn't double-spawn.""" - app = long_running_fake_vite - try: - hooks.on_builder_inited(app) # type: ignore[arg-type] - first_proc = getattr(app, hooks._PROC_ATTR, None) - first_pid = first_proc.pid if first_proc else None - - # Re-fire: simulating sphinx-autobuild's behavior. - hooks.on_builder_inited(app) # type: ignore[arg-type] - second_proc = getattr(app, hooks._PROC_ATTR, None) - second_pid = second_proc.pid if second_proc else None - - assert first_proc is second_proc - assert first_pid == second_pid - finally: - hooks.teardown(app, terminate_timeout=2.0) # type: ignore[arg-type] - - -def test_on_build_finished_leaves_watch_running( - long_running_fake_vite: _FakeApp, -) -> None: - """build-finished is a no-op: the watch keeps running for the next rebuild.""" - app = long_running_fake_vite - try: - hooks.on_builder_inited(app) # type: ignore[arg-type] - proc = getattr(app, hooks._PROC_ATTR, None) - assert proc is not None - deadline = time.monotonic() + 1.0 - while not proc.is_running and time.monotonic() < deadline: - time.sleep(0.01) - assert proc.is_running - - hooks.on_build_finished(app, exception=None) # type: ignore[arg-type] - # Still running after build-finished. - assert proc.is_running - finally: - hooks.teardown(app, terminate_timeout=2.0) # type: ignore[arg-type] - - -def test_teardown_terminates_process_and_stops_bus( - long_running_fake_vite: _FakeApp, -) -> None: - """Explicit teardown stops both the process and the bus, idempotently.""" - app = long_running_fake_vite - hooks.on_builder_inited(app) # type: ignore[arg-type] - proc = getattr(app, hooks._PROC_ATTR, None) - bus = getattr(app, hooks._BUS_ATTR, None) - assert proc is not None and bus is not None - - hooks.teardown(app, terminate_timeout=2.0) # type: ignore[arg-type] - - assert not proc.is_running - assert not bus.is_running - assert getattr(app, hooks._PROC_ATTR, None) is None - assert getattr(app, hooks._BUS_ATTR, None) is None - - # Calling teardown again is a no-op. - hooks.teardown(app, terminate_timeout=2.0) # type: ignore[arg-type] - - -def test_teardown_no_op_when_never_spawned() -> None: - """Teardown on an app that never reached should_spawn does nothing harmful.""" - app = _FakeApp() - hooks.teardown(app) # type: ignore[arg-type] - - -def test_on_build_finished_logs_exception( - long_running_fake_vite: _FakeApp, -) -> None: - """An exception passed to build-finished surfaces at DEBUG (not WARNING). - - Sphinx's logger setup (memory handlers, namespace prefix) interacts - with pytest's ``caplog`` in test-order-dependent ways once any - Sphinx scenario fixture has initialized a real Sphinx app. Sidestep - by attaching our own handler directly to the underlying stdlib - Logger that ``sphinx.util.logging.getLogger`` wraps. - """ - import logging - - app = long_running_fake_vite - captured: list[logging.LogRecord] = [] - - class _CaptureHandler(logging.Handler): - def emit(self, record: logging.LogRecord) -> None: - captured.append(record) - - handler = _CaptureHandler(level=logging.DEBUG) - underlying = logging.getLogger("sphinx.gp_sphinx_vite.hooks") - underlying.addHandler(handler) - underlying.setLevel(logging.DEBUG) - - try: - hooks.on_builder_inited(app) # type: ignore[arg-type] - hooks.on_build_finished( - app, # type: ignore[arg-type] - exception=RuntimeError("sphinx fell over"), - ) - assert any("sphinx fell over" in r.getMessage() for r in captured), [ - r.getMessage() for r in captured - ] - finally: - underlying.removeHandler(handler) - hooks.teardown(app, terminate_timeout=2.0) # type: ignore[arg-type] - - -# Config-attribute happy-path coverage: t.cast asserts test reads the -# expected attrs. A regression here means the hooks layer changed its -# private-attr names (and other places — atexit handlers, downstream -# tests — would silently break). -def test_on_builder_inited_skips_install_when_node_modules_present( - tmp_path: pathlib.Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Pre-existing node_modules/ → no install attempt; vite spawns directly. - - Closes step-9 follow-up D's invariant: the auto-install path is only - invoked when node_modules is missing. Avoids re-running pnpm on every - builder-inited firing (sphinx-autobuild fires per rebuild). - """ - # Pre-create node_modules so _ensure_node_modules sees it as present. - (tmp_path / "node_modules").mkdir() - vite_script = _write_fake_vite( - tmp_path, - body="""\ - import time - print("vite watching", flush=True) - while True: - time.sleep(0.1) - """, - ) - _patch_vite_command(monkeypatch, vite_script) - - def _fail_install() -> tuple[str, ...]: - msg = "pnpm_install_command should not be called when node_modules/ exists" - raise AssertionError(msg) - - monkeypatch.setattr(hooks, "pnpm_install_command", _fail_install) - - app = _FakeApp( - config=_FakeConfig( - gp_sphinx_vite_mode="dev", - gp_sphinx_vite_root=str(tmp_path), - ), - ) - try: - hooks.on_builder_inited(app) # type: ignore[arg-type] - proc = getattr(app, hooks._PROC_ATTR, None) - assert proc is not None, "vite should have spawned" - finally: - hooks.teardown(app, terminate_timeout=2.0) # type: ignore[arg-type] - - -def test_on_builder_inited_runs_install_when_node_modules_missing( - tmp_path: pathlib.Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Missing node_modules/ → install runs (and creates it), then vite spawns. - - The install marker file (``installed.flag``) is the deterministic proof - the fake-pnpm script ran; ``node_modules/`` creation is the side-effect - that silences subsequent _ensure_node_modules calls on a re-fire. - """ - # NO node_modules/ pre-created — install path must fire. - install_marker = tmp_path / "installed.flag" - install_script = tmp_path / "fake_pnpm.py" - install_script.write_text( - textwrap.dedent( - f"""\ - import pathlib - (pathlib.Path({str(install_marker)!r})).write_text("ran") - (pathlib.Path({str(tmp_path / "node_modules")!r})).mkdir() - """, - ), - ) - vite_script = _write_fake_vite( - tmp_path, - with_node_modules=False, - body="""\ - import time - print("vite watching", flush=True) - while True: - time.sleep(0.1) - """, - ) - _patch_vite_command(monkeypatch, vite_script) - _patch_install_command(monkeypatch, install_script) - - app = _FakeApp( - config=_FakeConfig( - gp_sphinx_vite_mode="dev", - gp_sphinx_vite_root=str(tmp_path), - ), - ) - try: - hooks.on_builder_inited(app) # type: ignore[arg-type] - assert install_marker.exists(), "fake-pnpm install should have run" - assert (tmp_path / "node_modules").exists(), ( - "install should have created node_modules" - ) - proc = getattr(app, hooks._PROC_ATTR, None) - assert proc is not None, "vite should have spawned after successful install" - finally: - hooks.teardown(app, terminate_timeout=2.0) # type: ignore[arg-type] - - -def test_on_builder_inited_skips_vite_when_install_fails( - tmp_path: pathlib.Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Install exits non-zero → vite is not spawned; warning is logged. - - Mirrors the real-world failure mode where ``pnpm install`` fails (no - pnpm-lock.yaml, network error, registry timeout, etc.). The - orchestration must not burn cycles on a guaranteed-failed - ``pnpm exec vite`` and must surface the failure visibly. - """ - install_script = tmp_path / "fake_pnpm.py" - install_script.write_text( - textwrap.dedent( - """\ - import sys - print("simulated pnpm-install failure", flush=True) - sys.exit(1) - """, - ), - ) - - def _fail_vite() -> tuple[str, ...]: - msg = "vite_watch_command should not be called after install failure" - raise AssertionError(msg) - - monkeypatch.setattr(hooks, "vite_watch_command", _fail_vite) - _patch_install_command(monkeypatch, install_script) - - # We still need a package.json so config.should_spawn passes the - # vite_root resolution check. - (tmp_path / "package.json").write_text('{"name": "fake-vite-root"}\n') - - app = _FakeApp( - config=_FakeConfig( - gp_sphinx_vite_mode="dev", - gp_sphinx_vite_root=str(tmp_path), - ), - ) - hooks.on_builder_inited(app) # type: ignore[arg-type] - # No vite process should have been set on the app. - assert getattr(app, hooks._PROC_ATTR, None) is None, ( - "vite must not be spawned after a failed install" - ) - # Bus is created during the install phase; it's fine if it's still - # around — teardown handles it cleanly. - hooks.teardown(app, terminate_timeout=2.0) # type: ignore[arg-type] - - -def test_private_attr_names_are_stable() -> None: - """The private attribute names the hooks set on app are part of the contract.""" - assert hooks._BUS_ATTR == "_gp_sphinx_vite_bus" - assert hooks._PROC_ATTR == "_gp_sphinx_vite_proc" - - -_PRIVATE_ATTRS_TYPED: tuple[str, str, str] = ( - hooks._BUS_ATTR, - hooks._PROC_ATTR, - hooks._TEARDOWN_REGISTERED_ATTR, -) - - -def test_all_private_attrs_share_prefix() -> None: - """Every private attribute starts with `_gp_sphinx_vite_`.""" - for attr in _PRIVATE_ATTRS_TYPED: - assert attr.startswith("_gp_sphinx_vite_"), attr diff --git a/tests/test_gp_sphinx_vite_integration.py b/tests/test_gp_sphinx_vite_integration.py deleted file mode 100644 index 5067fff9..00000000 --- a/tests/test_gp_sphinx_vite_integration.py +++ /dev/null @@ -1,160 +0,0 @@ -"""Integration test: gp_sphinx_vite wired into a real Sphinx build. - -Exercises the full path — entry-point loaded from the Sphinx -extensions list, ``setup()`` invoked, ``builder-inited`` fires, the -hook spawns ViteProcess against a fake-vite script, ``build-finished`` -fires (no-op), and the test explicitly tears down. The unit tests in -``test_gp_sphinx_vite_hooks.py`` cover the same surface against a -hand-rolled FakeApp; this file proves the wiring through Sphinx itself. - -Skipped in CI environments that scrub Python interpreters from PATH — -the fake vite is just ``sys.executable`` running an inline script. -""" - -from __future__ import annotations - -import shutil -import sys -import textwrap -import typing as t - -import pytest - -from tests._sphinx_scenarios import ( - ScenarioFile, - SharedSphinxResult, - SphinxScenario, - build_isolated_sphinx_result, -) - -if t.TYPE_CHECKING: - import pathlib - - -pytestmark = pytest.mark.skipif( - shutil.which(sys.executable.split("/")[-1]) is None and not sys.executable, - reason="No Python interpreter found on PATH for the fake-vite child", -) - - -_INDEX_RST = textwrap.dedent( - """\ - Integration Demo - ================ - - Hello. - """, -) - - -def _conf_py(*, fake_vite_root: str, fake_vite_argv: tuple[str, ...]) -> str: - """Build a conf.py that wires gp_sphinx_vite + monkey-patches the watch command. - - The monkey-patch happens at conf.py time (which runs before - builder-inited fires), via ``gp_sphinx_vite.process.vite_watch_command`` - being replaced. The hook reads it from - ``gp_sphinx_vite.hooks.vite_watch_command`` (its own module-level - rebinding done at import time), so we patch *that* name. - """ - return textwrap.dedent( - f"""\ - import gp_sphinx_vite.hooks - gp_sphinx_vite.hooks.vite_watch_command = lambda: {fake_vite_argv!r} - - extensions = ["gp_sphinx_vite"] - html_theme = "basic" - master_doc = "index" - project = "integration demo" - gp_sphinx_vite_mode = "dev" - gp_sphinx_vite_root = {fake_vite_root!r} - """, - ) - - -@pytest.mark.integration -def test_sphinx_build_spawns_via_extension(tmp_path: pathlib.Path) -> None: - """A Sphinx build with the extension active spawns the watch process.""" - fake_vite_dir = tmp_path / "fake-vite-root" - fake_vite_dir.mkdir() - (fake_vite_dir / "package.json").write_text('{"name": "fake-vite-integration"}\n') - # Pre-create node_modules/ so _ensure_node_modules short-circuits the - # auto-install path (CI runners don't have pnpm on PATH). - (fake_vite_dir / "node_modules").mkdir() - fake_script = fake_vite_dir / "fake_vite.py" - fake_script.write_text( - textwrap.dedent( - """\ - import time - print("vite ready", flush=True) - while True: - time.sleep(0.1) - """, - ), - ) - - fake_vite_argv = (sys.executable, str(fake_script)) - scenario = SphinxScenario( - files=( - ScenarioFile( - "conf.py", - _conf_py( - fake_vite_root=str(fake_vite_dir), - fake_vite_argv=fake_vite_argv, - ), - ), - ScenarioFile("index.rst", _INDEX_RST), - ), - ) - - result: SharedSphinxResult = build_isolated_sphinx_result( - cache_root=tmp_path / "scenario-cache", - tmp_path=tmp_path / "scenario-tmp", - scenario=scenario, - purge_modules=("gp_sphinx_vite", "gp_sphinx_vite.hooks"), - ) - - proc = getattr(result.app, "_gp_sphinx_vite_proc", None) - bus = getattr(result.app, "_gp_sphinx_vite_bus", None) - try: - assert proc is not None, "hooks did not stash a ViteProcess on the app" - assert bus is not None, "hooks did not stash an AsyncioBus on the app" - assert proc.is_running, "ViteProcess exited before the test could observe it" - assert bus.is_running, "AsyncioBus stopped before the test could observe it" - finally: - # Explicit teardown — atexit-based cleanup runs at interpreter - # exit, which is fine for production but leaves the test - # process holding the bus thread until then. - from gp_sphinx_vite import hooks - - hooks.teardown(result.app, terminate_timeout=2.0) - - -@pytest.mark.integration -def test_sphinx_build_no_op_in_prod_mode(tmp_path: pathlib.Path) -> None: - """`gp_sphinx_vite_mode = "prod"` builds without spawning anything.""" - scenario = SphinxScenario( - files=( - ScenarioFile( - "conf.py", - textwrap.dedent( - """\ - extensions = ["gp_sphinx_vite"] - html_theme = "basic" - master_doc = "index" - project = "no-op demo" - gp_sphinx_vite_mode = "prod" - """, - ), - ), - ScenarioFile("index.rst", _INDEX_RST), - ), - ) - - result: SharedSphinxResult = build_isolated_sphinx_result( - cache_root=tmp_path / "scenario-cache", - tmp_path=tmp_path / "scenario-tmp", - scenario=scenario, - purge_modules=("gp_sphinx_vite",), - ) - assert getattr(result.app, "_gp_sphinx_vite_proc", None) is None - assert getattr(result.app, "_gp_sphinx_vite_bus", None) is None diff --git a/tests/test_gp_sphinx_vite_process.py b/tests/test_gp_sphinx_vite_process.py deleted file mode 100644 index 611e5fc3..00000000 --- a/tests/test_gp_sphinx_vite_process.py +++ /dev/null @@ -1,241 +0,0 @@ -"""Tests for :class:`gp_sphinx_vite.process.ViteProcess`. - -Drives the design via TDD against a synthetic "fake-vite" Python -script generated per-test. The fake script is parametric — accepts -flags for output-rate, exit-on, and SIGTERM-ignoring — so each -spawn / log / terminate / kill scenario gets its own focused -fixture without shelling out to a system tool. -""" - -from __future__ import annotations - -import asyncio -import logging -import pathlib -import sys -import textwrap -import typing as t - -import pytest -from gp_sphinx_vite.process import ViteProcess, vite_watch_command - - -def _write_fake_vite( - tmp_path: pathlib.Path, - *, - body: str, -) -> pathlib.Path: - """Write ``body`` to a fake-vite script and return its path. - - Each test gets its own script so we can vary behavior cheaply. - """ - path = tmp_path / "fake_vite.py" - path.write_text(textwrap.dedent(body)) - return path - - -def _fake_vite_argv(script: pathlib.Path) -> list[str]: - return [sys.executable, str(script)] - - -@pytest.mark.asyncio -async def test_start_runs_quick_command_and_exits(tmp_path: pathlib.Path) -> None: - """A short script that prints once and exits 0 returns 0.""" - script = _write_fake_vite( - tmp_path, - body="""\ - import sys - print("vite v7 ready") - sys.exit(0) - """, - ) - proc = ViteProcess(label="fake") - await proc.start(_fake_vite_argv(script), cwd=tmp_path) - code = await proc.wait() - assert code == 0 - assert not proc.is_running - - -@pytest.mark.asyncio -async def test_stdout_lines_logged_with_label_prefix( - tmp_path: pathlib.Path, - caplog: pytest.LogCaptureFixture, -) -> None: - """Each stdout line lands in the log with the configured ``[label]`` prefix.""" - script = _write_fake_vite( - tmp_path, - body="""\ - import sys - print("built furo.css in 12ms") - print("watching for changes") - sys.exit(0) - """, - ) - custom_logger = logging.getLogger("test_gp_sphinx_vite.fake") - proc = ViteProcess(label="fake", logger=custom_logger) - with caplog.at_level(logging.INFO, logger=custom_logger.name): - await proc.start(_fake_vite_argv(script), cwd=tmp_path) - await proc.wait() - messages = [r.getMessage() for r in caplog.records if r.name == custom_logger.name] - assert "[fake] built furo.css in 12ms" in messages - assert "[fake] watching for changes" in messages - - -@pytest.mark.asyncio -async def test_stderr_lines_logged_at_warning_level( - tmp_path: pathlib.Path, - caplog: pytest.LogCaptureFixture, -) -> None: - """Each stderr line surfaces at WARNING (so Sphinx's `app.warn` aligns).""" - script = _write_fake_vite( - tmp_path, - body="""\ - import sys - print("vite warning: deprecated rule", file=sys.stderr) - sys.exit(0) - """, - ) - custom_logger = logging.getLogger("test_gp_sphinx_vite.fake_stderr") - proc = ViteProcess(label="fake", logger=custom_logger) - with caplog.at_level(logging.WARNING, logger=custom_logger.name): - await proc.start(_fake_vite_argv(script), cwd=tmp_path) - await proc.wait() - warnings = [ - r - for r in caplog.records - if r.name == custom_logger.name and r.levelno == logging.WARNING - ] - assert any( - r.getMessage() == "[fake] vite warning: deprecated rule" for r in warnings - ) - - -@pytest.mark.asyncio -async def test_terminate_signals_long_running_process(tmp_path: pathlib.Path) -> None: - """A long-running script terminates promptly under SIGTERM.""" - script = _write_fake_vite( - tmp_path, - body="""\ - import time - while True: - time.sleep(0.5) - """, - ) - proc = ViteProcess(label="fake") - await proc.start(_fake_vite_argv(script), cwd=tmp_path) - assert proc.is_running - await asyncio.sleep(0.05) # let the child get into its sleep - code = await proc.terminate(timeout=2.0) - assert not proc.is_running - # SIGTERM exits with -SIGTERM (-15) on POSIX; some platforms vary. - assert code is not None - assert code != 0 - - -@pytest.mark.asyncio -async def test_terminate_escalates_to_sigkill_when_sigterm_ignored( - tmp_path: pathlib.Path, -) -> None: - """A script that traps SIGTERM is force-killed after the timeout.""" - script = _write_fake_vite( - tmp_path, - body="""\ - import signal, time - signal.signal(signal.SIGTERM, signal.SIG_IGN) - while True: - time.sleep(0.5) - """, - ) - proc = ViteProcess(label="fake") - await proc.start(_fake_vite_argv(script), cwd=tmp_path) - await asyncio.sleep(0.05) - code = await proc.terminate(timeout=0.5) - assert not proc.is_running - assert code is not None # SIGKILL exit -9 — escaped from SIG_IGN trap - - -@pytest.mark.asyncio -async def test_terminate_idempotent_on_exited_process(tmp_path: pathlib.Path) -> None: - """Calling terminate after natural exit is a no-op (doesn't raise).""" - script = _write_fake_vite( - tmp_path, - body="""\ - import sys - print("done") - sys.exit(7) - """, - ) - proc = ViteProcess(label="fake") - await proc.start(_fake_vite_argv(script), cwd=tmp_path) - await proc.wait() - assert proc.returncode == 7 - # Second-call terminate must not blow up. - code = await proc.terminate(timeout=1.0) - assert code == 7 - - -@pytest.mark.asyncio -async def test_terminate_no_op_before_start() -> None: - """terminate() on an unstarted process returns None silently.""" - proc = ViteProcess() - assert await proc.terminate() is None - assert not proc.is_running - - -@pytest.mark.asyncio -async def test_double_start_raises(tmp_path: pathlib.Path) -> None: - """start() twice on the same instance is a programming error.""" - script = _write_fake_vite(tmp_path, body="import sys; sys.exit(0)\n") - proc = ViteProcess() - await proc.start(_fake_vite_argv(script), cwd=tmp_path) - with pytest.raises(RuntimeError, match=r"start.*twice"): - await proc.start(_fake_vite_argv(script), cwd=tmp_path) - await proc.wait() - - -@pytest.mark.asyncio -async def test_wait_before_start_raises() -> None: - """wait() on an unstarted process is a programming error.""" - proc = ViteProcess() - with pytest.raises(RuntimeError, match=r"wait.*before start"): - await proc.wait() - - -@pytest.mark.asyncio -async def test_pythonunbuffered_injected_into_child_env(tmp_path: pathlib.Path) -> None: - """``PYTHONUNBUFFERED=1`` is set in the child env even if absent from caller env.""" - script = _write_fake_vite( - tmp_path, - body="""\ - import os, sys - sys.exit(0 if os.environ.get("PYTHONUNBUFFERED") == "1" else 1) - """, - ) - proc = ViteProcess(label="fake") - # Caller's env does NOT have the var; expect it to be injected. - sentinel_env: dict[str, str] = {"PATH": __import__("os").environ.get("PATH", "")} - await proc.start(_fake_vite_argv(script), cwd=tmp_path, env=sentinel_env) - code = await proc.wait() - assert code == 0 - - -def test_vite_watch_command_default() -> None: - """The canonical watch argv is the pnpm exec form.""" - assert vite_watch_command() == ("pnpm", "exec", "vite", "build", "--watch") - - -def test_vite_watch_command_alternate_package_manager() -> None: - """The package_manager kwarg overrides the runner.""" - assert vite_watch_command(package_manager="npm") == ( - "npm", - "exec", - "vite", - "build", - "--watch", - ) - - -def test_vite_watch_command_returns_tuple_for_pass_through() -> None: - """Output is a tuple (immutable; safe to pass into subprocess primitives).""" - cmd: t.Tuple[str, ...] = vite_watch_command() # noqa: UP006 — narrow assertion - assert isinstance(cmd, tuple) diff --git a/tests/test_package_reference.py b/tests/test_package_reference.py index d28d21a3..7c635973 100644 --- a/tests/test_package_reference.py +++ b/tests/test_package_reference.py @@ -20,7 +20,6 @@ def test_workspace_packages_lists_publishable_packages() -> None: names = {package["name"] for package in package_reference.workspace_packages()} assert names == { "gp-furo-theme", - "gp-sphinx-vite", "sphinx-gp-opengraph", "sphinx-gp-sitemap", "gp-sphinx", diff --git a/uv.lock b/uv.lock index a59be2dc..1af27414 100644 --- a/uv.lock +++ b/uv.lock @@ -15,7 +15,6 @@ exclude-newer-span = "P3D" members = [ "gp-furo-theme", "gp-sphinx", - "gp-sphinx-vite", "gp-sphinx-workspace", "sphinx-autodoc-api-style", "sphinx-autodoc-argparse", @@ -471,18 +470,6 @@ requires-dist = [ ] provides-extras = ["argparse"] -[[package]] -name = "gp-sphinx-vite" -version = "0.0.1a16.dev2" -source = { editable = "packages/gp-sphinx-vite" } -dependencies = [ - { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, -] - -[package.metadata] -requires-dist = [{ name = "sphinx", specifier = ">=8.1" }] - [[package]] name = "gp-sphinx-workspace" version = "0.0.1a16.dev2" @@ -497,7 +484,6 @@ dev = [ { name = "coverage" }, { name = "gp-furo-theme" }, { name = "gp-sphinx" }, - { name = "gp-sphinx-vite" }, { name = "hatchling" }, { name = "mypy" }, { name = "pillow" }, @@ -538,7 +524,6 @@ dev = [ { name = "coverage" }, { name = "gp-furo-theme", editable = "packages/gp-furo-theme" }, { name = "gp-sphinx", editable = "packages/gp-sphinx" }, - { name = "gp-sphinx-vite", editable = "packages/gp-sphinx-vite" }, { name = "hatchling", specifier = ">=1.0" }, { name = "mypy" }, { name = "pillow" }, From 17580965729cd52072555e3144ff0b95bac7073f Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 3 May 2026 12:59:14 -0500 Subject: [PATCH 43/53] =?UTF-8?q?release(workspace):=20bump=20v0.0.1a16.de?= =?UTF-8?q?v2=20=E2=86=92=20v0.0.1a16.dev3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: Cut a fresh dev release on top of the Phase 2 + Phase 3 consolidation so libtmux-mcp PR #33 (and any downstream consumer of the workspace's PyPI packages) can pin to a version where sphinx-vite-builder ships the working extension head, the hatchling build-hook variant, and gp-sphinx-vite has been retired. what: - Bump 0.0.1a16.dev2 → 0.0.1a16.dev3 across all 16 workspace package pyproject.toml + __init__.py __version__ entries + uv.lock + the workspace root pyproject.toml + the smoke-test version assertions - check-versions exits 0 with all packages aligned --- packages/gp-furo-theme/pyproject.toml | 2 +- .../src/gp_furo_theme/__init__.py | 2 +- packages/gp-sphinx/pyproject.toml | 14 ++++---- packages/gp-sphinx/src/gp_sphinx/__init__.py | 2 +- .../sphinx-autodoc-api-style/pyproject.toml | 6 ++-- .../sphinx-autodoc-argparse/pyproject.toml | 2 +- .../src/sphinx_autodoc_argparse/__init__.py | 2 +- .../sphinx-autodoc-docutils/pyproject.toml | 8 ++--- .../src/sphinx_autodoc_docutils/__init__.py | 2 +- .../sphinx-autodoc-fastmcp/pyproject.toml | 8 ++--- .../src/sphinx_autodoc_fastmcp/__init__.py | 2 +- .../pyproject.toml | 8 ++--- packages/sphinx-autodoc-sphinx/pyproject.toml | 8 ++--- .../src/sphinx_autodoc_sphinx/__init__.py | 2 +- .../pyproject.toml | 2 +- .../sphinx_autodoc_typehints_gp/extension.py | 2 +- packages/sphinx-fonts/pyproject.toml | 2 +- .../sphinx-fonts/src/sphinx_fonts/__init__.py | 2 +- packages/sphinx-gp-opengraph/pyproject.toml | 2 +- .../src/sphinx_gp_opengraph/__init__.py | 2 +- packages/sphinx-gp-sitemap/pyproject.toml | 2 +- .../src/sphinx_gp_sitemap/__init__.py | 2 +- packages/sphinx-gp-theme/pyproject.toml | 4 +-- .../src/sphinx_gp_theme/__init__.py | 2 +- .../sphinx-ux-autodoc-layout/pyproject.toml | 2 +- packages/sphinx-ux-badges/pyproject.toml | 2 +- .../src/sphinx_ux_badges/__init__.py | 2 +- packages/sphinx-vite-builder/pyproject.toml | 2 +- .../src/sphinx_vite_builder/__init__.py | 2 +- pyproject.toml | 4 +-- tests/ci/test_package_tools.py | 8 ++--- tests/test_sphinx_vite_builder.py | 2 +- uv.lock | 34 +++++++++---------- 33 files changed, 74 insertions(+), 74 deletions(-) diff --git a/packages/gp-furo-theme/pyproject.toml b/packages/gp-furo-theme/pyproject.toml index 97f9022a..23f0dfa4 100644 --- a/packages/gp-furo-theme/pyproject.toml +++ b/packages/gp-furo-theme/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "gp-furo-theme" -version = "0.0.1a16.dev2" +version = "0.0.1a16.dev3" description = "Tailwind v4 port of the Furo Sphinx theme for git-pull project documentation" requires-python = ">=3.10,<4.0" authors = [ diff --git a/packages/gp-furo-theme/src/gp_furo_theme/__init__.py b/packages/gp-furo-theme/src/gp_furo_theme/__init__.py index eb4a54c0..b3e6cad1 100644 --- a/packages/gp-furo-theme/src/gp_furo_theme/__init__.py +++ b/packages/gp-furo-theme/src/gp_furo_theme/__init__.py @@ -39,7 +39,7 @@ from .navigation import get_navigation_tree -__version__ = "0.0.1a16.dev2" +__version__ = "0.0.1a16.dev3" THEME_NAME = "gp-furo" THEME_PATH = (pathlib.Path(__file__).parent / "theme" / THEME_NAME).resolve() diff --git a/packages/gp-sphinx/pyproject.toml b/packages/gp-sphinx/pyproject.toml index 1cb1c679..3bbeb7e3 100644 --- a/packages/gp-sphinx/pyproject.toml +++ b/packages/gp-sphinx/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "gp-sphinx" -version = "0.0.1a16.dev2" +version = "0.0.1a16.dev3" description = "Shared Sphinx documentation platform for git-pull projects" requires-python = ">=3.10,<4.0" authors = [ @@ -26,15 +26,15 @@ readme = "README.md" keywords = ["sphinx", "documentation", "configuration"] dependencies = [ "sphinx>=8.1,<9", - "sphinx-gp-theme==0.0.1a16.dev2", - "sphinx-fonts==0.0.1a16.dev2", + "sphinx-gp-theme==0.0.1a16.dev3", + "sphinx-fonts==0.0.1a16.dev3", "myst-parser", "docutils", - "sphinx-autodoc-typehints-gp==0.0.1a16.dev2", + "sphinx-autodoc-typehints-gp==0.0.1a16.dev3", "sphinx-inline-tabs", "sphinx-copybutton", - "sphinx-gp-opengraph==0.0.1a16.dev2", - "sphinx-gp-sitemap==0.0.1a16.dev2", + "sphinx-gp-opengraph==0.0.1a16.dev3", + "sphinx-gp-sitemap==0.0.1a16.dev3", "sphinxext-rediraffe", "sphinx-design", "linkify-it-py", @@ -43,7 +43,7 @@ dependencies = [ [project.optional-dependencies] argparse = [ - "sphinx-autodoc-argparse==0.0.1a16.dev2", + "sphinx-autodoc-argparse==0.0.1a16.dev3", ] [project.urls] diff --git a/packages/gp-sphinx/src/gp_sphinx/__init__.py b/packages/gp-sphinx/src/gp_sphinx/__init__.py index 72c742d0..c1773d9f 100644 --- a/packages/gp-sphinx/src/gp_sphinx/__init__.py +++ b/packages/gp-sphinx/src/gp_sphinx/__init__.py @@ -7,7 +7,7 @@ __title__ = "gp-sphinx" __package_name__ = "gp_sphinx" __description__ = "Shared Sphinx documentation platform for git-pull projects" -__version__ = "0.0.1a16.dev2" +__version__ = "0.0.1a16.dev3" __author__ = "Tony Narlock" __github__ = "https://github.com/git-pull/gp-sphinx" __docs__ = "https://gp-sphinx.git-pull.com" diff --git a/packages/sphinx-autodoc-api-style/pyproject.toml b/packages/sphinx-autodoc-api-style/pyproject.toml index aa6ca58a..2bca1973 100644 --- a/packages/sphinx-autodoc-api-style/pyproject.toml +++ b/packages/sphinx-autodoc-api-style/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sphinx-autodoc-api-style" -version = "0.0.1a16.dev2" +version = "0.0.1a16.dev3" description = "Sphinx extension for enhanced autodoc API entry styling (badges and cards)" requires-python = ">=3.10,<4.0" authors = [ @@ -27,8 +27,8 @@ readme = "README.md" keywords = ["sphinx", "autodoc", "documentation", "api", "badges"] dependencies = [ "sphinx>=8.1", - "sphinx-ux-badges==0.0.1a16.dev2", - "sphinx-ux-autodoc-layout==0.0.1a16.dev2", + "sphinx-ux-badges==0.0.1a16.dev3", + "sphinx-ux-autodoc-layout==0.0.1a16.dev3", ] [project.urls] diff --git a/packages/sphinx-autodoc-argparse/pyproject.toml b/packages/sphinx-autodoc-argparse/pyproject.toml index 2e026276..8518d1e6 100644 --- a/packages/sphinx-autodoc-argparse/pyproject.toml +++ b/packages/sphinx-autodoc-argparse/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sphinx-autodoc-argparse" -version = "0.0.1a16.dev2" +version = "0.0.1a16.dev3" description = "Modern Sphinx extension for documenting argparse-based CLI tools" requires-python = ">=3.10,<4.0" authors = [ diff --git a/packages/sphinx-autodoc-argparse/src/sphinx_autodoc_argparse/__init__.py b/packages/sphinx-autodoc-argparse/src/sphinx_autodoc_argparse/__init__.py index d22f66ff..9fec75f4 100644 --- a/packages/sphinx-autodoc-argparse/src/sphinx_autodoc_argparse/__init__.py +++ b/packages/sphinx-autodoc-argparse/src/sphinx_autodoc_argparse/__init__.py @@ -44,7 +44,7 @@ if t.TYPE_CHECKING: from sphinx.application import Sphinx -__version__ = "0.0.1a16.dev2" +__version__ = "0.0.1a16.dev3" class SetupDict(t.TypedDict): diff --git a/packages/sphinx-autodoc-docutils/pyproject.toml b/packages/sphinx-autodoc-docutils/pyproject.toml index 6712b1a9..dc252332 100644 --- a/packages/sphinx-autodoc-docutils/pyproject.toml +++ b/packages/sphinx-autodoc-docutils/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sphinx-autodoc-docutils" -version = "0.0.1a16.dev2" +version = "0.0.1a16.dev3" description = "Sphinx extension for documenting docutils directives and roles as first-class reference entries" requires-python = ">=3.10,<4.0" authors = [ @@ -27,9 +27,9 @@ readme = "README.md" keywords = ["sphinx", "docutils", "directives", "roles", "documentation", "autodoc"] dependencies = [ "sphinx>=8.1", - "sphinx-ux-badges==0.0.1a16.dev2", - "sphinx-ux-autodoc-layout==0.0.1a16.dev2", - "sphinx-autodoc-typehints-gp==0.0.1a16.dev2", + "sphinx-ux-badges==0.0.1a16.dev3", + "sphinx-ux-autodoc-layout==0.0.1a16.dev3", + "sphinx-autodoc-typehints-gp==0.0.1a16.dev3", ] [project.urls] diff --git a/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/__init__.py b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/__init__.py index 1b121dd6..05506cc2 100644 --- a/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/__init__.py +++ b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/__init__.py @@ -77,7 +77,7 @@ def _add_static_path(app: Sphinx) -> None: app.add_css_file("css/sphinx_autodoc_docutils.css") return { - "version": "0.0.1a16.dev2", + "version": "0.0.1a16.dev3", "parallel_read_safe": True, "parallel_write_safe": True, } diff --git a/packages/sphinx-autodoc-fastmcp/pyproject.toml b/packages/sphinx-autodoc-fastmcp/pyproject.toml index d40a6b6d..3f36efeb 100644 --- a/packages/sphinx-autodoc-fastmcp/pyproject.toml +++ b/packages/sphinx-autodoc-fastmcp/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sphinx-autodoc-fastmcp" -version = "0.0.1a16.dev2" +version = "0.0.1a16.dev3" description = "Sphinx extension for documenting FastMCP tools (cards, badges, cross-refs)" requires-python = ">=3.10,<4.0" authors = [ @@ -27,9 +27,9 @@ readme = "README.md" keywords = ["sphinx", "fastmcp", "mcp", "documentation", "badges"] dependencies = [ "sphinx>=8.1", - "sphinx-ux-badges==0.0.1a16.dev2", - "sphinx-ux-autodoc-layout==0.0.1a16.dev2", - "sphinx-autodoc-typehints-gp==0.0.1a16.dev2", + "sphinx-ux-badges==0.0.1a16.dev3", + "sphinx-ux-autodoc-layout==0.0.1a16.dev3", + "sphinx-autodoc-typehints-gp==0.0.1a16.dev3", ] [project.urls] diff --git a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/__init__.py b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/__init__.py index 87f03bce..c6458611 100644 --- a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/__init__.py +++ b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/__init__.py @@ -51,7 +51,7 @@ logging.getLogger(__name__).addHandler(logging.NullHandler()) -_EXTENSION_VERSION = "0.0.1a16.dev2" +_EXTENSION_VERSION = "0.0.1a16.dev3" def setup(app: Sphinx) -> dict[str, t.Any]: diff --git a/packages/sphinx-autodoc-pytest-fixtures/pyproject.toml b/packages/sphinx-autodoc-pytest-fixtures/pyproject.toml index e7860096..ebb9378b 100644 --- a/packages/sphinx-autodoc-pytest-fixtures/pyproject.toml +++ b/packages/sphinx-autodoc-pytest-fixtures/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sphinx-autodoc-pytest-fixtures" -version = "0.0.1a16.dev2" +version = "0.0.1a16.dev3" description = "Sphinx extension for documenting pytest fixtures as first-class objects" requires-python = ">=3.10,<4.0" authors = [ @@ -30,9 +30,9 @@ keywords = ["sphinx", "pytest", "fixtures", "documentation", "autodoc"] dependencies = [ "sphinx>=8.1", "pytest", - "sphinx-ux-badges==0.0.1a16.dev2", - "sphinx-ux-autodoc-layout==0.0.1a16.dev2", - "sphinx-autodoc-typehints-gp==0.0.1a16.dev2", + "sphinx-ux-badges==0.0.1a16.dev3", + "sphinx-ux-autodoc-layout==0.0.1a16.dev3", + "sphinx-autodoc-typehints-gp==0.0.1a16.dev3", ] [project.urls] diff --git a/packages/sphinx-autodoc-sphinx/pyproject.toml b/packages/sphinx-autodoc-sphinx/pyproject.toml index 4e4173cf..0b45053b 100644 --- a/packages/sphinx-autodoc-sphinx/pyproject.toml +++ b/packages/sphinx-autodoc-sphinx/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sphinx-autodoc-sphinx" -version = "0.0.1a16.dev2" +version = "0.0.1a16.dev3" description = "Sphinx extension for documenting extension config values as first-class conf.py reference entries" requires-python = ">=3.10,<4.0" authors = [ @@ -27,9 +27,9 @@ readme = "README.md" keywords = ["sphinx", "configuration", "conf.py", "documentation", "autodoc"] dependencies = [ "sphinx>=8.1", - "sphinx-ux-badges==0.0.1a16.dev2", - "sphinx-ux-autodoc-layout==0.0.1a16.dev2", - "sphinx-autodoc-typehints-gp==0.0.1a16.dev2", + "sphinx-ux-badges==0.0.1a16.dev3", + "sphinx-ux-autodoc-layout==0.0.1a16.dev3", + "sphinx-autodoc-typehints-gp==0.0.1a16.dev3", ] [project.urls] diff --git a/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/__init__.py b/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/__init__.py index 8e6796a0..48cfb614 100644 --- a/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/__init__.py +++ b/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/__init__.py @@ -61,7 +61,7 @@ def _add_static_path(app: Sphinx) -> None: app.add_css_file("css/sphinx_autodoc_sphinx.css") return { - "version": "0.0.1a16.dev2", + "version": "0.0.1a16.dev3", "parallel_read_safe": True, "parallel_write_safe": True, } diff --git a/packages/sphinx-autodoc-typehints-gp/pyproject.toml b/packages/sphinx-autodoc-typehints-gp/pyproject.toml index fcf3b5ab..7e108ef3 100644 --- a/packages/sphinx-autodoc-typehints-gp/pyproject.toml +++ b/packages/sphinx-autodoc-typehints-gp/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "sphinx-autodoc-typehints-gp" -version = "0.0.1a16.dev2" +version = "0.0.1a16.dev3" description = "Cross-referenced type annotations for Sphinx autodoc" readme = "README.md" requires-python = ">=3.10" diff --git a/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/extension.py b/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/extension.py index 4674232d..15c3848e 100644 --- a/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/extension.py +++ b/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/extension.py @@ -593,7 +593,7 @@ def setup(app: Sphinx) -> dict[str, t.Any]: # are skipped by the built-in handler. app.connect("object-description-transform", merge_typehints, priority=499) return { - "version": "0.0.1a16.dev2", + "version": "0.0.1a16.dev3", "parallel_read_safe": True, "parallel_write_safe": True, } diff --git a/packages/sphinx-fonts/pyproject.toml b/packages/sphinx-fonts/pyproject.toml index 971964fa..4c12db4e 100644 --- a/packages/sphinx-fonts/pyproject.toml +++ b/packages/sphinx-fonts/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sphinx-fonts" -version = "0.0.1a16.dev2" +version = "0.0.1a16.dev3" description = "Sphinx extension for self-hosted fonts via Fontsource CDN" requires-python = ">=3.10,<4.0" authors = [ diff --git a/packages/sphinx-fonts/src/sphinx_fonts/__init__.py b/packages/sphinx-fonts/src/sphinx_fonts/__init__.py index 661c7402..c8f056ba 100644 --- a/packages/sphinx-fonts/src/sphinx_fonts/__init__.py +++ b/packages/sphinx-fonts/src/sphinx_fonts/__init__.py @@ -25,7 +25,7 @@ logger = logging.getLogger(__name__) logger.addHandler(logging.NullHandler()) -__version__ = "0.0.1a16.dev2" +__version__ = "0.0.1a16.dev3" CDN_TEMPLATE = ( "https://cdn.jsdelivr.net/npm/{package}@{version}" diff --git a/packages/sphinx-gp-opengraph/pyproject.toml b/packages/sphinx-gp-opengraph/pyproject.toml index cf869b80..7087ff88 100644 --- a/packages/sphinx-gp-opengraph/pyproject.toml +++ b/packages/sphinx-gp-opengraph/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sphinx-gp-opengraph" -version = "0.0.1a16.dev2" +version = "0.0.1a16.dev3" description = "OpenGraph and Twitter meta-tag emission for Sphinx — matplotlib-free" requires-python = ">=3.10,<4.0" authors = [ diff --git a/packages/sphinx-gp-opengraph/src/sphinx_gp_opengraph/__init__.py b/packages/sphinx-gp-opengraph/src/sphinx_gp_opengraph/__init__.py index dd84a1b3..c3ce5e55 100644 --- a/packages/sphinx-gp-opengraph/src/sphinx_gp_opengraph/__init__.py +++ b/packages/sphinx-gp-opengraph/src/sphinx_gp_opengraph/__init__.py @@ -42,7 +42,7 @@ logger = logging.getLogger(__name__) logger.addHandler(logging.NullHandler()) -_EXTENSION_VERSION = "0.0.1a16.dev2" +_EXTENSION_VERSION = "0.0.1a16.dev3" DEFAULT_DESCRIPTION_LENGTH = 200 diff --git a/packages/sphinx-gp-sitemap/pyproject.toml b/packages/sphinx-gp-sitemap/pyproject.toml index c9560944..375a124a 100644 --- a/packages/sphinx-gp-sitemap/pyproject.toml +++ b/packages/sphinx-gp-sitemap/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sphinx-gp-sitemap" -version = "0.0.1a16.dev2" +version = "0.0.1a16.dev3" description = "Sitemap generator for Sphinx — Sphinx 8.1+ idioms, parallel-build safe" requires-python = ">=3.10,<4.0" authors = [ diff --git a/packages/sphinx-gp-sitemap/src/sphinx_gp_sitemap/__init__.py b/packages/sphinx-gp-sitemap/src/sphinx_gp_sitemap/__init__.py index 417b135e..0f27d161 100644 --- a/packages/sphinx-gp-sitemap/src/sphinx_gp_sitemap/__init__.py +++ b/packages/sphinx-gp-sitemap/src/sphinx_gp_sitemap/__init__.py @@ -47,7 +47,7 @@ from sphinx.util.typing import ExtensionMetadata -_EXTENSION_VERSION = "0.0.1a16.dev2" +_EXTENSION_VERSION = "0.0.1a16.dev3" _SITEMAP_NS = "http://www.sitemaps.org/schemas/sitemap/0.9" _XHTML_NS = "http://www.w3.org/1999/xhtml" diff --git a/packages/sphinx-gp-theme/pyproject.toml b/packages/sphinx-gp-theme/pyproject.toml index 73b22f84..280eff49 100644 --- a/packages/sphinx-gp-theme/pyproject.toml +++ b/packages/sphinx-gp-theme/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sphinx-gp-theme" -version = "0.0.1a16.dev2" +version = "0.0.1a16.dev3" description = "Furo child theme for git-pull project documentation" requires-python = ">=3.10,<4.0" authors = [ @@ -27,7 +27,7 @@ readme = "README.md" keywords = ["sphinx", "theme", "furo", "documentation"] dependencies = [ "sphinx>=8.1", - "gp-furo-theme==0.0.1a16.dev2", + "gp-furo-theme==0.0.1a16.dev3", ] [project.entry-points."sphinx.html_themes"] diff --git a/packages/sphinx-gp-theme/src/sphinx_gp_theme/__init__.py b/packages/sphinx-gp-theme/src/sphinx_gp_theme/__init__.py index cffefd16..cc839203 100644 --- a/packages/sphinx-gp-theme/src/sphinx_gp_theme/__init__.py +++ b/packages/sphinx-gp-theme/src/sphinx_gp_theme/__init__.py @@ -23,7 +23,7 @@ if t.TYPE_CHECKING: from sphinx.application import Sphinx -__version__ = "0.0.1a16.dev2" +__version__ = "0.0.1a16.dev3" def get_theme_path() -> pathlib.Path: diff --git a/packages/sphinx-ux-autodoc-layout/pyproject.toml b/packages/sphinx-ux-autodoc-layout/pyproject.toml index b8e9cb15..5f711bf0 100644 --- a/packages/sphinx-ux-autodoc-layout/pyproject.toml +++ b/packages/sphinx-ux-autodoc-layout/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "sphinx-ux-autodoc-layout" -version = "0.0.1a16.dev2" +version = "0.0.1a16.dev3" description = "Componentized layout for Sphinx autodoc output" readme = "README.md" requires-python = ">=3.10" diff --git a/packages/sphinx-ux-badges/pyproject.toml b/packages/sphinx-ux-badges/pyproject.toml index 8219fa09..e71ba638 100644 --- a/packages/sphinx-ux-badges/pyproject.toml +++ b/packages/sphinx-ux-badges/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sphinx-ux-badges" -version = "0.0.1a16.dev2" +version = "0.0.1a16.dev3" description = "Shared badge node and CSS for Sphinx autodoc extensions" requires-python = ">=3.10,<4.0" authors = [ diff --git a/packages/sphinx-ux-badges/src/sphinx_ux_badges/__init__.py b/packages/sphinx-ux-badges/src/sphinx_ux_badges/__init__.py index 655a21e2..a0a7f49c 100644 --- a/packages/sphinx-ux-badges/src/sphinx_ux_badges/__init__.py +++ b/packages/sphinx-ux-badges/src/sphinx_ux_badges/__init__.py @@ -48,7 +48,7 @@ logging.getLogger(__name__).addHandler(logging.NullHandler()) -_EXTENSION_VERSION = "0.0.1a16.dev2" +_EXTENSION_VERSION = "0.0.1a16.dev3" def setup(app: Sphinx) -> dict[str, t.Any]: diff --git a/packages/sphinx-vite-builder/pyproject.toml b/packages/sphinx-vite-builder/pyproject.toml index e6bdfe4d..bdaa29bc 100644 --- a/packages/sphinx-vite-builder/pyproject.toml +++ b/packages/sphinx-vite-builder/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sphinx-vite-builder" -version = "0.0.1a16.dev2" +version = "0.0.1a16.dev3" description = "PEP 517 build backend + Sphinx extension that orchestrates Vite via pnpm" requires-python = ">=3.10,<4.0" authors = [ diff --git a/packages/sphinx-vite-builder/src/sphinx_vite_builder/__init__.py b/packages/sphinx-vite-builder/src/sphinx_vite_builder/__init__.py index ecc8b020..9445ccb9 100644 --- a/packages/sphinx-vite-builder/src/sphinx_vite_builder/__init__.py +++ b/packages/sphinx-vite-builder/src/sphinx_vite_builder/__init__.py @@ -24,7 +24,7 @@ if t.TYPE_CHECKING: from sphinx.application import Sphinx -__version__ = "0.0.1a16.dev2" +__version__ = "0.0.1a16.dev3" logger = logging.getLogger(__name__) logger.addHandler(logging.NullHandler()) diff --git a/pyproject.toml b/pyproject.toml index e1c0b35a..6b1e384a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "gp-sphinx-workspace" -version = "0.0.1a16.dev2" +version = "0.0.1a16.dev3" description = "Workspace root for gp-sphinx packages" requires-python = ">=3.10,<4.0" authors = [ @@ -9,7 +9,7 @@ authors = [ license = { text = "MIT" } readme = "README.md" dependencies = [ - "gp-sphinx==0.0.1a16.dev2", + "gp-sphinx==0.0.1a16.dev3", ] [tool.uv.workspace] diff --git a/tests/ci/test_package_tools.py b/tests/ci/test_package_tools.py index 74b07a90..1d7e7385 100644 --- a/tests/ci/test_package_tools.py +++ b/tests/ci/test_package_tools.py @@ -13,7 +13,7 @@ def test_workspace_version_is_lockstep() -> None: """All workspace packages share the same version.""" - assert package_tools.workspace_version() == "0.0.1a16.dev2" + assert package_tools.workspace_version() == "0.0.1a16.dev3" def test_check_versions_passes_for_repo() -> None: @@ -30,12 +30,12 @@ def test_smoke_targets_cover_workspace_packages() -> None: def test_release_metadata_accepts_repo_tag() -> None: """Repo-wide release tags resolve to the shared version.""" - assert package_tools.release_metadata("v0.0.1a16.dev2") == { - "version": "0.0.1a16.dev2" + assert package_tools.release_metadata("v0.0.1a16.dev3") == { + "version": "0.0.1a16.dev3" } def test_release_metadata_rejects_package_tag() -> None: """Package-scoped tags are no longer valid release inputs.""" with pytest.raises(SystemExit, match="invalid release tag format"): - package_tools.release_metadata("gp-sphinx@v0.0.1a16.dev2") + package_tools.release_metadata("gp-sphinx@v0.0.1a16.dev3") diff --git a/tests/test_sphinx_vite_builder.py b/tests/test_sphinx_vite_builder.py index 51658da6..a45f566e 100644 --- a/tests/test_sphinx_vite_builder.py +++ b/tests/test_sphinx_vite_builder.py @@ -15,7 +15,7 @@ def test_version_matches_workspace_lock() -> None: """Version follows the gp-sphinx workspace lockstep.""" - assert __version__ == "0.0.1a16.dev2" + assert __version__ == "0.0.1a16.dev3" class _FakeApp: diff --git a/uv.lock b/uv.lock index 1af27414..04b46350 100644 --- a/uv.lock +++ b/uv.lock @@ -387,7 +387,7 @@ wheels = [ [[package]] name = "gp-furo-theme" -version = "0.0.1a16.dev2" +version = "0.0.1a16.dev3" source = { editable = "packages/gp-furo-theme" } dependencies = [ { name = "accessible-pygments" }, @@ -423,7 +423,7 @@ wheels = [ [[package]] name = "gp-sphinx" -version = "0.0.1a16.dev2" +version = "0.0.1a16.dev3" source = { editable = "packages/gp-sphinx" } dependencies = [ { name = "docutils" }, @@ -472,7 +472,7 @@ provides-extras = ["argparse"] [[package]] name = "gp-sphinx-workspace" -version = "0.0.1a16.dev2" +version = "0.0.1a16.dev3" source = { editable = "." } dependencies = [ { name = "gp-sphinx" }, @@ -1565,7 +1565,7 @@ wheels = [ [[package]] name = "sphinx-autodoc-api-style" -version = "0.0.1a16.dev2" +version = "0.0.1a16.dev3" source = { editable = "packages/sphinx-autodoc-api-style" } dependencies = [ { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, @@ -1583,7 +1583,7 @@ requires-dist = [ [[package]] name = "sphinx-autodoc-argparse" -version = "0.0.1a16.dev2" +version = "0.0.1a16.dev3" source = { editable = "packages/sphinx-autodoc-argparse" } dependencies = [ { name = "docutils" }, @@ -1601,7 +1601,7 @@ requires-dist = [ [[package]] name = "sphinx-autodoc-docutils" -version = "0.0.1a16.dev2" +version = "0.0.1a16.dev3" source = { editable = "packages/sphinx-autodoc-docutils" } dependencies = [ { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, @@ -1621,7 +1621,7 @@ requires-dist = [ [[package]] name = "sphinx-autodoc-fastmcp" -version = "0.0.1a16.dev2" +version = "0.0.1a16.dev3" source = { editable = "packages/sphinx-autodoc-fastmcp" } dependencies = [ { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, @@ -1641,7 +1641,7 @@ requires-dist = [ [[package]] name = "sphinx-autodoc-pytest-fixtures" -version = "0.0.1a16.dev2" +version = "0.0.1a16.dev3" source = { editable = "packages/sphinx-autodoc-pytest-fixtures" } dependencies = [ { name = "pytest" }, @@ -1663,7 +1663,7 @@ requires-dist = [ [[package]] name = "sphinx-autodoc-sphinx" -version = "0.0.1a16.dev2" +version = "0.0.1a16.dev3" source = { editable = "packages/sphinx-autodoc-sphinx" } dependencies = [ { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, @@ -1683,7 +1683,7 @@ requires-dist = [ [[package]] name = "sphinx-autodoc-typehints-gp" -version = "0.0.1a16.dev2" +version = "0.0.1a16.dev3" source = { editable = "packages/sphinx-autodoc-typehints-gp" } dependencies = [ { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, @@ -1752,7 +1752,7 @@ wheels = [ [[package]] name = "sphinx-fonts" -version = "0.0.1a16.dev2" +version = "0.0.1a16.dev3" source = { editable = "packages/sphinx-fonts" } dependencies = [ { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, @@ -1764,7 +1764,7 @@ requires-dist = [{ name = "sphinx", specifier = ">=8.1" }] [[package]] name = "sphinx-gp-opengraph" -version = "0.0.1a16.dev2" +version = "0.0.1a16.dev3" source = { editable = "packages/sphinx-gp-opengraph" } dependencies = [ { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, @@ -1776,7 +1776,7 @@ requires-dist = [{ name = "sphinx", specifier = ">=8.1" }] [[package]] name = "sphinx-gp-sitemap" -version = "0.0.1a16.dev2" +version = "0.0.1a16.dev3" source = { editable = "packages/sphinx-gp-sitemap" } dependencies = [ { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, @@ -1788,7 +1788,7 @@ requires-dist = [{ name = "sphinx", specifier = ">=8.1" }] [[package]] name = "sphinx-gp-theme" -version = "0.0.1a16.dev2" +version = "0.0.1a16.dev3" source = { editable = "packages/sphinx-gp-theme" } dependencies = [ { name = "gp-furo-theme" }, @@ -1817,7 +1817,7 @@ wheels = [ [[package]] name = "sphinx-ux-autodoc-layout" -version = "0.0.1a16.dev2" +version = "0.0.1a16.dev3" source = { editable = "packages/sphinx-ux-autodoc-layout" } dependencies = [ { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, @@ -1829,7 +1829,7 @@ requires-dist = [{ name = "sphinx", specifier = ">=8.1" }] [[package]] name = "sphinx-ux-badges" -version = "0.0.1a16.dev2" +version = "0.0.1a16.dev3" source = { editable = "packages/sphinx-ux-badges" } dependencies = [ { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, @@ -1841,7 +1841,7 @@ requires-dist = [{ name = "sphinx", specifier = ">=8.1" }] [[package]] name = "sphinx-vite-builder" -version = "0.0.1a16.dev2" +version = "0.0.1a16.dev3" source = { editable = "packages/sphinx-vite-builder" } dependencies = [ { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, From f1de85f5b68f02b7818fe0edfa6b45728f61194e Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 3 May 2026 13:35:26 -0500 Subject: [PATCH 44/53] test(sphinx-vite-builder[hooks]): cover Mode.PROD one-shot run_vite_build path why: PR #29's commit 2d975df extended on_builder_inited so PROD-mode builds (plain sphinx-build) call run_vite_build() once before sphinx proceeds, mirroring the spec's "one-shot for prod, watch for dev". The branch was exercised end-to-end by `just build-docs` but no isolated test asserted the call shape; the existing prod-mode test relied on the web/-absent short-circuit + a vite_watch_command sentinel that PROD never reaches anyway. what: - tests/test_sphinx_vite_builder_hooks.py: test_on_builder_inited_runs_one_shot_in_prod_mode - monkeypatches hooks.run_vite_build to capture invocation, gives the fake app a real `/web/` so config.vite_root resolves - asserts run_vite_build is called exactly once with project_root = vite_root.parent.resolve() (matches the call shape in hooks.py where the PROD branch passes the parent because run_vite_build resolves `web/` relative to project_root) - asserts neither _PROC_ATTR nor _BUS_ATTR is set (PROD doesn't spawn the watch process or start the asyncio bus) --- tests/test_sphinx_vite_builder_hooks.py | 41 +++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/tests/test_sphinx_vite_builder_hooks.py b/tests/test_sphinx_vite_builder_hooks.py index fa85317b..2d5d7166 100644 --- a/tests/test_sphinx_vite_builder_hooks.py +++ b/tests/test_sphinx_vite_builder_hooks.py @@ -125,6 +125,47 @@ def _fail() -> tuple[str, ...]: assert getattr(app, hooks._BUS_ATTR, None) is None +def test_on_builder_inited_runs_one_shot_in_prod_mode( + tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """`mode="prod"` with a vite_root → one-shot run_vite_build, no spawn. + + Covers the PROD branch added alongside the placeholder rewrite: under + plain ``sphinx-build`` the extension delegates to the same orchestration + primitive the PEP 517 backend uses, blocking the build until vite + finishes and never spawning the long-running watch. + """ + captured: list[pathlib.Path | None] = [] + + def _capture( + project_root: pathlib.Path | None = None, + *, + package_manager: str = "pnpm", + ) -> None: + del package_manager + captured.append(project_root) + + monkeypatch.setattr(hooks, "run_vite_build", _capture) + + vite_root = tmp_path / "web" + vite_root.mkdir() + app = _FakeApp( + config=_FakeConfig( + sphinx_vite_builder_mode="prod", + sphinx_vite_builder_root=str(vite_root), + ), + ) + + hooks.on_builder_inited(app) # type: ignore[arg-type] + + # `run_vite_build` resolves `web/` relative to its `project_root` + # arg, so the hook passes the parent of vite_root. + assert captured == [vite_root.parent.resolve()] + # PROD path is one-shot: no watch process, no bus. + assert getattr(app, hooks._PROC_ATTR, None) is None + assert getattr(app, hooks._BUS_ATTR, None) is None + + def test_on_builder_inited_no_op_when_root_is_none( monkeypatch: pytest.MonkeyPatch, ) -> None: From 8b5ead0eada15fc5b26507f31f6a969643ff0483 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 3 May 2026 13:36:51 -0500 Subject: [PATCH 45/53] test(sphinx-vite-builder[bus]): port AsyncioBus lifecycle suite from retired gp-sphinx-vite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: When PR #29 commit 75f63ba retired the gp-sphinx-vite package it deleted tests/test_gp_sphinx_vite_bus.py (220 LOC) without porting the unique AsyncioBus coverage. The bus implementation lives on at sphinx_vite_builder._internal.bus with the same public surface, so its lifecycle invariants (start/stop idempotency, single-use contract, task cancellation on stop, exception isolation in call_soon, call_sync's sync-from-async semantics) need a dedicated suite again. what: - tests/test_sphinx_vite_builder_bus.py — verbatim port of the deleted tests with two transformations: * import: gp_sphinx_vite.bus → sphinx_vite_builder._internal.bus * caplog logger name: gp_sphinx_vite.bus → sphinx_vite_builder._internal.bus - preserves the 12 test functions covering: start makes is_running; double-start idempotent; stop-before-start idempotent; call_sync result + exception propagation; call_sync-before-start raises; call_soon fire-and-forget + ERROR-log-on-failure with exc_info; stop cancels long-running tasks within timeout; fresh-instance- after-stop works; start-after-stop raises (single-use contract); defensive stop() doesn't lock out a subsequent start --- tests/test_sphinx_vite_builder_bus.py | 220 ++++++++++++++++++++++++++ 1 file changed, 220 insertions(+) create mode 100644 tests/test_sphinx_vite_builder_bus.py diff --git a/tests/test_sphinx_vite_builder_bus.py b/tests/test_sphinx_vite_builder_bus.py new file mode 100644 index 00000000..664933da --- /dev/null +++ b/tests/test_sphinx_vite_builder_bus.py @@ -0,0 +1,220 @@ +"""Tests for :class:`sphinx_vite_builder._internal.bus.AsyncioBus`. + +Bus is the thread + event-loop bridge that lets Sphinx's sync hooks +drive ``AsyncProcess``. Tests cover the lifecycle (start/stop/restart), +the two scheduling primitives (``call_sync`` / ``call_soon``), and a +few edge cases (call before start, double start, exceptions in +fire-and-forget coroutines). + +Tests are intentionally synchronous (no ``@pytest.mark.asyncio``) — the +whole point of the bus is to run async code from a sync caller. +""" + +from __future__ import annotations + +import asyncio +import logging +import threading +import time + +import pytest +from sphinx_vite_builder._internal.bus import AsyncioBus + + +def test_start_makes_bus_running() -> None: + """start() leaves the bus in a running state.""" + bus = AsyncioBus() + try: + assert not bus.is_running + bus.start() + assert bus.is_running + finally: + bus.stop() + + +def test_double_start_is_idempotent() -> None: + """A second start() against an already-running bus is a no-op.""" + bus = AsyncioBus() + try: + bus.start() + bus.start() # must not raise / spawn a second thread + assert bus.is_running + finally: + bus.stop() + + +def test_stop_before_start_is_idempotent() -> None: + """stop() on a bus that was never started is a no-op.""" + bus = AsyncioBus() + bus.stop() # must not raise + assert not bus.is_running + + +def test_call_sync_executes_coroutine_and_returns_result() -> None: + """call_sync schedules a coroutine and blocks on its result.""" + + async def _add(a: int, b: int) -> int: + return a + b + + bus = AsyncioBus() + try: + bus.start() + result = bus.call_sync(_add(2, 3)) + finally: + bus.stop() + assert result == 5 + + +def test_call_sync_propagates_exceptions() -> None: + """An exception in the coroutine surfaces in the caller's frame.""" + + async def _boom() -> None: + msg = "intentional" + raise RuntimeError(msg) + + bus = AsyncioBus() + try: + bus.start() + with pytest.raises(RuntimeError, match=r"intentional"): + bus.call_sync(_boom()) + finally: + bus.stop() + + +def test_call_sync_before_start_raises() -> None: + """call_sync against an unstarted bus is a programming error.""" + bus = AsyncioBus() + + async def _noop() -> None: + return None + + with pytest.raises(RuntimeError, match=r"call_sync.*before start"): + bus.call_sync(_noop()) + + +def test_call_soon_runs_coroutine_in_background() -> None: + """call_soon schedules without blocking; the side effect lands eventually.""" + flag = threading.Event() + + async def _set_flag() -> None: + flag.set() + + bus = AsyncioBus() + try: + bus.start() + bus.call_soon(_set_flag()) + assert flag.wait(timeout=1.0) + finally: + bus.stop() + + +def test_call_soon_logs_exception_without_raising( + caplog: pytest.LogCaptureFixture, +) -> None: + """A background failure logs at ERROR but does not crash the bus.""" + + async def _boom() -> None: + msg = "background failure" + raise RuntimeError(msg) + + flag = threading.Event() + + async def _flag_after() -> None: + flag.set() + + bus = AsyncioBus() + try: + with caplog.at_level(logging.ERROR, logger="sphinx_vite_builder._internal.bus"): + bus.start() + bus.call_soon(_boom()) + # Schedule a follow-up so we know the bus is still alive. + bus.call_soon(_flag_after()) + assert flag.wait(timeout=1.0) + finally: + bus.stop() + + errors = [r for r in caplog.records if r.levelno == logging.ERROR] + assert errors, "expected an ERROR log record from the failing coroutine" + assert any("background coroutine" in r.getMessage() for r in errors) + # The original exception is attached via exc_info — verify it. + assert any( + r.exc_info is not None + and r.exc_info[1] is not None + and "background failure" in str(r.exc_info[1]) + for r in errors + ) + + +def test_stop_cancels_pending_tasks() -> None: + """In-flight long-running tasks are cancelled cleanly on stop().""" + started = threading.Event() + + async def _hang() -> None: + started.set() + await asyncio.sleep(60) # would outlast the test + + bus = AsyncioBus() + bus.start() + bus.call_soon(_hang()) + started.wait(timeout=1.0) + + t0 = time.monotonic() + bus.stop(timeout=2.0) + elapsed = time.monotonic() - t0 + assert not bus.is_running + # Should return well under the 60-second sleep. + assert elapsed < 2.0 + + +def test_can_construct_a_new_bus_after_stop() -> None: + """After stop(), a fresh AsyncioBus instance starts cleanly.""" + + async def _ping() -> str: + return "pong" + + first = AsyncioBus(name="first") + first.start() + first.stop() + + second = AsyncioBus(name="second") + try: + second.start() + assert second.call_sync(_ping()) == "pong" + finally: + second.stop() + + +def test_start_after_stop_raises_runtime_error() -> None: + """start() on a previously stopped instance enforces "single-use". + + The class docstring promises "After ``stop()`` it is not safe to + start again — construct a new instance." Without enforcement, + ``stop()`` resets ``_thread`` / ``_loop`` to ``None``, which makes + the idempotency guard in ``start()`` pass through and silently + spawn a fresh thread + loop — silently violating the contract. + + The ``_stopped`` flag enforces the documented behaviour: a raise + on misuse rather than a confusing zombie restart. + """ + bus = AsyncioBus() + bus.start() + bus.stop() + with pytest.raises(RuntimeError, match=r"single-use"): + bus.start() + + +def test_stop_before_start_does_not_lock_out_subsequent_start() -> None: + """A no-op stop() (no prior start()) leaves the bus startable. + + Stop-before-start is documented as idempotent. Marking the bus + "stopped" in that case would conflate "never lived" with + "lifecycle complete" — a fresh-from-the-constructor bus would + refuse to start after a defensive ``stop()`` in a finally block. + """ + bus = AsyncioBus() + bus.stop() # no-op + try: + bus.start() + assert bus.is_running + finally: + bus.stop() From fc5c12365c91d447f6d1e9c8e6431a7a5bcf94f7 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 3 May 2026 13:39:55 -0500 Subject: [PATCH 46/53] pkg(sphinx-vite-builder[hooks]): wrap handler exceptions as ExtensionError(modname=...) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: Sphinx's EventManager.emit() (sphinx/events.py:405-456) auto-wraps non-SphinxError exceptions raised from event handlers using ``safe_getattr(listener.handler, '__module__', None)`` for the modname kwarg — which for our hooks resolves to ``sphinx_vite_builder._internal.hooks`` and surfaces to users as ``Extension error (sphinx_vite_builder._internal.hooks)``. Wrapping explicitly with ``modname='sphinx_vite_builder'`` keeps the user-facing attribution clean and points consumers at the published package, not the internal module path. Because ``ExtensionError`` IS a ``SphinxError``, EventManager's auto-wrap path skips it — no double-wrap risk. what: - _internal/hooks.py: add _raise_as_extension_error helper that re-raises any Exception as ``ExtensionError(orig_exc=exc, modname='sphinx_vite_builder') from exc`` - Wrap the PROD-mode ``run_vite_build()`` call to catch SphinxViteBuilderError (PnpmMissingError / NodeModulesInstallError / ViteFailedError all inherit from it) - Wrap the DEV-mode ``bus.call_sync(proc.start(...))`` to catch both SphinxViteBuilderError and OSError — the latter covers FileNotFoundError raised by ``asyncio.create_subprocess_exec`` when the package manager isn't on PATH - tests/test_sphinx_vite_builder_hooks.py: three new tests asserting modname attribution + orig_exc preservation across the three error classes — PROD-mode PnpmMissingError, DEV-mode SphinxViteBuilderError, and DEV-mode OSError(FileNotFoundError) --- .../sphinx_vite_builder/_internal/hooks.py | 40 ++++- tests/test_sphinx_vite_builder_hooks.py | 140 ++++++++++++++++++ 2 files changed, 178 insertions(+), 2 deletions(-) diff --git a/packages/sphinx-vite-builder/src/sphinx_vite_builder/_internal/hooks.py b/packages/sphinx-vite-builder/src/sphinx_vite_builder/_internal/hooks.py index 6641ddf7..c483cfd2 100644 --- a/packages/sphinx-vite-builder/src/sphinx_vite_builder/_internal/hooks.py +++ b/packages/sphinx-vite-builder/src/sphinx_vite_builder/_internal/hooks.py @@ -33,10 +33,12 @@ import typing as t import weakref +from sphinx.errors import ExtensionError from sphinx.util import logging as sphinx_logging from .bus import AsyncioBus from .config import Mode, SphinxViteBuilderConfig, detect_mode, resolve_vite_root +from .errors import SphinxViteBuilderError from .process import AsyncProcess from .vite import pnpm_install_command, run_vite_build, vite_watch_command @@ -62,6 +64,27 @@ ) +def _raise_as_extension_error(exc: Exception) -> t.NoReturn: + """Re-raise ``exc`` as :class:`sphinx.errors.ExtensionError`. + + Sphinx's ``EventManager.emit()`` (``sphinx/events.py:405-456``) + auto-wraps non-``SphinxError`` exceptions raised from event handlers + using ``safe_getattr(listener.handler, '__module__', None)`` for the + ``modname`` kwarg — which for our hooks resolves to + ``'sphinx_vite_builder._internal.hooks'``. Wrapping explicitly with + ``modname='sphinx_vite_builder'`` keeps the user-facing + ``Extension error (sphinx_vite_builder)`` category clean and points + consumers at the published package, not the internal module path. + Because :class:`ExtensionError` IS a :class:`SphinxError`, the + auto-wrap path skips it: no double-wrap risk. + """ + raise ExtensionError( + str(exc), + orig_exc=exc, + modname="sphinx_vite_builder", + ) from exc + + def _build_config(app: Sphinx) -> SphinxViteBuilderConfig: """Snapshot the live config values into a frozen dataclass.""" return SphinxViteBuilderConfig( @@ -136,7 +159,10 @@ def on_builder_inited(app: Sphinx) -> None: # same fast-fail diagnostics as the PEP 517 backend uses. # ``run_vite_build`` resolves ``web/`` relative to its # ``project_root`` argument, so pass the parent of vite_root. - run_vite_build(project_root=config.vite_root.parent) + try: + run_vite_build(project_root=config.vite_root.parent) + except SphinxViteBuilderError as exc: + _raise_as_extension_error(exc) return existing_proc: AsyncProcess | None = getattr(app, _PROC_ATTR, None) @@ -167,7 +193,17 @@ def on_builder_inited(app: Sphinx) -> None: command = vite_watch_command() logger.info("[vite] spawning %s in %s", " ".join(command), config.vite_root) - bus.call_sync(proc.start(command, cwd=config.vite_root)) + try: + bus.call_sync(proc.start(command, cwd=config.vite_root)) + except (SphinxViteBuilderError, OSError) as exc: + # OSError covers the FileNotFoundError that + # ``asyncio.create_subprocess_exec`` raises when the package + # manager (pnpm) is not on PATH. SphinxViteBuilderError covers + # any typed diagnostic raised through the bus from inside + # AsyncProcess.start. Both get the same modname-attributed + # ExtensionError treatment so users see "Extension error + # (sphinx_vite_builder)" instead of the internal module path. + _raise_as_extension_error(exc) if not getattr(app, _TEARDOWN_REGISTERED_ATTR, False): _install_teardown_handlers(app) diff --git a/tests/test_sphinx_vite_builder_hooks.py b/tests/test_sphinx_vite_builder_hooks.py index 2d5d7166..90dee07f 100644 --- a/tests/test_sphinx_vite_builder_hooks.py +++ b/tests/test_sphinx_vite_builder_hooks.py @@ -17,7 +17,12 @@ import time import pytest +from sphinx.errors import ExtensionError from sphinx_vite_builder._internal import hooks +from sphinx_vite_builder._internal.errors import ( + PnpmMissingError, + SphinxViteBuilderError, +) @dataclasses.dataclass @@ -463,3 +468,138 @@ def test_all_private_attrs_share_prefix() -> None: """Every private attribute starts with `_sphinx_vite_builder_`.""" for attr in _PRIVATE_ATTRS_TYPED: assert attr.startswith("_sphinx_vite_builder_"), attr + + +def test_prod_mode_failure_raises_extension_error_with_modname( + tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A typed diagnostic from PROD-mode run_vite_build → ExtensionError. + + Sphinx's EventManager.emit() auto-wraps non-SphinxError exceptions + raised from event handlers using the handler's __module__ as the + modname — which would surface to users as "Extension error + (sphinx_vite_builder._internal.hooks)". The wrap-with-modname in + on_builder_inited keeps the user-facing attribution clean + ("Extension error (sphinx_vite_builder)") and preserves the original + diagnostic via orig_exc. + """ + + def _raise_pnpm_missing( + project_root: pathlib.Path | None = None, + *, + package_manager: str = "pnpm", + ) -> None: + del project_root, package_manager + msg = "pnpm is not on PATH" + raise PnpmMissingError(msg) + + monkeypatch.setattr(hooks, "run_vite_build", _raise_pnpm_missing) + + vite_root = tmp_path / "web" + vite_root.mkdir() + app = _FakeApp( + config=_FakeConfig( + sphinx_vite_builder_mode="prod", + sphinx_vite_builder_root=str(vite_root), + ), + ) + + with pytest.raises(ExtensionError) as excinfo: + hooks.on_builder_inited(app) # type: ignore[arg-type] + + assert excinfo.value.modname == "sphinx_vite_builder" + assert isinstance(excinfo.value.orig_exc, PnpmMissingError) + assert isinstance(excinfo.value.__cause__, PnpmMissingError) + assert "pnpm is not on PATH" in str(excinfo.value) + + +def test_dev_mode_spawn_failure_raises_extension_error_with_modname( + tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A typed diagnostic from DEV-mode bus.call_sync → ExtensionError. + + The DEV-mode path doesn't go through run_vite_build; it spawns the + long-running watch via bus.call_sync(proc.start(...)) directly. Any + SphinxViteBuilderError surfacing through the bus should still get + the same ExtensionError(modname='sphinx_vite_builder') treatment. + """ + + class _ExplodingProc: + async def start(self, command: tuple[str, ...], *, cwd: pathlib.Path) -> None: + del command, cwd + msg = "simulated vite spawn failure" + raise SphinxViteBuilderError(msg) + + def _proc_factory(*, label: str = "", logger: object = None) -> _ExplodingProc: + del label, logger + return _ExplodingProc() + + # Pre-create node_modules so _ensure_node_modules short-circuits. + (tmp_path / "node_modules").mkdir() + monkeypatch.setattr(hooks, "AsyncProcess", _proc_factory) + + app = _FakeApp( + config=_FakeConfig( + sphinx_vite_builder_mode="dev", + sphinx_vite_builder_root=str(tmp_path), + ), + ) + + try: + with pytest.raises(ExtensionError) as excinfo: + hooks.on_builder_inited(app) # type: ignore[arg-type] + finally: + # The bus was started before the spawn raised — clean up so the + # daemon thread doesn't outlive the test. + bus = getattr(app, hooks._BUS_ATTR, None) + if bus is not None: + bus.stop(timeout=1.0) + setattr(app, hooks._BUS_ATTR, None) + + assert excinfo.value.modname == "sphinx_vite_builder" + assert isinstance(excinfo.value.orig_exc, SphinxViteBuilderError) + assert "simulated vite spawn failure" in str(excinfo.value) + + +def test_dev_mode_oserror_from_missing_pnpm_raises_extension_error( + tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """An ``OSError`` (typical: pnpm missing) → ExtensionError, not bare bubble. + + ``asyncio.create_subprocess_exec`` raises ``FileNotFoundError`` (an + ``OSError`` subclass) when the executable isn't on PATH. Without + explicit handling, that bubbles to Sphinx's auto-wrap path with the + wrong modname; the catch in on_builder_inited surfaces it under + ``sphinx_vite_builder``. + """ + + class _OsErrorProc: + async def start(self, command: tuple[str, ...], *, cwd: pathlib.Path) -> None: + del command, cwd + raise FileNotFoundError(2, "No such file or directory: 'pnpm'") + + def _proc_factory(*, label: str = "", logger: object = None) -> _OsErrorProc: + del label, logger + return _OsErrorProc() + + (tmp_path / "node_modules").mkdir() + monkeypatch.setattr(hooks, "AsyncProcess", _proc_factory) + + app = _FakeApp( + config=_FakeConfig( + sphinx_vite_builder_mode="dev", + sphinx_vite_builder_root=str(tmp_path), + ), + ) + + try: + with pytest.raises(ExtensionError) as excinfo: + hooks.on_builder_inited(app) # type: ignore[arg-type] + finally: + bus = getattr(app, hooks._BUS_ATTR, None) + if bus is not None: + bus.stop(timeout=1.0) + setattr(app, hooks._BUS_ATTR, None) + + assert excinfo.value.modname == "sphinx_vite_builder" + assert isinstance(excinfo.value.orig_exc, OSError) From 5c192cc43de17e909e6755bc88d638b9b5567922 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 3 May 2026 13:41:46 -0500 Subject: [PATCH 47/53] =?UTF-8?q?ci(smoke[sphinx-vite-builder]):=20add=20S?= =?UTF-8?q?cenario=203=20=E2=80=94=20hatch=20build-hook=20entry-point=20di?= =?UTF-8?q?scovery?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: PR #29 added the hatchling build-hook variant (``[project.entry-points.hatch] vite = "sphinx_vite_builder.hatch_plugin"``) but the smoke runner only exercised the runtime + PEP 517 backend heads. A regression that drops or misconfigures the entry point would slip past CI until a downstream consumer's ``[tool.hatch.build.hooks.vite]`` activation silently no-ops. Add the third scenario the Phase 3A issue called out so a fresh-venv install catches metadata regressions at release time. what: - scripts/ci/package_tools.py smoke_sphinx_vite_builder: third venv scenario installs wheel + hatchling, asserts ``importlib.metadata.entry_points(group='hatch')`` exposes the ``vite`` entry, that it loads to ``sphinx_vite_builder.hatch_plugin``, that the loaded module has ``hatch_register_build_hook``, and that the returned hook list contains a class with ``PLUGIN_NAME == 'vite'`` - Docstring updated: "Two scenarios" → "Three scenarios"; new bullet documents the discovery contract - Verified locally: ``uv build --package sphinx-vite-builder`` followed by ``uv run python scripts/ci/package_tools.py smoke sphinx-vite-builder --dist-dir dist`` exits 0 --- scripts/ci/package_tools.py | 43 +++++++++++++++++++++++++++++++++++-- 1 file changed, 41 insertions(+), 2 deletions(-) diff --git a/scripts/ci/package_tools.py b/scripts/ci/package_tools.py index 00d7b0f7..d591f9ae 100644 --- a/scripts/ci/package_tools.py +++ b/scripts/ci/package_tools.py @@ -735,9 +735,9 @@ def smoke_sphinx_autodoc_fastmcp(dist_dir: pathlib.Path, version: str) -> None: def smoke_sphinx_vite_builder(dist_dir: pathlib.Path, version: str) -> None: - """Verify both heads of sphinx-vite-builder against the built wheel. + """Verify the three activation paths of sphinx-vite-builder against the wheel. - Two scenarios, each in its own venv so they cannot mask each + Three scenarios, each in its own venv so they cannot mask each other: 1. **Runtime install (no hatchling).** The wheel must import and @@ -755,6 +755,13 @@ def smoke_sphinx_vite_builder(dist_dir: pathlib.Path, version: str) -> None: the wheel's ``build`` module exposes the documented PEP 517 hooks (``build_wheel``, ``build_sdist``, ``build_editable``) and they are callable. + 3. **Hatchling build-hook discovery.** The Phase 3 Milestone A + variant registers ``[project.entry-points.hatch] vite = + "sphinx_vite_builder.hatch_plugin"``. From a fresh venv with + hatchling installed, ``importlib.metadata.entry_points + (group='hatch')`` must surface the ``vite`` entry pointing at + the right module. Catches a regression that drops the entry + point from the wheel's metadata. """ wheel = _target_wheel_path(dist_dir, "sphinx-vite-builder") # Scenario 1: runtime venv, no hatchling. @@ -800,6 +807,38 @@ def smoke_sphinx_vite_builder(dist_dir: pathlib.Path, version: str) -> None: "assert callable(build.prepare_metadata_for_build_wheel)" ), ) + # Scenario 3: hatchling build-hook entry-point discovery. + with tempfile.TemporaryDirectory() as tmp: + python_path = _create_venv(pathlib.Path(tmp)) + _install_into_venv(python_path, wheel, "hatchling>=1.0", find_links=dist_dir) + _run_python( + python_path, + "\n".join( + ( + "from importlib.metadata import entry_points", + "eps = entry_points(group='hatch')", + "matched = [ep for ep in eps if ep.name == 'vite']", + "assert matched, (", + " 'vite hook not registered in hatch entry-point group; '", + " 'check [project.entry-points.hatch] in pyproject.toml'", + ")", + "assert matched[0].value == 'sphinx_vite_builder.hatch_plugin', (", + " f'wrong entry-point target: {matched[0].value!r}'", + ")", + "module = matched[0].load()", + "assert hasattr(module, 'hatch_register_build_hook'), (", + " 'loaded entry point lacks hatch_register_build_hook hookimpl'", + ")", + "hooks = module.hatch_register_build_hook()", + "assert hooks, (", + " 'hatch_register_build_hook returned no hook classes'", + ")", + "assert any(h.PLUGIN_NAME == 'vite' for h in hooks), (", + " f'no hook class with PLUGIN_NAME=vite: {hooks!r}'", + ")", + ), + ), + ) _PACKAGE_SMOKE_RUNNERS: dict[str, t.Callable[[pathlib.Path, str], None]] = { From d40cebafece257e25638a28d22abaf4bf08391f6 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 3 May 2026 13:42:41 -0500 Subject: [PATCH 48/53] docs(whats-new): document sphinx-vite-builder Phase 1+2+3 why: PR #29 + the post-PR follow-on commits constitute a substantial addition to the workspace's "what's new" story but the document hasn't mentioned the package. Add a feature-centric section in the existing prose convention covering the three activation paths, the wheel-vs-source asymmetry, the fast-fail diagnostic family, and the gp-sphinx-vite retirement. what: - docs/whats-new.md: new ## section "sphinx-vite-builder: Vite + pnpm orchestration end-to-end" - Cross-link {doc} to packages/sphinx-vite-builder - Lists the three orthogonal activations (PEP 517 backend, hatchling build hook, Sphinx extension), the central asymmetry, the PnpmMissingError / NodeModulesInstallError / ViteFailedError diagnostic family with self-healing CI recipes, and notes that vite_orchestration=True consumers continue to work after the gp-sphinx-vite retirement --- docs/whats-new.md | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/docs/whats-new.md b/docs/whats-new.md index 53065f31..7428ab6e 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -76,3 +76,40 @@ snapshots stay stable across environments. The majority of tests now operate directly on the docutils doctree — constructing `nodes.*` objects in Python — instead of running full Sphinx builds. This makes tests faster, more precise, and easier to debug. + +## sphinx-vite-builder: Vite + pnpm orchestration end-to-end + +{doc}`sphinx-vite-builder ` consolidates +the workspace's Vite story into a single package with three orthogonal +activation paths sharing one async-subprocess core: + +- **PEP 517 build backend** — `build-backend = + "sphinx_vite_builder.build"` runs `pnpm exec vite build` before + delegating wheel/sdist construction to `hatchling.build`. End users + who `pip install` from PyPI get a wheel with the static tree + pre-baked at release time and never need pnpm or Node. +- **Hatchling build hook** — `[tool.hatch.build.hooks.vite]` + composes with any other hatchling hook stack, so projects already + using `build-backend = "hatchling.build"` can adopt Vite without + swapping the backend. +- **Sphinx extension** — `extensions = ["sphinx_vite_builder"]` in + `conf.py` auto-orchestrates Vite during docs builds: a one-shot + `pnpm exec vite build` for plain `sphinx-build`, a long-running + `pnpm exec vite build --watch` child process under + `sphinx-autobuild`, with graceful SIGTERM → SIGKILL teardown on + signal / `atexit`. + +The whole product is the **wheel-vs-source asymmetry**: a `web/` +directory triggers strict orchestration with fast-fail diagnostics +(`PnpmMissingError`, `NodeModulesInstallError`, `ViteFailedError`, +each carrying a copy-pasteable hint), while an absent `web/` (the +unpacked-sdist case) short-circuits cleanly so wheels published to +PyPI need zero toolchain on the consumer side. Errors are +self-healing in CI: detected providers (GitHub Actions, CircleCI, +Azure Pipelines, GitLab CI) get the right setup recipe inlined into +the error message. + +The legacy `gp-sphinx-vite` extension has been retired in favour of +`sphinx-vite-builder`; consumers using +`merge_sphinx_config(vite_orchestration=True)` continue to work +without code changes. From 06cc946847080db0509ab0e70c4d4beba68e3063 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 3 May 2026 13:43:34 -0500 Subject: [PATCH 49/53] docs(architecture+README): mark sphinx-vite-builder as publishable externally why: Phase 3 Milestone B called for explicit "publishable for use outside this workspace" framing in the workspace's two highest-traffic landing surfaces. The README's Build utils bullet and the architecture grid card hadn't been updated to mention the hatchling build-hook variant or the external-adoption story. what: - docs/architecture.md sphinx-vite-builder grid card: add the hatchling build-hook activation path alongside the PEP 517 backend mention; document the PROD-mode one-shot vite call (sphinx-build, not just sphinx-autobuild's watch); add the explicit "publishable for use outside this workspace" sentence - README.md:65 Build utils bullet: append "hatchling build hook" to the activation list and add "publishable to PyPI for use outside this workspace" parenthetical --- README.md | 2 +- docs/architecture.md | 9 +++++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index b796fa70..51a3d73f 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ Lower layers never depend on higher ones: - **Common libraries** — `sphinx-ux-badges`, `sphinx-ux-autodoc-layout`, `sphinx-autodoc-typehints-gp`, `sphinx-fonts` - **Autodoc extensions** — `sphinx-autodoc-api-style`, `sphinx-autodoc-argparse`, `sphinx-autodoc-docutils`, `sphinx-autodoc-fastmcp`, `sphinx-autodoc-pytest-fixtures`, `sphinx-autodoc-sphinx` -- **Build utils** — `sphinx-vite-builder` ([PEP 517](https://peps.python.org/pep-0517/) backend + Sphinx extension that runs Vite via pnpm) +- **Build utils** — `sphinx-vite-builder` ([PEP 517](https://peps.python.org/pep-0517/) backend + hatchling build hook + Sphinx extension that runs Vite via pnpm; publishable to PyPI for use outside this workspace) - **Theme and coordinator** — `gp-sphinx`, `sphinx-gp-theme`, `gp-furo-theme` - **SEO** — `sphinx-gp-opengraph`, `sphinx-gp-sitemap` (auto-loaded by `gp-sphinx` when `docs_url` is set) diff --git a/docs/architecture.md b/docs/architecture.md index 0b3bd4ad..210ee2f3 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -133,11 +133,16 @@ and seamless live-reload during authoring. :link: packages/sphinx-vite-builder :link-type: doc -[PEP 517](https://peps.python.org/pep-0517/) build backend that runs +[PEP 517](https://peps.python.org/pep-0517/) build backend (or +hatchling build hook via `[tool.hatch.build.hooks.vite]`) that runs `pnpm exec vite build` before delegating wheel/sdist construction to hatchling. Also a Sphinx extension that auto-orchestrates -`vite build --watch` during `sphinx-autobuild`. +`vite build --watch` during `sphinx-autobuild` and one-shot +`vite build` during plain `sphinx-build`. Source builds error loudly without pnpm/Node; wheels ship turn-key. +**Publishable for use outside this workspace** — any vite + Sphinx +project can adopt either activation path without depending on the +gp-sphinx coordinator. ::: :::: From 5d1a142622947094e9a207a48eb16b48761df360 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 3 May 2026 14:19:02 -0500 Subject: [PATCH 50/53] docs(CHANGES) sphinx-vite-builder consolidation and a15 unstyled-wheel fix --- CHANGES | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/CHANGES b/CHANGES index a76c6c58..57c28934 100644 --- a/CHANGES +++ b/CHANGES @@ -18,6 +18,30 @@ $ uv add gp-sphinx --prerelease allow +### What's new + +#### New package: `sphinx-vite-builder` + +PEP 517 backend, hatchling build hook (`[tool.hatch.build.hooks.vite]`), +and Sphinx extension that orchestrate Vite via pnpm. Wheels ship with +the static tree pre-baked; source builds error loudly when pnpm or +Node isn't on PATH, with copy-pasteable CI setup recipes for GitHub +Actions, CircleCI, Azure Pipelines, and GitLab CI inlined into the +error. `SPHINX_VITE_BUILDER_SKIP=1` short-circuits the orchestration +when an external pipeline owns Vite. Replaces and supersedes +`gp-sphinx-vite`; `merge_sphinx_config(vite_orchestration=True)` +auto-injects the new extension. (#29) + +### Bug fixes + +#### `gp-furo-theme`: Wheels now ship with vite-built CSS and JS + +`0.0.1a15` published wheels with an empty `static/` tree, leaving +docs sites across every consumer unstyled. The new +`sphinx-vite-builder.build` backend runs Vite at release time and +hatchling packs the resulting assets, so a `pip install` from PyPI +gets styled docs without the consumer rebuilding assets locally. (#29) + ## gp-sphinx 0.0.1a15 (2026-05-02) ### What's new From b01ab6914bc499c304c84c9bc440827aeda723b2 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 3 May 2026 14:56:24 -0500 Subject: [PATCH 51/53] docs: split sphinx-vite-builder out of Internal toctree + drop stale doctest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: A docs audit surfaced two inconsistencies left over from the gp-sphinx-vite retirement: the workspace-internal toctree caption in docs/index.md still grouped sphinx-vite-builder with gp-sphinx / sphinx-gp-theme / gp-furo-theme even though the package is framed as "publishable for use outside this workspace" elsewhere; and the workspace_packages doctest in docs/_ext/package_reference.py still listed gp-sphinx-vite (deleted in commit 75f63ba) in its expected-set membership check. what: - docs/index.md: split sphinx-vite-builder into its own ``Build utils`` toctree caption, matching the section header in docs/packages/index.md. The Internal caption stays for gp-sphinx, sphinx-gp-theme, gp-furo-theme — the actual coordinator + theme packages - docs/_ext/package_reference.py: doctest set for workspace_packages drops "gp-sphinx-vite" and adds "sphinx-vite-builder". The doctest is a membership check on the alphabetically-first package (gp-furo-theme), so the change is a name swap rather than a behaviour fix --- docs/_ext/package_reference.py | 2 +- docs/index.md | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/_ext/package_reference.py b/docs/_ext/package_reference.py index 33020398..3209d00f 100644 --- a/docs/_ext/package_reference.py +++ b/docs/_ext/package_reference.py @@ -38,7 +38,7 @@ >>> package = workspace_packages()[0] >>> package["name"] in { ... "gp-furo-theme", -... "gp-sphinx-vite", +... "sphinx-vite-builder", ... "sphinx-gp-opengraph", ... "sphinx-gp-sitemap", ... "gp-sphinx", diff --git a/docs/index.md b/docs/index.md index f3d48177..84f6033e 100644 --- a/docs/index.md +++ b/docs/index.md @@ -133,6 +133,12 @@ packages/sphinx-autodoc-typehints-gp packages/gp-sphinx packages/sphinx-gp-theme packages/gp-furo-theme +``` + +```{toctree} +:caption: Build utils +:hidden: + packages/sphinx-vite-builder ``` From 83e841d67ac0bf6504215292f1a21dc3070e31a0 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 3 May 2026 15:00:20 -0500 Subject: [PATCH 52/53] docs(sphinx-vite-builder[test-process]): drop stale gp-sphinx-vite suite reference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: A diff audit caught the one remaining stale lineage note in the new code. The end-to-end SIGTERM test docstring claimed it "mirrors the gp-sphinx-vite suite" — but that suite was deleted alongside the package in commit 75f63ba. what: - tests/test_sphinx_vite_builder_process.py module docstring: drop the trailing "(mirrors the gp-sphinx-vite suite)" parenthetical. The behavioural contract description that precedes it stands on its own without the lineage hint. --- tests/test_sphinx_vite_builder_process.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_sphinx_vite_builder_process.py b/tests/test_sphinx_vite_builder_process.py index aea9edde..152289b3 100644 --- a/tests/test_sphinx_vite_builder_process.py +++ b/tests/test_sphinx_vite_builder_process.py @@ -10,7 +10,7 @@ The unit tests monkeypatch ``os.killpg`` so they are deterministic and don't depend on real process trees; an end-to-end behavioural test exercises the integrated terminate path with a fake child that traps -SIGTERM (mirrors the gp-sphinx-vite suite). +SIGTERM. """ from __future__ import annotations From d24e3b02910c6b74f8e19d8ad17099bad1710b2c Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 3 May 2026 15:03:21 -0500 Subject: [PATCH 53/53] =?UTF-8?q?release(workspace):=20bump=20v0.0.1a16.de?= =?UTF-8?q?v3=20=E2=86=92=20v0.0.1a16.dev4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/gp-furo-theme/pyproject.toml | 2 +- .../src/gp_furo_theme/__init__.py | 2 +- packages/gp-sphinx/pyproject.toml | 14 ++++---- packages/gp-sphinx/src/gp_sphinx/__init__.py | 2 +- .../sphinx-autodoc-api-style/pyproject.toml | 6 ++-- .../sphinx-autodoc-argparse/pyproject.toml | 2 +- .../src/sphinx_autodoc_argparse/__init__.py | 2 +- .../sphinx-autodoc-docutils/pyproject.toml | 8 ++--- .../src/sphinx_autodoc_docutils/__init__.py | 2 +- .../sphinx-autodoc-fastmcp/pyproject.toml | 8 ++--- .../src/sphinx_autodoc_fastmcp/__init__.py | 2 +- .../pyproject.toml | 8 ++--- packages/sphinx-autodoc-sphinx/pyproject.toml | 8 ++--- .../src/sphinx_autodoc_sphinx/__init__.py | 2 +- .../pyproject.toml | 2 +- .../sphinx_autodoc_typehints_gp/extension.py | 2 +- packages/sphinx-fonts/pyproject.toml | 2 +- .../sphinx-fonts/src/sphinx_fonts/__init__.py | 2 +- packages/sphinx-gp-opengraph/pyproject.toml | 2 +- .../src/sphinx_gp_opengraph/__init__.py | 2 +- packages/sphinx-gp-sitemap/pyproject.toml | 2 +- .../src/sphinx_gp_sitemap/__init__.py | 2 +- packages/sphinx-gp-theme/pyproject.toml | 4 +-- .../src/sphinx_gp_theme/__init__.py | 2 +- .../sphinx-ux-autodoc-layout/pyproject.toml | 2 +- packages/sphinx-ux-badges/pyproject.toml | 2 +- .../src/sphinx_ux_badges/__init__.py | 2 +- packages/sphinx-vite-builder/pyproject.toml | 2 +- .../src/sphinx_vite_builder/__init__.py | 2 +- pyproject.toml | 4 +-- tests/ci/test_package_tools.py | 8 ++--- tests/test_sphinx_vite_builder.py | 2 +- uv.lock | 34 +++++++++---------- 33 files changed, 74 insertions(+), 74 deletions(-) diff --git a/packages/gp-furo-theme/pyproject.toml b/packages/gp-furo-theme/pyproject.toml index 23f0dfa4..5f39ad7f 100644 --- a/packages/gp-furo-theme/pyproject.toml +++ b/packages/gp-furo-theme/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "gp-furo-theme" -version = "0.0.1a16.dev3" +version = "0.0.1a16.dev4" description = "Tailwind v4 port of the Furo Sphinx theme for git-pull project documentation" requires-python = ">=3.10,<4.0" authors = [ diff --git a/packages/gp-furo-theme/src/gp_furo_theme/__init__.py b/packages/gp-furo-theme/src/gp_furo_theme/__init__.py index b3e6cad1..9ca675f3 100644 --- a/packages/gp-furo-theme/src/gp_furo_theme/__init__.py +++ b/packages/gp-furo-theme/src/gp_furo_theme/__init__.py @@ -39,7 +39,7 @@ from .navigation import get_navigation_tree -__version__ = "0.0.1a16.dev3" +__version__ = "0.0.1a16.dev4" THEME_NAME = "gp-furo" THEME_PATH = (pathlib.Path(__file__).parent / "theme" / THEME_NAME).resolve() diff --git a/packages/gp-sphinx/pyproject.toml b/packages/gp-sphinx/pyproject.toml index 3bbeb7e3..9a41f345 100644 --- a/packages/gp-sphinx/pyproject.toml +++ b/packages/gp-sphinx/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "gp-sphinx" -version = "0.0.1a16.dev3" +version = "0.0.1a16.dev4" description = "Shared Sphinx documentation platform for git-pull projects" requires-python = ">=3.10,<4.0" authors = [ @@ -26,15 +26,15 @@ readme = "README.md" keywords = ["sphinx", "documentation", "configuration"] dependencies = [ "sphinx>=8.1,<9", - "sphinx-gp-theme==0.0.1a16.dev3", - "sphinx-fonts==0.0.1a16.dev3", + "sphinx-gp-theme==0.0.1a16.dev4", + "sphinx-fonts==0.0.1a16.dev4", "myst-parser", "docutils", - "sphinx-autodoc-typehints-gp==0.0.1a16.dev3", + "sphinx-autodoc-typehints-gp==0.0.1a16.dev4", "sphinx-inline-tabs", "sphinx-copybutton", - "sphinx-gp-opengraph==0.0.1a16.dev3", - "sphinx-gp-sitemap==0.0.1a16.dev3", + "sphinx-gp-opengraph==0.0.1a16.dev4", + "sphinx-gp-sitemap==0.0.1a16.dev4", "sphinxext-rediraffe", "sphinx-design", "linkify-it-py", @@ -43,7 +43,7 @@ dependencies = [ [project.optional-dependencies] argparse = [ - "sphinx-autodoc-argparse==0.0.1a16.dev3", + "sphinx-autodoc-argparse==0.0.1a16.dev4", ] [project.urls] diff --git a/packages/gp-sphinx/src/gp_sphinx/__init__.py b/packages/gp-sphinx/src/gp_sphinx/__init__.py index c1773d9f..0a3cd583 100644 --- a/packages/gp-sphinx/src/gp_sphinx/__init__.py +++ b/packages/gp-sphinx/src/gp_sphinx/__init__.py @@ -7,7 +7,7 @@ __title__ = "gp-sphinx" __package_name__ = "gp_sphinx" __description__ = "Shared Sphinx documentation platform for git-pull projects" -__version__ = "0.0.1a16.dev3" +__version__ = "0.0.1a16.dev4" __author__ = "Tony Narlock" __github__ = "https://github.com/git-pull/gp-sphinx" __docs__ = "https://gp-sphinx.git-pull.com" diff --git a/packages/sphinx-autodoc-api-style/pyproject.toml b/packages/sphinx-autodoc-api-style/pyproject.toml index 2bca1973..a53c900e 100644 --- a/packages/sphinx-autodoc-api-style/pyproject.toml +++ b/packages/sphinx-autodoc-api-style/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sphinx-autodoc-api-style" -version = "0.0.1a16.dev3" +version = "0.0.1a16.dev4" description = "Sphinx extension for enhanced autodoc API entry styling (badges and cards)" requires-python = ">=3.10,<4.0" authors = [ @@ -27,8 +27,8 @@ readme = "README.md" keywords = ["sphinx", "autodoc", "documentation", "api", "badges"] dependencies = [ "sphinx>=8.1", - "sphinx-ux-badges==0.0.1a16.dev3", - "sphinx-ux-autodoc-layout==0.0.1a16.dev3", + "sphinx-ux-badges==0.0.1a16.dev4", + "sphinx-ux-autodoc-layout==0.0.1a16.dev4", ] [project.urls] diff --git a/packages/sphinx-autodoc-argparse/pyproject.toml b/packages/sphinx-autodoc-argparse/pyproject.toml index 8518d1e6..05ca0096 100644 --- a/packages/sphinx-autodoc-argparse/pyproject.toml +++ b/packages/sphinx-autodoc-argparse/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sphinx-autodoc-argparse" -version = "0.0.1a16.dev3" +version = "0.0.1a16.dev4" description = "Modern Sphinx extension for documenting argparse-based CLI tools" requires-python = ">=3.10,<4.0" authors = [ diff --git a/packages/sphinx-autodoc-argparse/src/sphinx_autodoc_argparse/__init__.py b/packages/sphinx-autodoc-argparse/src/sphinx_autodoc_argparse/__init__.py index 9fec75f4..3ca043de 100644 --- a/packages/sphinx-autodoc-argparse/src/sphinx_autodoc_argparse/__init__.py +++ b/packages/sphinx-autodoc-argparse/src/sphinx_autodoc_argparse/__init__.py @@ -44,7 +44,7 @@ if t.TYPE_CHECKING: from sphinx.application import Sphinx -__version__ = "0.0.1a16.dev3" +__version__ = "0.0.1a16.dev4" class SetupDict(t.TypedDict): diff --git a/packages/sphinx-autodoc-docutils/pyproject.toml b/packages/sphinx-autodoc-docutils/pyproject.toml index dc252332..9c36a09c 100644 --- a/packages/sphinx-autodoc-docutils/pyproject.toml +++ b/packages/sphinx-autodoc-docutils/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sphinx-autodoc-docutils" -version = "0.0.1a16.dev3" +version = "0.0.1a16.dev4" description = "Sphinx extension for documenting docutils directives and roles as first-class reference entries" requires-python = ">=3.10,<4.0" authors = [ @@ -27,9 +27,9 @@ readme = "README.md" keywords = ["sphinx", "docutils", "directives", "roles", "documentation", "autodoc"] dependencies = [ "sphinx>=8.1", - "sphinx-ux-badges==0.0.1a16.dev3", - "sphinx-ux-autodoc-layout==0.0.1a16.dev3", - "sphinx-autodoc-typehints-gp==0.0.1a16.dev3", + "sphinx-ux-badges==0.0.1a16.dev4", + "sphinx-ux-autodoc-layout==0.0.1a16.dev4", + "sphinx-autodoc-typehints-gp==0.0.1a16.dev4", ] [project.urls] diff --git a/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/__init__.py b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/__init__.py index 05506cc2..a5534ae7 100644 --- a/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/__init__.py +++ b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/__init__.py @@ -77,7 +77,7 @@ def _add_static_path(app: Sphinx) -> None: app.add_css_file("css/sphinx_autodoc_docutils.css") return { - "version": "0.0.1a16.dev3", + "version": "0.0.1a16.dev4", "parallel_read_safe": True, "parallel_write_safe": True, } diff --git a/packages/sphinx-autodoc-fastmcp/pyproject.toml b/packages/sphinx-autodoc-fastmcp/pyproject.toml index 3f36efeb..08930424 100644 --- a/packages/sphinx-autodoc-fastmcp/pyproject.toml +++ b/packages/sphinx-autodoc-fastmcp/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sphinx-autodoc-fastmcp" -version = "0.0.1a16.dev3" +version = "0.0.1a16.dev4" description = "Sphinx extension for documenting FastMCP tools (cards, badges, cross-refs)" requires-python = ">=3.10,<4.0" authors = [ @@ -27,9 +27,9 @@ readme = "README.md" keywords = ["sphinx", "fastmcp", "mcp", "documentation", "badges"] dependencies = [ "sphinx>=8.1", - "sphinx-ux-badges==0.0.1a16.dev3", - "sphinx-ux-autodoc-layout==0.0.1a16.dev3", - "sphinx-autodoc-typehints-gp==0.0.1a16.dev3", + "sphinx-ux-badges==0.0.1a16.dev4", + "sphinx-ux-autodoc-layout==0.0.1a16.dev4", + "sphinx-autodoc-typehints-gp==0.0.1a16.dev4", ] [project.urls] diff --git a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/__init__.py b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/__init__.py index c6458611..a02749bf 100644 --- a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/__init__.py +++ b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/__init__.py @@ -51,7 +51,7 @@ logging.getLogger(__name__).addHandler(logging.NullHandler()) -_EXTENSION_VERSION = "0.0.1a16.dev3" +_EXTENSION_VERSION = "0.0.1a16.dev4" def setup(app: Sphinx) -> dict[str, t.Any]: diff --git a/packages/sphinx-autodoc-pytest-fixtures/pyproject.toml b/packages/sphinx-autodoc-pytest-fixtures/pyproject.toml index ebb9378b..c8729bbe 100644 --- a/packages/sphinx-autodoc-pytest-fixtures/pyproject.toml +++ b/packages/sphinx-autodoc-pytest-fixtures/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sphinx-autodoc-pytest-fixtures" -version = "0.0.1a16.dev3" +version = "0.0.1a16.dev4" description = "Sphinx extension for documenting pytest fixtures as first-class objects" requires-python = ">=3.10,<4.0" authors = [ @@ -30,9 +30,9 @@ keywords = ["sphinx", "pytest", "fixtures", "documentation", "autodoc"] dependencies = [ "sphinx>=8.1", "pytest", - "sphinx-ux-badges==0.0.1a16.dev3", - "sphinx-ux-autodoc-layout==0.0.1a16.dev3", - "sphinx-autodoc-typehints-gp==0.0.1a16.dev3", + "sphinx-ux-badges==0.0.1a16.dev4", + "sphinx-ux-autodoc-layout==0.0.1a16.dev4", + "sphinx-autodoc-typehints-gp==0.0.1a16.dev4", ] [project.urls] diff --git a/packages/sphinx-autodoc-sphinx/pyproject.toml b/packages/sphinx-autodoc-sphinx/pyproject.toml index 0b45053b..b5f47415 100644 --- a/packages/sphinx-autodoc-sphinx/pyproject.toml +++ b/packages/sphinx-autodoc-sphinx/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sphinx-autodoc-sphinx" -version = "0.0.1a16.dev3" +version = "0.0.1a16.dev4" description = "Sphinx extension for documenting extension config values as first-class conf.py reference entries" requires-python = ">=3.10,<4.0" authors = [ @@ -27,9 +27,9 @@ readme = "README.md" keywords = ["sphinx", "configuration", "conf.py", "documentation", "autodoc"] dependencies = [ "sphinx>=8.1", - "sphinx-ux-badges==0.0.1a16.dev3", - "sphinx-ux-autodoc-layout==0.0.1a16.dev3", - "sphinx-autodoc-typehints-gp==0.0.1a16.dev3", + "sphinx-ux-badges==0.0.1a16.dev4", + "sphinx-ux-autodoc-layout==0.0.1a16.dev4", + "sphinx-autodoc-typehints-gp==0.0.1a16.dev4", ] [project.urls] diff --git a/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/__init__.py b/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/__init__.py index 48cfb614..bc4bf40b 100644 --- a/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/__init__.py +++ b/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/__init__.py @@ -61,7 +61,7 @@ def _add_static_path(app: Sphinx) -> None: app.add_css_file("css/sphinx_autodoc_sphinx.css") return { - "version": "0.0.1a16.dev3", + "version": "0.0.1a16.dev4", "parallel_read_safe": True, "parallel_write_safe": True, } diff --git a/packages/sphinx-autodoc-typehints-gp/pyproject.toml b/packages/sphinx-autodoc-typehints-gp/pyproject.toml index 7e108ef3..003e510b 100644 --- a/packages/sphinx-autodoc-typehints-gp/pyproject.toml +++ b/packages/sphinx-autodoc-typehints-gp/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "sphinx-autodoc-typehints-gp" -version = "0.0.1a16.dev3" +version = "0.0.1a16.dev4" description = "Cross-referenced type annotations for Sphinx autodoc" readme = "README.md" requires-python = ">=3.10" diff --git a/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/extension.py b/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/extension.py index 15c3848e..664cfeff 100644 --- a/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/extension.py +++ b/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/extension.py @@ -593,7 +593,7 @@ def setup(app: Sphinx) -> dict[str, t.Any]: # are skipped by the built-in handler. app.connect("object-description-transform", merge_typehints, priority=499) return { - "version": "0.0.1a16.dev3", + "version": "0.0.1a16.dev4", "parallel_read_safe": True, "parallel_write_safe": True, } diff --git a/packages/sphinx-fonts/pyproject.toml b/packages/sphinx-fonts/pyproject.toml index 4c12db4e..a388a551 100644 --- a/packages/sphinx-fonts/pyproject.toml +++ b/packages/sphinx-fonts/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sphinx-fonts" -version = "0.0.1a16.dev3" +version = "0.0.1a16.dev4" description = "Sphinx extension for self-hosted fonts via Fontsource CDN" requires-python = ">=3.10,<4.0" authors = [ diff --git a/packages/sphinx-fonts/src/sphinx_fonts/__init__.py b/packages/sphinx-fonts/src/sphinx_fonts/__init__.py index c8f056ba..f1cdd06a 100644 --- a/packages/sphinx-fonts/src/sphinx_fonts/__init__.py +++ b/packages/sphinx-fonts/src/sphinx_fonts/__init__.py @@ -25,7 +25,7 @@ logger = logging.getLogger(__name__) logger.addHandler(logging.NullHandler()) -__version__ = "0.0.1a16.dev3" +__version__ = "0.0.1a16.dev4" CDN_TEMPLATE = ( "https://cdn.jsdelivr.net/npm/{package}@{version}" diff --git a/packages/sphinx-gp-opengraph/pyproject.toml b/packages/sphinx-gp-opengraph/pyproject.toml index 7087ff88..425d3169 100644 --- a/packages/sphinx-gp-opengraph/pyproject.toml +++ b/packages/sphinx-gp-opengraph/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sphinx-gp-opengraph" -version = "0.0.1a16.dev3" +version = "0.0.1a16.dev4" description = "OpenGraph and Twitter meta-tag emission for Sphinx — matplotlib-free" requires-python = ">=3.10,<4.0" authors = [ diff --git a/packages/sphinx-gp-opengraph/src/sphinx_gp_opengraph/__init__.py b/packages/sphinx-gp-opengraph/src/sphinx_gp_opengraph/__init__.py index c3ce5e55..e6ff6133 100644 --- a/packages/sphinx-gp-opengraph/src/sphinx_gp_opengraph/__init__.py +++ b/packages/sphinx-gp-opengraph/src/sphinx_gp_opengraph/__init__.py @@ -42,7 +42,7 @@ logger = logging.getLogger(__name__) logger.addHandler(logging.NullHandler()) -_EXTENSION_VERSION = "0.0.1a16.dev3" +_EXTENSION_VERSION = "0.0.1a16.dev4" DEFAULT_DESCRIPTION_LENGTH = 200 diff --git a/packages/sphinx-gp-sitemap/pyproject.toml b/packages/sphinx-gp-sitemap/pyproject.toml index 375a124a..9460e259 100644 --- a/packages/sphinx-gp-sitemap/pyproject.toml +++ b/packages/sphinx-gp-sitemap/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sphinx-gp-sitemap" -version = "0.0.1a16.dev3" +version = "0.0.1a16.dev4" description = "Sitemap generator for Sphinx — Sphinx 8.1+ idioms, parallel-build safe" requires-python = ">=3.10,<4.0" authors = [ diff --git a/packages/sphinx-gp-sitemap/src/sphinx_gp_sitemap/__init__.py b/packages/sphinx-gp-sitemap/src/sphinx_gp_sitemap/__init__.py index 0f27d161..4a9144e5 100644 --- a/packages/sphinx-gp-sitemap/src/sphinx_gp_sitemap/__init__.py +++ b/packages/sphinx-gp-sitemap/src/sphinx_gp_sitemap/__init__.py @@ -47,7 +47,7 @@ from sphinx.util.typing import ExtensionMetadata -_EXTENSION_VERSION = "0.0.1a16.dev3" +_EXTENSION_VERSION = "0.0.1a16.dev4" _SITEMAP_NS = "http://www.sitemaps.org/schemas/sitemap/0.9" _XHTML_NS = "http://www.w3.org/1999/xhtml" diff --git a/packages/sphinx-gp-theme/pyproject.toml b/packages/sphinx-gp-theme/pyproject.toml index 280eff49..5746f6f7 100644 --- a/packages/sphinx-gp-theme/pyproject.toml +++ b/packages/sphinx-gp-theme/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sphinx-gp-theme" -version = "0.0.1a16.dev3" +version = "0.0.1a16.dev4" description = "Furo child theme for git-pull project documentation" requires-python = ">=3.10,<4.0" authors = [ @@ -27,7 +27,7 @@ readme = "README.md" keywords = ["sphinx", "theme", "furo", "documentation"] dependencies = [ "sphinx>=8.1", - "gp-furo-theme==0.0.1a16.dev3", + "gp-furo-theme==0.0.1a16.dev4", ] [project.entry-points."sphinx.html_themes"] diff --git a/packages/sphinx-gp-theme/src/sphinx_gp_theme/__init__.py b/packages/sphinx-gp-theme/src/sphinx_gp_theme/__init__.py index cc839203..06e3dcc5 100644 --- a/packages/sphinx-gp-theme/src/sphinx_gp_theme/__init__.py +++ b/packages/sphinx-gp-theme/src/sphinx_gp_theme/__init__.py @@ -23,7 +23,7 @@ if t.TYPE_CHECKING: from sphinx.application import Sphinx -__version__ = "0.0.1a16.dev3" +__version__ = "0.0.1a16.dev4" def get_theme_path() -> pathlib.Path: diff --git a/packages/sphinx-ux-autodoc-layout/pyproject.toml b/packages/sphinx-ux-autodoc-layout/pyproject.toml index 5f711bf0..bc1cd491 100644 --- a/packages/sphinx-ux-autodoc-layout/pyproject.toml +++ b/packages/sphinx-ux-autodoc-layout/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "sphinx-ux-autodoc-layout" -version = "0.0.1a16.dev3" +version = "0.0.1a16.dev4" description = "Componentized layout for Sphinx autodoc output" readme = "README.md" requires-python = ">=3.10" diff --git a/packages/sphinx-ux-badges/pyproject.toml b/packages/sphinx-ux-badges/pyproject.toml index e71ba638..788ff056 100644 --- a/packages/sphinx-ux-badges/pyproject.toml +++ b/packages/sphinx-ux-badges/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sphinx-ux-badges" -version = "0.0.1a16.dev3" +version = "0.0.1a16.dev4" description = "Shared badge node and CSS for Sphinx autodoc extensions" requires-python = ">=3.10,<4.0" authors = [ diff --git a/packages/sphinx-ux-badges/src/sphinx_ux_badges/__init__.py b/packages/sphinx-ux-badges/src/sphinx_ux_badges/__init__.py index a0a7f49c..bc50404c 100644 --- a/packages/sphinx-ux-badges/src/sphinx_ux_badges/__init__.py +++ b/packages/sphinx-ux-badges/src/sphinx_ux_badges/__init__.py @@ -48,7 +48,7 @@ logging.getLogger(__name__).addHandler(logging.NullHandler()) -_EXTENSION_VERSION = "0.0.1a16.dev3" +_EXTENSION_VERSION = "0.0.1a16.dev4" def setup(app: Sphinx) -> dict[str, t.Any]: diff --git a/packages/sphinx-vite-builder/pyproject.toml b/packages/sphinx-vite-builder/pyproject.toml index bdaa29bc..d08976c8 100644 --- a/packages/sphinx-vite-builder/pyproject.toml +++ b/packages/sphinx-vite-builder/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sphinx-vite-builder" -version = "0.0.1a16.dev3" +version = "0.0.1a16.dev4" description = "PEP 517 build backend + Sphinx extension that orchestrates Vite via pnpm" requires-python = ">=3.10,<4.0" authors = [ diff --git a/packages/sphinx-vite-builder/src/sphinx_vite_builder/__init__.py b/packages/sphinx-vite-builder/src/sphinx_vite_builder/__init__.py index 9445ccb9..ab1fd4ef 100644 --- a/packages/sphinx-vite-builder/src/sphinx_vite_builder/__init__.py +++ b/packages/sphinx-vite-builder/src/sphinx_vite_builder/__init__.py @@ -24,7 +24,7 @@ if t.TYPE_CHECKING: from sphinx.application import Sphinx -__version__ = "0.0.1a16.dev3" +__version__ = "0.0.1a16.dev4" logger = logging.getLogger(__name__) logger.addHandler(logging.NullHandler()) diff --git a/pyproject.toml b/pyproject.toml index 6b1e384a..443f1dcb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "gp-sphinx-workspace" -version = "0.0.1a16.dev3" +version = "0.0.1a16.dev4" description = "Workspace root for gp-sphinx packages" requires-python = ">=3.10,<4.0" authors = [ @@ -9,7 +9,7 @@ authors = [ license = { text = "MIT" } readme = "README.md" dependencies = [ - "gp-sphinx==0.0.1a16.dev3", + "gp-sphinx==0.0.1a16.dev4", ] [tool.uv.workspace] diff --git a/tests/ci/test_package_tools.py b/tests/ci/test_package_tools.py index 1d7e7385..51a8ee84 100644 --- a/tests/ci/test_package_tools.py +++ b/tests/ci/test_package_tools.py @@ -13,7 +13,7 @@ def test_workspace_version_is_lockstep() -> None: """All workspace packages share the same version.""" - assert package_tools.workspace_version() == "0.0.1a16.dev3" + assert package_tools.workspace_version() == "0.0.1a16.dev4" def test_check_versions_passes_for_repo() -> None: @@ -30,12 +30,12 @@ def test_smoke_targets_cover_workspace_packages() -> None: def test_release_metadata_accepts_repo_tag() -> None: """Repo-wide release tags resolve to the shared version.""" - assert package_tools.release_metadata("v0.0.1a16.dev3") == { - "version": "0.0.1a16.dev3" + assert package_tools.release_metadata("v0.0.1a16.dev4") == { + "version": "0.0.1a16.dev4" } def test_release_metadata_rejects_package_tag() -> None: """Package-scoped tags are no longer valid release inputs.""" with pytest.raises(SystemExit, match="invalid release tag format"): - package_tools.release_metadata("gp-sphinx@v0.0.1a16.dev3") + package_tools.release_metadata("gp-sphinx@v0.0.1a16.dev4") diff --git a/tests/test_sphinx_vite_builder.py b/tests/test_sphinx_vite_builder.py index a45f566e..ad2817a5 100644 --- a/tests/test_sphinx_vite_builder.py +++ b/tests/test_sphinx_vite_builder.py @@ -15,7 +15,7 @@ def test_version_matches_workspace_lock() -> None: """Version follows the gp-sphinx workspace lockstep.""" - assert __version__ == "0.0.1a16.dev3" + assert __version__ == "0.0.1a16.dev4" class _FakeApp: diff --git a/uv.lock b/uv.lock index 04b46350..e62ca6de 100644 --- a/uv.lock +++ b/uv.lock @@ -387,7 +387,7 @@ wheels = [ [[package]] name = "gp-furo-theme" -version = "0.0.1a16.dev3" +version = "0.0.1a16.dev4" source = { editable = "packages/gp-furo-theme" } dependencies = [ { name = "accessible-pygments" }, @@ -423,7 +423,7 @@ wheels = [ [[package]] name = "gp-sphinx" -version = "0.0.1a16.dev3" +version = "0.0.1a16.dev4" source = { editable = "packages/gp-sphinx" } dependencies = [ { name = "docutils" }, @@ -472,7 +472,7 @@ provides-extras = ["argparse"] [[package]] name = "gp-sphinx-workspace" -version = "0.0.1a16.dev3" +version = "0.0.1a16.dev4" source = { editable = "." } dependencies = [ { name = "gp-sphinx" }, @@ -1565,7 +1565,7 @@ wheels = [ [[package]] name = "sphinx-autodoc-api-style" -version = "0.0.1a16.dev3" +version = "0.0.1a16.dev4" source = { editable = "packages/sphinx-autodoc-api-style" } dependencies = [ { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, @@ -1583,7 +1583,7 @@ requires-dist = [ [[package]] name = "sphinx-autodoc-argparse" -version = "0.0.1a16.dev3" +version = "0.0.1a16.dev4" source = { editable = "packages/sphinx-autodoc-argparse" } dependencies = [ { name = "docutils" }, @@ -1601,7 +1601,7 @@ requires-dist = [ [[package]] name = "sphinx-autodoc-docutils" -version = "0.0.1a16.dev3" +version = "0.0.1a16.dev4" source = { editable = "packages/sphinx-autodoc-docutils" } dependencies = [ { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, @@ -1621,7 +1621,7 @@ requires-dist = [ [[package]] name = "sphinx-autodoc-fastmcp" -version = "0.0.1a16.dev3" +version = "0.0.1a16.dev4" source = { editable = "packages/sphinx-autodoc-fastmcp" } dependencies = [ { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, @@ -1641,7 +1641,7 @@ requires-dist = [ [[package]] name = "sphinx-autodoc-pytest-fixtures" -version = "0.0.1a16.dev3" +version = "0.0.1a16.dev4" source = { editable = "packages/sphinx-autodoc-pytest-fixtures" } dependencies = [ { name = "pytest" }, @@ -1663,7 +1663,7 @@ requires-dist = [ [[package]] name = "sphinx-autodoc-sphinx" -version = "0.0.1a16.dev3" +version = "0.0.1a16.dev4" source = { editable = "packages/sphinx-autodoc-sphinx" } dependencies = [ { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, @@ -1683,7 +1683,7 @@ requires-dist = [ [[package]] name = "sphinx-autodoc-typehints-gp" -version = "0.0.1a16.dev3" +version = "0.0.1a16.dev4" source = { editable = "packages/sphinx-autodoc-typehints-gp" } dependencies = [ { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, @@ -1752,7 +1752,7 @@ wheels = [ [[package]] name = "sphinx-fonts" -version = "0.0.1a16.dev3" +version = "0.0.1a16.dev4" source = { editable = "packages/sphinx-fonts" } dependencies = [ { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, @@ -1764,7 +1764,7 @@ requires-dist = [{ name = "sphinx", specifier = ">=8.1" }] [[package]] name = "sphinx-gp-opengraph" -version = "0.0.1a16.dev3" +version = "0.0.1a16.dev4" source = { editable = "packages/sphinx-gp-opengraph" } dependencies = [ { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, @@ -1776,7 +1776,7 @@ requires-dist = [{ name = "sphinx", specifier = ">=8.1" }] [[package]] name = "sphinx-gp-sitemap" -version = "0.0.1a16.dev3" +version = "0.0.1a16.dev4" source = { editable = "packages/sphinx-gp-sitemap" } dependencies = [ { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, @@ -1788,7 +1788,7 @@ requires-dist = [{ name = "sphinx", specifier = ">=8.1" }] [[package]] name = "sphinx-gp-theme" -version = "0.0.1a16.dev3" +version = "0.0.1a16.dev4" source = { editable = "packages/sphinx-gp-theme" } dependencies = [ { name = "gp-furo-theme" }, @@ -1817,7 +1817,7 @@ wheels = [ [[package]] name = "sphinx-ux-autodoc-layout" -version = "0.0.1a16.dev3" +version = "0.0.1a16.dev4" source = { editable = "packages/sphinx-ux-autodoc-layout" } dependencies = [ { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, @@ -1829,7 +1829,7 @@ requires-dist = [{ name = "sphinx", specifier = ">=8.1" }] [[package]] name = "sphinx-ux-badges" -version = "0.0.1a16.dev3" +version = "0.0.1a16.dev4" source = { editable = "packages/sphinx-ux-badges" } dependencies = [ { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, @@ -1841,7 +1841,7 @@ requires-dist = [{ name = "sphinx", specifier = ">=8.1" }] [[package]] name = "sphinx-vite-builder" -version = "0.0.1a16.dev3" +version = "0.0.1a16.dev4" source = { editable = "packages/sphinx-vite-builder" } dependencies = [ { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },