anyplotlib 0.1.0__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.
- anyplotlib/__init__.py +53 -0
- anyplotlib/_base_plot.py +229 -0
- anyplotlib/_electron.py +74 -0
- anyplotlib/_repr_utils.py +345 -0
- anyplotlib/_utils.py +194 -0
- anyplotlib/axes/__init__.py +6 -0
- anyplotlib/axes/_axes.py +510 -0
- anyplotlib/axes/_inset_axes.py +126 -0
- anyplotlib/callbacks.py +328 -0
- anyplotlib/embed.py +194 -0
- anyplotlib/figure/__init__.py +7 -0
- anyplotlib/figure/_figure.py +633 -0
- anyplotlib/figure/_gridspec.py +99 -0
- anyplotlib/figure/_subplots.py +100 -0
- anyplotlib/figure_esm.js +6011 -0
- anyplotlib/markers.py +704 -0
- anyplotlib/plot1d/__init__.py +6 -0
- anyplotlib/plot1d/_plot1d.py +1376 -0
- anyplotlib/plot1d/_plotbar.py +436 -0
- anyplotlib/plot2d/__init__.py +5 -0
- anyplotlib/plot2d/_plot2d.py +726 -0
- anyplotlib/plot2d/_plotmesh.py +116 -0
- anyplotlib/plot3d/__init__.py +4 -0
- anyplotlib/plot3d/_plot3d.py +524 -0
- anyplotlib/plotxy/__init__.py +4 -0
- anyplotlib/plotxy/_plotxy.py +273 -0
- anyplotlib/sphinx_anywidget/__init__.py +177 -0
- anyplotlib/sphinx_anywidget/_directive.py +245 -0
- anyplotlib/sphinx_anywidget/_repr_utils.py +298 -0
- anyplotlib/sphinx_anywidget/_scraper.py +390 -0
- anyplotlib/sphinx_anywidget/_wheel_builder.py +84 -0
- anyplotlib/sphinx_anywidget/static/anywidget_bridge.js +1099 -0
- anyplotlib/sphinx_anywidget/static/anywidget_overlay.css +100 -0
- anyplotlib/widgets/__init__.py +18 -0
- anyplotlib/widgets/_base.py +218 -0
- anyplotlib/widgets/_widgets1d.py +109 -0
- anyplotlib/widgets/_widgets2d.py +141 -0
- anyplotlib/widgets/_widgets3d.py +50 -0
- anyplotlib-0.1.0.dist-info/METADATA +160 -0
- anyplotlib-0.1.0.dist-info/RECORD +42 -0
- anyplotlib-0.1.0.dist-info/WHEEL +4 -0
- anyplotlib-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
"""
|
|
2
|
+
_plotxy.py — ``PlotXY``: a blank **data-coordinate 2-D axis**.
|
|
3
|
+
|
|
4
|
+
Where :class:`~anyplotlib.plot1d.Plot1D` is a curve over a (monotonic) x-axis,
|
|
5
|
+
``PlotXY`` is a coordinate canvas in the matplotlib sense: you set ``xlim`` /
|
|
6
|
+
``ylim`` (+ optional ``aspect="equal"``) and draw ``scatter`` / ``plot`` /
|
|
7
|
+
``fill`` / ``text`` as **collection-style artists** in data coordinates — the
|
|
8
|
+
surface orix's ``StereographicPlot`` (and an IPF triangle) needs.
|
|
9
|
+
|
|
10
|
+
It is built on ``Plot1D`` because the 1-D marker collections
|
|
11
|
+
(``add_points`` / ``add_lines`` / ``add_polygons`` / ``add_texts``) already map
|
|
12
|
+
through the 1-D data→canvas transform, which is exactly matplotlib's
|
|
13
|
+
``transLimits → transAxes`` (``_axisValToFrac`` for x + ``_valToPy1d`` for y →
|
|
14
|
+
unit box → panel rect). The primary curve is hidden (``alpha=0``); the markers
|
|
15
|
+
ARE the content. ``aspect="equal"`` is honoured by the renderer's xy-aspect step
|
|
16
|
+
(``state["aspect"]``); without it the data simply fills the panel (auto aspect).
|
|
17
|
+
|
|
18
|
+
Mirrors matplotlib: ``scatter`` → a ``PathCollection`` (offsets + per-point
|
|
19
|
+
colours/sizes), ``plot`` → ``Line2D``, ``fill`` → ``Polygon``, ``text`` →
|
|
20
|
+
``Text``.
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import numpy as np
|
|
25
|
+
|
|
26
|
+
from anyplotlib._utils import _build_colormap_lut
|
|
27
|
+
from anyplotlib.plot1d._plot1d import Plot1D
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _regular_grid_edges(x, y, rtol=1e-6):
|
|
31
|
+
"""If ``x``/``y`` corner grids are separable + axis-aligned, return the 1-D
|
|
32
|
+
edge vectors ``(xe, ye)``; otherwise ``None``.
|
|
33
|
+
|
|
34
|
+
"Separable + axis-aligned" means every row of ``x`` is identical and every
|
|
35
|
+
column of ``y`` is identical (the usual ``np.meshgrid`` of 1-D edges) — the
|
|
36
|
+
case a single stretched raster reproduces exactly. Spacing may be
|
|
37
|
+
non-uniform; ``drawImage`` only needs a common bounding box, and each cell
|
|
38
|
+
is one pixel, so non-uniform *edges* would distort — hence we additionally
|
|
39
|
+
require uniform spacing for the raster fast path (checked by the caller).
|
|
40
|
+
"""
|
|
41
|
+
if x.shape != y.shape or x.ndim != 2:
|
|
42
|
+
return None
|
|
43
|
+
xe = x[0, :]
|
|
44
|
+
ye = y[:, 0]
|
|
45
|
+
if not np.allclose(x, xe[None, :], rtol=rtol, atol=0.0):
|
|
46
|
+
return None
|
|
47
|
+
if not np.allclose(y, ye[:, None], rtol=rtol, atol=0.0):
|
|
48
|
+
return None
|
|
49
|
+
return xe, ye
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _is_uniform(v, rtol=1e-4):
|
|
53
|
+
"""True if 1-D ``v`` is monotonic with (near-)constant spacing."""
|
|
54
|
+
d = np.diff(np.asarray(v, float))
|
|
55
|
+
if d.size == 0 or not np.all(np.isfinite(d)):
|
|
56
|
+
return False
|
|
57
|
+
if np.any(d > 0) and np.any(d < 0):
|
|
58
|
+
return False
|
|
59
|
+
step = d.mean()
|
|
60
|
+
if step == 0:
|
|
61
|
+
return False
|
|
62
|
+
return bool(np.allclose(d, step, rtol=rtol, atol=abs(step) * rtol))
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _colseq(c, n):
|
|
66
|
+
"""A matplotlib-style colour arg → ``list[str]`` of length ``n`` (or None)."""
|
|
67
|
+
if c is None:
|
|
68
|
+
return None
|
|
69
|
+
if isinstance(c, str):
|
|
70
|
+
return [c] * n
|
|
71
|
+
return [str(x) for x in c]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class PlotXY(Plot1D):
|
|
75
|
+
"""Coordinate-only 2-D axis: ``scatter`` / ``plot`` / ``fill`` / ``text`` in
|
|
76
|
+
data coordinates, with ``set_xlim`` / ``set_ylim`` / ``set_aspect``."""
|
|
77
|
+
|
|
78
|
+
#: Heavy state keys routed to the geometry channel (see Figure._push).
|
|
79
|
+
#: ``raster_geom`` holds the base64 RGBA bytes of any ``add_raster`` /
|
|
80
|
+
#: ``pcolormesh`` raster (keyed by marker-set id), so re-aiming the view
|
|
81
|
+
#: never re-transmits the image. Declared here rather than on Plot1D so
|
|
82
|
+
#: plain line/bar panels keep the single-trait path (no empty geom trait).
|
|
83
|
+
_GEOM_KEYS = frozenset({"raster_geom"})
|
|
84
|
+
|
|
85
|
+
def __init__(self, *, xlim=(0.0, 1.0), ylim=(0.0, 1.0), aspect=None,
|
|
86
|
+
units: str = "", y_units: str = ""):
|
|
87
|
+
super().__init__(
|
|
88
|
+
np.zeros(2, dtype=float),
|
|
89
|
+
x_axis=np.asarray([xlim[0], xlim[1]], dtype=float),
|
|
90
|
+
units=units, y_units=y_units, alpha=0.0, # hidden primary curve
|
|
91
|
+
)
|
|
92
|
+
s = self._state
|
|
93
|
+
s["line_alpha"] = 0.0
|
|
94
|
+
s["data_min"] = float(ylim[0])
|
|
95
|
+
s["data_max"] = float(ylim[1])
|
|
96
|
+
s["y_range"] = [float(ylim[0]), float(ylim[1])]
|
|
97
|
+
s["aspect"] = "equal" if aspect == "equal" else None
|
|
98
|
+
|
|
99
|
+
# ── view (data limits = the transData domain) ────────────────────────────
|
|
100
|
+
def set_xlim(self, xmin: float, xmax: float) -> None:
|
|
101
|
+
self._state["x_axis"] = np.asarray([float(xmin), float(xmax)], dtype=float)
|
|
102
|
+
self._push()
|
|
103
|
+
|
|
104
|
+
def set_ylim(self, ymin: float, ymax: float) -> None:
|
|
105
|
+
self._state["y_range"] = [float(ymin), float(ymax)]
|
|
106
|
+
self._state["data_min"] = float(ymin)
|
|
107
|
+
self._state["data_max"] = float(ymax)
|
|
108
|
+
self._push()
|
|
109
|
+
|
|
110
|
+
def get_xlim(self) -> tuple:
|
|
111
|
+
x = self._state["x_axis"]
|
|
112
|
+
return (float(x[0]), float(x[-1]))
|
|
113
|
+
|
|
114
|
+
def get_ylim(self) -> tuple:
|
|
115
|
+
yr = self._state["y_range"]
|
|
116
|
+
return (float(yr[0]), float(yr[1]))
|
|
117
|
+
|
|
118
|
+
def set_aspect(self, aspect) -> None:
|
|
119
|
+
"""``"equal"`` → one data unit is the same pixel length on x and y
|
|
120
|
+
(matplotlib ``apply_aspect``); ``"auto"`` / ``None`` → fill the panel."""
|
|
121
|
+
self._state["aspect"] = "equal" if aspect == "equal" else None
|
|
122
|
+
self._push()
|
|
123
|
+
|
|
124
|
+
def get_aspect(self):
|
|
125
|
+
return self._state.get("aspect")
|
|
126
|
+
|
|
127
|
+
# ── matplotlib-parity artists (each returns its collection MarkerGroup) ──
|
|
128
|
+
def scatter(self, x, y, *, s=8, c=None, facecolors=None,
|
|
129
|
+
edgecolors="#1f77b4", alpha=1.0, name=None):
|
|
130
|
+
"""Data-coord scatter → a ``PathCollection``-style points group."""
|
|
131
|
+
x = np.asarray(x, float).ravel()
|
|
132
|
+
y = np.asarray(y, float).ravel()
|
|
133
|
+
offs = np.column_stack([x, y])
|
|
134
|
+
face = _colseq(c if c is not None else facecolors, len(x))
|
|
135
|
+
return self.add_points(offs, name=name, sizes=s, color=edgecolors,
|
|
136
|
+
facecolors=face, alpha=alpha)
|
|
137
|
+
|
|
138
|
+
def plot(self, x, y, *, color="#1f77b4", linewidth=1.5, name=None):
|
|
139
|
+
"""Data-coord polyline → a ``Line2D``-style lines group."""
|
|
140
|
+
pts = np.column_stack([np.asarray(x, float).ravel(),
|
|
141
|
+
np.asarray(y, float).ravel()])
|
|
142
|
+
segs = [[pts[i].tolist(), pts[i + 1].tolist()] for i in range(len(pts) - 1)]
|
|
143
|
+
return self.add_lines(segs, name=name, edgecolors=color, linewidths=linewidth)
|
|
144
|
+
|
|
145
|
+
def fill(self, x, y, *, facecolor=None, edgecolor="#1f77b4", alpha=0.3,
|
|
146
|
+
linewidth=1.5, name=None):
|
|
147
|
+
"""Data-coord filled polygon → a ``Polygon``-style group."""
|
|
148
|
+
verts = np.column_stack([np.asarray(x, float).ravel(),
|
|
149
|
+
np.asarray(y, float).ravel()]).tolist()
|
|
150
|
+
return self.add_polygons([verts], name=name, facecolors=facecolor,
|
|
151
|
+
edgecolors=edgecolor, alpha=alpha,
|
|
152
|
+
linewidths=linewidth)
|
|
153
|
+
|
|
154
|
+
def text(self, x, y, s, *, color="#000000", fontsize=12, name=None):
|
|
155
|
+
"""Data-coord text → a ``Text``-style group (one label)."""
|
|
156
|
+
return self.add_texts([[float(x), float(y)]], [str(s)], name=name,
|
|
157
|
+
color=color, fontsize=fontsize)
|
|
158
|
+
|
|
159
|
+
def pcolormesh(self, x, y, c, *, cmap="viridis", vmin=None, vmax=None,
|
|
160
|
+
edgecolor=None, alpha=1.0, clip_path=None, smooth=False,
|
|
161
|
+
name=None):
|
|
162
|
+
"""Data-coord quad mesh — matplotlib ``pcolormesh``.
|
|
163
|
+
|
|
164
|
+
``x``/``y`` are the ``(N+1, M+1)`` cell-corner grids and ``c`` the
|
|
165
|
+
``(N, M)`` field: either a scalar array (mapped through *cmap* between
|
|
166
|
+
*vmin*/*vmax*) or an array of CSS colour strings. Masked / non-finite
|
|
167
|
+
cells are skipped — so an ``orix`` pole-density histogram (masked
|
|
168
|
+
outside the fundamental sector) clips itself to the sector. Drawn as
|
|
169
|
+
one polygon ``MarkerGroup`` with per-cell face colours (a
|
|
170
|
+
``PathCollection``); the edges default to the face colour for a
|
|
171
|
+
seamless heatmap.
|
|
172
|
+
|
|
173
|
+
``clip_path`` is an optional ``(K, 2)`` data-coord polygon the mesh is
|
|
174
|
+
clipped to (matplotlib ``set_clip_path``) — pass the curved sector
|
|
175
|
+
boundary so the edge cells don't overflow it.
|
|
176
|
+
|
|
177
|
+
**Fast path.** When the grid is a regular, axis-aligned, uniformly
|
|
178
|
+
spaced mesh of a scalar field (the common heatmap / density-histogram
|
|
179
|
+
case), the whole mesh is rendered as one stretched RGBA raster
|
|
180
|
+
(:meth:`add_raster`) instead of one polygon per cell — orders of
|
|
181
|
+
magnitude less data to serialise and one ``drawImage`` to draw.
|
|
182
|
+
Irregular meshes, colour-string ``c``, or an explicit ``edgecolor``
|
|
183
|
+
fall back to the per-cell polygon path. ``smooth=True`` bilinearly
|
|
184
|
+
interpolates the raster for a smooth heat field (default is crisp
|
|
185
|
+
nearest-neighbour cells); it applies only on the raster fast path.
|
|
186
|
+
"""
|
|
187
|
+
x = np.asarray(x, float)
|
|
188
|
+
y = np.asarray(y, float)
|
|
189
|
+
cm = np.ma.asarray(c)
|
|
190
|
+
nr, nc = cm.shape
|
|
191
|
+
mask = np.ma.getmaskarray(cm)
|
|
192
|
+
|
|
193
|
+
if cm.dtype.kind in "fiub": # scalar field → LUT
|
|
194
|
+
vals = np.ma.filled(cm.astype(float), np.nan)
|
|
195
|
+
finite = vals[np.isfinite(vals)]
|
|
196
|
+
lo = float(vmin) if vmin is not None else (
|
|
197
|
+
float(finite.min()) if finite.size else 0.0)
|
|
198
|
+
hi = float(vmax) if vmax is not None else (
|
|
199
|
+
float(finite.max()) if finite.size else 1.0)
|
|
200
|
+
lut = _build_colormap_lut(cmap)
|
|
201
|
+
span = (hi - lo) or 1.0
|
|
202
|
+
|
|
203
|
+
# ── Raster fast path ────────────────────────────────────────────
|
|
204
|
+
edges = _regular_grid_edges(x, y) if edgecolor is None else None
|
|
205
|
+
if edges is not None:
|
|
206
|
+
xe, ye = edges
|
|
207
|
+
if _is_uniform(xe) and _is_uniform(ye):
|
|
208
|
+
return self._pcolormesh_raster(
|
|
209
|
+
xe, ye, vals, mask, lut, lo, span,
|
|
210
|
+
alpha=alpha, clip_path=clip_path, smooth=smooth,
|
|
211
|
+
name=name)
|
|
212
|
+
|
|
213
|
+
def _color(i, j):
|
|
214
|
+
v = vals[i, j]
|
|
215
|
+
if not np.isfinite(v):
|
|
216
|
+
return None
|
|
217
|
+
t = min(1.0, max(0.0, (v - lo) / span))
|
|
218
|
+
r, g, b = lut[int(round(t * 255))]
|
|
219
|
+
return f"#{r:02x}{g:02x}{b:02x}"
|
|
220
|
+
else: # already colour strings
|
|
221
|
+
def _color(i, j):
|
|
222
|
+
return None if mask[i, j] else str(cm[i, j])
|
|
223
|
+
|
|
224
|
+
verts, faces = [], []
|
|
225
|
+
for i in range(nr):
|
|
226
|
+
for j in range(nc):
|
|
227
|
+
if mask[i, j]:
|
|
228
|
+
continue
|
|
229
|
+
col = _color(i, j)
|
|
230
|
+
if col is None:
|
|
231
|
+
continue
|
|
232
|
+
verts.append([[x[i, j], y[i, j]],
|
|
233
|
+
[x[i + 1, j], y[i + 1, j]],
|
|
234
|
+
[x[i + 1, j + 1], y[i + 1, j + 1]],
|
|
235
|
+
[x[i, j + 1], y[i, j + 1]]])
|
|
236
|
+
faces.append(col)
|
|
237
|
+
|
|
238
|
+
edges = faces if edgecolor is None else edgecolor
|
|
239
|
+
return self.add_polygons(verts, name=name, facecolors=faces,
|
|
240
|
+
edgecolors=edges, alpha=alpha, linewidths=0.5,
|
|
241
|
+
clip_path=clip_path)
|
|
242
|
+
|
|
243
|
+
def _pcolormesh_raster(self, xe, ye, vals, mask, lut, lo, span, *,
|
|
244
|
+
alpha, clip_path, smooth, name):
|
|
245
|
+
"""Render a regular scalar mesh as one stretched RGBA raster.
|
|
246
|
+
|
|
247
|
+
``vals``/``mask`` are ``(nr, nc)``; ``xe``/``ye`` the ``nc+1``/``nr+1``
|
|
248
|
+
uniform edge vectors. Each cell maps to one pixel; colours come from
|
|
249
|
+
the same LUT / lo / span math as the polygon path so the two are
|
|
250
|
+
pixel-equivalent (up to per-cell quantisation).
|
|
251
|
+
"""
|
|
252
|
+
lut_arr = np.asarray(lut, dtype=np.uint8) # (256, 3)
|
|
253
|
+
opaque = (~mask) & np.isfinite(vals)
|
|
254
|
+
# NaN/masked cells become alpha 0; give them a safe index (0) so the
|
|
255
|
+
# cast never overflows — their RGB is invisible regardless.
|
|
256
|
+
t = np.clip((np.where(opaque, vals, lo) - lo) / span, 0.0, 1.0)
|
|
257
|
+
idx = np.rint(t * 255).astype(np.intp) # matches int(round(...))
|
|
258
|
+
rgba = np.zeros((*vals.shape, 4), dtype=np.uint8)
|
|
259
|
+
rgba[..., :3] = lut_arr[idx]
|
|
260
|
+
rgba[..., 3] = np.where(opaque, 255, 0).astype(np.uint8)
|
|
261
|
+
|
|
262
|
+
# Orient so image row 0 = highest data-y, col 0 = lowest data-x —
|
|
263
|
+
# add_raster stretches the image across ``extent`` with row 0 at the
|
|
264
|
+
# top (max y) and col 0 at the left (min x).
|
|
265
|
+
if ye[0] <= ye[-1]: # ascending y → flip rows so top = max y
|
|
266
|
+
rgba = rgba[::-1, :, :]
|
|
267
|
+
if xe[0] > xe[-1]: # descending x → flip cols so left = min x
|
|
268
|
+
rgba = rgba[:, ::-1, :]
|
|
269
|
+
|
|
270
|
+
extent = (float(min(xe[0], xe[-1])), float(max(xe[0], xe[-1])),
|
|
271
|
+
float(min(ye[0], ye[-1])), float(max(ye[0], ye[-1])))
|
|
272
|
+
return self.add_raster(np.ascontiguousarray(rgba), extent=extent,
|
|
273
|
+
clip_path=clip_path, smooth=smooth, name=name)
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""
|
|
2
|
+
sphinx_anywidget
|
|
3
|
+
================
|
|
4
|
+
|
|
5
|
+
A generic Sphinx extension that makes any ``anywidget.AnyWidget``-based
|
|
6
|
+
figure interactive in documentation pages — powered by Pyodide, with no
|
|
7
|
+
server or Jupyter kernel required.
|
|
8
|
+
|
|
9
|
+
Quick start (any project)
|
|
10
|
+
-------------------------
|
|
11
|
+
|
|
12
|
+
In your ``conf.py``::
|
|
13
|
+
|
|
14
|
+
extensions = [
|
|
15
|
+
"anyplotlib.sphinx_anywidget",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
# Package whose wheel is built and served to Pyodide at runtime.
|
|
19
|
+
anywidget_pyodide_package = "mypackage"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
The extension:
|
|
23
|
+
* builds a pure-Python wheel at docs-build time;
|
|
24
|
+
* injects ``anywidget_bridge.js`` (per-figure ⚡ badges + Pyodide boot);
|
|
25
|
+
* provides ``AnywidgetScraper`` for Sphinx Gallery (``# Interactive`` tag);
|
|
26
|
+
* registers ``.. anywidget-figure::`` RST directive.
|
|
27
|
+
|
|
28
|
+
Monkey-patch approach
|
|
29
|
+
---------------------
|
|
30
|
+
``anywidget_bridge.js`` patches ``AnyWidget.__init__`` in Pyodide to add a
|
|
31
|
+
``traitlets.observe(names=All)`` observer. When any ``sync=True`` trait
|
|
32
|
+
changes and the widget has ``_anywidget_fig_id`` set, the observer calls
|
|
33
|
+
``window._anywidgetPush(fig_id, name, value_str)`` which postMessages the
|
|
34
|
+
new state into the matching iframe — no library-side Pyodide code needed.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
from pathlib import Path
|
|
40
|
+
|
|
41
|
+
from anyplotlib.sphinx_anywidget._scraper import AnywidgetScraper, ViewerScraper # noqa: F401
|
|
42
|
+
|
|
43
|
+
_HERE = Path(__file__).parent
|
|
44
|
+
_STATIC_SRC = _HERE / "static"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def setup(app):
|
|
48
|
+
"""Register sphinx_anywidget with Sphinx."""
|
|
49
|
+
app.add_config_value("anywidget_pyodide_package", default=None, rebuild="html")
|
|
50
|
+
|
|
51
|
+
from anyplotlib.sphinx_anywidget._directive import AnywidgetFigureDirective
|
|
52
|
+
app.add_directive("anywidget-figure", AnywidgetFigureDirective)
|
|
53
|
+
|
|
54
|
+
app.connect("builder-inited", _copy_static_assets)
|
|
55
|
+
app.connect("builder-inited", _build_pyodide_wheel)
|
|
56
|
+
|
|
57
|
+
# anywidget_config.js is written dynamically by _build_pyodide_wheel;
|
|
58
|
+
# it must load BEFORE anywidget_bridge.js so _inferPackageName sees the name.
|
|
59
|
+
app.add_js_file("anywidget_config.js", loading_method="defer", priority=490)
|
|
60
|
+
app.add_js_file("anywidget_bridge.js", loading_method="defer", priority=500)
|
|
61
|
+
app.add_css_file("anywidget_overlay.css")
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
"version": "0.1.0",
|
|
65
|
+
"parallel_read_safe": True,
|
|
66
|
+
"parallel_write_safe": True,
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _copy_static_assets(app):
|
|
71
|
+
"""Add the extension's static/ dir to html_static_path."""
|
|
72
|
+
src_str = str(_STATIC_SRC)
|
|
73
|
+
if hasattr(app.config, "html_static_path"):
|
|
74
|
+
if src_str not in app.config.html_static_path:
|
|
75
|
+
app.config.html_static_path.append(src_str)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _build_pyodide_wheel(app):
|
|
79
|
+
"""Build the configured package wheel for the Pyodide bridge."""
|
|
80
|
+
pkg = getattr(app.config, "anywidget_pyodide_package", None)
|
|
81
|
+
if not pkg:
|
|
82
|
+
pkg = _infer_package_name(app)
|
|
83
|
+
|
|
84
|
+
conf_dir = Path(app.confdir)
|
|
85
|
+
static_dir = conf_dir / "_static"
|
|
86
|
+
static_dir.mkdir(parents=True, exist_ok=True)
|
|
87
|
+
|
|
88
|
+
import json as _json
|
|
89
|
+
import re as _re
|
|
90
|
+
|
|
91
|
+
if not pkg:
|
|
92
|
+
print(
|
|
93
|
+
"[sphinx_anywidget] WARNING: anywidget_pyodide_package not set; "
|
|
94
|
+
"Pyodide interactive mode disabled."
|
|
95
|
+
)
|
|
96
|
+
# Write a config that explicitly disables interactive mode so the
|
|
97
|
+
# bridge's heuristic package detection never runs.
|
|
98
|
+
config_js = (
|
|
99
|
+
"window._anywidgetPackage = null;\n"
|
|
100
|
+
"window._anywidgetInteractiveDisabled = true;\n"
|
|
101
|
+
)
|
|
102
|
+
(static_dir / "anywidget_config.js").write_text(config_js, encoding="utf-8")
|
|
103
|
+
return
|
|
104
|
+
|
|
105
|
+
# Write a tiny config script so anywidget_bridge.js can find the package
|
|
106
|
+
# name without fragile heuristics. Loaded before anywidget_bridge.js.
|
|
107
|
+
config_js = f"window._anywidgetPackage = {_json.dumps(pkg)};\n"
|
|
108
|
+
(static_dir / "anywidget_config.js").write_text(config_js, encoding="utf-8")
|
|
109
|
+
|
|
110
|
+
# If a stable wheel was already placed here (e.g. by the CI ``uv build``
|
|
111
|
+
# step that runs before sphinx-build), reuse it — but only when it is
|
|
112
|
+
# newer than every source file, otherwise the ⚡ interactive mode would
|
|
113
|
+
# run stale code.
|
|
114
|
+
normalised = _re.sub(r"[-.]", "_", pkg)
|
|
115
|
+
wheels_dir = static_dir / "wheels"
|
|
116
|
+
stable = wheels_dir / f"{normalised}-0.0.0-py3-none-any.whl"
|
|
117
|
+
if stable.exists():
|
|
118
|
+
project_root = _find_project_root(conf_dir)
|
|
119
|
+
pkg_dir = project_root / pkg
|
|
120
|
+
newest_src = 0.0
|
|
121
|
+
if pkg_dir.is_dir():
|
|
122
|
+
newest_src = max(
|
|
123
|
+
(f.stat().st_mtime for f in pkg_dir.rglob("*")
|
|
124
|
+
if f.suffix in (".py", ".js", ".css") and "tests" not in f.parts),
|
|
125
|
+
default=0.0,
|
|
126
|
+
)
|
|
127
|
+
if stable.stat().st_mtime >= newest_src:
|
|
128
|
+
# ASCII only: Windows consoles (cp1252) can't print '→'
|
|
129
|
+
print(f"[sphinx_anywidget] wheel up to date -> {stable}")
|
|
130
|
+
return
|
|
131
|
+
print("[sphinx_anywidget] wheel older than source -> rebuilding")
|
|
132
|
+
stable.unlink()
|
|
133
|
+
|
|
134
|
+
from anyplotlib.sphinx_anywidget._wheel_builder import build_wheel
|
|
135
|
+
project_root = _find_project_root(conf_dir)
|
|
136
|
+
build_wheel(static_dir, pkg, project_root)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _find_project_root(start: Path) -> Path:
|
|
140
|
+
"""Walk up from *start* to find the directory containing pyproject.toml or setup.py.
|
|
141
|
+
|
|
142
|
+
Falls back to ``start.parent`` for backwards compatibility so behaviour is
|
|
143
|
+
unchanged for the common ``docs/conf.py`` layout where the project root is
|
|
144
|
+
directly above the docs directory.
|
|
145
|
+
"""
|
|
146
|
+
markers = ("pyproject.toml", "setup.py", "setup.cfg")
|
|
147
|
+
current = start.resolve()
|
|
148
|
+
# Check start itself and each parent up to the filesystem root.
|
|
149
|
+
for directory in [current, *current.parents]:
|
|
150
|
+
if any((directory / m).exists() for m in markers):
|
|
151
|
+
return directory
|
|
152
|
+
# No marker found — fall back to conf_dir's parent (original behaviour).
|
|
153
|
+
return start.parent
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _infer_package_name(app) -> str | None:
|
|
157
|
+
"""Infer package name from pyproject.toml near conf.py."""
|
|
158
|
+
try:
|
|
159
|
+
import tomllib
|
|
160
|
+
except ImportError:
|
|
161
|
+
try:
|
|
162
|
+
import tomli as tomllib # type: ignore[no-redef]
|
|
163
|
+
except ImportError:
|
|
164
|
+
return None
|
|
165
|
+
conf_dir = Path(app.confdir)
|
|
166
|
+
for candidate in [conf_dir / "pyproject.toml", conf_dir.parent / "pyproject.toml"]:
|
|
167
|
+
if candidate.exists():
|
|
168
|
+
with open(candidate, "rb") as fh:
|
|
169
|
+
data = tomllib.load(fh)
|
|
170
|
+
name = (
|
|
171
|
+
data.get("project", {}).get("name")
|
|
172
|
+
or data.get("tool", {}).get("poetry", {}).get("name")
|
|
173
|
+
)
|
|
174
|
+
if name:
|
|
175
|
+
return name
|
|
176
|
+
return None
|
|
177
|
+
|