sphinx-vite-builder 0.0.1a16.dev0__py3-none-any.whl

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,43 @@
1
+ """sphinx-vite-builder: PEP 517 backend + Sphinx extension.
2
+
3
+ Two orthogonal entry points share one subprocess core:
4
+
5
+ - :mod:`sphinx_vite_builder.build` — the PEP 517 backend module that
6
+ consumer packages reference via
7
+ ``[build-system].build-backend = "sphinx_vite_builder.build"``.
8
+ - :func:`sphinx_vite_builder.setup` — the Sphinx extension entry point
9
+ that ``conf.py`` references via
10
+ ``extensions = ["sphinx_vite_builder"]``.
11
+
12
+ Neither head calls the other; they share the implementation modules
13
+ under :mod:`sphinx_vite_builder._internal`.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import typing as t
19
+
20
+ __version__ = "0.0.1a16.dev0"
21
+
22
+ if t.TYPE_CHECKING:
23
+ from sphinx.application import Sphinx
24
+
25
+
26
+ def setup(app: Sphinx) -> dict[str, t.Any]:
27
+ """Register the Sphinx-extension head.
28
+
29
+ Phase 1 ships the PEP 517 backend; the extension head's full
30
+ implementation (vite watch on ``sphinx-autobuild``, one-shot build
31
+ on ``sphinx-build``) lands in a follow-up commit. For now this
32
+ stub registers the extension so consumers can declare it without
33
+ a no-such-module error, and returns the safety metadata.
34
+ """
35
+ del app
36
+ return {
37
+ "parallel_read_safe": True,
38
+ "parallel_write_safe": True,
39
+ "version": __version__,
40
+ }
41
+
42
+
43
+ __all__ = ("__version__", "setup")
@@ -0,0 +1,7 @@
1
+ """Private implementation modules for ``sphinx-vite-builder``.
2
+
3
+ Anything imported from this package is *not* part of the stable public
4
+ surface — both heads (PEP 517 backend in :mod:`sphinx_vite_builder.build`
5
+ and Sphinx extension in :mod:`sphinx_vite_builder`) reach in here, but
6
+ external callers should not.
7
+ """
@@ -0,0 +1,191 @@
1
+ """Thread + asyncio event-loop bridge.
2
+
3
+ Sphinx's event hooks (``builder-inited``, ``build-finished``, …) are
4
+ synchronous callables. The orchestration logic that they drive is
5
+ asyncio-based (:class:`sphinx_vite_builder._internal.process.AsyncProcess`
6
+ uses ``asyncio.create_subprocess_exec``, pipe drainers, etc.). The bridge
7
+ between them is a *single* event loop running in a single daemon
8
+ thread, kept alive across ``builder-inited`` re-fires for
9
+ ``sphinx-autobuild``.
10
+
11
+ Usage from a Sphinx hook:
12
+
13
+ .. code-block:: python
14
+
15
+ bus = AsyncioBus()
16
+ bus.start()
17
+ bus.call_sync(some_coro())
18
+ # ...
19
+ bus.stop(timeout=5.0)
20
+
21
+ The bus has no Sphinx-specific knowledge; tests construct one and drive
22
+ it directly.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import asyncio
28
+ import logging
29
+ import threading
30
+ import typing as t
31
+
32
+ if t.TYPE_CHECKING:
33
+ from collections.abc import Coroutine
34
+
35
+ logger = logging.getLogger(__name__)
36
+
37
+
38
+ class AsyncioBus:
39
+ """A single asyncio event loop running in a daemon thread.
40
+
41
+ Lifecycle:
42
+
43
+ 1. :meth:`start` spawns the thread; waits until the loop is ready.
44
+ 2. Hooks run :meth:`call_sync` (block on result) or :meth:`call_soon`
45
+ (fire-and-forget).
46
+ 3. :meth:`stop` schedules the loop to stop, joins the thread.
47
+
48
+ The bus is single-use. After ``stop()`` it is not safe to start
49
+ again — construct a new instance.
50
+ """
51
+
52
+ def __init__(self, *, name: str = "sphinx-vite-builder-bus") -> None:
53
+ self._name = name
54
+ self._loop: asyncio.AbstractEventLoop | None = None
55
+ self._thread: threading.Thread | None = None
56
+ self._ready = threading.Event()
57
+ # ``_stopped`` is set once a started bus has actually been torn
58
+ # down. It enforces the class-level "single-use" contract from
59
+ # ``start()``; a stop-before-start is a no-op and leaves this
60
+ # ``False`` (the bus was never really live).
61
+ self._stopped = False
62
+
63
+ @property
64
+ def is_running(self) -> bool:
65
+ """True iff the loop thread is alive."""
66
+ return self._thread is not None and self._thread.is_alive()
67
+
68
+ def start(self) -> None:
69
+ """Start the background event loop.
70
+
71
+ Idempotent if the bus is already running; raises
72
+ :class:`RuntimeError` if the bus has previously been stopped
73
+ (the class is single-use, per the class docstring).
74
+ """
75
+ if self._stopped:
76
+ msg = "AsyncioBus is single-use; construct a new instance after stop()"
77
+ raise RuntimeError(msg)
78
+ if self._thread is not None and self._thread.is_alive():
79
+ return
80
+ self._ready.clear()
81
+ self._thread = threading.Thread(
82
+ target=self._run,
83
+ daemon=True,
84
+ name=self._name,
85
+ )
86
+ self._thread.start()
87
+ # Block until the loop has been assigned. Without this, a
88
+ # call_sync() racing the thread startup would deref None.
89
+ self._ready.wait()
90
+
91
+ def call_sync(
92
+ self,
93
+ coro: Coroutine[t.Any, t.Any, t.Any],
94
+ *,
95
+ timeout: float | None = None,
96
+ ) -> t.Any:
97
+ """Schedule ``coro`` on the loop and block on its result.
98
+
99
+ Parameters
100
+ ----------
101
+ coro
102
+ The coroutine object (call-but-don't-await first).
103
+ timeout
104
+ Forwarded to ``concurrent.futures.Future.result``. ``None``
105
+ blocks indefinitely.
106
+
107
+ Raises
108
+ ------
109
+ RuntimeError
110
+ If the bus is not running.
111
+ """
112
+ if self._loop is None:
113
+ # Close the coroutine before raising so we don't leak a
114
+ # "coroutine was never awaited" warning at gc time.
115
+ coro.close()
116
+ msg = "AsyncioBus.call_sync() called before start()"
117
+ raise RuntimeError(msg)
118
+ future = asyncio.run_coroutine_threadsafe(coro, self._loop)
119
+ return future.result(timeout=timeout)
120
+
121
+ def call_soon(
122
+ self,
123
+ coro: Coroutine[t.Any, t.Any, t.Any],
124
+ ) -> None:
125
+ """Schedule ``coro`` and return immediately. Errors go to the bus logger."""
126
+ if self._loop is None:
127
+ coro.close()
128
+ msg = "AsyncioBus.call_soon() called before start()"
129
+ raise RuntimeError(msg)
130
+ future = asyncio.run_coroutine_threadsafe(coro, self._loop)
131
+
132
+ def _log_exception(fut: t.Any) -> None:
133
+ exc = fut.exception()
134
+ if exc is not None:
135
+ logger.error(
136
+ "background coroutine on %s raised",
137
+ self._name,
138
+ exc_info=exc,
139
+ )
140
+
141
+ future.add_done_callback(_log_exception)
142
+
143
+ def stop(self, *, timeout: float = 5.0) -> None:
144
+ """Stop the loop and join the thread. Idempotent.
145
+
146
+ Once a started bus has been stopped, ``_stopped`` is set so a
147
+ subsequent ``start()`` on the same instance raises rather than
148
+ silently re-spawning. A stop-before-start is a no-op and leaves
149
+ ``_stopped`` unchanged.
150
+ """
151
+ if self._loop is None or self._thread is None:
152
+ return
153
+ if not self._thread.is_alive():
154
+ self._thread = None
155
+ self._loop = None
156
+ self._stopped = True
157
+ return
158
+
159
+ self._loop.call_soon_threadsafe(self._loop.stop)
160
+ self._thread.join(timeout=timeout)
161
+ if self._thread.is_alive():
162
+ logger.warning(
163
+ "%s thread did not exit within %.1fs of loop.stop()",
164
+ self._name,
165
+ timeout,
166
+ )
167
+ self._thread = None
168
+ self._loop = None
169
+ self._stopped = True
170
+
171
+ def _run(self) -> None:
172
+ """Thread target: own and run the loop."""
173
+ loop = asyncio.new_event_loop()
174
+ asyncio.set_event_loop(loop)
175
+ self._loop = loop
176
+ self._ready.set()
177
+ try:
178
+ loop.run_forever()
179
+ finally:
180
+ try:
181
+ # Cancel any remaining tasks so they don't leak past
182
+ # the loop's death.
183
+ pending = asyncio.all_tasks(loop)
184
+ for task in pending:
185
+ task.cancel()
186
+ if pending:
187
+ loop.run_until_complete(
188
+ asyncio.gather(*pending, return_exceptions=True),
189
+ )
190
+ finally:
191
+ loop.close()
@@ -0,0 +1,42 @@
1
+ """Diagnostic error types raised by the backend and extension.
2
+
3
+ Each error carries a multi-line, copy-pasteable hint so the failure is
4
+ fixable from the message itself. The PEP 517 backend re-raises these as
5
+ their own type (so build frontends like pip / uv display the full
6
+ message); the Sphinx extension re-wraps them in
7
+ :class:`sphinx.errors.ConfigError` so Sphinx halts the build with the
8
+ same content.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+
14
+ class SphinxViteBuilderError(Exception):
15
+ """Base class for all sphinx-vite-builder-raised diagnostic errors."""
16
+
17
+
18
+ class PnpmMissingError(SphinxViteBuilderError):
19
+ """Raised when ``pnpm`` is not on ``PATH``.
20
+
21
+ pnpm is not pip-installable, so the backend cannot bootstrap it the
22
+ way maturin bootstraps Rust via ``puccinialin``. The hint surfaces
23
+ the canonical install paths (``corepack enable``, the pnpm.io
24
+ install URL) so the user has an actionable next step.
25
+ """
26
+
27
+
28
+ class NodeModulesInstallError(SphinxViteBuilderError):
29
+ """Raised when ``pnpm install`` exits non-zero.
30
+
31
+ Surfaces the install command that failed and a re-run recipe. Callers
32
+ should attach the captured stderr to the message before raising so
33
+ the underlying pnpm diagnostic is visible at the call site.
34
+ """
35
+
36
+
37
+ class ViteFailedError(SphinxViteBuilderError):
38
+ """Raised when ``pnpm exec vite build`` exits non-zero.
39
+
40
+ Surfaces the vite invocation that failed plus context (cwd, exit
41
+ code). Callers should attach the captured stderr.
42
+ """
@@ -0,0 +1,242 @@
1
+ """Async subprocess wrapper used by the Vite/pnpm orchestration.
2
+
3
+ Wraps :func:`asyncio.create_subprocess_exec` with the conventions the
4
+ backend and extension heads need:
5
+
6
+ - ``stdout`` / ``stderr`` are piped through line-buffered drainers that
7
+ prefix each line with a label and route it to a
8
+ :class:`logging.Logger` — info for stdout, warning for stderr.
9
+ - ``PYTHONUNBUFFERED=1`` is forced into the child env so Python tools
10
+ invoked via the package-manager bridge don't withhold their output.
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).
15
+ - :meth:`AsyncProcess.terminate` is graceful-then-forceful: SIGTERM,
16
+ await up to ``timeout`` seconds, escalate to SIGKILL if the child is
17
+ still alive. Idempotent: calling on an already-exited process is a
18
+ no-op.
19
+
20
+ Argument lists are passed directly to the asyncio subprocess primitive;
21
+ no shell, no string interpolation, no command-injection surface.
22
+
23
+ The class is intentionally generic over "what command to run" so the
24
+ same wrapper covers the production vite / pnpm calls and the fake
25
+ shell scripts used in tests.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import asyncio
31
+ import contextlib
32
+ import logging
33
+ import os
34
+ import pathlib
35
+ import sys
36
+ import typing as t
37
+
38
+ if t.TYPE_CHECKING:
39
+ pass
40
+
41
+ _module_logger = logging.getLogger(__name__)
42
+
43
+
44
+ class AsyncProcess:
45
+ """Async wrapper around a subprocess (one-shot or long-running).
46
+
47
+ Used for both ``pnpm install`` (one-shot, awaited) and
48
+ ``pnpm exec vite build --watch`` (long-running, terminated on
49
+ teardown).
50
+ """
51
+
52
+ def __init__(
53
+ self,
54
+ *,
55
+ label: str = "subprocess",
56
+ logger: logging.Logger | logging.LoggerAdapter[t.Any] | None = None,
57
+ ) -> None:
58
+ # Accepts either a stdlib Logger or a LoggerAdapter (Sphinx's
59
+ # ``sphinx.util.logging.SphinxLoggerAdapter`` is a LoggerAdapter
60
+ # subclass). Both expose the .log() method the drainers use.
61
+ self._label = label
62
+ self._logger: logging.Logger | logging.LoggerAdapter[t.Any] = (
63
+ logger if logger is not None else _module_logger
64
+ )
65
+ self._process: asyncio.subprocess.Process | None = None
66
+ self._drainers: list[asyncio.Task[None]] = []
67
+ self._stderr_buffer: list[str] = []
68
+
69
+ @property
70
+ def is_running(self) -> bool:
71
+ """True iff the child has been started and has not yet exited."""
72
+ return self._process is not None and self._process.returncode is None
73
+
74
+ @property
75
+ def returncode(self) -> int | None:
76
+ """Process exit code, or ``None`` if the child hasn't exited (yet)."""
77
+ return self._process.returncode if self._process is not None else None
78
+
79
+ @property
80
+ def pid(self) -> int | None:
81
+ """Child process ID, or ``None`` if not started."""
82
+ return self._process.pid if self._process is not None else None
83
+
84
+ @property
85
+ def captured_stderr(self) -> str:
86
+ """Joined stderr lines captured by the drainer.
87
+
88
+ Useful for surfacing the underlying tool's diagnostic in error
89
+ messages when a build fails.
90
+ """
91
+ return "\n".join(self._stderr_buffer)
92
+
93
+ async def start(
94
+ self,
95
+ command: t.Sequence[str],
96
+ *,
97
+ cwd: pathlib.Path,
98
+ env: t.Mapping[str, str] | None = None,
99
+ ) -> None:
100
+ """Spawn ``command`` and start draining its stdout / stderr.
101
+
102
+ Parameters
103
+ ----------
104
+ command
105
+ Argument list. Passed straight to the asyncio subprocess
106
+ primitive; no shell.
107
+ cwd
108
+ Working directory for the child.
109
+ env
110
+ Optional environment override. ``PYTHONUNBUFFERED=1`` is
111
+ always injected on top of whatever this provides (or, if
112
+ ``None``, on top of :data:`os.environ`).
113
+
114
+ Raises
115
+ ------
116
+ RuntimeError
117
+ If :meth:`start` is called twice on the same instance
118
+ without an intervening :meth:`terminate`.
119
+ """
120
+ if self._process is not None:
121
+ msg = "AsyncProcess.start() called twice; spawn a new instance instead"
122
+ raise RuntimeError(msg)
123
+
124
+ merged_env = dict(env) if env is not None else dict(os.environ)
125
+ merged_env["PYTHONUNBUFFERED"] = "1"
126
+
127
+ # POSIX-only: ``start_new_session`` puts the child in its own
128
+ # session/process group so SIGTERM to that group takes down
129
+ # ``pnpm exec`` plus all its descendants. On Windows there's no
130
+ # equivalent; the asyncio default is fine.
131
+ spawn_kwargs: dict[str, t.Any] = {}
132
+ if sys.platform != "win32":
133
+ spawn_kwargs["start_new_session"] = True
134
+
135
+ self._process = await asyncio.create_subprocess_exec(
136
+ *command,
137
+ cwd=str(cwd),
138
+ env=merged_env,
139
+ stdout=asyncio.subprocess.PIPE,
140
+ stderr=asyncio.subprocess.PIPE,
141
+ **spawn_kwargs,
142
+ )
143
+
144
+ # Pipe drainers — capture-and-log line by line so callers get
145
+ # immediate visibility into the tool's progress.
146
+ assert self._process.stdout is not None
147
+ assert self._process.stderr is not None
148
+ self._drainers = [
149
+ asyncio.create_task(
150
+ self._drain(self._process.stdout, level=logging.INFO),
151
+ name=f"{self._label}-stdout-drainer",
152
+ ),
153
+ asyncio.create_task(
154
+ self._drain(
155
+ self._process.stderr,
156
+ level=logging.WARNING,
157
+ capture=self._stderr_buffer,
158
+ ),
159
+ name=f"{self._label}-stderr-drainer",
160
+ ),
161
+ ]
162
+
163
+ async def wait(self) -> int:
164
+ """Wait for the child to exit; return its exit code.
165
+
166
+ Drains the stdout / stderr pipes to completion before returning.
167
+ """
168
+ if self._process is None:
169
+ msg = "AsyncProcess.wait() called before start()"
170
+ raise RuntimeError(msg)
171
+ returncode = await self._process.wait()
172
+ # Let the drainers consume any final buffered lines before
173
+ # returning to the caller.
174
+ await asyncio.gather(*self._drainers, return_exceptions=True)
175
+ return returncode
176
+
177
+ async def terminate(self, *, timeout: float = 5.0) -> int | None:
178
+ """Send SIGTERM; escalate to SIGKILL after ``timeout`` seconds.
179
+
180
+ Idempotent — calling on an already-exited process is a no-op
181
+ and returns the existing exit code (or ``None`` if never started).
182
+
183
+ Parameters
184
+ ----------
185
+ timeout
186
+ Seconds to wait for graceful exit after SIGTERM.
187
+
188
+ Returns
189
+ -------
190
+ int | None
191
+ The child's exit code, or ``None`` if :meth:`start` was
192
+ never called.
193
+ """
194
+ if self._process is None:
195
+ return None
196
+ if self._process.returncode is not None:
197
+ return self._process.returncode
198
+
199
+ self._process.terminate()
200
+ try:
201
+ await asyncio.wait_for(self._process.wait(), timeout=timeout)
202
+ except asyncio.TimeoutError:
203
+ self._logger.warning(
204
+ "[%s] did not exit within %.1fs of SIGTERM; sending SIGKILL",
205
+ self._label,
206
+ timeout,
207
+ )
208
+ # ProcessLookupError race: the child can exit between
209
+ # TimeoutError and kill().
210
+ with contextlib.suppress(ProcessLookupError):
211
+ self._process.kill()
212
+ await self._process.wait()
213
+
214
+ # Wait for drainers to consume their last buffered line before
215
+ # the caller proceeds; surface no exception if a drainer raised.
216
+ await asyncio.gather(*self._drainers, return_exceptions=True)
217
+ return self._process.returncode
218
+
219
+ async def _drain(
220
+ self,
221
+ stream: asyncio.StreamReader,
222
+ *,
223
+ level: int,
224
+ capture: list[str] | None = None,
225
+ ) -> None:
226
+ """Consume ``stream`` line by line; log each line.
227
+
228
+ Optionally append every (non-empty) line to ``capture`` so callers
229
+ can surface it in error messages.
230
+ """
231
+ while True:
232
+ try:
233
+ line = await stream.readline()
234
+ except (BrokenPipeError, ConnectionResetError):
235
+ return
236
+ if not line:
237
+ return
238
+ text = line.decode("utf-8", errors="replace").rstrip("\n")
239
+ if text:
240
+ self._logger.log(level, "[%s] %s", self._label, text)
241
+ if capture is not None:
242
+ capture.append(text)
@@ -0,0 +1,387 @@
1
+ """Vite + pnpm orchestration: detection, install, one-shot build, watch.
2
+
3
+ This module is the shared orchestration core consumed by both heads:
4
+
5
+ - The PEP 517 backend (:mod:`sphinx_vite_builder.build`) calls
6
+ :func:`run_vite_build` from each of its hooks, before delegating to
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``.
11
+
12
+ Fast-fail discipline: every prerequisite is checked up front so the
13
+ caller gets an actionable diagnostic instead of a generic spawn-failure
14
+ deep in the asyncio plumbing.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import logging
20
+ import os
21
+ import pathlib
22
+ import shutil
23
+ import textwrap
24
+ import typing as t
25
+
26
+ from .bus import AsyncioBus
27
+ from .errors import (
28
+ NodeModulesInstallError,
29
+ PnpmMissingError,
30
+ ViteFailedError,
31
+ )
32
+ from .process import AsyncProcess
33
+
34
+ logger = logging.getLogger(__name__)
35
+
36
+
37
+ def pnpm_install_command(*, package_manager: str = "pnpm") -> tuple[str, ...]:
38
+ """Build the canonical "install workspace deps" argv.
39
+
40
+ ``--frozen-lockfile`` matches the workspace's pinned ``pnpm-lock.yaml``;
41
+ pnpm refuses to mutate the lockfile or auto-resolve unspecified deps,
42
+ so the install is reproducible across machines and CI.
43
+
44
+ Examples
45
+ --------
46
+ >>> pnpm_install_command()
47
+ ('pnpm', 'install', '--frozen-lockfile')
48
+ >>> pnpm_install_command(package_manager="npm")
49
+ ('npm', 'install', '--frozen-lockfile')
50
+ """
51
+ return (package_manager, "install", "--frozen-lockfile")
52
+
53
+
54
+ def vite_build_command(*, package_manager: str = "pnpm") -> tuple[str, ...]:
55
+ """Build the canonical one-shot "vite build" argv.
56
+
57
+ Examples
58
+ --------
59
+ >>> vite_build_command()
60
+ ('pnpm', 'exec', 'vite', 'build')
61
+ >>> vite_build_command(package_manager="npm")
62
+ ('npm', 'exec', 'vite', 'build')
63
+ """
64
+ return (package_manager, "exec", "vite", "build")
65
+
66
+
67
+ def vite_watch_command(*, package_manager: str = "pnpm") -> tuple[str, ...]:
68
+ """Build the canonical Vite-watch argv.
69
+
70
+ Examples
71
+ --------
72
+ >>> vite_watch_command()
73
+ ('pnpm', 'exec', 'vite', 'build', '--watch')
74
+ >>> vite_watch_command(package_manager="npm")
75
+ ('npm', 'exec', 'vite', 'build', '--watch')
76
+ """
77
+ return (package_manager, "exec", "vite", "build", "--watch")
78
+
79
+
80
+ def _detect_ci_provider() -> str | None:
81
+ """Return a canonical CI-provider name, or ``None`` if not in CI.
82
+
83
+ Detection precedence: most-specific provider wins. Each provider's
84
+ canonical env var (per their docs) is checked first; the generic
85
+ ``CI=true`` is the fallback for "we know we're in CI but don't
86
+ recognise the provider".
87
+
88
+ Provider env vars (canonical, per upstream docs):
89
+
90
+ - GitHub Actions: ``GITHUB_ACTIONS=true``
91
+ - CircleCI: ``CIRCLECI=true``
92
+ - Azure Pipelines: ``TF_BUILD=True`` (Team Foundation Build)
93
+ - GitLab CI: ``GITLAB_CI=true``
94
+ - Generic: ``CI=true``
95
+ """
96
+ env = os.environ
97
+ # Map of "canonical name" → "env var to check (truthy)". Order
98
+ # matters: more-specific entries first.
99
+ providers: tuple[tuple[str, str], ...] = (
100
+ ("github-actions", "GITHUB_ACTIONS"),
101
+ ("circleci", "CIRCLECI"),
102
+ ("azure-pipelines", "TF_BUILD"),
103
+ ("gitlab", "GITLAB_CI"),
104
+ ("ci", "CI"),
105
+ )
106
+ for name, var in providers:
107
+ value = env.get(var, "").strip().lower()
108
+ if value in {"1", "true"}:
109
+ return name
110
+ return None
111
+
112
+
113
+ # Each per-provider snippet is a *copy-pasteable* config fragment that
114
+ # adds pnpm + Node to the platform's pipeline before the Python build
115
+ # step runs. Versions follow the upstream pnpm CI guide
116
+ # (https://pnpm.io/continuous-integration); update in lockstep with
117
+ # the workspace's pnpm-lock.yaml's `packageManager` if it pins
118
+ # something different.
119
+ _CI_SETUP_RECIPES: dict[str, str] = {
120
+ "github-actions": textwrap.dedent(
121
+ """\
122
+ - uses: pnpm/action-setup@v6
123
+ with:
124
+ version: 10
125
+ - uses: actions/setup-node@v6
126
+ with:
127
+ node-version: 22
128
+ cache: pnpm""",
129
+ ),
130
+ "circleci": textwrap.dedent(
131
+ """\
132
+ - run:
133
+ name: Set up pnpm
134
+ command: |
135
+ npm install --global corepack@latest
136
+ corepack enable
137
+ corepack prepare pnpm@latest-10 --activate""",
138
+ ),
139
+ "azure-pipelines": textwrap.dedent(
140
+ """\
141
+ - task: NodeTool@0
142
+ inputs:
143
+ versionSpec: '22.x'
144
+ displayName: 'Set up Node'
145
+ - script: |
146
+ npm install --global corepack@latest
147
+ corepack enable
148
+ corepack prepare pnpm@latest-10 --activate
149
+ displayName: 'Set up pnpm'""",
150
+ ),
151
+ "gitlab": textwrap.dedent(
152
+ """\
153
+ before_script:
154
+ - npm install --global corepack@latest
155
+ - corepack enable
156
+ - corepack prepare pnpm@latest-10 --activate""",
157
+ ),
158
+ "ci": " # Use your CI's package-manager setup mechanism to install pnpm",
159
+ }
160
+
161
+
162
+ def _format_ci_recipe_block(provider: str | None) -> str:
163
+ """Return a multi-line CI-specific setup snippet, or an empty string.
164
+
165
+ Called by :func:`_format_pnpm_missing_hint` when CI is detected so
166
+ the user's error message includes a copy-pasteable config fragment
167
+ for their platform.
168
+ """
169
+ if provider is None:
170
+ return ""
171
+
172
+ pretty: dict[str, str] = {
173
+ "github-actions": "GitHub Actions",
174
+ "circleci": "CircleCI",
175
+ "azure-pipelines": "Azure Pipelines",
176
+ "gitlab": "GitLab CI",
177
+ "ci": "this CI environment",
178
+ }
179
+ label = pretty.get(provider, provider)
180
+ recipe = _CI_SETUP_RECIPES.get(provider, "")
181
+ return textwrap.dedent(
182
+ f"""\
183
+
184
+ Detected CI provider: {label}. Add the following to your pipeline
185
+ config (before the Python build step that triggers this backend):
186
+
187
+ {textwrap.indent(recipe, " ")}
188
+ """,
189
+ ).rstrip()
190
+
191
+
192
+ def _format_pnpm_missing_hint(vite_root: pathlib.Path) -> str:
193
+ """Multi-line, copy-pasteable hint when pnpm is not on PATH."""
194
+ ci_recipe = _format_ci_recipe_block(_detect_ci_provider())
195
+ return (
196
+ textwrap.dedent(
197
+ f"""\
198
+ sphinx-vite-builder: cannot bootstrap the vite toolchain.
199
+ `pnpm` is not on PATH. Install it via one of:
200
+
201
+ corepack enable # Node 16.10+ ships corepack
202
+ curl -fsSL https://get.pnpm.io/install.sh | sh -
203
+
204
+ See https://pnpm.io/installation
205
+
206
+ Then re-run with `pnpm` available, or, if this environment is not
207
+ supposed to build assets (e.g. a wheel-only install with the
208
+ static tree pre-baked), set `SPHINX_VITE_BUILDER_SKIP=1`.
209
+
210
+ Vite project root resolved to: {vite_root}
211
+ """,
212
+ ).rstrip()
213
+ + ci_recipe
214
+ )
215
+
216
+
217
+ def _format_install_failed_hint(
218
+ *,
219
+ vite_root: pathlib.Path,
220
+ install_cmd: tuple[str, ...],
221
+ returncode: int,
222
+ stderr: str,
223
+ ) -> str:
224
+ """Multi-line hint when ``pnpm install`` exits non-zero."""
225
+ cmd_str = " ".join(install_cmd)
226
+ stderr_block = stderr.strip() or "(no stderr captured)"
227
+ return textwrap.dedent(
228
+ f"""\
229
+ sphinx-vite-builder: `{cmd_str}` exited with code {returncode} in {vite_root}.
230
+ The vite-managed theme assets cannot be produced; aborting the
231
+ build rather than shipping unstyled docs.
232
+
233
+ Fix:
234
+ cd {vite_root}
235
+ {cmd_str}
236
+
237
+ Captured stderr:
238
+ {textwrap.indent(stderr_block, " ")}
239
+ """,
240
+ ).rstrip()
241
+
242
+
243
+ def _format_vite_failed_hint(
244
+ *,
245
+ vite_root: pathlib.Path,
246
+ build_cmd: tuple[str, ...],
247
+ returncode: int,
248
+ stderr: str,
249
+ ) -> str:
250
+ """Multi-line hint when ``pnpm exec vite build`` exits non-zero."""
251
+ cmd_str = " ".join(build_cmd)
252
+ stderr_block = stderr.strip() or "(no stderr captured)"
253
+ return textwrap.dedent(
254
+ f"""\
255
+ sphinx-vite-builder: `{cmd_str}` exited with code {returncode} in {vite_root}.
256
+ Vite reported a build error; the resulting wheel/docs would be
257
+ unstyled. Aborting before any further build steps.
258
+
259
+ Captured stderr:
260
+ {textwrap.indent(stderr_block, " ")}
261
+ """,
262
+ ).rstrip()
263
+
264
+
265
+ def _resolve_vite_root(project_root: pathlib.Path) -> pathlib.Path | None:
266
+ """Locate the Vite project root next to ``project_root``.
267
+
268
+ Convention: a sibling ``web/`` directory containing ``package.json``
269
+ is the Vite project. Returns ``None`` if no such directory exists
270
+ (e.g. when the build runs inside an unpacked sdist where ``web/``
271
+ was excluded — in that case the caller should treat the static
272
+ tree as pre-baked and skip vite).
273
+ """
274
+ candidate = project_root / "web"
275
+ if candidate.is_dir() and (candidate / "package.json").is_file():
276
+ return candidate
277
+ return None
278
+
279
+
280
+ async def _run_install(
281
+ vite_root: pathlib.Path,
282
+ *,
283
+ install_cmd: tuple[str, ...],
284
+ ) -> None:
285
+ """Run ``pnpm install --frozen-lockfile``; raise on non-zero exit."""
286
+ proc = AsyncProcess(label="pnpm-install", logger=logger)
287
+ await proc.start(install_cmd, cwd=vite_root)
288
+ returncode = await proc.wait()
289
+ if returncode != 0:
290
+ raise NodeModulesInstallError(
291
+ _format_install_failed_hint(
292
+ vite_root=vite_root,
293
+ install_cmd=install_cmd,
294
+ returncode=returncode,
295
+ stderr=proc.captured_stderr,
296
+ ),
297
+ )
298
+
299
+
300
+ async def _run_build(
301
+ vite_root: pathlib.Path,
302
+ *,
303
+ build_cmd: tuple[str, ...],
304
+ ) -> None:
305
+ """Run ``pnpm exec vite build``; raise on non-zero exit."""
306
+ proc = AsyncProcess(label="vite-build", logger=logger)
307
+ await proc.start(build_cmd, cwd=vite_root)
308
+ returncode = await proc.wait()
309
+ if returncode != 0:
310
+ raise ViteFailedError(
311
+ _format_vite_failed_hint(
312
+ vite_root=vite_root,
313
+ build_cmd=build_cmd,
314
+ returncode=returncode,
315
+ stderr=proc.captured_stderr,
316
+ ),
317
+ )
318
+
319
+
320
+ def run_vite_build(
321
+ project_root: pathlib.Path | None = None,
322
+ *,
323
+ package_manager: str = "pnpm",
324
+ ) -> None:
325
+ """Run a one-shot ``pnpm exec vite build`` in ``<project_root>/web``.
326
+
327
+ Short-circuits when:
328
+
329
+ - ``SPHINX_VITE_BUILDER_SKIP=1`` is set in the environment (escape
330
+ hatch for downstream packagers who pre-bake the static tree
331
+ themselves).
332
+ - The expected ``web/`` directory is absent — the typical case
333
+ when building a wheel from an unpacked sdist (where ``web/`` was
334
+ excluded but ``static/`` was pre-baked into the sdist).
335
+
336
+ Otherwise, fast-fails:
337
+
338
+ - :class:`PnpmMissingError` if ``pnpm`` is not on ``PATH``.
339
+ - :class:`NodeModulesInstallError` if ``pnpm install`` exits
340
+ non-zero.
341
+ - :class:`ViteFailedError` if ``pnpm exec vite build`` exits
342
+ non-zero.
343
+ """
344
+ if os.environ.get("SPHINX_VITE_BUILDER_SKIP"):
345
+ logger.info("SPHINX_VITE_BUILDER_SKIP set; skipping vite build")
346
+ return
347
+
348
+ project_root = (project_root or pathlib.Path.cwd()).resolve()
349
+ vite_root = _resolve_vite_root(project_root)
350
+ if vite_root is None:
351
+ logger.info(
352
+ "no web/ alongside %s; assuming pre-baked static tree (sdist build)",
353
+ project_root,
354
+ )
355
+ return
356
+
357
+ if shutil.which(package_manager) is None:
358
+ raise PnpmMissingError(_format_pnpm_missing_hint(vite_root))
359
+
360
+ install_cmd = pnpm_install_command(package_manager=package_manager)
361
+ build_cmd = vite_build_command(package_manager=package_manager)
362
+ needs_install = not (vite_root / "node_modules").is_dir()
363
+
364
+ bus = AsyncioBus(name="sphinx-vite-builder-build-bus")
365
+ bus.start()
366
+ try:
367
+ if needs_install:
368
+ logger.info("installing JS deps in %s via %s", vite_root, install_cmd)
369
+ bus.call_sync(_run_install(vite_root, install_cmd=install_cmd))
370
+ logger.info("building vite assets in %s", vite_root)
371
+ bus.call_sync(_run_build(vite_root, build_cmd=build_cmd))
372
+ finally:
373
+ bus.stop()
374
+
375
+
376
+ __all__: tuple[str, ...] = (
377
+ "pnpm_install_command",
378
+ "run_vite_build",
379
+ "vite_build_command",
380
+ "vite_watch_command",
381
+ )
382
+
383
+
384
+ # Re-exports for type-checker friendliness when consumers import
385
+ # the orchestration module directly.
386
+ _AsyncProcess: t.TypeAlias = AsyncProcess
387
+ _AsyncioBus: t.TypeAlias = AsyncioBus
@@ -0,0 +1,94 @@
1
+ """PEP 517 / PEP 660 build backend module.
2
+
3
+ Consumer ``pyproject.toml`` references this module via:
4
+
5
+ .. code-block:: toml
6
+
7
+ [build-system]
8
+ requires = ["hatchling>=1.0", "sphinx-vite-builder"]
9
+ build-backend = "sphinx_vite_builder.build"
10
+ backend-path = ["../sphinx-vite-builder/src"] # for in-tree workspace builds
11
+
12
+ Each PEP 517 hook runs :func:`run_vite_build` to populate the consumer
13
+ package's vite-managed ``static/`` tree, then delegates to
14
+ :mod:`hatchling.build` for the actual sdist / wheel / editable
15
+ construction. The hooks are pure functions, defined at module scope,
16
+ mirroring the canonical layout of `flit_core.buildapi` and
17
+ `hatchling.build`.
18
+
19
+ Optional hooks (``get_requires_for_build_*`` and
20
+ ``prepare_metadata_for_build_*``) are aliased verbatim to hatchling's
21
+ implementations — vite has no influence on dependency resolution or
22
+ distribution metadata, so passing those calls through unmodified is
23
+ both correct and trivially side-effect-free.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import typing as t
29
+
30
+ import hatchling.build as _hatchling
31
+
32
+ from ._internal.vite import run_vite_build
33
+
34
+
35
+ def build_wheel(
36
+ wheel_directory: str,
37
+ config_settings: dict[str, t.Any] | None = None,
38
+ metadata_directory: str | None = None,
39
+ ) -> str:
40
+ """PEP 517 ``build_wheel``: vite-build, then hatchling-pack."""
41
+ run_vite_build()
42
+ return _hatchling.build_wheel(wheel_directory, config_settings, metadata_directory)
43
+
44
+
45
+ def build_editable(
46
+ wheel_directory: str,
47
+ config_settings: dict[str, t.Any] | None = None,
48
+ metadata_directory: str | None = None,
49
+ ) -> str:
50
+ """PEP 660 ``build_editable``: vite-build, then hatchling-pack-editable."""
51
+ run_vite_build()
52
+ return _hatchling.build_editable(
53
+ wheel_directory, config_settings, metadata_directory
54
+ )
55
+
56
+
57
+ def build_sdist(
58
+ sdist_directory: str,
59
+ config_settings: dict[str, t.Any] | None = None,
60
+ ) -> str:
61
+ """PEP 517 ``build_sdist``: pre-bake static so the sdist→wheel chain works.
62
+
63
+ Running vite at sdist-build time means the resulting ``.tar.gz``
64
+ contains a populated ``static/`` tree (even though the source repo
65
+ gitignores it). Downstream consumers can then ``pip install`` from
66
+ the sdist without pnpm or Node — the wheel-from-sdist build will
67
+ skip vite (no ``web/`` in the unpacked tree) and ship the
68
+ pre-baked assets via hatchling's normal file selection.
69
+ """
70
+ run_vite_build()
71
+ return _hatchling.build_sdist(sdist_directory, config_settings)
72
+
73
+
74
+ # The optional hooks have no vite-side concern — pass through verbatim.
75
+ # Keeping them as module-level aliases (rather than wrapping functions)
76
+ # preserves their identity for build frontends that introspect the
77
+ # module surface.
78
+ get_requires_for_build_wheel = _hatchling.get_requires_for_build_wheel
79
+ get_requires_for_build_sdist = _hatchling.get_requires_for_build_sdist
80
+ get_requires_for_build_editable = _hatchling.get_requires_for_build_editable
81
+ prepare_metadata_for_build_wheel = _hatchling.prepare_metadata_for_build_wheel
82
+ prepare_metadata_for_build_editable = _hatchling.prepare_metadata_for_build_editable
83
+
84
+
85
+ __all__: tuple[str, ...] = (
86
+ "build_editable",
87
+ "build_sdist",
88
+ "build_wheel",
89
+ "get_requires_for_build_editable",
90
+ "get_requires_for_build_sdist",
91
+ "get_requires_for_build_wheel",
92
+ "prepare_metadata_for_build_editable",
93
+ "prepare_metadata_for_build_wheel",
94
+ )
File without changes
@@ -0,0 +1,106 @@
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).
@@ -0,0 +1,12 @@
1
+ sphinx_vite_builder/__init__.py,sha256=DRzFcEjIvO6EiVhejmwKIfCkIq0kkWavYsETA35sypM,1310
2
+ sphinx_vite_builder/build.py,sha256=JZdoqFuC7lnm545v83V2PJbhcYTobC_krt0dGIfWUnc,3400
3
+ sphinx_vite_builder/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ sphinx_vite_builder/_internal/__init__.py,sha256=LsWjSKGvTuBuvRo5RO6nhKwd3kQCYjYgKQWzbLzHMbM,315
5
+ sphinx_vite_builder/_internal/bus.py,sha256=XXMZzwxkfLXwTElIxObcg-kCllJ9nkACRSs2uSwYWZQ,6356
6
+ sphinx_vite_builder/_internal/errors.py,sha256=28-cqrd-EujwGR5KAv4ivajuf1UsvoXYGT5d7qDY584,1505
7
+ sphinx_vite_builder/_internal/process.py,sha256=xBmC21kL00xta3YYH4OdmKwaWYQClCPyjoqLS7osX8M,8755
8
+ sphinx_vite_builder/_internal/vite.py,sha256=UwAa9vYgV5OH5iQlk6bVdGA7upRY1AOWsiyoGqEPOyI,12369
9
+ sphinx_vite_builder-0.0.1a16.dev0.dist-info/METADATA,sha256=rARfbqBtuiotl7-zOe24tef-k9Of_5yTntqMEvNVT1A,4012
10
+ sphinx_vite_builder-0.0.1a16.dev0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
11
+ sphinx_vite_builder-0.0.1a16.dev0.dist-info/entry_points.txt,sha256=PD4YaXZpK576ROxF4--oD7eFjjRgUX3Mk_C4B_dXvAU,62
12
+ sphinx_vite_builder-0.0.1a16.dev0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [sphinx.extensions]
2
+ sphinx-vite-builder = sphinx_vite_builder