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.
- sphinx_vite_builder/__init__.py +43 -0
- sphinx_vite_builder/_internal/__init__.py +7 -0
- sphinx_vite_builder/_internal/bus.py +191 -0
- sphinx_vite_builder/_internal/errors.py +42 -0
- sphinx_vite_builder/_internal/process.py +242 -0
- sphinx_vite_builder/_internal/vite.py +387 -0
- sphinx_vite_builder/build.py +94 -0
- sphinx_vite_builder/py.typed +0 -0
- sphinx_vite_builder-0.0.1a16.dev0.dist-info/METADATA +106 -0
- sphinx_vite_builder-0.0.1a16.dev0.dist-info/RECORD +12 -0
- sphinx_vite_builder-0.0.1a16.dev0.dist-info/WHEEL +4 -0
- sphinx_vite_builder-0.0.1a16.dev0.dist-info/entry_points.txt +2 -0
|
@@ -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,,
|