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,390 @@
|
|
|
1
|
+
"""
|
|
2
|
+
sphinx_anywidget/_scraper.py
|
|
3
|
+
=============================
|
|
4
|
+
|
|
5
|
+
Generic Sphinx Gallery image scraper for any ``anywidget.AnyWidget`` subclass.
|
|
6
|
+
|
|
7
|
+
Drop-in replacement for the anyplotlib-specific ``_sg_html_scraper.ViewerScraper``.
|
|
8
|
+
Works with **any** library built on anywidget — just add the scraper to your
|
|
9
|
+
``sphinx_gallery_conf["image_scrapers"]``.
|
|
10
|
+
|
|
11
|
+
Interactive tagging
|
|
12
|
+
-------------------
|
|
13
|
+
If a code block's last expression line contains a ``# Interactive`` comment
|
|
14
|
+
(case-insensitive), the scraper:
|
|
15
|
+
|
|
16
|
+
* embeds the full example Python source in a ``<script type="text/x-python">``
|
|
17
|
+
tag so the Pyodide bridge can re-run it live;
|
|
18
|
+
* adds an **⚡ activation badge** to the figure iframe wrapper.
|
|
19
|
+
|
|
20
|
+
Example::
|
|
21
|
+
|
|
22
|
+
fig, ax = vw.subplots(1, 1, figsize=(640, 400))
|
|
23
|
+
ax.imshow(data)
|
|
24
|
+
fig # Interactive
|
|
25
|
+
|
|
26
|
+
Without the comment the figure is rendered as a plain static iframe with
|
|
27
|
+
no Pyodide wiring.
|
|
28
|
+
|
|
29
|
+
Usage in ``conf.py``::
|
|
30
|
+
|
|
31
|
+
from anyplotlib.sphinx_anywidget import AnywidgetScraper
|
|
32
|
+
|
|
33
|
+
sphinx_gallery_conf = {
|
|
34
|
+
"image_scrapers": (AnywidgetScraper(), "matplotlib"),
|
|
35
|
+
...
|
|
36
|
+
}
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
from __future__ import annotations
|
|
40
|
+
|
|
41
|
+
import json as _json
|
|
42
|
+
import re
|
|
43
|
+
import tempfile
|
|
44
|
+
from html import escape as _html_escape
|
|
45
|
+
from pathlib import Path
|
|
46
|
+
from uuid import uuid4
|
|
47
|
+
|
|
48
|
+
# Maximum iframe width (px) that fits inside the pydata-sphinx-theme column.
|
|
49
|
+
MAX_DOC_WIDTH = 684
|
|
50
|
+
|
|
51
|
+
# Sentinel that marks a code block as interactive.
|
|
52
|
+
_INTERACTIVE_RE = re.compile(r"#\s*interactive\s*$", re.IGNORECASE | re.MULTILINE)
|
|
53
|
+
|
|
54
|
+
# Pattern that extracts _PYODIDE_PACKAGES = [...] declarations from source.
|
|
55
|
+
_PYODIDE_PACKAGES_RE = re.compile(
|
|
56
|
+
r"^_PYODIDE_PACKAGES\s*=\s*(\[[^\]]*\])", re.MULTILINE
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
# Pattern that extracts _PYODIDE_MOCK_PACKAGES = [...] declarations from source.
|
|
60
|
+
# These are passed to micropip.add_mock_package() before the wheel install so
|
|
61
|
+
# that packages unavailable in Pyodide (e.g. dask, rosettasciio) are silently
|
|
62
|
+
# skipped during dependency resolution.
|
|
63
|
+
_PYODIDE_MOCK_PACKAGES_RE = re.compile(
|
|
64
|
+
r"^_PYODIDE_MOCK_PACKAGES\s*=\s*(\[[^\]]*\])", re.MULTILINE
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# ---------------------------------------------------------------------------
|
|
69
|
+
# Helpers
|
|
70
|
+
# ---------------------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
def _find_widget(globals_dict: dict):
|
|
73
|
+
"""Return the most-recently assigned anywidget in *globals_dict*, or None.
|
|
74
|
+
|
|
75
|
+
Accepts any object that:
|
|
76
|
+
* has a ``_repr_html_`` callable, and
|
|
77
|
+
* belongs to a class that either inherits from ``anywidget.AnyWidget``
|
|
78
|
+
(identified by the ``_esm`` attribute) or whose module starts with
|
|
79
|
+
``anywidget``.
|
|
80
|
+
"""
|
|
81
|
+
for val in reversed(list(globals_dict.values())):
|
|
82
|
+
if not callable(getattr(val, "_repr_html_", None)):
|
|
83
|
+
continue
|
|
84
|
+
# Check for anywidget fingerprint: _esm attribute
|
|
85
|
+
if hasattr(val, "_esm") and hasattr(val, "traits"):
|
|
86
|
+
return val
|
|
87
|
+
# Fallback: module check
|
|
88
|
+
module = getattr(type(val), "__module__", "") or ""
|
|
89
|
+
if "widget" in module.lower():
|
|
90
|
+
return val
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _make_thumbnail_png(widget) -> bytes:
|
|
95
|
+
"""Render *widget* in headless Chromium and return a dark-theme PNG screenshot."""
|
|
96
|
+
from anyplotlib.sphinx_anywidget._repr_utils import build_standalone_html
|
|
97
|
+
|
|
98
|
+
html = build_standalone_html(widget, resizable=False)
|
|
99
|
+
html = html.replace(
|
|
100
|
+
"renderFn({ model, el });",
|
|
101
|
+
"renderFn({ model, el }); window._aplReady = true;",
|
|
102
|
+
)
|
|
103
|
+
html = html.replace("background: transparent;", "background: #1e1e2e;")
|
|
104
|
+
|
|
105
|
+
with tempfile.NamedTemporaryFile(
|
|
106
|
+
suffix=".html", mode="w", encoding="utf-8", delete=False
|
|
107
|
+
) as fh:
|
|
108
|
+
fh.write(html)
|
|
109
|
+
tmp_path = Path(fh.name)
|
|
110
|
+
|
|
111
|
+
def _run_playwright(tmp_path: Path) -> bytes:
|
|
112
|
+
from playwright.sync_api import sync_playwright
|
|
113
|
+
|
|
114
|
+
with sync_playwright() as pw:
|
|
115
|
+
browser = pw.chromium.launch(
|
|
116
|
+
headless=True, args=["--no-sandbox", "--disable-setuid-sandbox"]
|
|
117
|
+
)
|
|
118
|
+
try:
|
|
119
|
+
page = browser.new_page()
|
|
120
|
+
page.emulate_media(color_scheme="dark")
|
|
121
|
+
page.goto(tmp_path.as_uri())
|
|
122
|
+
page.wait_for_function(
|
|
123
|
+
"() => window._aplReady === true", timeout=15_000
|
|
124
|
+
)
|
|
125
|
+
page.evaluate(
|
|
126
|
+
"() => new Promise(r =>"
|
|
127
|
+
" requestAnimationFrame(() => requestAnimationFrame(r)))"
|
|
128
|
+
)
|
|
129
|
+
return page.locator("#widget-root").screenshot()
|
|
130
|
+
finally:
|
|
131
|
+
page.close()
|
|
132
|
+
browser.close()
|
|
133
|
+
|
|
134
|
+
try:
|
|
135
|
+
import asyncio
|
|
136
|
+
import concurrent.futures
|
|
137
|
+
|
|
138
|
+
try:
|
|
139
|
+
loop = asyncio.get_running_loop()
|
|
140
|
+
except RuntimeError:
|
|
141
|
+
loop = None
|
|
142
|
+
|
|
143
|
+
if loop is not None and loop.is_running():
|
|
144
|
+
# Playwright sync API cannot be used inside a running asyncio loop.
|
|
145
|
+
# Run it in a separate thread where there is no event loop.
|
|
146
|
+
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
|
|
147
|
+
future = pool.submit(_run_playwright, tmp_path)
|
|
148
|
+
png_bytes = future.result()
|
|
149
|
+
else:
|
|
150
|
+
png_bytes = _run_playwright(tmp_path)
|
|
151
|
+
finally:
|
|
152
|
+
tmp_path.unlink(missing_ok=True)
|
|
153
|
+
|
|
154
|
+
return png_bytes
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _iframe_html(
|
|
158
|
+
src: str,
|
|
159
|
+
w: int,
|
|
160
|
+
h: int,
|
|
161
|
+
fig_id: str | None = None,
|
|
162
|
+
interactive: bool = False,
|
|
163
|
+
max_width: int | None = None,
|
|
164
|
+
max_height: int | None = None,
|
|
165
|
+
) -> str:
|
|
166
|
+
"""Return a single-line HTML snippet embedding *src* responsively.
|
|
167
|
+
|
|
168
|
+
Parameters
|
|
169
|
+
----------
|
|
170
|
+
src : str
|
|
171
|
+
Relative URL to the standalone widget HTML file.
|
|
172
|
+
w, h : int
|
|
173
|
+
Native pixel dimensions of the widget.
|
|
174
|
+
fig_id : str or None
|
|
175
|
+
Stable identifier; used as the ``data-awi-fig`` attribute.
|
|
176
|
+
interactive : bool
|
|
177
|
+
When True, renders the ⚡ activation badge.
|
|
178
|
+
max_width : int or None
|
|
179
|
+
Override the default ``MAX_DOC_WIDTH`` cap (pixels).
|
|
180
|
+
max_height : int or None
|
|
181
|
+
Maximum display height in pixels. When provided, the scale factor is
|
|
182
|
+
also constrained so the rendered iframe never exceeds this height.
|
|
183
|
+
"""
|
|
184
|
+
uid = fig_id or f"f{uuid4().hex[:8]}"
|
|
185
|
+
cap = max_width if max_width is not None else MAX_DOC_WIDTH
|
|
186
|
+
|
|
187
|
+
init_scale = min(1.0, cap / w)
|
|
188
|
+
if max_height is not None and h > 0:
|
|
189
|
+
init_scale = min(init_scale, max_height / h)
|
|
190
|
+
init_w = round(w * init_scale)
|
|
191
|
+
init_h = round(h * init_scale)
|
|
192
|
+
scale_css = f"{init_scale:.6f}".rstrip("0").rstrip(".")
|
|
193
|
+
|
|
194
|
+
# JS: re-scale on resize
|
|
195
|
+
resize_js = (
|
|
196
|
+
f"(function(){{"
|
|
197
|
+
f"var wrap=document.getElementById('{uid}'),"
|
|
198
|
+
f"ifr=wrap.querySelector('iframe'),"
|
|
199
|
+
f"nw={w},nh={h};"
|
|
200
|
+
f"function r(){{"
|
|
201
|
+
f"var avail=wrap.parentElement?wrap.parentElement.offsetWidth:0;"
|
|
202
|
+
f"if(!avail)return;"
|
|
203
|
+
f"var s=Math.min(1,avail/nw);"
|
|
204
|
+
f"wrap.style.width=Math.round(nw*s)+'px';"
|
|
205
|
+
f"wrap.style.height=Math.round(nh*s)+'px';"
|
|
206
|
+
f"ifr.style.transform='scale('+s+')';"
|
|
207
|
+
f"}}"
|
|
208
|
+
f"requestAnimationFrame(r);window.addEventListener('resize',r);"
|
|
209
|
+
f"}})()"
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
# Badge HTML — only the ⚡ button when interactive; nothing otherwise.
|
|
213
|
+
badge_parts = []
|
|
214
|
+
if interactive:
|
|
215
|
+
badge_parts.append(
|
|
216
|
+
f'<button class="awi-badge-icon awi-activate-btn" '
|
|
217
|
+
f'data-awi-fig="{uid}" '
|
|
218
|
+
f'title="Make interactive (boots Pyodide — may take ~10 s)">⚡</button>'
|
|
219
|
+
)
|
|
220
|
+
if not badge_parts:
|
|
221
|
+
badge_html = ""
|
|
222
|
+
else:
|
|
223
|
+
badge_html = (
|
|
224
|
+
f'<div class="awi-badge" data-awi-badge="{uid}">'
|
|
225
|
+
+ "".join(badge_parts)
|
|
226
|
+
+ "</div>"
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
return (
|
|
230
|
+
f'<div style="display:block;text-align:center;line-height:0;margin:12px 0;">'
|
|
231
|
+
f'<div id="{uid}" class="awi-fig-wrap" data-awi-fig="{uid}" '
|
|
232
|
+
f'style="display:inline-block;overflow:hidden;'
|
|
233
|
+
f'position:relative;width:{init_w}px;height:{init_h}px;">'
|
|
234
|
+
f'<iframe src="{src}" data-awi-fig="{uid}" frameborder="0" scrolling="no" '
|
|
235
|
+
f'style="width:{w}px;height:{h}px;border:none;overflow:hidden;display:block;'
|
|
236
|
+
f'transform-origin:top left;transform:scale({scale_css});'
|
|
237
|
+
f'position:absolute;top:0;left:0;">'
|
|
238
|
+
f'</iframe>'
|
|
239
|
+
f'{badge_html}'
|
|
240
|
+
f'</div>'
|
|
241
|
+
f'<script>{resize_js}</script>'
|
|
242
|
+
f'</div>'
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
# ---------------------------------------------------------------------------
|
|
247
|
+
# Scraper
|
|
248
|
+
# ---------------------------------------------------------------------------
|
|
249
|
+
|
|
250
|
+
class AnywidgetScraper:
|
|
251
|
+
"""Sphinx Gallery image scraper for any ``anywidget.AnyWidget`` subclass.
|
|
252
|
+
"""
|
|
253
|
+
|
|
254
|
+
def __init__(self):
|
|
255
|
+
# Maps src_file → list of fig_ids emitted so far (creation order).
|
|
256
|
+
self._example_figs: dict = {}
|
|
257
|
+
|
|
258
|
+
def __repr__(self) -> str:
|
|
259
|
+
return "AnywidgetScraper()"
|
|
260
|
+
|
|
261
|
+
def __call__(self, block, block_vars, gallery_conf):
|
|
262
|
+
globals_dict = block_vars.get("example_globals", {})
|
|
263
|
+
widget = _find_widget(globals_dict)
|
|
264
|
+
if widget is None:
|
|
265
|
+
return ""
|
|
266
|
+
|
|
267
|
+
src_file = str(block_vars.get("src_file", ""))
|
|
268
|
+
|
|
269
|
+
# ── detect # Interactive tag ──────────────────────────────────────
|
|
270
|
+
block_source = block[1] if isinstance(block, (list, tuple)) else ""
|
|
271
|
+
is_interactive = bool(_INTERACTIVE_RE.search(block_source))
|
|
272
|
+
|
|
273
|
+
# ── assign a stable fig_id and fig_index ─────────────────────────
|
|
274
|
+
if src_file not in self._example_figs:
|
|
275
|
+
self._example_figs[src_file] = []
|
|
276
|
+
fig_index = len(self._example_figs[src_file])
|
|
277
|
+
|
|
278
|
+
# ── 1. Write the thumbnail PNG ────────────────────────────────────
|
|
279
|
+
image_path_iterator = block_vars["image_path_iterator"]
|
|
280
|
+
png_path = Path(next(image_path_iterator))
|
|
281
|
+
png_path.parent.mkdir(parents=True, exist_ok=True)
|
|
282
|
+
png_path.write_bytes(_make_thumbnail_png(widget))
|
|
283
|
+
|
|
284
|
+
fig_id = png_path.stem # stable, unique stem from Sphinx Gallery
|
|
285
|
+
self._example_figs[src_file].append(fig_id)
|
|
286
|
+
|
|
287
|
+
# ── 2. Write the standalone HTML ──────────────────────────────────
|
|
288
|
+
try:
|
|
289
|
+
from anyplotlib.sphinx_anywidget._repr_utils import (
|
|
290
|
+
build_standalone_html, _widget_px,
|
|
291
|
+
)
|
|
292
|
+
docs_dir = Path(gallery_conf["src_dir"])
|
|
293
|
+
widgets_dir = docs_dir / "_static" / "viewer_widgets"
|
|
294
|
+
widgets_dir.mkdir(parents=True, exist_ok=True)
|
|
295
|
+
|
|
296
|
+
html_name = png_path.stem + ".html"
|
|
297
|
+
html_path = widgets_dir / html_name
|
|
298
|
+
|
|
299
|
+
inner_html = build_standalone_html(widget, resizable=False, fig_id=fig_id)
|
|
300
|
+
html_path.write_text(inner_html, encoding="utf-8")
|
|
301
|
+
w, h = _widget_px(widget)
|
|
302
|
+
have_html = True
|
|
303
|
+
except Exception as exc:
|
|
304
|
+
print(f"[sphinx_anywidget] WARNING: could not write iframe HTML: {exc}")
|
|
305
|
+
have_html = False
|
|
306
|
+
|
|
307
|
+
# ── 3. Return rST ─────────────────────────────────────────────────
|
|
308
|
+
if have_html:
|
|
309
|
+
try:
|
|
310
|
+
src_dir = Path(gallery_conf["src_dir"])
|
|
311
|
+
page_dir = png_path.parent.parent # strip /images
|
|
312
|
+
rel_parts = page_dir.relative_to(src_dir).parts
|
|
313
|
+
depth = len(rel_parts)
|
|
314
|
+
except Exception:
|
|
315
|
+
depth = 1
|
|
316
|
+
prefix = "../" * depth
|
|
317
|
+
src = f"{prefix}_static/viewer_widgets/{html_name}"
|
|
318
|
+
|
|
319
|
+
iframe_block = _iframe_html(
|
|
320
|
+
src, w, h,
|
|
321
|
+
fig_id=fig_id,
|
|
322
|
+
interactive=is_interactive,
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
rst = "\n\n.. raw:: html\n\n " + iframe_block + "\n\n"
|
|
326
|
+
|
|
327
|
+
if is_interactive:
|
|
328
|
+
# Embed the example Python source so the Pyodide bridge can
|
|
329
|
+
# re-execute it and wire live callbacks.
|
|
330
|
+
python_src = ""
|
|
331
|
+
try:
|
|
332
|
+
python_src = Path(src_file).read_text(encoding="utf-8")
|
|
333
|
+
except Exception:
|
|
334
|
+
pass
|
|
335
|
+
|
|
336
|
+
if python_src:
|
|
337
|
+
data_src = _html_escape(_json.dumps(python_src), quote=True)
|
|
338
|
+
|
|
339
|
+
# Detect _PYODIDE_PACKAGES = [...] in the source.
|
|
340
|
+
_pkg_attr = ""
|
|
341
|
+
m = _PYODIDE_PACKAGES_RE.search(python_src)
|
|
342
|
+
if m:
|
|
343
|
+
try:
|
|
344
|
+
import ast as _ast
|
|
345
|
+
pkgs = _ast.literal_eval(m.group(1))
|
|
346
|
+
if pkgs:
|
|
347
|
+
_pkg_attr = (
|
|
348
|
+
f' data-pyodide-packages='
|
|
349
|
+
f'"{_html_escape(_json.dumps(pkgs), quote=True)}"'
|
|
350
|
+
)
|
|
351
|
+
except Exception:
|
|
352
|
+
pass
|
|
353
|
+
|
|
354
|
+
# Detect _PYODIDE_MOCK_PACKAGES = [...] in the source.
|
|
355
|
+
_mock_attr = ""
|
|
356
|
+
m2 = _PYODIDE_MOCK_PACKAGES_RE.search(python_src)
|
|
357
|
+
if m2:
|
|
358
|
+
try:
|
|
359
|
+
import ast as _ast2
|
|
360
|
+
mock_pkgs = _ast2.literal_eval(m2.group(1))
|
|
361
|
+
if mock_pkgs:
|
|
362
|
+
_mock_attr = (
|
|
363
|
+
f' data-pyodide-mock-packages='
|
|
364
|
+
f'"{_html_escape(_json.dumps(mock_pkgs), quote=True)}"'
|
|
365
|
+
)
|
|
366
|
+
except Exception:
|
|
367
|
+
pass
|
|
368
|
+
|
|
369
|
+
python_block = (
|
|
370
|
+
f'<script type="text/x-python"'
|
|
371
|
+
f' data-fig-id="{fig_id}"'
|
|
372
|
+
f' data-fig-index="{fig_index}"'
|
|
373
|
+
f' data-src-file="{Path(src_file).stem}"'
|
|
374
|
+
f'{_pkg_attr}'
|
|
375
|
+
f'{_mock_attr}'
|
|
376
|
+
f' data-src="{data_src}"></script>'
|
|
377
|
+
)
|
|
378
|
+
rst += "\n\n.. raw:: html\n\n " + python_block + "\n\n"
|
|
379
|
+
|
|
380
|
+
return rst
|
|
381
|
+
else:
|
|
382
|
+
return (
|
|
383
|
+
f"\n\n.. image:: {png_path.name}\n"
|
|
384
|
+
f" :width: 100%\n\n"
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
# Back-compat alias used by the existing anyplotlib docs.
|
|
389
|
+
ViewerScraper = AnywidgetScraper
|
|
390
|
+
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""
|
|
2
|
+
sphinx_anywidget/_wheel_builder.py
|
|
3
|
+
====================================
|
|
4
|
+
|
|
5
|
+
Builds a project wheel at docs-build time so the Pyodide bridge can install
|
|
6
|
+
the exact library version that generated the docs — no PyPI release required.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
import subprocess
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def build_wheel(
|
|
17
|
+
static_dir: Path,
|
|
18
|
+
package_name: str,
|
|
19
|
+
project_root: Path,
|
|
20
|
+
) -> "Path | None":
|
|
21
|
+
"""Build a pure-Python wheel into *static_dir/wheels/*.
|
|
22
|
+
|
|
23
|
+
The wheel is renamed to ``{package_name}-0.0.0-py3-none-any.whl``.
|
|
24
|
+
``0.0.0`` is a valid PEP 440 sentinel micropip accepts for URL installs.
|
|
25
|
+
|
|
26
|
+
Parameters
|
|
27
|
+
----------
|
|
28
|
+
static_dir :
|
|
29
|
+
The docs ``_static`` directory; a ``wheels/`` sub-dir is created.
|
|
30
|
+
package_name :
|
|
31
|
+
PyPI / importable name (e.g. ``"anyplotlib"``).
|
|
32
|
+
project_root :
|
|
33
|
+
Directory containing ``pyproject.toml`` / ``setup.py``.
|
|
34
|
+
|
|
35
|
+
Returns
|
|
36
|
+
-------
|
|
37
|
+
Path or None
|
|
38
|
+
Path to the written wheel, or *None* on failure.
|
|
39
|
+
"""
|
|
40
|
+
wheels_dir = static_dir / "wheels"
|
|
41
|
+
wheels_dir.mkdir(parents=True, exist_ok=True)
|
|
42
|
+
|
|
43
|
+
# PEP 427 normalises distribution names: hyphens and dots → underscores.
|
|
44
|
+
normalised = re.sub(r"[-.]", "_", package_name)
|
|
45
|
+
|
|
46
|
+
stable = wheels_dir / f"{normalised}-0.0.0-py3-none-any.whl"
|
|
47
|
+
|
|
48
|
+
# Build into a temporary sub-directory so we never clobber the existing
|
|
49
|
+
# stable wheel until we know the new build actually succeeded.
|
|
50
|
+
import tempfile
|
|
51
|
+
with tempfile.TemporaryDirectory(dir=wheels_dir) as tmp_str:
|
|
52
|
+
tmp_dir = Path(tmp_str)
|
|
53
|
+
result = subprocess.run(
|
|
54
|
+
[
|
|
55
|
+
"uv", "build", "--wheel",
|
|
56
|
+
"--out-dir", str(tmp_dir),
|
|
57
|
+
str(project_root),
|
|
58
|
+
],
|
|
59
|
+
capture_output=True,
|
|
60
|
+
text=True,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
if result.returncode != 0:
|
|
64
|
+
print(
|
|
65
|
+
f"\n[sphinx_anywidget] WARNING: wheel build failed "
|
|
66
|
+
f"for {package_name!r}:\n{result.stderr}"
|
|
67
|
+
)
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
new_wheels = sorted(tmp_dir.glob(f"{normalised}*.whl"))
|
|
71
|
+
if not new_wheels:
|
|
72
|
+
print(f"\n[sphinx_anywidget] WARNING: no wheel found for {package_name!r}")
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
# Build succeeded — now replace the stable wheel atomically.
|
|
76
|
+
stable.unlink(missing_ok=True)
|
|
77
|
+
# Remove any other stale versioned wheels before moving the new one.
|
|
78
|
+
for old in wheels_dir.glob(f"{normalised}*.whl"):
|
|
79
|
+
old.unlink(missing_ok=True)
|
|
80
|
+
new_wheels[-1].rename(stable)
|
|
81
|
+
# ASCII only: Windows consoles (cp1252) can't print '→' during builds
|
|
82
|
+
print(f"[sphinx_anywidget] wheel -> {stable}")
|
|
83
|
+
return stable
|
|
84
|
+
|