sphinx-vite-builder 0.0.1a16.dev0__tar.gz → 0.0.1a16.dev2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,208 @@
1
+ # AGENTS.md — `sphinx-vite-builder`
2
+
3
+ Guidance for AI agents (Claude Code, Cursor, Copilot, Codex, …) and
4
+ human contributors working on this package. Mirrors the higher-level
5
+ guidance at `gp-sphinx/CLAUDE.md`; `packages/sphinx-vite-builder/CLAUDE.md`
6
+ points here so Claude Code reads the same content as other agent runners.
7
+
8
+ ## What this package is
9
+
10
+ Two orthogonal entry points sharing one subprocess core:
11
+
12
+ 1. **PEP 517 build backend** at `sphinx_vite_builder.build`. Runs
13
+ `pnpm exec vite build` before delegating wheel/sdist construction
14
+ to `hatchling.build`. Consumer packages declare it via
15
+ `[build-system].build-backend = "sphinx_vite_builder.build"`.
16
+ 2. **Sphinx extension** at `sphinx_vite_builder:setup`. Hooks
17
+ `builder-inited` and `build-finished` so `sphinx-build` /
18
+ `sphinx-autobuild` automatically run vite — one-shot for prod, a
19
+ long-lived `vite build --watch` child process for autobuild — with
20
+ graceful teardown on signal / `atexit`.
21
+
22
+ Both heads consume the smart-subprocess core under
23
+ `sphinx_vite_builder._internal/`: `process.py` (`AsyncProcess` —
24
+ asyncio subprocess wrapper with POSIX session isolation,
25
+ SIGTERM-then-SIGKILL teardown, line-buffered stdout/stderr drainers,
26
+ captured stderr for error surfacing), `bus.py` (`AsyncioBus` — single
27
+ asyncio loop in a daemon thread for sync↔async bridging),
28
+ `vite.py` (orchestration: detect `web/`, check pnpm via `shutil.which`,
29
+ spawn install/build), and `errors.py` (`PnpmMissingError`,
30
+ `NodeModulesInstallError`, `ViteFailedError`).
31
+
32
+ **Phase 1 status:** The PEP 517 backend is fully implemented and
33
+ tested. The Sphinx extension `setup()` is a placeholder — it
34
+ registers cleanly in `conf.py` but doesn't yet hook the docs build
35
+ lifecycle. The full extension implementation (event handlers, vite
36
+ watch, teardown) lands in a follow-up release.
37
+
38
+ ## The design contract — keep this invariant
39
+
40
+ > **Sources should check for node, pnpm, etc and error if it's not
41
+ > good, then build. Wheels should have the build files baked in and
42
+ > not need node and pnpm at all.**
43
+
44
+ This is the central invariant. When you change anything in the
45
+ backend, vite orchestration, or release pipeline, ask: "does this
46
+ preserve the source-vs-wheel asymmetry?"
47
+
48
+ ### Source builds — fail loud, fail informatively
49
+
50
+ A consumer building from source (cloned tree, `[tool.uv.sources]` git
51
+ URL, `pip install <repo-path>`) goes through the PEP 517 chain. The
52
+ backend's `build_wheel` / `build_editable` / `build_sdist` hooks
53
+ **MUST** run vite, and vite **MUST** be available. If pnpm or Node is
54
+ missing:
55
+
56
+ - Raise the typed exception (`PnpmMissingError`,
57
+ `NodeModulesInstallError`, or `ViteFailedError`) — never a bare
58
+ `subprocess.CalledProcessError` or `FileNotFoundError`. The typed
59
+ exception lets consumers `except` cleanly via the
60
+ `SphinxViteBuilderError` base class.
61
+ - Each error's message MUST be a multi-line, copy-pasteable hint
62
+ formatted by `_format_*_hint()`. Include the canonical install
63
+ paths (`corepack enable`, `https://pnpm.io/installation`), the
64
+ resolved vite-root path, and the `SPHINX_VITE_BUILDER_SKIP=1`
65
+ escape-hatch line.
66
+ - Detect CI via `_detect_ci_provider()`. When CI is detected, append
67
+ the platform-specific YAML/config snippet from `_CI_SETUP_RECIPES`
68
+ so the consumer can copy-paste the fix into their pipeline.
69
+ Supported providers: GitHub Actions, CircleCI, Azure Pipelines,
70
+ GitLab CI, plus a generic fallback for `CI=true`.
71
+
72
+ ### Wheel installs — zero toolchain required
73
+
74
+ A consumer doing `pip install <package>==<version>` from PyPI gets a
75
+ wheel that **already contains** the vite-built `static/` tree
76
+ (populated by the backend at release time, packed via hatchling's
77
+ `artifacts` directive). The PEP 517 chain doesn't run. No backend
78
+ invocation. No `_run_vite_build()`. No `shutil.which("pnpm")`. The
79
+ end user sees Python and only Python.
80
+
81
+ ### Wheel-from-sdist — the bridge case
82
+
83
+ A consumer doing `pip install <pkg>.tar.gz` (or `--no-binary :all:`)
84
+ runs the wheel-from-sdist chain:
85
+
86
+ 1. pip/uv unpacks the sdist into a temp dir
87
+ 2. Calls our backend's `build_wheel` against the unpacked tree
88
+ 3. The backend's vite-orchestration sees no `web/` (excluded from
89
+ sdist via `[tool.hatch.build.targets.sdist] exclude = ["web/"]`)
90
+ and short-circuits cleanly — `_resolve_vite_root()` returns
91
+ `None`, vite is never invoked
92
+ 4. Hatchling packs the pre-baked `static/` (carried in the sdist via
93
+ `[tool.hatch.build] artifacts`) into the wheel
94
+
95
+ Net: **sdist install also requires zero toolchain**. The
96
+ `web/`-absent short-circuit is the load-bearing primitive.
97
+
98
+ ## QA permutations — keep them green
99
+
100
+ The install-path permutations every change must keep green:
101
+
102
+ | # | Path | Toolchain | Expected |
103
+ |---|---|---|---|
104
+ | 1 | wheel install from PyPI | none | succeeds; static present |
105
+ | 2 | sdist install from PyPI (`--no-binary :all:`) | none | succeeds; static present (backend short-circuits) |
106
+ | 3 | source build (`uv build` from cloned tree) | with pnpm + Node | succeeds; wheel contains static |
107
+ | 4 | source build (`uv build` from cloned tree) | none | fails with `PnpmMissingError` + CI-platform recipe |
108
+
109
+ When you add a new failure mode or a new short-circuit branch, add a
110
+ new row here AND a corresponding test in
111
+ `tests/test_sphinx_vite_builder_vite.py`.
112
+
113
+ ## Architecture map
114
+
115
+ ```
116
+ sphinx_vite_builder/
117
+ ├── __init__.py Sphinx extension entry: setup(app)
118
+ ├── build.py PEP 517/660 hooks (delegate to hatchling)
119
+ ├── py.typed
120
+ └── _internal/
121
+ ├── errors.py SphinxViteBuilderError + 3 subclasses
122
+ ├── process.py AsyncProcess (asyncio subprocess wrapper)
123
+ ├── bus.py AsyncioBus (sync↔async bridge)
124
+ └── vite.py run_vite_build() + CI detection + hint formatters
125
+ ```
126
+
127
+ Neither head calls the other; both consume `_internal/`. The PEP 517
128
+ hooks in `build.py` MUST stay 1:1 mirrors of `flit_core.buildapi` and
129
+ `hatchling.build` — every hook runs `run_vite_build()` then delegates.
130
+ Optional hooks (`get_requires_for_build_*`, `prepare_metadata_for_build_*`)
131
+ alias to hatchling by identity — vite has no influence on dependency
132
+ resolution or distribution metadata, so wrapping them would be wrong.
133
+
134
+ ## When you add a new public function
135
+
136
+ - Add doctests. Every public function MUST have working doctests
137
+ per the workspace convention. Use ELLIPSIS for variable output.
138
+ - Add NumPy-style docstrings: short summary, Parameters, Returns,
139
+ Raises, Examples.
140
+ - Add type annotations everywhere, including return types
141
+ (`-> None` on test functions). mypy runs strict mode.
142
+
143
+ ## When you add a new error path
144
+
145
+ - Add a new `*Error` subclass in `errors.py` if the failure has a
146
+ distinct semantic meaning. Inherit from `SphinxViteBuilderError`.
147
+ - Add a `_format_*_hint(...)` function in `vite.py` for the
148
+ copy-pasteable diagnostic. Wrap the raise:
149
+ `raise NewError(_format_new_error_hint(...))`.
150
+ - If the new path is reachable in CI, ensure the message includes
151
+ enough context to fix-from-the-message-itself. Add a CI-recipe
152
+ block via `_format_ci_recipe_block()` if relevant.
153
+ - Add tests for: positive case (path triggers correctly), each error
154
+ branch (specific exception, with key strings present in message),
155
+ inheritance from `SphinxViteBuilderError`.
156
+
157
+ ## When you change `release.yml` or the workspace
158
+
159
+ The workspace's `release.yml` MUST keep the pnpm + Node setup steps
160
+ that run before `uv build`, otherwise the wheels published to PyPI
161
+ would be empty of static (the prior broken-release pattern that
162
+ motivated this whole package). The required steps are:
163
+
164
+ ```yaml
165
+ - uses: pnpm/action-setup@v6
166
+ with:
167
+ version: 10
168
+ - uses: actions/setup-node@v6
169
+ with:
170
+ node-version: 22
171
+ cache: pnpm
172
+ ```
173
+
174
+ If you find yourself removing those, ask: "is the source-build path
175
+ still going to produce a populated `static/` in the wheel?" The
176
+ answer must be yes.
177
+
178
+ ## When in doubt
179
+
180
+ - Read the full plan at gp-sphinx issue #28.
181
+ - Look at how PR #29 wired everything together initially.
182
+ - Look at how libtmux-mcp PR #33 exercises both consumer paths in
183
+ CI — the WITH-wheels and WITHOUT-wheels permutations both have
184
+ green runs that are good reference points.
185
+ - Run the local QA matrix: clean venv install of the published
186
+ wheel, clean venv install of the published sdist (`--no-binary`),
187
+ clean clone + `uv build` with toolchain stripped (must fail
188
+ loudly), clean clone + `uv build` with toolchain present (must
189
+ succeed).
190
+
191
+ ## What NOT to do
192
+
193
+ - **Do not** add `web/` to the sdist. Excluding it is what makes the
194
+ wheel-from-sdist short-circuit work.
195
+ - **Do not** commit anything from `static/` to git. Build artefacts
196
+ are produced reproducibly; check-in is forbidden by workspace
197
+ policy.
198
+ - **Do not** silently swallow vite/pnpm subprocess failures. Every
199
+ non-zero exit goes through a typed exception with a
200
+ copy-pasteable hint.
201
+ - **Do not** auto-install pnpm via the backend (the maturin
202
+ `puccinialin`-style trick doesn't apply here — pnpm isn't
203
+ pip-installable). The contract is "pnpm is your responsibility,
204
+ here's how to install it on your platform".
205
+ - **Do not** change `SPHINX_VITE_BUILDER_SKIP=1` semantics without
206
+ thinking through the wheel-vs-source asymmetry. The escape hatch
207
+ is correct for wheel-only environments; using it during a real
208
+ source build silently produces broken artefacts.
@@ -0,0 +1,8 @@
1
+ # CLAUDE.md
2
+
3
+ Claude Code reads this file. Other agent runners (Cursor, Copilot,
4
+ Codex, …) read [`AGENTS.md`](AGENTS.md). The two files have identical
5
+ content via this passthrough — every guideline lives in `AGENTS.md`,
6
+ and edits go there.
7
+
8
+ → See [`AGENTS.md`](AGENTS.md).
@@ -0,0 +1,200 @@
1
+ Metadata-Version: 2.4
2
+ Name: sphinx-vite-builder
3
+ Version: 0.0.1a16.dev2
4
+ Summary: PEP 517 build backend + Sphinx extension that orchestrates Vite via pnpm
5
+ Project-URL: Repository, https://github.com/git-pull/gp-sphinx
6
+ Author-email: Tony Narlock <tony@git-pull.com>
7
+ License: MIT
8
+ Keywords: backend,build,extension,pep517,pnpm,sphinx,vite
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Framework :: Sphinx
11
+ Classifier: Framework :: Sphinx :: Extension
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Programming Language :: Python :: 3.14
20
+ Classifier: Topic :: Documentation
21
+ Classifier: Topic :: Documentation :: Sphinx
22
+ Classifier: Topic :: Software Development :: Build Tools
23
+ Classifier: Typing :: Typed
24
+ Requires-Python: <4.0,>=3.10
25
+ Requires-Dist: sphinx>=8.1
26
+ Description-Content-Type: text/markdown
27
+
28
+ # sphinx-vite-builder
29
+
30
+ PEP 517 build backend and Sphinx extension that transparently orchestrates
31
+ [Vite](https://vitejs.dev/) builds via [pnpm](https://pnpm.io/) for
32
+ Sphinx-theme packages whose static assets (CSS / JS) are produced by a
33
+ JavaScript toolchain.
34
+
35
+ ## What it solves
36
+
37
+ A common pattern for modern Sphinx themes is a Python package whose
38
+ `theme/<name>/static/` directory ships built CSS and JS that were
39
+ produced by a JS build tool (Vite, webpack, …). The build artefacts are
40
+ gitignored — they're reproducibly built, not source code. But that
41
+ creates two friction points:
42
+
43
+ 1. **Editable installs and source-tree builds** crash with confusing
44
+ errors when the static dir is empty (e.g. hatchling's
45
+ `Forced include not found`).
46
+ 2. **CI workflows** must duplicate `pnpm install + vite build` setup
47
+ steps in every job that touches the package.
48
+
49
+ `sphinx-vite-builder` owns the Vite invocation end-to-end — exactly the
50
+ way [maturin](https://github.com/PyO3/maturin) owns Cargo for
51
+ Rust+Python packages, or
52
+ [sphinx-theme-builder](https://github.com/pradyunsg/sphinx-theme-builder)
53
+ owns webpack for older Sphinx themes.
54
+
55
+ ## The contract
56
+
57
+ > **Sources should check for node, pnpm, etc and error if it's not
58
+ > good, then build. Wheels should have the build files baked in and
59
+ > not need node and pnpm at all.**
60
+
61
+ This is the central invariant of the package. The two install paths
62
+ behave asymmetrically by design:
63
+
64
+ ### Wheel installs — zero toolchain required
65
+
66
+ A user running `pip install <package>` from PyPI gets a wheel that
67
+ **already contains** the vite-built `static/` tree, populated by this
68
+ backend at release time. The PEP 517 chain doesn't run on the
69
+ consumer side. No backend invocation. No `pnpm`. No Node. The end
70
+ user sees Python and only Python.
71
+
72
+ No pnpm, no Node — just Python:
73
+
74
+ ```console
75
+ $ pip install gp-furo-theme
76
+ ```
77
+
78
+ ### Source builds — fail loud, fail informatively
79
+
80
+ A contributor (or downstream packager building from source) goes
81
+ through the PEP 517 chain. The backend runs `pnpm exec vite build`
82
+ to produce `static/`, and that requires pnpm + Node on PATH. If the
83
+ toolchain is missing, the backend raises a typed exception with a
84
+ multi-line, copy-pasteable hint:
85
+
86
+ ```
87
+ sphinx-vite-builder: cannot bootstrap the vite toolchain.
88
+ `pnpm` is not on PATH. Install it via one of:
89
+
90
+ corepack enable # Node 16.10+ ships corepack
91
+ curl -fsSL https://get.pnpm.io/install.sh | sh -
92
+
93
+ See https://pnpm.io/installation
94
+
95
+
96
+
97
+ Detected CI provider: GitHub Actions. Add the following to your pipeline
98
+ config (before the Python build step that triggers this backend):
99
+
100
+ - uses: pnpm/action-setup@v6
101
+ with:
102
+ version: 10
103
+ - uses: actions/setup-node@v6
104
+ with:
105
+ node-version: 22
106
+ cache: pnpm
107
+ ```
108
+
109
+ The error includes the resolved vite-root path, the platform-specific
110
+ CI setup recipe (GitHub Actions, CircleCI, Azure Pipelines, GitLab CI,
111
+ or generic), and the `SPHINX_VITE_BUILDER_SKIP=1` escape hatch for
112
+ environments that genuinely don't need vite to run.
113
+
114
+ ### The `web/`-absent short-circuit (sdist install bridge)
115
+
116
+ A user running `pip install <pkg>.tar.gz` from an sdist runs the
117
+ PEP 517 chain too — but the sdist excludes `web/` (the Vite source
118
+ tree). The backend detects the absence, short-circuits cleanly, and
119
+ hatchling packs the pre-baked `static/` (carried in the sdist via
120
+ `[tool.hatch.build] artifacts`) into the wheel. **Sdist installs
121
+ need no toolchain either.**
122
+
123
+ The asymmetry is the whole product: the same backend is strict
124
+ (running and failing loudly) when there's a `web/` to act on, and
125
+ silent (skipping cleanly) when there's no `web/` to begin with. The
126
+ two shapes match the two consumer worlds.
127
+
128
+ ## Two heads, one subprocess core
129
+
130
+ ### PEP 517 build backend
131
+
132
+ Drop-in replacement for `hatchling.build`. Runs `pnpm exec vite build`
133
+ before delegating wheel/sdist construction to hatchling.
134
+
135
+ ```toml
136
+ # packages/your-theme/pyproject.toml
137
+ [build-system]
138
+ requires = ["hatchling>=1.0", "sphinx-vite-builder"]
139
+ build-backend = "sphinx_vite_builder.build"
140
+
141
+ [tool.hatch.build.targets.sdist]
142
+ exclude = ["web/"] # so the sdist→wheel chain hits the short-circuit
143
+
144
+ [tool.hatch.build]
145
+ artifacts = ["src/<your-theme>/theme/<theme-name>/static/"]
146
+ ```
147
+
148
+ ### Sphinx extension (Phase 1: placeholder)
149
+
150
+ The extension entry point is currently a placeholder registered in
151
+ `conf.py` to prevent import errors. Full lifecycle integration —
152
+ running Vite before the docs build and spawning a watched Vite
153
+ process during `sphinx-autobuild` — lands in a follow-up release.
154
+
155
+ For now, the [PEP 517](https://peps.python.org/pep-0517/) backend
156
+ handles all Vite orchestration during source builds and wheel
157
+ generation; that path is fully implemented and tested.
158
+
159
+ ```python
160
+ # docs/conf.py
161
+ extensions = ["sphinx_vite_builder"]
162
+ ```
163
+
164
+ ## Fast-fail diagnostics — error type reference
165
+
166
+ | Error | When | Hint surface |
167
+ |---|---|---|
168
+ | `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` |
169
+ | `NodeModulesInstallError` | `pnpm install` exited non-zero | `cd <vite-root> && pnpm install --frozen-lockfile` rerun command, captured stderr |
170
+ | `ViteFailedError` | `pnpm exec vite build` exited non-zero | invocation context (cwd, exit code), captured stderr |
171
+
172
+ All three inherit from `SphinxViteBuilderError`, so consumers can
173
+ `except SphinxViteBuilderError` for a single catch surface.
174
+
175
+ ## CI detection
176
+
177
+ The `PnpmMissingError` hint is **self-healing** when the backend
178
+ detects a CI environment. Detection precedence (most-specific wins):
179
+
180
+ | CI provider | Env var | Recipe shape |
181
+ |---|---|---|
182
+ | GitHub Actions | `GITHUB_ACTIONS=true` | `pnpm/action-setup@v6` + `actions/setup-node@v6` |
183
+ | CircleCI | `CIRCLECI=true` | `corepack enable && corepack prepare pnpm@latest-10 --activate` step |
184
+ | Azure Pipelines | `TF_BUILD=True` | `NodeTool@0` + corepack script |
185
+ | GitLab CI | `GITLAB_CI=true` | `before_script` corepack invocations |
186
+ | Generic | `CI=true` | "Use your CI's package-manager setup mechanism" |
187
+
188
+ Source: each provider's own canonical detection variable per the pnpm
189
+ [Continuous Integration docs](https://pnpm.io/continuous-integration).
190
+
191
+ ## License
192
+
193
+ MIT — see [LICENSE](LICENSE).
194
+
195
+ ## Agent / contributor guidance
196
+
197
+ See [`AGENTS.md`](AGENTS.md) for the design contract, architecture
198
+ map, and conventions agents and contributors should follow when
199
+ making changes. ([`CLAUDE.md`](CLAUDE.md) is a passthrough to
200
+ `AGENTS.md` for Claude Code.)
@@ -0,0 +1,173 @@
1
+ # sphinx-vite-builder
2
+
3
+ PEP 517 build backend and Sphinx extension that transparently orchestrates
4
+ [Vite](https://vitejs.dev/) builds via [pnpm](https://pnpm.io/) for
5
+ Sphinx-theme packages whose static assets (CSS / JS) are produced by a
6
+ JavaScript toolchain.
7
+
8
+ ## What it solves
9
+
10
+ A common pattern for modern Sphinx themes is a Python package whose
11
+ `theme/<name>/static/` directory ships built CSS and JS that were
12
+ produced by a JS build tool (Vite, webpack, …). The build artefacts are
13
+ gitignored — they're reproducibly built, not source code. But that
14
+ creates two friction points:
15
+
16
+ 1. **Editable installs and source-tree builds** crash with confusing
17
+ errors when the static dir is empty (e.g. hatchling's
18
+ `Forced include not found`).
19
+ 2. **CI workflows** must duplicate `pnpm install + vite build` setup
20
+ steps in every job that touches the package.
21
+
22
+ `sphinx-vite-builder` owns the Vite invocation end-to-end — exactly the
23
+ way [maturin](https://github.com/PyO3/maturin) owns Cargo for
24
+ Rust+Python packages, or
25
+ [sphinx-theme-builder](https://github.com/pradyunsg/sphinx-theme-builder)
26
+ owns webpack for older Sphinx themes.
27
+
28
+ ## The contract
29
+
30
+ > **Sources should check for node, pnpm, etc and error if it's not
31
+ > good, then build. Wheels should have the build files baked in and
32
+ > not need node and pnpm at all.**
33
+
34
+ This is the central invariant of the package. The two install paths
35
+ behave asymmetrically by design:
36
+
37
+ ### Wheel installs — zero toolchain required
38
+
39
+ A user running `pip install <package>` from PyPI gets a wheel that
40
+ **already contains** the vite-built `static/` tree, populated by this
41
+ backend at release time. The PEP 517 chain doesn't run on the
42
+ consumer side. No backend invocation. No `pnpm`. No Node. The end
43
+ user sees Python and only Python.
44
+
45
+ No pnpm, no Node — just Python:
46
+
47
+ ```console
48
+ $ pip install gp-furo-theme
49
+ ```
50
+
51
+ ### Source builds — fail loud, fail informatively
52
+
53
+ A contributor (or downstream packager building from source) goes
54
+ through the PEP 517 chain. The backend runs `pnpm exec vite build`
55
+ to produce `static/`, and that requires pnpm + Node on PATH. If the
56
+ toolchain is missing, the backend raises a typed exception with a
57
+ multi-line, copy-pasteable hint:
58
+
59
+ ```
60
+ sphinx-vite-builder: cannot bootstrap the vite toolchain.
61
+ `pnpm` is not on PATH. Install it via one of:
62
+
63
+ corepack enable # Node 16.10+ ships corepack
64
+ curl -fsSL https://get.pnpm.io/install.sh | sh -
65
+
66
+ See https://pnpm.io/installation
67
+
68
+
69
+
70
+ Detected CI provider: GitHub Actions. Add the following to your pipeline
71
+ config (before the Python build step that triggers this backend):
72
+
73
+ - uses: pnpm/action-setup@v6
74
+ with:
75
+ version: 10
76
+ - uses: actions/setup-node@v6
77
+ with:
78
+ node-version: 22
79
+ cache: pnpm
80
+ ```
81
+
82
+ The error includes the resolved vite-root path, the platform-specific
83
+ CI setup recipe (GitHub Actions, CircleCI, Azure Pipelines, GitLab CI,
84
+ or generic), and the `SPHINX_VITE_BUILDER_SKIP=1` escape hatch for
85
+ environments that genuinely don't need vite to run.
86
+
87
+ ### The `web/`-absent short-circuit (sdist install bridge)
88
+
89
+ A user running `pip install <pkg>.tar.gz` from an sdist runs the
90
+ PEP 517 chain too — but the sdist excludes `web/` (the Vite source
91
+ tree). The backend detects the absence, short-circuits cleanly, and
92
+ hatchling packs the pre-baked `static/` (carried in the sdist via
93
+ `[tool.hatch.build] artifacts`) into the wheel. **Sdist installs
94
+ need no toolchain either.**
95
+
96
+ The asymmetry is the whole product: the same backend is strict
97
+ (running and failing loudly) when there's a `web/` to act on, and
98
+ silent (skipping cleanly) when there's no `web/` to begin with. The
99
+ two shapes match the two consumer worlds.
100
+
101
+ ## Two heads, one subprocess core
102
+
103
+ ### PEP 517 build backend
104
+
105
+ Drop-in replacement for `hatchling.build`. Runs `pnpm exec vite build`
106
+ before delegating wheel/sdist construction to hatchling.
107
+
108
+ ```toml
109
+ # packages/your-theme/pyproject.toml
110
+ [build-system]
111
+ requires = ["hatchling>=1.0", "sphinx-vite-builder"]
112
+ build-backend = "sphinx_vite_builder.build"
113
+
114
+ [tool.hatch.build.targets.sdist]
115
+ exclude = ["web/"] # so the sdist→wheel chain hits the short-circuit
116
+
117
+ [tool.hatch.build]
118
+ artifacts = ["src/<your-theme>/theme/<theme-name>/static/"]
119
+ ```
120
+
121
+ ### Sphinx extension (Phase 1: placeholder)
122
+
123
+ The extension entry point is currently a placeholder registered in
124
+ `conf.py` to prevent import errors. Full lifecycle integration —
125
+ running Vite before the docs build and spawning a watched Vite
126
+ process during `sphinx-autobuild` — lands in a follow-up release.
127
+
128
+ For now, the [PEP 517](https://peps.python.org/pep-0517/) backend
129
+ handles all Vite orchestration during source builds and wheel
130
+ generation; that path is fully implemented and tested.
131
+
132
+ ```python
133
+ # docs/conf.py
134
+ extensions = ["sphinx_vite_builder"]
135
+ ```
136
+
137
+ ## Fast-fail diagnostics — error type reference
138
+
139
+ | Error | When | Hint surface |
140
+ |---|---|---|
141
+ | `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` |
142
+ | `NodeModulesInstallError` | `pnpm install` exited non-zero | `cd <vite-root> && pnpm install --frozen-lockfile` rerun command, captured stderr |
143
+ | `ViteFailedError` | `pnpm exec vite build` exited non-zero | invocation context (cwd, exit code), captured stderr |
144
+
145
+ All three inherit from `SphinxViteBuilderError`, so consumers can
146
+ `except SphinxViteBuilderError` for a single catch surface.
147
+
148
+ ## CI detection
149
+
150
+ The `PnpmMissingError` hint is **self-healing** when the backend
151
+ detects a CI environment. Detection precedence (most-specific wins):
152
+
153
+ | CI provider | Env var | Recipe shape |
154
+ |---|---|---|
155
+ | GitHub Actions | `GITHUB_ACTIONS=true` | `pnpm/action-setup@v6` + `actions/setup-node@v6` |
156
+ | CircleCI | `CIRCLECI=true` | `corepack enable && corepack prepare pnpm@latest-10 --activate` step |
157
+ | Azure Pipelines | `TF_BUILD=True` | `NodeTool@0` + corepack script |
158
+ | GitLab CI | `GITLAB_CI=true` | `before_script` corepack invocations |
159
+ | Generic | `CI=true` | "Use your CI's package-manager setup mechanism" |
160
+
161
+ Source: each provider's own canonical detection variable per the pnpm
162
+ [Continuous Integration docs](https://pnpm.io/continuous-integration).
163
+
164
+ ## License
165
+
166
+ MIT — see [LICENSE](LICENSE).
167
+
168
+ ## Agent / contributor guidance
169
+
170
+ See [`AGENTS.md`](AGENTS.md) for the design contract, architecture
171
+ map, and conventions agents and contributors should follow when
172
+ making changes. ([`CLAUDE.md`](CLAUDE.md) is a passthrough to
173
+ `AGENTS.md` for Claude Code.)
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sphinx-vite-builder"
3
- version = "0.0.1a16.dev0"
3
+ version = "0.0.1a16.dev2"
4
4
  description = "PEP 517 build backend + Sphinx extension that orchestrates Vite via pnpm"
5
5
  requires-python = ">=3.10,<4.0"
6
6
  authors = [
@@ -31,7 +31,6 @@ keywords = ["sphinx", "extension", "vite", "pnpm", "pep517", "build", "backend"]
31
31
  # required at build time of consumer packages but not at runtime, so it
32
32
  # stays in [build-system].requires of *consumers* rather than here.
33
33
  dependencies = [
34
- "hatchling>=1.0",
35
34
  "sphinx>=8.1",
36
35
  ]
37
36
 
@@ -15,9 +15,13 @@ under :mod:`sphinx_vite_builder._internal`.
15
15
 
16
16
  from __future__ import annotations
17
17
 
18
+ import logging
18
19
  import typing as t
19
20
 
20
- __version__ = "0.0.1a16.dev0"
21
+ __version__ = "0.0.1a16.dev2"
22
+
23
+ logger = logging.getLogger(__name__)
24
+ logger.addHandler(logging.NullHandler())
21
25
 
22
26
  if t.TYPE_CHECKING:
23
27
  from sphinx.application import Sphinx
@@ -31,6 +35,19 @@ def setup(app: Sphinx) -> dict[str, t.Any]:
31
35
  on ``sphinx-build``) lands in a follow-up commit. For now this
32
36
  stub registers the extension so consumers can declare it without
33
37
  a no-such-module error, and returns the safety metadata.
38
+
39
+ Examples
40
+ --------
41
+ The Phase-1 stub returns the parallel-safety metadata Sphinx
42
+ expects, regardless of the application object passed in:
43
+
44
+ >>> class FakeApp:
45
+ ... pass
46
+ >>> metadata = setup(FakeApp()) # type: ignore[arg-type]
47
+ >>> metadata["parallel_read_safe"]
48
+ True
49
+ >>> metadata["parallel_write_safe"]
50
+ True
34
51
  """
35
52
  del app
36
53
  return {
@@ -9,9 +9,11 @@ backend and extension heads need:
9
9
  - ``PYTHONUNBUFFERED=1`` is forced into the child env so Python tools
10
10
  invoked via the package-manager bridge don't withhold their output.
11
11
  - On POSIX, the child runs in a new session (``start_new_session=True``)
12
- so ``SIGTERM`` cleanly takes down the entire process tree (``pnpm exec``
13
- shells out to multiple intermediate processes without session
14
- isolation, only the top-level pnpm wrapper would exit).
12
+ and :meth:`AsyncProcess.terminate` signals the whole process group
13
+ via :func:`os.killpg` so ``pnpm exec`` plus every descendant exits
14
+ together. ``asyncio.subprocess.Process.terminate`` would only signal
15
+ the leader's PID, leaving the vite child orphaned (pnpm does not
16
+ forward signals to its ``exec`` target).
15
17
  - :meth:`AsyncProcess.terminate` is graceful-then-forceful: SIGTERM,
16
18
  await up to ``timeout`` seconds, escalate to SIGKILL if the child is
17
19
  still alive. Idempotent: calling on an already-exited process is a
@@ -32,6 +34,7 @@ import contextlib
32
34
  import logging
33
35
  import os
34
36
  import pathlib
37
+ import signal
35
38
  import sys
36
39
  import typing as t
37
40
 
@@ -196,7 +199,19 @@ class AsyncProcess:
196
199
  if self._process.returncode is not None:
197
200
  return self._process.returncode
198
201
 
199
- self._process.terminate()
202
+ # POSIX: the child was spawned with ``start_new_session=True``,
203
+ # so ``self._process.pid`` is the leader of its own session and
204
+ # equals its process-group ID. SIGTERM the whole group so
205
+ # ``pnpm exec`` plus all its descendants (including the vite
206
+ # process pnpm doesn't forward signals to) exit. asyncio's
207
+ # ``Process.terminate()`` is PID-only — it would leave vite
208
+ # orphaned. Windows has no ``killpg``; the asyncio default is
209
+ # the right primitive there.
210
+ with contextlib.suppress(ProcessLookupError):
211
+ if sys.platform != "win32":
212
+ os.killpg(self._process.pid, signal.SIGTERM)
213
+ else:
214
+ self._process.terminate()
200
215
  try:
201
216
  await asyncio.wait_for(self._process.wait(), timeout=timeout)
202
217
  except asyncio.TimeoutError:
@@ -208,7 +223,10 @@ class AsyncProcess:
208
223
  # ProcessLookupError race: the child can exit between
209
224
  # TimeoutError and kill().
210
225
  with contextlib.suppress(ProcessLookupError):
211
- self._process.kill()
226
+ if sys.platform != "win32":
227
+ os.killpg(self._process.pid, signal.SIGKILL)
228
+ else:
229
+ self._process.kill()
212
230
  await self._process.wait()
213
231
 
214
232
  # Wait for drainers to consume their last buffered line before
@@ -1,13 +1,14 @@
1
- """Vite + pnpm orchestration: detection, install, one-shot build, watch.
1
+ """Vite + pnpm orchestration: detection, install, one-shot build.
2
2
 
3
3
  This module is the shared orchestration core consumed by both heads:
4
4
 
5
5
  - The PEP 517 backend (:mod:`sphinx_vite_builder.build`) calls
6
6
  :func:`run_vite_build` from each of its hooks, before delegating to
7
7
  hatchling.
8
- - The Sphinx extension (:mod:`sphinx_vite_builder`) calls
9
- :func:`run_vite_build` (one-shot) or its watch sibling from
10
- ``builder-inited``.
8
+ - The Sphinx extension (:mod:`sphinx_vite_builder`) — Phase 1
9
+ placeholder — will call :func:`run_vite_build` from
10
+ ``builder-inited`` once the extension head lands in a follow-up
11
+ release.
11
12
 
12
13
  Fast-fail discipline: every prerequisite is checked up front so the
13
14
  caller gets an actionable diagnostic instead of a generic spawn-failure
@@ -340,6 +341,22 @@ def run_vite_build(
340
341
  non-zero.
341
342
  - :class:`ViteFailedError` if ``pnpm exec vite build`` exits
342
343
  non-zero.
344
+
345
+ Examples
346
+ --------
347
+ The ``SPHINX_VITE_BUILDER_SKIP`` environment variable
348
+ short-circuits the whole orchestration before any subprocess is
349
+ spawned — exercising it from a doctest verifies the escape hatch
350
+ keeps working without touching pnpm, Node, or the filesystem
351
+ tree:
352
+
353
+ >>> import os, pathlib, tempfile
354
+ >>> os.environ["SPHINX_VITE_BUILDER_SKIP"] = "1"
355
+ >>> try:
356
+ ... with tempfile.TemporaryDirectory() as tmp:
357
+ ... run_vite_build(pathlib.Path(tmp))
358
+ ... finally:
359
+ ... del os.environ["SPHINX_VITE_BUILDER_SKIP"]
343
360
  """
344
361
  if os.environ.get("SPHINX_VITE_BUILDER_SKIP"):
345
362
  logger.info("SPHINX_VITE_BUILDER_SKIP set; skipping vite build")
@@ -37,7 +37,41 @@ def build_wheel(
37
37
  config_settings: dict[str, t.Any] | None = None,
38
38
  metadata_directory: str | None = None,
39
39
  ) -> str:
40
- """PEP 517 ``build_wheel``: vite-build, then hatchling-pack."""
40
+ """PEP 517 ``build_wheel``: vite-build, then hatchling-pack.
41
+
42
+ Examples
43
+ --------
44
+ With ``SPHINX_VITE_BUILDER_SKIP=1`` set, the hook short-circuits
45
+ vite and delegates straight to :mod:`hatchling.build` against a
46
+ minimal synthetic project, producing a real ``.whl``:
47
+
48
+ >>> import os, pathlib, tempfile, textwrap
49
+ >>> os.environ["SPHINX_VITE_BUILDER_SKIP"] = "1"
50
+ >>> cwd = os.getcwd()
51
+ >>> with tempfile.TemporaryDirectory() as tmp:
52
+ ... project = pathlib.Path(tmp)
53
+ ... _ = (project / "pyproject.toml").write_text(textwrap.dedent('''
54
+ ... [build-system]
55
+ ... requires = ["hatchling"]
56
+ ... build-backend = "hatchling.build"
57
+ ... [project]
58
+ ... name = "doctest-pkg"
59
+ ... version = "0.0.0"
60
+ ... ''').lstrip())
61
+ ... pkg = project / "doctest_pkg"
62
+ ... pkg.mkdir()
63
+ ... _ = (pkg / "__init__.py").write_text("")
64
+ ... dist = project / "dist"
65
+ ... dist.mkdir()
66
+ ... os.chdir(project)
67
+ ... try:
68
+ ... name = build_wheel(str(dist))
69
+ ... finally:
70
+ ... os.chdir(cwd)
71
+ ... del os.environ["SPHINX_VITE_BUILDER_SKIP"]
72
+ >>> name.endswith(".whl")
73
+ True
74
+ """
41
75
  run_vite_build()
42
76
  return _hatchling.build_wheel(wheel_directory, config_settings, metadata_directory)
43
77
 
@@ -47,7 +81,18 @@ def build_editable(
47
81
  config_settings: dict[str, t.Any] | None = None,
48
82
  metadata_directory: str | None = None,
49
83
  ) -> str:
50
- """PEP 660 ``build_editable``: vite-build, then hatchling-pack-editable."""
84
+ """PEP 660 ``build_editable``: vite-build, then hatchling-pack-editable.
85
+
86
+ The delegation pattern matches :func:`build_wheel`; see that
87
+ function's docstring for an end-to-end example exercising the
88
+ ``SPHINX_VITE_BUILDER_SKIP=1`` short-circuit.
89
+
90
+ Examples
91
+ --------
92
+ >>> import inspect
93
+ >>> sorted(inspect.signature(build_editable).parameters)
94
+ ['config_settings', 'metadata_directory', 'wheel_directory']
95
+ """
51
96
  run_vite_build()
52
97
  return _hatchling.build_editable(
53
98
  wheel_directory, config_settings, metadata_directory
@@ -66,6 +111,16 @@ def build_sdist(
66
111
  the sdist without pnpm or Node — the wheel-from-sdist build will
67
112
  skip vite (no ``web/`` in the unpacked tree) and ship the
68
113
  pre-baked assets via hatchling's normal file selection.
114
+
115
+ The delegation pattern matches :func:`build_wheel`; see that
116
+ function's docstring for an end-to-end example exercising the
117
+ ``SPHINX_VITE_BUILDER_SKIP=1`` short-circuit.
118
+
119
+ Examples
120
+ --------
121
+ >>> import inspect
122
+ >>> sorted(inspect.signature(build_sdist).parameters)
123
+ ['config_settings', 'sdist_directory']
69
124
  """
70
125
  run_vite_build()
71
126
  return _hatchling.build_sdist(sdist_directory, config_settings)
@@ -1,106 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: sphinx-vite-builder
3
- Version: 0.0.1a16.dev0
4
- Summary: PEP 517 build backend + Sphinx extension that orchestrates Vite via pnpm
5
- Project-URL: Repository, https://github.com/git-pull/gp-sphinx
6
- Author-email: Tony Narlock <tony@git-pull.com>
7
- License: MIT
8
- Keywords: backend,build,extension,pep517,pnpm,sphinx,vite
9
- Classifier: Development Status :: 3 - Alpha
10
- Classifier: Framework :: Sphinx
11
- Classifier: Framework :: Sphinx :: Extension
12
- Classifier: Intended Audience :: Developers
13
- Classifier: License :: OSI Approved :: MIT License
14
- Classifier: Programming Language :: Python :: 3
15
- Classifier: Programming Language :: Python :: 3.10
16
- Classifier: Programming Language :: Python :: 3.11
17
- Classifier: Programming Language :: Python :: 3.12
18
- Classifier: Programming Language :: Python :: 3.13
19
- Classifier: Programming Language :: Python :: 3.14
20
- Classifier: Topic :: Documentation
21
- Classifier: Topic :: Documentation :: Sphinx
22
- Classifier: Topic :: Software Development :: Build Tools
23
- Classifier: Typing :: Typed
24
- Requires-Python: <4.0,>=3.10
25
- Requires-Dist: hatchling>=1.0
26
- Requires-Dist: sphinx>=8.1
27
- Description-Content-Type: text/markdown
28
-
29
- # sphinx-vite-builder
30
-
31
- PEP 517 build backend and Sphinx extension that transparently orchestrates
32
- [Vite](https://vitejs.dev/) builds via [pnpm](https://pnpm.io/) for
33
- Sphinx-theme packages whose static assets (CSS / JS) are produced by a
34
- JavaScript toolchain.
35
-
36
- ## What it solves
37
-
38
- A common pattern for modern Sphinx themes is a Python package whose
39
- `theme/<name>/static/` directory ships built CSS and JS that were
40
- produced by a JS build tool (Vite, webpack, …). The build artefacts are
41
- gitignored — they're reproducibly built, not source code. But that
42
- creates two friction points:
43
-
44
- 1. **Editable installs and source-tree builds** crash with confusing
45
- errors when the static dir is empty (e.g. hatchling's
46
- `Forced include not found`).
47
- 2. **CI workflows** must duplicate `pnpm install + vite build` setup
48
- steps in every job that touches the package.
49
-
50
- `sphinx-vite-builder` owns the Vite invocation end-to-end — exactly the
51
- way [maturin](https://github.com/PyO3/maturin) owns Cargo for
52
- Rust+Python packages, or
53
- [sphinx-theme-builder](https://github.com/pradyunsg/sphinx-theme-builder)
54
- owns webpack for older Sphinx themes.
55
-
56
- ## Two heads, one subprocess core
57
-
58
- ### PEP 517 build backend
59
-
60
- Drop-in replacement for `hatchling.build`. Runs `pnpm exec vite build`
61
- before delegating wheel/sdist construction to hatchling.
62
-
63
- ```toml
64
- # packages/your-theme/pyproject.toml
65
- [build-system]
66
- requires = ["hatchling>=1.0", "sphinx-vite-builder"]
67
- build-backend = "sphinx_vite_builder.build"
68
- backend-path = ["../sphinx-vite-builder/src"] # for in-tree workspace consumption
69
- ```
70
-
71
- The backend short-circuits when `web/` (the Vite source tree) is absent
72
- — so `pip install <pkg>.tar.gz` from an unpacked sdist works without
73
- pnpm or Node, because the sdist already contains pre-baked
74
- `static/`.
75
-
76
- ### Sphinx extension
77
-
78
- Loaded from `conf.py`. Runs Vite as part of the docs lifecycle:
79
-
80
- - `sphinx-build` → `pnpm exec vite build` once before the docs build
81
- - `sphinx-autobuild` → `pnpm exec vite build --watch` as a child process
82
- for the lifetime of the autobuild server, with idempotent re-fire on
83
- rebuilds and graceful teardown on signal / `atexit`
84
-
85
- ```python
86
- # docs/conf.py
87
- extensions = ["sphinx_vite_builder"]
88
- sphinx_vite_root = "../packages/your-theme/web" # path to vite project
89
- sphinx_vite_mode = "auto" # auto | dev | prod | disabled
90
- ```
91
-
92
- ## Fast-fail diagnostics
93
-
94
- When prerequisites are missing the backend / extension raises
95
- actionable errors rather than producing broken output:
96
-
97
- - `PnpmMissingError` — `pnpm` not on `PATH`; hint includes
98
- `corepack enable` and the `pnpm.io` install URL
99
- - `NodeModulesInstallError` — `pnpm install` exited non-zero; hint
100
- includes the rerun command
101
- - `ViteFailedError` — `pnpm exec vite build` exited non-zero; hint
102
- surfaces the captured stderr
103
-
104
- ## License
105
-
106
- MIT — see [LICENSE](LICENSE).
@@ -1,78 +0,0 @@
1
- # sphinx-vite-builder
2
-
3
- PEP 517 build backend and Sphinx extension that transparently orchestrates
4
- [Vite](https://vitejs.dev/) builds via [pnpm](https://pnpm.io/) for
5
- Sphinx-theme packages whose static assets (CSS / JS) are produced by a
6
- JavaScript toolchain.
7
-
8
- ## What it solves
9
-
10
- A common pattern for modern Sphinx themes is a Python package whose
11
- `theme/<name>/static/` directory ships built CSS and JS that were
12
- produced by a JS build tool (Vite, webpack, …). The build artefacts are
13
- gitignored — they're reproducibly built, not source code. But that
14
- creates two friction points:
15
-
16
- 1. **Editable installs and source-tree builds** crash with confusing
17
- errors when the static dir is empty (e.g. hatchling's
18
- `Forced include not found`).
19
- 2. **CI workflows** must duplicate `pnpm install + vite build` setup
20
- steps in every job that touches the package.
21
-
22
- `sphinx-vite-builder` owns the Vite invocation end-to-end — exactly the
23
- way [maturin](https://github.com/PyO3/maturin) owns Cargo for
24
- Rust+Python packages, or
25
- [sphinx-theme-builder](https://github.com/pradyunsg/sphinx-theme-builder)
26
- owns webpack for older Sphinx themes.
27
-
28
- ## Two heads, one subprocess core
29
-
30
- ### PEP 517 build backend
31
-
32
- Drop-in replacement for `hatchling.build`. Runs `pnpm exec vite build`
33
- before delegating wheel/sdist construction to hatchling.
34
-
35
- ```toml
36
- # packages/your-theme/pyproject.toml
37
- [build-system]
38
- requires = ["hatchling>=1.0", "sphinx-vite-builder"]
39
- build-backend = "sphinx_vite_builder.build"
40
- backend-path = ["../sphinx-vite-builder/src"] # for in-tree workspace consumption
41
- ```
42
-
43
- The backend short-circuits when `web/` (the Vite source tree) is absent
44
- — so `pip install <pkg>.tar.gz` from an unpacked sdist works without
45
- pnpm or Node, because the sdist already contains pre-baked
46
- `static/`.
47
-
48
- ### Sphinx extension
49
-
50
- Loaded from `conf.py`. Runs Vite as part of the docs lifecycle:
51
-
52
- - `sphinx-build` → `pnpm exec vite build` once before the docs build
53
- - `sphinx-autobuild` → `pnpm exec vite build --watch` as a child process
54
- for the lifetime of the autobuild server, with idempotent re-fire on
55
- rebuilds and graceful teardown on signal / `atexit`
56
-
57
- ```python
58
- # docs/conf.py
59
- extensions = ["sphinx_vite_builder"]
60
- sphinx_vite_root = "../packages/your-theme/web" # path to vite project
61
- sphinx_vite_mode = "auto" # auto | dev | prod | disabled
62
- ```
63
-
64
- ## Fast-fail diagnostics
65
-
66
- When prerequisites are missing the backend / extension raises
67
- actionable errors rather than producing broken output:
68
-
69
- - `PnpmMissingError` — `pnpm` not on `PATH`; hint includes
70
- `corepack enable` and the `pnpm.io` install URL
71
- - `NodeModulesInstallError` — `pnpm install` exited non-zero; hint
72
- includes the rerun command
73
- - `ViteFailedError` — `pnpm exec vite build` exited non-zero; hint
74
- surfaces the captured stderr
75
-
76
- ## License
77
-
78
- MIT — see [LICENSE](LICENSE).