reglscatterpy 0.4.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.
- reglscatterpy/__init__.py +32 -0
- reglscatterpy/__main__.py +53 -0
- reglscatterpy/_compose.py +69 -0
- reglscatterpy/_export.py +315 -0
- reglscatterpy/_extract.py +271 -0
- reglscatterpy/_palettes.py +22 -0
- reglscatterpy/_payload.py +432 -0
- reglscatterpy/_widget.py +247 -0
- reglscatterpy/scatterplot.py +273 -0
- reglscatterpy/static/widget.js +700 -0
- reglscatterpy-0.4.0.dist-info/METADATA +292 -0
- reglscatterpy-0.4.0.dist-info/RECORD +14 -0
- reglscatterpy-0.4.0.dist-info/WHEEL +4 -0
- reglscatterpy-0.4.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""reglscatterpy - interactive WebGL scatterplots for single-cell data in Python.
|
|
2
|
+
|
|
3
|
+
The Python companion to the R package ``reglScatterplotR``. Both wrap the
|
|
4
|
+
``regl-scatterplot`` WebGL engine; this package adds AnnData / MuData /
|
|
5
|
+
SpatialData awareness so you can go from a scanpy object to an interactive
|
|
6
|
+
million-point plot in one call::
|
|
7
|
+
|
|
8
|
+
import scanpy as sc
|
|
9
|
+
import reglscatterpy as rs
|
|
10
|
+
|
|
11
|
+
adata = sc.datasets.pbmc3k_processed()
|
|
12
|
+
rs.scatterplot(adata, x="X_umap", color_by="louvain")
|
|
13
|
+
rs.scatterplot(adata, x="X_umap", color_by="CST3") # a gene
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from . import _export
|
|
17
|
+
from ._compose import compose
|
|
18
|
+
from ._export import record_html, save_html, save_notebook_html
|
|
19
|
+
from ._extract import PlotData, extract
|
|
20
|
+
from .scatterplot import scatterplot
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"scatterplot",
|
|
24
|
+
"compose",
|
|
25
|
+
"save_html",
|
|
26
|
+
"save_notebook_html",
|
|
27
|
+
"record_html",
|
|
28
|
+
"extract",
|
|
29
|
+
"PlotData",
|
|
30
|
+
"__version__",
|
|
31
|
+
]
|
|
32
|
+
__version__ = "0.4.0"
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Command-line report export: ``reglscatterpy-report notebook.ipynb``.
|
|
2
|
+
|
|
3
|
+
Convert a notebook to a self-contained, offline HTML report with every plot
|
|
4
|
+
baked in. By default it does NOT re-run the notebook (it uses the existing
|
|
5
|
+
outputs), so a heavy notebook isn't re-executed — run it once with
|
|
6
|
+
``reglscatterpy.record_html()`` at the top, then export. Use ``--execute`` to
|
|
7
|
+
re-run a notebook that wasn't recorded.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import sys
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def main(argv=None):
|
|
17
|
+
parser = argparse.ArgumentParser(
|
|
18
|
+
prog="reglscatterpy-report",
|
|
19
|
+
description="Convert a notebook to a self-contained, offline HTML report "
|
|
20
|
+
"with interactive reglscatterpy plots baked in.",
|
|
21
|
+
)
|
|
22
|
+
parser.add_argument("notebook", help="path to the .ipynb to export")
|
|
23
|
+
parser.add_argument(
|
|
24
|
+
"-o", "--output", default=None, help="output .html (default: notebook name)"
|
|
25
|
+
)
|
|
26
|
+
parser.add_argument(
|
|
27
|
+
"--execute",
|
|
28
|
+
action="store_true",
|
|
29
|
+
help="re-run the notebook before exporting (default: use existing outputs)",
|
|
30
|
+
)
|
|
31
|
+
parser.add_argument(
|
|
32
|
+
"--kernel", default="python3", help="kernel to use with --execute"
|
|
33
|
+
)
|
|
34
|
+
parser.add_argument(
|
|
35
|
+
"--timeout", type=int, default=600, help="per-cell timeout with --execute"
|
|
36
|
+
)
|
|
37
|
+
args = parser.parse_args(argv)
|
|
38
|
+
|
|
39
|
+
from ._export import save_notebook_html
|
|
40
|
+
|
|
41
|
+
out = save_notebook_html(
|
|
42
|
+
args.notebook,
|
|
43
|
+
args.output,
|
|
44
|
+
execute=args.execute,
|
|
45
|
+
kernel_name=args.kernel,
|
|
46
|
+
timeout=args.timeout,
|
|
47
|
+
)
|
|
48
|
+
print(f"wrote {out}")
|
|
49
|
+
return 0
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
if __name__ == "__main__": # pragma: no cover
|
|
53
|
+
sys.exit(main())
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Arrange several scatterplots in a linked grid.
|
|
2
|
+
|
|
3
|
+
``compose([...])`` lays out multiple :func:`scatterplot` widgets in a grid and
|
|
4
|
+
links them so panning/zooming one moves the others and a lasso selection is
|
|
5
|
+
mirrored across the group (handy for comparing embeddings of the same cells).
|
|
6
|
+
|
|
7
|
+
Linking reuses the widget's own ``syncPlots`` mechanism: each plot is given a
|
|
8
|
+
shared sync-group id, so the (shared, page-global) widget registry wires their
|
|
9
|
+
cameras and selections together client-side - no extra plumbing.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from math import ceil, sqrt
|
|
15
|
+
from typing import Optional, Sequence
|
|
16
|
+
|
|
17
|
+
__all__ = ["compose"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def compose(plots: Sequence, cols: Optional[int] = None, sync: bool = True):
|
|
21
|
+
"""Lay out scatterplot widgets in a linked grid.
|
|
22
|
+
|
|
23
|
+
Parameters
|
|
24
|
+
----------
|
|
25
|
+
plots
|
|
26
|
+
A list of widgets returned by :func:`scatterplot`.
|
|
27
|
+
cols
|
|
28
|
+
Number of columns (defaults to a near-square grid).
|
|
29
|
+
sync
|
|
30
|
+
When ``True`` (default), pan/zoom and lasso selection are synchronised
|
|
31
|
+
across all plots.
|
|
32
|
+
|
|
33
|
+
Returns
|
|
34
|
+
-------
|
|
35
|
+
An ``ipywidgets.GridBox`` containing the linked plots.
|
|
36
|
+
"""
|
|
37
|
+
try:
|
|
38
|
+
import ipywidgets
|
|
39
|
+
except ModuleNotFoundError as exc: # pragma: no cover - anywidget pulls ipywidgets
|
|
40
|
+
raise ModuleNotFoundError("compose() needs ipywidgets.") from exc
|
|
41
|
+
|
|
42
|
+
plots = list(plots)
|
|
43
|
+
if not plots:
|
|
44
|
+
raise ValueError("compose() needs at least one plot.")
|
|
45
|
+
if len(plots) > 16:
|
|
46
|
+
import warnings
|
|
47
|
+
warnings.warn(
|
|
48
|
+
f"compose() with {len(plots)} linked plots may be slow to render; "
|
|
49
|
+
"consider fewer or smaller plots.",
|
|
50
|
+
stacklevel=2,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
ids = [f"sp_compose_{i}" for i in range(len(plots))]
|
|
54
|
+
group = ids if sync else None
|
|
55
|
+
for w, pid in zip(plots, ids):
|
|
56
|
+
spec = dict(getattr(w, "_spec", {}) or {})
|
|
57
|
+
spec["plotId"] = pid
|
|
58
|
+
spec["syncPlots"] = group
|
|
59
|
+
spec["syncState"] = bool(sync)
|
|
60
|
+
w._spec = spec # re-renders the widget with the sync wiring
|
|
61
|
+
|
|
62
|
+
cols = cols or ceil(sqrt(len(plots)))
|
|
63
|
+
return ipywidgets.GridBox(
|
|
64
|
+
plots,
|
|
65
|
+
layout=ipywidgets.Layout(
|
|
66
|
+
grid_template_columns=f"repeat({cols}, 1fr)",
|
|
67
|
+
grid_gap="8px",
|
|
68
|
+
),
|
|
69
|
+
)
|
reglscatterpy/_export.py
ADDED
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
"""Export plots to self-contained, offline HTML — one plot or a whole notebook.
|
|
2
|
+
|
|
3
|
+
Jupyter widgets need a live kernel to render, so a *reopened* notebook can't show
|
|
4
|
+
the plot until you re-run the cell, and plain ``jupyter nbconvert --to html``
|
|
5
|
+
produces blank plots (the widget bundle isn't saved in the notebook). The
|
|
6
|
+
functions here work around that the way R's htmlwidgets do — by **inlining the
|
|
7
|
+
widget bundle and the plot's data** into the HTML — with NO R involved; it's pure
|
|
8
|
+
Python (gzip + base64 + templating).
|
|
9
|
+
|
|
10
|
+
Three entry points:
|
|
11
|
+
|
|
12
|
+
* :func:`save_html` — one plot to a standalone ``.html`` (like ``saveWidget``).
|
|
13
|
+
* :func:`record_html` — flip this on at the top of a notebook; every plot then
|
|
14
|
+
bakes a static, interactive copy into its *own cell output* as you run it. The
|
|
15
|
+
plots survive reopening the notebook and ``jupyter nbconvert --to html``
|
|
16
|
+
**with no re-execution**. (Trade-off: a recorded plot is one-way — pan/zoom/
|
|
17
|
+
lasso work, but ``w.selection`` no longer round-trips to Python. Turn it off
|
|
18
|
+
for the live round-trip.)
|
|
19
|
+
* :func:`save_notebook_html` — convert a whole notebook to one HTML report.
|
|
20
|
+
Defaults to ``execute=False``: it uses the existing (e.g. recorded) outputs,
|
|
21
|
+
so a heavy notebook is **not re-run**. Pass ``execute=True`` to re-run a
|
|
22
|
+
notebook that wasn't recorded.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import base64
|
|
28
|
+
import functools
|
|
29
|
+
import gzip
|
|
30
|
+
import html as _html
|
|
31
|
+
import json
|
|
32
|
+
import os
|
|
33
|
+
import pathlib
|
|
34
|
+
import uuid
|
|
35
|
+
import warnings
|
|
36
|
+
|
|
37
|
+
__all__ = ["save_html", "save_notebook_html", "record_html"]
|
|
38
|
+
|
|
39
|
+
_STATIC = pathlib.Path(__file__).parent / "static" / "widget.js"
|
|
40
|
+
|
|
41
|
+
_WIDGET_VIEW_MIME = "application/vnd.jupyter.widget-view+json"
|
|
42
|
+
|
|
43
|
+
# Static-render modes. _REPORT_REPR is flipped only inside an export kernel
|
|
44
|
+
# (save_notebook_html(execute=True)); _RECORD is flipped by the user via
|
|
45
|
+
# record_html() in a live notebook. Either makes ReglScatter emit a
|
|
46
|
+
# self-contained text/html snapshot instead of the live widget view. The normal
|
|
47
|
+
# interactive widget is unaffected unless one of these is on.
|
|
48
|
+
_REPORT_REPR = False
|
|
49
|
+
_RECORD = False
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _enable_report_repr():
|
|
53
|
+
"""Make widgets render as shared-bundle HTML snapshots (export kernel only)."""
|
|
54
|
+
global _REPORT_REPR
|
|
55
|
+
_REPORT_REPR = True
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _report_repr_enabled():
|
|
59
|
+
return _REPORT_REPR
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def record_html(enable=True):
|
|
63
|
+
"""Bake a static, offline copy of every plot into its cell output.
|
|
64
|
+
|
|
65
|
+
Call once near the top of a notebook::
|
|
66
|
+
|
|
67
|
+
import reglscatterpy as rs
|
|
68
|
+
rs.record_html()
|
|
69
|
+
|
|
70
|
+
From then on each ``scatterplot(...)`` displays a self-contained interactive
|
|
71
|
+
plot that is stored in the notebook output — so reopening the notebook or
|
|
72
|
+
running ``jupyter nbconvert --to html`` shows the plots with **no
|
|
73
|
+
re-execution**. Recorded plots are one-way (no ``w.selection`` round-trip);
|
|
74
|
+
call ``rs.record_html(False)`` to return to the live, kernel-linked widget.
|
|
75
|
+
"""
|
|
76
|
+
global _RECORD
|
|
77
|
+
_RECORD = bool(enable)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _record_enabled():
|
|
81
|
+
return _RECORD
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@functools.lru_cache(maxsize=1)
|
|
85
|
+
def _bundle_gz_b64():
|
|
86
|
+
# gzip then base64: the ~1.3 MB ESM bundle drops to ~0.5 MB inlined, vs
|
|
87
|
+
# ~1.75 MB for plain base64. Decompressed in-browser via DecompressionStream
|
|
88
|
+
# (baseline in all modern browsers since 2023).
|
|
89
|
+
return base64.b64encode(gzip.compress(_STATIC.read_bytes(), 9)).decode("ascii")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# Helper: turn the gzip+base64 bundle into a blob URL (a Promise) so import()
|
|
93
|
+
# can load it. Idempotent + shared across all plots in one document.
|
|
94
|
+
_LOADER_JS = (
|
|
95
|
+
"window.__rsLoad = window.__rsLoad || function(b64){"
|
|
96
|
+
"var bin = Uint8Array.from(atob(b64), function(c){return c.charCodeAt(0);});"
|
|
97
|
+
"var s = new Blob([bin]).stream().pipeThrough(new DecompressionStream('gzip'));"
|
|
98
|
+
"return new Response(s).arrayBuffer().then(function(buf){"
|
|
99
|
+
"return URL.createObjectURL(new Blob([buf], {type: 'text/javascript'}));});};"
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _bundle_call():
|
|
104
|
+
"""JS that decompresses the bundle once into the shared ``__rsBundleURL``."""
|
|
105
|
+
return (
|
|
106
|
+
'window.__rsBundleURL = window.__rsBundleURL || window.__rsLoad("'
|
|
107
|
+
+ _bundle_gz_b64()
|
|
108
|
+
+ '");'
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _state(widget):
|
|
113
|
+
spec = dict(getattr(widget, "_spec", {}) or {})
|
|
114
|
+
if not spec:
|
|
115
|
+
raise ValueError(
|
|
116
|
+
"This widget has no plot spec to export "
|
|
117
|
+
"(was it created by reglscatterpy.scatterplot?)."
|
|
118
|
+
)
|
|
119
|
+
height = int(getattr(widget, "_height", 500) or 500)
|
|
120
|
+
return {
|
|
121
|
+
"_spec": spec,
|
|
122
|
+
"_selection": [int(i) for i in (getattr(widget, "_selection", []) or [])],
|
|
123
|
+
"_width": int(getattr(widget, "_width", 0) or 0),
|
|
124
|
+
"_height": height,
|
|
125
|
+
}, height
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _fragment(widget, div_id=None):
|
|
129
|
+
"""An interactive plot as an HTML fragment that imports ``__rsBundleURL``."""
|
|
130
|
+
state, height = _state(widget)
|
|
131
|
+
div_id = div_id or ("rs_" + uuid.uuid4().hex[:12])
|
|
132
|
+
state_b64 = base64.b64encode(
|
|
133
|
+
json.dumps(state, separators=(",", ":")).encode("utf-8")
|
|
134
|
+
).decode("ascii")
|
|
135
|
+
return (
|
|
136
|
+
f'<div id="{div_id}" style="width:100%;height:{height}px"></div>\n'
|
|
137
|
+
'<script type="module">\n'
|
|
138
|
+
"(function(){\n"
|
|
139
|
+
f' const STATE = JSON.parse(atob("{state_b64}"));\n'
|
|
140
|
+
" const listeners = {};\n"
|
|
141
|
+
" const model = {\n"
|
|
142
|
+
" get: (k) => STATE[k],\n"
|
|
143
|
+
' set: (k, v) => { STATE[k] = v; (listeners["change:" + k] || []).forEach((f) => f()); },\n'
|
|
144
|
+
" save_changes: () => {},\n"
|
|
145
|
+
" on: (ev, cb) => { (listeners[ev] = listeners[ev] || []).push(cb); },\n"
|
|
146
|
+
" off: (ev, cb) => { if (listeners[ev]) listeners[ev] = listeners[ev].filter((f) => f !== cb); },\n"
|
|
147
|
+
" };\n"
|
|
148
|
+
f" Promise.resolve(window.__rsBundleURL).then((u) => import(u)).then((mod) => {{\n"
|
|
149
|
+
f' (mod.default || mod).render({{ model, el: document.getElementById("{div_id}") }});\n'
|
|
150
|
+
" });\n"
|
|
151
|
+
"})();\n"
|
|
152
|
+
"</script>"
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def report_fragment(widget):
|
|
157
|
+
"""Shared-bundle fragment (the bundle is injected once by the exporter)."""
|
|
158
|
+
return _fragment(widget)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def record_fragment(widget):
|
|
162
|
+
"""Self-contained fragment: carries the bundle so it works on reopen too.
|
|
163
|
+
|
|
164
|
+
Each recorded output inlines the (gzipped) bundle, but a runtime guard means
|
|
165
|
+
it is only decompressed once per page; :func:`save_notebook_html` later
|
|
166
|
+
de-dupes the repeated copies down to one in the report file.
|
|
167
|
+
"""
|
|
168
|
+
setup = "<script>" + _LOADER_JS + _bundle_call() + "</script>\n"
|
|
169
|
+
return setup + _fragment(widget)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
_PAGE = """<!doctype html>
|
|
173
|
+
<html lang="en">
|
|
174
|
+
<head>
|
|
175
|
+
<meta charset="utf-8">
|
|
176
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
177
|
+
<title>__TITLE__</title>
|
|
178
|
+
<style>html, body { margin: 0; padding: 0; background: __PAGEBG__; }</style>
|
|
179
|
+
<script>__LOADER__</script>
|
|
180
|
+
</head>
|
|
181
|
+
<body>
|
|
182
|
+
__FRAGMENT__
|
|
183
|
+
</body>
|
|
184
|
+
</html>
|
|
185
|
+
"""
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def save_html(widget, path, title="reglscatterpy plot"):
|
|
189
|
+
"""Write one plot to a standalone, offline HTML file (like ``saveWidget``)."""
|
|
190
|
+
spec = dict(getattr(widget, "_spec", {}) or {})
|
|
191
|
+
page = (
|
|
192
|
+
_PAGE.replace("__TITLE__", _html.escape(str(title)))
|
|
193
|
+
.replace("__PAGEBG__", str(spec.get("backgroundColor") or "#ffffff"))
|
|
194
|
+
.replace("__LOADER__", _LOADER_JS + _bundle_call())
|
|
195
|
+
.replace("__FRAGMENT__", _fragment(widget))
|
|
196
|
+
)
|
|
197
|
+
out = pathlib.Path(path)
|
|
198
|
+
out.write_text(page, encoding="utf-8")
|
|
199
|
+
return str(out)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _strip_widget_views(nb):
|
|
203
|
+
"""Drop widget-view outputs so nbconvert renders our text/html instead.
|
|
204
|
+
|
|
205
|
+
Returns the number of plot outputs that had a widget view but no recorded
|
|
206
|
+
text/html (those will be blank — the notebook wasn't run with record_html).
|
|
207
|
+
"""
|
|
208
|
+
unrecorded = 0
|
|
209
|
+
for cell in nb.get("cells", []):
|
|
210
|
+
if cell.get("cell_type") != "code":
|
|
211
|
+
continue
|
|
212
|
+
for out in cell.get("outputs", []):
|
|
213
|
+
data = out.get("data")
|
|
214
|
+
if not isinstance(data, dict) or _WIDGET_VIEW_MIME not in data:
|
|
215
|
+
continue
|
|
216
|
+
if "text/html" in data:
|
|
217
|
+
del data[_WIDGET_VIEW_MIME] # keep our static plot
|
|
218
|
+
else:
|
|
219
|
+
unrecorded += 1
|
|
220
|
+
return unrecorded
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def save_notebook_html(
|
|
224
|
+
notebook,
|
|
225
|
+
out_path=None,
|
|
226
|
+
execute=False,
|
|
227
|
+
kernel_name="python3",
|
|
228
|
+
timeout=600,
|
|
229
|
+
):
|
|
230
|
+
"""Convert a whole notebook to a self-contained HTML **report**.
|
|
231
|
+
|
|
232
|
+
Unlike plain ``jupyter nbconvert --to html`` (which leaves reglscatterpy
|
|
233
|
+
plots blank), this bakes every plot in as an interactive, kernel-free figure,
|
|
234
|
+
sharing **one** copy of the bundle across all plots.
|
|
235
|
+
|
|
236
|
+
Parameters
|
|
237
|
+
----------
|
|
238
|
+
notebook
|
|
239
|
+
Path to the ``.ipynb``.
|
|
240
|
+
out_path
|
|
241
|
+
Destination ``.html`` (defaults to the notebook name with ``.html``).
|
|
242
|
+
execute
|
|
243
|
+
Re-run the notebook before exporting. **Default ``False``** — it uses the
|
|
244
|
+
notebook's existing outputs, so a heavy notebook is *not* re-run. This
|
|
245
|
+
works when the notebook was run with :func:`record_html` (recommended).
|
|
246
|
+
Pass ``True`` to re-run a notebook that wasn't recorded.
|
|
247
|
+
kernel_name, timeout
|
|
248
|
+
Passed to the execute step when ``execute=True``.
|
|
249
|
+
|
|
250
|
+
Returns
|
|
251
|
+
-------
|
|
252
|
+
str
|
|
253
|
+
The path written.
|
|
254
|
+
"""
|
|
255
|
+
# Recursion guard: when this runs inside an export kernel (execute=True),
|
|
256
|
+
# any save_notebook_html call in the notebook itself is a safe no-op.
|
|
257
|
+
if os.environ.get("REGLSCATTERPY_EXPORTING") == "1":
|
|
258
|
+
return ""
|
|
259
|
+
|
|
260
|
+
try:
|
|
261
|
+
import nbformat
|
|
262
|
+
from nbconvert import HTMLExporter
|
|
263
|
+
except ModuleNotFoundError as exc: # pragma: no cover - optional dep
|
|
264
|
+
raise ModuleNotFoundError(
|
|
265
|
+
"save_notebook_html() needs nbconvert + nbformat (and, to execute, "
|
|
266
|
+
"nbclient + ipykernel). Install with: pip install 'reglscatterpy[report]'"
|
|
267
|
+
) from exc
|
|
268
|
+
|
|
269
|
+
nb_path = pathlib.Path(notebook)
|
|
270
|
+
nb = nbformat.read(str(nb_path), as_version=4)
|
|
271
|
+
|
|
272
|
+
if execute:
|
|
273
|
+
from nbconvert.preprocessors import ExecutePreprocessor
|
|
274
|
+
|
|
275
|
+
setup = nbformat.v4.new_code_cell(
|
|
276
|
+
"import reglscatterpy as _rs; _rs._export._enable_report_repr()"
|
|
277
|
+
)
|
|
278
|
+
setup.metadata["tags"] = ["rs-report-setup"]
|
|
279
|
+
nb.cells.insert(0, setup)
|
|
280
|
+
os.environ["REGLSCATTERPY_EXPORTING"] = "1"
|
|
281
|
+
try:
|
|
282
|
+
ExecutePreprocessor(timeout=timeout, kernel_name=kernel_name).preprocess(
|
|
283
|
+
nb, {"metadata": {"path": str(nb_path.parent)}}
|
|
284
|
+
)
|
|
285
|
+
finally:
|
|
286
|
+
os.environ.pop("REGLSCATTERPY_EXPORTING", None)
|
|
287
|
+
nb.cells = [
|
|
288
|
+
c
|
|
289
|
+
for c in nb.cells
|
|
290
|
+
if "rs-report-setup" not in c.get("metadata", {}).get("tags", [])
|
|
291
|
+
]
|
|
292
|
+
else:
|
|
293
|
+
unrecorded = _strip_widget_views(nb)
|
|
294
|
+
if unrecorded:
|
|
295
|
+
warnings.warn(
|
|
296
|
+
f"{unrecorded} plot(s) have no recorded HTML and will be blank. "
|
|
297
|
+
"Run the notebook with reglscatterpy.record_html() at the top, "
|
|
298
|
+
"or call save_notebook_html(..., execute=True).",
|
|
299
|
+
stacklevel=2,
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
body, _ = HTMLExporter().from_notebook_node(nb)
|
|
303
|
+
# De-dupe: collapse every inlined bundle copy to the one shared __rsBundleURL,
|
|
304
|
+
# then embed that single copy once.
|
|
305
|
+
gz_call = _bundle_call()
|
|
306
|
+
body = body.replace("<script>" + _LOADER_JS + gz_call + "</script>\n", "")
|
|
307
|
+
inject = "<script>" + _LOADER_JS + gz_call + "</script>"
|
|
308
|
+
if "</head>" in body:
|
|
309
|
+
body = body.replace("</head>", inject + "\n</head>", 1)
|
|
310
|
+
else: # pragma: no cover - HTMLExporter always emits a head
|
|
311
|
+
body = inject + body
|
|
312
|
+
|
|
313
|
+
out = pathlib.Path(out_path) if out_path else nb_path.with_suffix(".html")
|
|
314
|
+
out.write_text(body, encoding="utf-8")
|
|
315
|
+
return str(out)
|