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.
@@ -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
+ )
@@ -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)