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 3b302ff0..752c3304 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 @@ -135,6 +180,7 @@ jobs: - sphinx-autodoc-typehints-gp - sphinx-ux-badges - sphinx-ux-autodoc-layout + - sphinx-vite-builder steps: - uses: actions/checkout@v6 @@ -157,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/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 diff --git a/README.md b/README.md index 598b92ba..51a3d73f 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 + 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) -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 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/architecture.md b/docs/architecture.md index 1d6c3bee..210ee2f3 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -2,17 +2,17 @@ # 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 -The rendering pipeline that all domain packages consume: +The rendering pipeline that every autodoc extension consumes: ::::{grid} 1 1 3 3 :gutter: 2 @@ -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 -Each domain package calls `app.setup_extension()` to auto-register its +**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 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 @@ -67,15 +113,51 @@ 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 (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` 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. +::: + +:::: + ## 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 all six -domain packages. +infrastructure propagates instantly and consistently across every autodoc +extension in the workspace. 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/index.md b/docs/index.md index ab54716e..84f6033e 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 @@ -133,7 +133,13 @@ packages/sphinx-autodoc-typehints-gp packages/gp-sphinx packages/sphinx-gp-theme packages/gp-furo-theme -packages/gp-sphinx-vite +``` + +```{toctree} +:caption: Build utils +:hidden: + +packages/sphinx-vite-builder ``` ```{toctree} 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/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 6362e210..07df90e9 100644 --- a/docs/packages/index.md +++ b/docs/packages/index.md @@ -1,38 +1,62 @@ # 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 + +## 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 new file mode 100644 index 00000000..baf41578 --- /dev/null +++ b/docs/packages/sphinx-vite-builder.md @@ -0,0 +1,78 @@ +# sphinx-vite-builder + +```{gp-sphinx-package-meta} sphinx-vite-builder +``` + +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 +[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](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 +`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 `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`, 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 and captured stderr. +- `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..7f7971e3 100644 --- a/docs/redirects.txt +++ b/docs/redirects.txt @@ -13,7 +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/docs/whats-new.md b/docs/whats-new.md index 20d4971c..7428ab6e 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 `, @@ -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. diff --git a/packages/gp-furo-theme/pyproject.toml b/packages/gp-furo-theme/pyproject.toml index cea8fe51..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.1a15" +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 = [ @@ -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/"] 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..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.1a15" +__version__ = "0.0.1a16.dev4" THEME_NAME = "gp-furo" THEME_PATH = (pathlib.Path(__file__).parent / "theme" / THEME_NAME).resolve() @@ -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-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/src/gp_sphinx_vite/__init__.py b/packages/gp-sphinx-vite/src/gp_sphinx_vite/__init__.py deleted file mode 100644 index 5b86c911..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.1a15" - -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/pyproject.toml b/packages/gp-sphinx/pyproject.toml index 8afa85d0..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.1a15" +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.1a15", - "sphinx-fonts==0.0.1a15", + "sphinx-gp-theme==0.0.1a16.dev4", + "sphinx-fonts==0.0.1a16.dev4", "myst-parser", "docutils", - "sphinx-autodoc-typehints-gp==0.0.1a15", + "sphinx-autodoc-typehints-gp==0.0.1a16.dev4", "sphinx-inline-tabs", "sphinx-copybutton", - "sphinx-gp-opengraph==0.0.1a15", - "sphinx-gp-sitemap==0.0.1a15", + "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.1a15", + "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 c6cdcb55..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.1a15" +__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/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-autodoc-api-style/pyproject.toml b/packages/sphinx-autodoc-api-style/pyproject.toml index 2991ad54..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.1a15" +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.1a15", - "sphinx-ux-autodoc-layout==0.0.1a15", + "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 77b16ae9..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.1a15" +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 5679b99a..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.1a15" +__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 7f337485..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.1a15" +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.1a15", - "sphinx-ux-autodoc-layout==0.0.1a15", - "sphinx-autodoc-typehints-gp==0.0.1a15", + "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 3fdceaec..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.1a15", + "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 4fb23325..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.1a15" +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.1a15", - "sphinx-ux-autodoc-layout==0.0.1a15", - "sphinx-autodoc-typehints-gp==0.0.1a15", + "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 895d215e..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.1a15" +_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 9ef7d8ac..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.1a15" +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.1a15", - "sphinx-ux-autodoc-layout==0.0.1a15", - "sphinx-autodoc-typehints-gp==0.0.1a15", + "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 211d0c8e..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.1a15" +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.1a15", - "sphinx-ux-autodoc-layout==0.0.1a15", - "sphinx-autodoc-typehints-gp==0.0.1a15", + "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 9d6af4e9..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.1a15", + "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 ff052f2f..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.1a15" +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 f8a0e4b4..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.1a15", + "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 f81fcf53..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.1a15" +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 ec26a7da..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.1a15" +__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 b7c5aeca..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.1a15" +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 fc4cb60e..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.1a15" +_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 31ebb00e..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.1a15" +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 f65c8298..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.1a15" +_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 aa08ca2c..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.1a15" +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.1a15", + "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 1460b09c..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.1a15" +__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 bac138d6..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.1a15" +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 4c93ea16..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.1a15" +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 c52a06b1..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.1a15" +_EXTENSION_VERSION = "0.0.1a16.dev4" def setup(app: Sphinx) -> dict[str, t.Any]: diff --git a/packages/sphinx-vite-builder/AGENTS.md b/packages/sphinx-vite-builder/AGENTS.md new file mode 100644 index 00000000..a9cd6f34 --- /dev/null +++ b/packages/sphinx-vite-builder/AGENTS.md @@ -0,0 +1,208 @@ +# 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`). + +**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 +> 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. + +## QA permutations — keep them green + +The install-path permutations every change must keep green: + +| # | 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 prior 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. 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). diff --git a/packages/sphinx-vite-builder/README.md b/packages/sphinx-vite-builder/README.md new file mode 100644 index 00000000..5f2f790a --- /dev/null +++ b/packages/sphinx-vite-builder/README.md @@ -0,0 +1,317 @@ +# 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. + +## 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. + +No pnpm, no Node — just Python: + +```console +$ 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. + +## Quick start — two activation variants + +`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. + +### 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 +[build-system] +requires = ["hatchling>=1.0", "sphinx-vite-builder"] +build-backend = "sphinx_vite_builder.build" + +[tool.hatch.build.targets.sdist] +exclude = ["web/"] # so the sdist→wheel chain hits the short-circuit + +[tool.hatch.build] +artifacts = ["src//theme//static/"] +``` + +### Variant 2 — Hatchling build hook (composable) + +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.): + +```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" +``` + +`"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) | + +`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 | +| `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 recipe gallery + +The `PnpmMissingError` hint is **self-healing** when the backend +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 +``` + +### 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 + +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.) diff --git a/packages/gp-sphinx-vite/pyproject.toml b/packages/sphinx-vite-builder/pyproject.toml similarity index 50% rename from packages/gp-sphinx-vite/pyproject.toml rename to packages/sphinx-vite-builder/pyproject.toml index 36248423..d08976c8 100644 --- a/packages/gp-sphinx-vite/pyproject.toml +++ b/packages/sphinx-vite-builder/pyproject.toml @@ -1,7 +1,7 @@ [project] -name = "gp-sphinx-vite" -version = "0.0.1a15" -description = "Transparent Vite + pnpm orchestration for Sphinx theme asset pipelines" +name = "sphinx-vite-builder" +version = "0.0.1a16.dev4" +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"} @@ -21,16 +21,28 @@ classifiers = [ "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", "theme", "documentation"] +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 = [ "sphinx>=8.1", ] [project.entry-points."sphinx.extensions"] -"gp-sphinx-vite" = "gp_sphinx_vite" +"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" @@ -40,4 +52,4 @@ requires = ["hatchling"] build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] -packages = ["src/gp_sphinx_vite"] +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..ab1fd4ef --- /dev/null +++ b/packages/sphinx-vite-builder/src/sphinx_vite_builder/__init__.py @@ -0,0 +1,93 @@ +"""sphinx-vite-builder — vite + pnpm orchestration for Sphinx-theme packages. + +Two orthogonal entry points sharing one subprocess core: + +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`. + +Both heads consume the smart-subprocess core under +:mod:`sphinx_vite_builder._internal`. +""" + +from __future__ import annotations + +import logging +import typing as t + +if t.TYPE_CHECKING: + from sphinx.application import Sphinx + +__version__ = "0.0.1a16.dev4" + +logger = logging.getLogger(__name__) +logger.addHandler(logging.NullHandler()) + + +def setup(app: Sphinx) -> dict[str, t.Any]: + """Register the Sphinx-extension head. + + 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 + -------- + >>> class FakeApp: + ... 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 + >>> "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 + """ + 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, + "version": __version__, + } + + +__all__: tuple[str, ...] = ("__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/gp-sphinx-vite/src/gp_sphinx_vite/bus.py b/packages/sphinx-vite-builder/src/sphinx_vite_builder/_internal/bus.py similarity index 95% rename from packages/gp-sphinx-vite/src/gp_sphinx_vite/bus.py rename to packages/sphinx-vite-builder/src/sphinx_vite_builder/_internal/bus.py index c3db0a73..f4042987 100644 --- a/packages/gp-sphinx-vite/src/gp_sphinx_vite/bus.py +++ b/packages/sphinx-vite-builder/src/sphinx_vite_builder/_internal/bus.py @@ -2,14 +2,12 @@ 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 +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``. -Pattern is the same as ``/home/d/work/cv/py/latex/dev.py:390-418``. - Usage from a Sphinx hook: .. code-block:: python @@ -51,7 +49,7 @@ class AsyncioBus: again — construct a new instance. """ - def __init__(self, *, name: str = "gp-sphinx-vite-bus") -> None: + 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 diff --git a/packages/gp-sphinx-vite/src/gp_sphinx_vite/config.py b/packages/sphinx-vite-builder/src/sphinx_vite_builder/_internal/config.py similarity index 90% rename from packages/gp-sphinx-vite/src/gp_sphinx_vite/config.py rename to packages/sphinx-vite-builder/src/sphinx_vite_builder/_internal/config.py index 41abebe5..eda1945f 100644 --- a/packages/gp-sphinx-vite/src/gp_sphinx_vite/config.py +++ b/packages/sphinx-vite-builder/src/sphinx_vite_builder/_internal/config.py @@ -1,11 +1,10 @@ -"""Mode detection + config dataclass for gp-sphinx-vite. +"""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`. -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 +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``. """ @@ -62,7 +61,7 @@ def detect_mode( 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`. + """Resolve a ``sphinx_vite_builder_mode`` config value to a concrete :class:`Mode`. Parameters ---------- @@ -149,14 +148,14 @@ def detect_mode( 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. + """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 - gp-furo-theme. Themes that want auto-wiring can set the config + any one theme. Themes that want auto-wiring can set the config value themselves from their own ``setup()`` callback. Examples @@ -174,8 +173,8 @@ def resolve_vite_root(explicit: str | os.PathLike[str] | None) -> pathlib.Path | @dataclasses.dataclass(frozen=True, slots=True) -class GpSphinxViteConfig: - """Frozen snapshot of the resolved gp-sphinx-vite configuration. +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 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/gp-sphinx-vite/src/gp_sphinx_vite/hooks.py b/packages/sphinx-vite-builder/src/sphinx_vite_builder/_internal/hooks.py similarity index 65% rename from packages/gp-sphinx-vite/src/gp_sphinx_vite/hooks.py rename to packages/sphinx-vite-builder/src/sphinx_vite_builder/_internal/hooks.py index 0258a3e6..c483cfd2 100644 --- a/packages/gp-sphinx-vite/src/gp_sphinx_vite/hooks.py +++ b/packages/sphinx-vite-builder/src/sphinx_vite_builder/_internal/hooks.py @@ -3,7 +3,7 @@ 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_*``. +``app._sphinx_vite_builder_*``. Lifecycle: @@ -12,16 +12,17 @@ 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. + 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. +: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 @@ -32,11 +33,14 @@ 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 GpSphinxViteConfig, detect_mode, resolve_vite_root -from .process import ViteProcess, pnpm_install_command, vite_watch_command +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 if t.TYPE_CHECKING: from sphinx.application import Sphinx @@ -48,9 +52,9 @@ # 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" +_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 @@ -60,11 +64,32 @@ ) -def _build_config(app: Sphinx) -> GpSphinxViteConfig: +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 GpSphinxViteConfig( - mode=detect_mode(config_value=app.config.gp_sphinx_vite_mode), - vite_root=resolve_vite_root(app.config.gp_sphinx_vite_root), + return SphinxViteBuilderConfig( + mode=detect_mode(config_value=app.config.sphinx_vite_builder_mode), + vite_root=resolve_vite_root(app.config.sphinx_vite_builder_root), ) @@ -75,7 +100,7 @@ def _ensure_node_modules(vite_root: pathlib.Path, bus: AsyncioBus) -> bool: ``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``. + 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, @@ -92,7 +117,7 @@ def _ensure_node_modules(vite_root: pathlib.Path, bus: AsyncioBus) -> bool: vite_root, " ".join(install_cmd), ) - install_proc = ViteProcess(label="pnpm-install", logger=logger) + 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: @@ -110,19 +135,37 @@ 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. + try: + run_vite_build(project_root=config.vite_root.parent) + except SphinxViteBuilderError as exc: + _raise_as_extension_error(exc) return - existing_proc: ViteProcess | None = getattr(app, _PROC_ATTR, None) + 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. @@ -145,12 +188,22 @@ def on_builder_inited(app: Sphinx) -> None: # spawn vite — pnpm exec would fail the same way. return - proc = ViteProcess(label="vite", logger=logger) + 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)) + 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) @@ -181,7 +234,7 @@ def teardown(app: Sphinx, *, terminate_timeout: float = 5.0) -> None: Idempotent: safe to call from multiple signal sources (atexit + SIGINT) without double-stop errors. """ - proc: ViteProcess | None = getattr(app, _PROC_ATTR, None) + 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 diff --git a/packages/gp-sphinx-vite/src/gp_sphinx_vite/process.py b/packages/sphinx-vite-builder/src/sphinx_vite_builder/_internal/process.py similarity index 55% rename from packages/gp-sphinx-vite/src/gp_sphinx_vite/process.py rename to packages/sphinx-vite-builder/src/sphinx_vite_builder/_internal/process.py index 89ccbfc2..d3fb7418 100644 --- a/packages/gp-sphinx-vite/src/gp_sphinx_vite/process.py +++ b/packages/sphinx-vite-builder/src/sphinx_vite_builder/_internal/process.py @@ -1,25 +1,30 @@ -"""Async subprocess wrapper for the Vite watch command. +"""Async subprocess wrapper used by the Vite/pnpm orchestration. Wraps :func:`asyncio.create_subprocess_exec` with the conventions the -orchestration layer needs: +backend and extension heads need: -- ``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``. +- ``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. -- :meth:`ViteProcess.terminate` is graceful-then-forceful: SIGTERM, +- On POSIX, the child runs in a new session (``start_new_session=True``) + 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 no-op. -Argument lists are passed directly to ``create_subprocess_exec``; no -shell, no string interpolation, no command injection surface. +Argument lists are passed directly to the asyncio subprocess primitive; +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. +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 @@ -29,6 +34,8 @@ import logging import os import pathlib +import signal +import sys import typing as t if t.TYPE_CHECKING: @@ -37,17 +44,22 @@ _module_logger = logging.getLogger(__name__) -class ViteProcess: - """Async wrapper around a long-running Vite child process.""" +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 = "vite", + 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 + # ``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] = ( @@ -55,6 +67,7 @@ def __init__( ) self._process: asyncio.subprocess.Process | None = None self._drainers: list[asyncio.Task[None]] = [] + self._stderr_buffer: list[str] = [] @property def is_running(self) -> bool: @@ -71,6 +84,15 @@ 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], @@ -78,7 +100,7 @@ async def start( cwd: pathlib.Path, env: t.Mapping[str, str] | None = None, ) -> None: - """Spawn ``command`` and start draining its stdout/stderr. + """Spawn ``command`` and start draining its stdout / stderr. Parameters ---------- @@ -86,36 +108,44 @@ async def start( 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. + 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`). + 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 :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" + 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 the parent - # process gets immediate visibility into Vite's progress. + # 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 = [ @@ -124,7 +154,11 @@ async def start( name=f"{self._label}-stdout-drainer", ), asyncio.create_task( - self._drain(self._process.stderr, level=logging.WARNING), + self._drain( + self._process.stderr, + level=logging.WARNING, + capture=self._stderr_buffer, + ), name=f"{self._label}-stderr-drainer", ), ] @@ -132,13 +166,14 @@ async def start( async def wait(self) -> int: """Wait for the child to exit; return its exit code. - Drains the stdout/stderr pipes to completion before returning. + Drains the stdout / stderr pipes to completion before returning. """ if self._process is None: - msg = "ViteProcess.wait() called before start()" + msg = "AsyncProcess.wait() called before start()" raise RuntimeError(msg) returncode = await self._process.wait() - # Let the drainers consume any final buffered lines before returning. + # Let the drainers consume any final buffered lines before + # returning to the caller. await asyncio.gather(*self._drainers, return_exceptions=True) return returncode @@ -151,9 +186,7 @@ async def terminate(self, *, timeout: float = 5.0) -> int | None: 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``. + Seconds to wait for graceful exit after SIGTERM. Returns ------- @@ -166,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: @@ -175,10 +220,13 @@ async def terminate(self, *, timeout: float = 5.0) -> int | None: self._label, timeout, ) - # ProcessLookupError race: child can exit between + # 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 @@ -191,8 +239,13 @@ async def _drain( stream: asyncio.StreamReader, *, level: int, + capture: list[str] | None = None, ) -> None: - """Consume ``stream`` line by line; log each line through ``self._logger``.""" + """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() @@ -203,41 +256,5 @@ async def _drain( 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") + 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..317461f4 --- /dev/null +++ b/packages/sphinx-vite-builder/src/sphinx_vite_builder/_internal/vite.py @@ -0,0 +1,404 @@ +"""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`) — 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 +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 _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: + + 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() + + ci_recipe + ) + + +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. + + 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") + 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..4edcd38e --- /dev/null +++ b/packages/sphinx-vite-builder/src/sphinx_vite_builder/build.py @@ -0,0 +1,149 @@ +"""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. + + Examples + -------- + 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() + 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. + + 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'] + """ + 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. + + 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'] + """ + 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/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/packages/gp-sphinx-vite/src/gp_sphinx_vite/py.typed b/packages/sphinx-vite-builder/src/sphinx_vite_builder/py.typed similarity index 100% rename from packages/gp-sphinx-vite/src/gp_sphinx_vite/py.typed rename to packages/sphinx-vite-builder/src/sphinx_vite_builder/py.typed diff --git a/pyproject.toml b/pyproject.toml index 05047eac..443f1dcb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "gp-sphinx-workspace" -version = "0.0.1a15" +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.1a15", + "gp-sphinx==0.0.1a16.dev4", ] [tool.uv.workspace] @@ -31,8 +31,8 @@ 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 } [dependency-groups] dev = [ @@ -48,7 +48,7 @@ dev = [ "sphinx-gp-opengraph", "sphinx-gp-sitemap", "gp-furo-theme", - "gp-sphinx-vite", + "sphinx-vite-builder", # Docs "sphinx-autobuild", # Testing @@ -71,6 +71,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] @@ -213,6 +219,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.", diff --git a/scripts/ci/package_tools.py b/scripts/ci/package_tools.py index 8d0eba0d..d591f9ae 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: @@ -775,6 +734,113 @@ 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 three activation paths of sphinx-vite-builder against the wheel. + + Three 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, 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 + ``[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. + 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. + with tempfile.TemporaryDirectory() as tmp: + python_path = _create_venv(pathlib.Path(tmp)) + _install_into_venv(python_path, wheel, find_links=dist_dir) + _run_python( + python_path, + "\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. + 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)" + ), + ) + # 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]] = { "sphinx-gp-opengraph": smoke_sphinx_gp_opengraph, "sphinx-gp-sitemap": smoke_sphinx_gp_sitemap, @@ -791,7 +857,7 @@ def smoke_sphinx_autodoc_fastmcp(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/ci/test_package_tools.py b/tests/ci/test_package_tools.py index 2c3d2d5e..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.1a15" + assert package_tools.workspace_version() == "0.0.1a16.dev4" 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.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.1a15") + package_tools.release_metadata("gp-sphinx@v0.0.1a16.dev4") 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: 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: 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 614402e6..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", @@ -35,6 +34,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..ad2817a5 --- /dev/null +++ b/tests/test_sphinx_vite_builder.py @@ -0,0 +1,84 @@ +"""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.1a16.dev4" + + +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]] = [] + + 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") + 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_gp_sphinx_vite_bus.py b/tests/test_sphinx_vite_builder_bus.py similarity index 95% rename from tests/test_gp_sphinx_vite_bus.py rename to tests/test_sphinx_vite_builder_bus.py index ff4a85b5..664933da 100644 --- a/tests/test_gp_sphinx_vite_bus.py +++ b/tests/test_sphinx_vite_builder_bus.py @@ -1,7 +1,7 @@ -"""Tests for :class:`gp_sphinx_vite.bus.AsyncioBus`. +"""Tests for :class:`sphinx_vite_builder._internal.bus.AsyncioBus`. Bus is the thread + event-loop bridge that lets Sphinx's sync hooks -drive ``ViteProcess``. Tests cover the lifecycle (start/stop/restart), +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). @@ -18,7 +18,7 @@ import time import pytest -from gp_sphinx_vite.bus import AsyncioBus +from sphinx_vite_builder._internal.bus import AsyncioBus def test_start_makes_bus_running() -> None: @@ -124,7 +124,7 @@ async def _flag_after() -> None: bus = AsyncioBus() try: - with caplog.at_level(logging.ERROR, logger="gp_sphinx_vite.bus"): + 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. diff --git a/tests/test_gp_sphinx_vite.py b/tests/test_sphinx_vite_builder_config.py similarity index 57% rename from tests/test_gp_sphinx_vite.py rename to tests/test_sphinx_vite_builder_config.py index 0c9994b0..9335c3d8 100644 --- a/tests/test_gp_sphinx_vite.py +++ b/tests/test_sphinx_vite_builder_config.py @@ -1,87 +1,22 @@ -"""Tests for gp_sphinx_vite package skeleton + config layer. +"""Tests for :mod:`sphinx_vite_builder._internal.config`. -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). +Pure-function coverage of the mode-detection + root-resolution layer; +no Sphinx fixtures, no subprocesses. """ 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, +from sphinx_vite_builder._internal.config import ( Mode, + SphinxViteBuilderConfig, detect_mode, resolve_vite_root, ) - -def test_version_matches_workspace_lock() -> None: - """Version follows gp-sphinx workspace lockstep.""" - assert __version__ == "0.0.1a15" - - -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. @@ -204,7 +139,7 @@ def test_detect_mode_parent_is_sphinx_autobuild() -> None: def test_resolve_vite_root_none_returns_none() -> None: - """An unset gp_sphinx_vite_root yields None.""" + """An unset sphinx_vite_builder_root yields None.""" assert resolve_vite_root(None) is None @@ -224,10 +159,15 @@ def test_resolve_vite_root_accepts_pathlike(tmp_path: pathlib.Path) -> None: 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 + 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: @@ -235,12 +175,10 @@ def test_mode_compares_equal_to_string_literal() -> None: 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. + ``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" - # 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_integration.py b/tests/test_sphinx_vite_builder_extension_integration.py similarity index 64% rename from tests/test_gp_sphinx_vite_integration.py rename to tests/test_sphinx_vite_builder_extension_integration.py index 5067fff9..d3ba61c1 100644 --- a/tests/test_gp_sphinx_vite_integration.py +++ b/tests/test_sphinx_vite_builder_extension_integration.py @@ -1,14 +1,11 @@ -"""Integration test: gp_sphinx_vite wired into a real Sphinx build. +"""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 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 +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. - -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 @@ -48,25 +45,25 @@ 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. + """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 ``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. + 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 gp_sphinx_vite.hooks - gp_sphinx_vite.hooks.vite_watch_command = lambda: {fake_vite_argv!r} + import sphinx_vite_builder._internal.hooks as _svb_hooks + _svb_hooks.vite_watch_command = lambda: {fake_vite_argv!r} - extensions = ["gp_sphinx_vite"] + extensions = ["sphinx_vite_builder"] html_theme = "basic" master_doc = "index" project = "integration demo" - gp_sphinx_vite_mode = "dev" - gp_sphinx_vite_root = {fake_vite_root!r} + sphinx_vite_builder_mode = "dev" + sphinx_vite_builder_root = {fake_vite_root!r} """, ) @@ -110,39 +107,43 @@ def test_sphinx_build_spawns_via_extension(tmp_path: pathlib.Path) -> None: cache_root=tmp_path / "scenario-cache", tmp_path=tmp_path / "scenario-tmp", scenario=scenario, - purge_modules=("gp_sphinx_vite", "gp_sphinx_vite.hooks"), + purge_modules=( + "sphinx_vite_builder", + "sphinx_vite_builder._internal", + "sphinx_vite_builder._internal.hooks", + ), ) - proc = getattr(result.app, "_gp_sphinx_vite_proc", None) - bus = getattr(result.app, "_gp_sphinx_vite_bus", None) + 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 a ViteProcess on the app" + 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, "ViteProcess exited before the test could observe it" + 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 gp_sphinx_vite import hooks + 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: - """`gp_sphinx_vite_mode = "prod"` builds without spawning anything.""" + """`sphinx_vite_builder_mode = "prod"` builds without spawning anything.""" scenario = SphinxScenario( files=( ScenarioFile( "conf.py", textwrap.dedent( """\ - extensions = ["gp_sphinx_vite"] + extensions = ["sphinx_vite_builder"] html_theme = "basic" master_doc = "index" project = "no-op demo" - gp_sphinx_vite_mode = "prod" + sphinx_vite_builder_mode = "prod" """, ), ), @@ -154,7 +155,7 @@ def test_sphinx_build_no_op_in_prod_mode(tmp_path: pathlib.Path) -> None: cache_root=tmp_path / "scenario-cache", tmp_path=tmp_path / "scenario-tmp", scenario=scenario, - purge_modules=("gp_sphinx_vite",), + purge_modules=("sphinx_vite_builder",), ) - assert getattr(result.app, "_gp_sphinx_vite_proc", None) is None - assert getattr(result.app, "_gp_sphinx_vite_bus", None) is None + 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_hatch_plugin.py b/tests/test_sphinx_vite_builder_hatch_plugin.py new file mode 100644 index 00000000..b69aae41 --- /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, + 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__ diff --git a/tests/test_gp_sphinx_vite_hooks.py b/tests/test_sphinx_vite_builder_hooks.py similarity index 62% rename from tests/test_gp_sphinx_vite_hooks.py rename to tests/test_sphinx_vite_builder_hooks.py index 94c61822..90dee07f 100644 --- a/tests/test_gp_sphinx_vite_hooks.py +++ b/tests/test_sphinx_vite_builder_hooks.py @@ -1,10 +1,10 @@ -"""Tests for :mod:`gp_sphinx_vite.hooks`. +"""Tests for :mod:`sphinx_vite_builder._internal.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 +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`` + ``ViteProcess`` chain end-to-end without booting a +``AsyncioBus`` + ``AsyncProcess`` chain end-to-end without booting a full Sphinx build. """ @@ -17,15 +17,20 @@ import time import pytest -from gp_sphinx_vite import hooks +from sphinx.errors import ExtensionError +from sphinx_vite_builder._internal import hooks +from sphinx_vite_builder._internal.errors import ( + PnpmMissingError, + SphinxViteBuilderError, +) @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 + sphinx_vite_builder_mode: str = "auto" + sphinx_vite_builder_root: str | None = None @dataclasses.dataclass @@ -71,11 +76,7 @@ def _fake_command() -> tuple[str, ...]: 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. - """ + """Replace ``pnpm_install_command()`` with one that runs ``script``.""" def _fake_command() -> tuple[str, ...]: return (sys.executable, str(script)) @@ -102,8 +103,8 @@ def long_running_fake_vite( _patch_vite_command(monkeypatch, script) return _FakeApp( config=_FakeConfig( - gp_sphinx_vite_mode="dev", - gp_sphinx_vite_root=str(tmp_path), + sphinx_vite_builder_mode="dev", + sphinx_vite_builder_root=str(tmp_path), ), ) @@ -114,8 +115,8 @@ def test_on_builder_inited_no_op_in_prod_mode( """`mode="prod"` → no process spawned, no bus started.""" app = _FakeApp( config=_FakeConfig( - gp_sphinx_vite_mode="prod", - gp_sphinx_vite_root=str(tmp_path), + sphinx_vite_builder_mode="prod", + sphinx_vite_builder_root=str(tmp_path), ), ) @@ -129,14 +130,55 @@ 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: """`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, + sphinx_vite_builder_mode="dev", + sphinx_vite_builder_root=None, ), ) @@ -260,7 +302,7 @@ def emit(self, record: logging.LogRecord) -> None: captured.append(record) handler = _CaptureHandler(level=logging.DEBUG) - underlying = logging.getLogger("sphinx.gp_sphinx_vite.hooks") + underlying = logging.getLogger("sphinx.sphinx_vite_builder._internal.hooks") underlying.addHandler(handler) underlying.setLevel(logging.DEBUG) @@ -278,20 +320,11 @@ def emit(self, record: logging.LogRecord) -> None: 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-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( @@ -313,8 +346,8 @@ def _fail_install() -> tuple[str, ...]: app = _FakeApp( config=_FakeConfig( - gp_sphinx_vite_mode="dev", - gp_sphinx_vite_root=str(tmp_path), + sphinx_vite_builder_mode="dev", + sphinx_vite_builder_root=str(tmp_path), ), ) try: @@ -335,7 +368,6 @@ def test_on_builder_inited_runs_install_when_node_modules_missing( 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( @@ -362,8 +394,8 @@ def test_on_builder_inited_runs_install_when_node_modules_missing( app = _FakeApp( config=_FakeConfig( - gp_sphinx_vite_mode="dev", - gp_sphinx_vite_root=str(tmp_path), + sphinx_vite_builder_mode="dev", + sphinx_vite_builder_root=str(tmp_path), ), ) try: @@ -382,13 +414,7 @@ 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 exits non-zero → vite is not spawned; warning is logged.""" install_script = tmp_path / "fake_pnpm.py" install_script.write_text( textwrap.dedent( @@ -413,8 +439,8 @@ def _fail_vite() -> tuple[str, ...]: app = _FakeApp( config=_FakeConfig( - gp_sphinx_vite_mode="dev", - gp_sphinx_vite_root=str(tmp_path), + sphinx_vite_builder_mode="dev", + sphinx_vite_builder_root=str(tmp_path), ), ) hooks.on_builder_inited(app) # type: ignore[arg-type] @@ -422,15 +448,13 @@ def _fail_vite() -> tuple[str, ...]: 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" + assert hooks._BUS_ATTR == "_sphinx_vite_builder_bus" + assert hooks._PROC_ATTR == "_sphinx_vite_builder_proc" _PRIVATE_ATTRS_TYPED: tuple[str, str, str] = ( @@ -441,6 +465,141 @@ def test_private_attr_names_are_stable() -> None: def test_all_private_attrs_share_prefix() -> None: - """Every private attribute starts with `_gp_sphinx_vite_`.""" + """Every private attribute starts with `_sphinx_vite_builder_`.""" for attr in _PRIVATE_ATTRS_TYPED: - assert attr.startswith("_gp_sphinx_vite_"), attr + 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) diff --git a/tests/test_sphinx_vite_builder_process.py b/tests/test_sphinx_vite_builder_process.py new file mode 100644 index 00000000..152289b3 --- /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. +""" + +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 diff --git a/tests/test_sphinx_vite_builder_vite.py b/tests/test_sphinx_vite_builder_vite.py new file mode 100644 index 00000000..38ee465e --- /dev/null +++ b/tests/test_sphinx_vite_builder_vite.py @@ -0,0 +1,488 @@ +"""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 typing as t + +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) + + +# --------------------------------------------------------------------------- +# 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 +# +# 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..e62ca6de 100644 --- a/uv.lock +++ b/uv.lock @@ -8,14 +8,13 @@ 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] members = [ "gp-furo-theme", "gp-sphinx", - "gp-sphinx-vite", "gp-sphinx-workspace", "sphinx-autodoc-api-style", "sphinx-autodoc-argparse", @@ -30,6 +29,7 @@ members = [ "sphinx-gp-theme", "sphinx-ux-autodoc-layout", "sphinx-ux-badges", + "sphinx-vite-builder", ] [[package]] @@ -387,7 +387,7 @@ wheels = [ [[package]] name = "gp-furo-theme" -version = "0.0.1a15" +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.1a15" +version = "0.0.1a16.dev4" source = { editable = "packages/gp-sphinx" } dependencies = [ { name = "docutils" }, @@ -470,21 +470,9 @@ requires-dist = [ ] provides-extras = ["argparse"] -[[package]] -name = "gp-sphinx-vite" -version = "0.0.1a15" -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.1a15" +version = "0.0.1a16.dev4" source = { editable = "." } dependencies = [ { name = "gp-sphinx" }, @@ -496,7 +484,7 @@ dev = [ { name = "coverage" }, { name = "gp-furo-theme" }, { name = "gp-sphinx" }, - { name = "gp-sphinx-vite" }, + { name = "hatchling" }, { name = "mypy" }, { name = "pillow" }, { name = "pytest" }, @@ -519,6 +507,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" }, @@ -535,7 +524,7 @@ 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" }, { name = "pytest" }, @@ -557,6 +546,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 +629,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" @@ -1559,7 +1565,7 @@ wheels = [ [[package]] name = "sphinx-autodoc-api-style" -version = "0.0.1a15" +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'" }, @@ -1577,7 +1583,7 @@ requires-dist = [ [[package]] name = "sphinx-autodoc-argparse" -version = "0.0.1a15" +version = "0.0.1a16.dev4" source = { editable = "packages/sphinx-autodoc-argparse" } dependencies = [ { name = "docutils" }, @@ -1595,7 +1601,7 @@ requires-dist = [ [[package]] name = "sphinx-autodoc-docutils" -version = "0.0.1a15" +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'" }, @@ -1615,7 +1621,7 @@ requires-dist = [ [[package]] name = "sphinx-autodoc-fastmcp" -version = "0.0.1a15" +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'" }, @@ -1635,7 +1641,7 @@ requires-dist = [ [[package]] name = "sphinx-autodoc-pytest-fixtures" -version = "0.0.1a15" +version = "0.0.1a16.dev4" source = { editable = "packages/sphinx-autodoc-pytest-fixtures" } dependencies = [ { name = "pytest" }, @@ -1657,7 +1663,7 @@ requires-dist = [ [[package]] name = "sphinx-autodoc-sphinx" -version = "0.0.1a15" +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'" }, @@ -1677,7 +1683,7 @@ requires-dist = [ [[package]] name = "sphinx-autodoc-typehints-gp" -version = "0.0.1a15" +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'" }, @@ -1746,7 +1752,7 @@ wheels = [ [[package]] name = "sphinx-fonts" -version = "0.0.1a15" +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'" }, @@ -1758,7 +1764,7 @@ requires-dist = [{ name = "sphinx", specifier = ">=8.1" }] [[package]] name = "sphinx-gp-opengraph" -version = "0.0.1a15" +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'" }, @@ -1770,7 +1776,7 @@ requires-dist = [{ name = "sphinx", specifier = ">=8.1" }] [[package]] name = "sphinx-gp-sitemap" -version = "0.0.1a15" +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'" }, @@ -1782,7 +1788,7 @@ requires-dist = [{ name = "sphinx", specifier = ">=8.1" }] [[package]] name = "sphinx-gp-theme" -version = "0.0.1a15" +version = "0.0.1a16.dev4" source = { editable = "packages/sphinx-gp-theme" } dependencies = [ { name = "gp-furo-theme" }, @@ -1811,7 +1817,7 @@ wheels = [ [[package]] name = "sphinx-ux-autodoc-layout" -version = "0.0.1a15" +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'" }, @@ -1823,7 +1829,7 @@ requires-dist = [{ name = "sphinx", specifier = ">=8.1" }] [[package]] name = "sphinx-ux-badges" -version = "0.0.1a15" +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'" }, @@ -1833,6 +1839,18 @@ dependencies = [ [package.metadata] requires-dist = [{ name = "sphinx", specifier = ">=8.1" }] +[[package]] +name = "sphinx-vite-builder" +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'" }, + { 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 = "sphinxcontrib-applehelp" version = "2.0.0" @@ -1988,6 +2006,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"