plot3 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.
plot3/payload.py ADDED
@@ -0,0 +1,216 @@
1
+ """Serializable PlotPayload — the contract between stats and the viewer.
2
+
3
+ Remote/local split:
4
+
5
+ * **Data / stats side** (local or GPU) produces a ``PlotPayload`` via
6
+ :func:`build_payload` / :meth:`ggplot.to_payload`.
7
+ * **Viewer side** turns that payload into HTML via :func:`render_payload`
8
+ or a payload-backed figure via :meth:`ggplot.from_payload`.
9
+
10
+ A payload does not hold a DataFrame. It holds the already-encoded wire format
11
+ that the three.js template consumes (JSON spec + base64 binary blobs).
12
+
13
+ Schema (``kind == "figure"``)::
14
+
15
+ {
16
+ "v": 1,
17
+ "kind": "figure",
18
+ "spec": { ... }, # same dict as build_spec()[0]
19
+ "blobs": { "p0x": "<b64>", ... },
20
+ }
21
+
22
+ Faceted figures are still assembled by :func:`plot3.build.build_doc` as a
23
+ grid of independent figure payloads (each panel is a normal figure payload).
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import json
29
+ import re
30
+ import os
31
+ from pathlib import Path
32
+ from typing import Any
33
+
34
+ # Wire-format version for PlotPayload (independent of spec["v"]).
35
+ PAYLOAD_VERSION = 1
36
+
37
+ __all__ = [
38
+ "PAYLOAD_VERSION",
39
+ "build_payload",
40
+ "render_payload",
41
+ "validate_payload",
42
+ "payload_from_spec",
43
+ "payload_blobs_list",
44
+ "save_payload",
45
+ "load_payload",
46
+ "display_payload",
47
+ ]
48
+
49
+
50
+ def payload_from_spec(
51
+ spec: dict[str, Any],
52
+ payloads: list[tuple[str, str]],
53
+ ) -> dict[str, Any]:
54
+ """Build a figure PlotPayload from :func:`plot3.build.build_spec` output."""
55
+ blobs = {str(pid): str(b64) for pid, b64 in payloads}
56
+ return {
57
+ "v": PAYLOAD_VERSION,
58
+ "kind": "figure",
59
+ "spec": spec,
60
+ "blobs": blobs,
61
+ }
62
+
63
+
64
+ def payload_blobs_list(payload: dict[str, Any]) -> list[tuple[str, str]]:
65
+ """Restore ``build_spec``-style ``[(id, b64), ...]`` from a payload."""
66
+ blobs = payload.get("blobs") or {}
67
+ if not isinstance(blobs, dict):
68
+ raise TypeError("payload['blobs'] must be a dict")
69
+ return [(str(pid), str(b64)) for pid, b64 in blobs.items()]
70
+
71
+
72
+ def validate_payload(payload: Any) -> dict[str, Any]:
73
+ """Validate a PlotPayload dict; return it on success.
74
+
75
+ Raises ``TypeError`` / ``ValueError`` with actionable messages.
76
+ """
77
+ if not isinstance(payload, dict):
78
+ raise TypeError(f"PlotPayload must be a dict, got {type(payload).__name__}")
79
+ if payload.get("v") != PAYLOAD_VERSION:
80
+ raise ValueError(
81
+ f"unsupported PlotPayload version {payload.get('v')!r}; "
82
+ f"expected v={PAYLOAD_VERSION}"
83
+ )
84
+ kind = payload.get("kind")
85
+ if kind != "figure":
86
+ raise ValueError(
87
+ f"unsupported PlotPayload kind {kind!r}; expected 'figure'"
88
+ )
89
+ spec = payload.get("spec")
90
+ if not isinstance(spec, dict):
91
+ raise TypeError("PlotPayload['spec'] must be a dict")
92
+ for key in ("v", "is3d", "theme", "labs", "scales", "layers"):
93
+ if key not in spec:
94
+ raise ValueError(f"PlotPayload spec missing required key {key!r}")
95
+ if not isinstance(spec["layers"], list):
96
+ raise TypeError("PlotPayload spec['layers'] must be a list")
97
+ blobs = payload.get("blobs")
98
+ if not isinstance(blobs, dict):
99
+ raise TypeError("PlotPayload['blobs'] must be a dict")
100
+ for pid, b64 in blobs.items():
101
+ if not isinstance(pid, str) or not isinstance(b64, str):
102
+ raise TypeError("PlotPayload blobs must map str id -> str base64")
103
+ return payload
104
+
105
+
106
+ def build_payload(g) -> dict[str, Any]:
107
+ """Compute a PlotPayload for a single-panel (non-faceted) figure.
108
+
109
+ Runs stats / encoding where the data lives. Does not produce HTML.
110
+
111
+ Faceted figures (``facet_wrap``) should use :func:`plot3.build.build_doc`,
112
+ which builds one payload per panel. Calling this on a faceted ggplot
113
+ raises ``ValueError``.
114
+ """
115
+ if getattr(g, "facet", None) is not None:
116
+ raise ValueError(
117
+ "to_payload() / build_payload() do not support facet_wrap(); "
118
+ "use build_doc() / fig.html() which assembles panel payloads"
119
+ )
120
+ from plot3.build import build_spec
121
+
122
+ spec, payloads = build_spec(g)
123
+ return payload_from_spec(spec, payloads)
124
+
125
+
126
+ def script_json(value) -> str:
127
+ """JSON safe inside an HTML ``<script>``: ``</script>`` or ``<!--`` in a
128
+ title or a category cannot end the script, so data never becomes markup."""
129
+ text = json.dumps(value, separators=(",", ":"))
130
+ return (
131
+ text.replace("<", "\\u003c").replace(">", "\\u003e").replace("&", "\\u0026")
132
+ .replace("\u2028", "\\u2028").replace("\u2029", "\\u2029")
133
+ )
134
+
135
+
136
+ def render_payload(
137
+ payload: dict[str, Any],
138
+ *,
139
+ log: bool = True,
140
+ ) -> str:
141
+ """Turn a validated figure PlotPayload into a standalone HTML document.
142
+
143
+ This is the **viewer** half: no DataFrame access, only ``spec`` + ``blobs``.
144
+ """
145
+ from plot3.viewer import _DOC_TEMPLATE as DOC_TEMPLATE
146
+ from plot3.viewer import _KATEX_BOOT
147
+
148
+ payload = validate_payload(payload)
149
+ spec = payload["spec"]
150
+ blobs = payload["blobs"]
151
+
152
+ # Preserve insertion order from build_spec (viewer looks up by id).
153
+ blocks = "\n".join(
154
+ f'<script type="text/plain" id="{pid}">{b64}</script>'
155
+ for pid, b64 in blobs.items()
156
+ )
157
+ parts = {
158
+ "SPEC": script_json(spec),
159
+ "PAYLOADS": blocks,
160
+ "KATEX": _KATEX_BOOT if spec.get("math") else "",
161
+ }
162
+ # One pass: text from the figure (a title that says "__KATEX__") is
163
+ # never searched again for placeholders.
164
+ doc = re.sub(r"__(SPEC|PAYLOADS|KATEX)__", lambda m: parts[m.group(1)], DOC_TEMPLATE)
165
+ if log:
166
+ kb = len(doc) // 1024
167
+ # Sizes are opt-in (PLOT3_VERBOSE=1), so a notebook cell is just
168
+ # the plot.
169
+ if os.environ.get("PLOT3_VERBOSE", "").strip() == "1":
170
+ rows = sum(int(sp.get("n", 0)) for sp in spec.get("layers", []))
171
+ n_layers = len(spec.get("layers", []))
172
+ print(
173
+ f"plot3: {n_layers} layer(s), {rows:,} rows -> {kb:,} KB "
174
+ f"portable HTML{' (3D)' if spec.get('is3d') else ''}"
175
+ )
176
+ if kb > 1500:
177
+ print("plot3: the figure is over 1.5 MB of HTML")
178
+ return doc
179
+
180
+
181
+ def save_payload(payload: dict[str, Any], path: str | Path) -> str:
182
+ """Write a PlotPayload to a JSON file (remote → local handoff artifact)."""
183
+ payload = validate_payload(payload)
184
+ path = Path(path)
185
+ path.parent.mkdir(parents=True, exist_ok=True)
186
+ path.write_text(
187
+ json.dumps(payload, separators=(",", ":"), ensure_ascii=False),
188
+ encoding="utf-8",
189
+ )
190
+ return str(path.resolve())
191
+
192
+
193
+ def load_payload(path: str | Path) -> dict[str, Any]:
194
+ """Load and validate a PlotPayload JSON file."""
195
+ path = Path(path)
196
+ data = json.loads(path.read_text(encoding="utf-8"))
197
+ return validate_payload(data)
198
+
199
+
200
+ def display_payload(
201
+ payload: dict[str, Any],
202
+ *,
203
+ browser: bool | None = None,
204
+ path: str | Path | None = None,
205
+ height: str | int = "480px",
206
+ hide: bool | None = None,
207
+ ):
208
+ """Show a PlotPayload without a live DataFrame (local viewer half).
209
+
210
+ Builds a payload-backed :class:`~plot3.ggplot.ggplot` and calls
211
+ :meth:`~plot3.ggplot.ggplot.show`.
212
+ """
213
+ from plot3.ggplot import ggplot
214
+
215
+ fig = ggplot.from_payload(payload, height=height, hide=hide)
216
+ return fig.show(browser=browser, path=path)
plot3/remote.py ADDED
@@ -0,0 +1,220 @@
1
+ """CRAFT / GPU bridge: stats on the remote, render on the host.
2
+
3
+ Phase D of the remote/local split.
4
+
5
+ Host (SolveIt + CRAFT connected)
6
+ ``remote_run_`` executes Python on the GPU kernel.
7
+ :func:`fetch_remote_payload` runs a ggplot expression there, pulls only
8
+ the compact PlotPayload (via temp file + SSH), and the host calls
9
+ :func:`plot3.payload.display_payload` / :func:`render_payload`.
10
+
11
+ Remote kernel (``%gpu`` cell body)
12
+ ``ggplot(...)._repr_mimebundle_`` includes
13
+ ``application/vnd.plot3.v1+json`` so a future host can intercept the
14
+ payload; HTML remains the fallback for display.
15
+
16
+ Environment
17
+ ``PLOT3_REMOTE=1`` forces remote-kernel display behaviour (payload mime
18
+ preferred, lighter show path).
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ import os
25
+ import shlex
26
+ import uuid
27
+ from typing import Any, Callable
28
+
29
+ # Custom Jupyter mime type for PlotPayload handoff (host may ignore → HTML).
30
+ MIME_PLOT3 = "application/vnd.plot3.v1+json"
31
+
32
+ __all__ = [
33
+ "MIME_PLOT3",
34
+ "is_remote_kernel",
35
+ "has_craft_host",
36
+ "fetch_remote_payload",
37
+ "remote_ggplot_payload",
38
+ "show_remote",
39
+ ]
40
+
41
+
42
+ def is_remote_kernel() -> bool:
43
+ """True when this process should act as the GPU/remote side of the bridge.
44
+
45
+ Heuristics (any match):
46
+
47
+ * ``PLOT3_REMOTE=1`` / ``true`` / ``yes``
48
+ * ``CRAFT_REMOTE=1`` or ``GPU_KERNEL=1``
49
+ * IPython ns has CRAFT remote markers but **not** host ``remote_run_``
50
+ """
51
+ for key in ("PLOT3_REMOTE", "CRAFT_REMOTE", "GPU_KERNEL"):
52
+ raw = (os.environ.get(key) or "").strip().lower()
53
+ if raw in ("1", "true", "yes", "on"):
54
+ return True
55
+ try:
56
+ from IPython import get_ipython
57
+
58
+ ip = get_ipython()
59
+ ns = (getattr(ip, "user_ns", None) or {}) if ip is not None else {}
60
+ except Exception:
61
+ ns = {}
62
+ # Host CRAFT always exposes remote_run_ for talking *to* the GPU.
63
+ if callable(ns.get("remote_run_")):
64
+ return False
65
+ # Remote kernel often has GPU-oriented markers without host helpers.
66
+ for key in ("_craft_remote", "IS_GPU_KERNEL", "cuda", "CUDA_VISIBLE_DEVICES"):
67
+ if key in ns:
68
+ return True
69
+ if os.environ.get("CUDA_VISIBLE_DEVICES") is not None and not callable(
70
+ ns.get("remote_run_")
71
+ ):
72
+ # Weak signal — only if explicitly on a GPU box without host CRAFT.
73
+ if (os.environ.get("PLOT3_ASSUME_REMOTE") or "").strip().lower() in (
74
+ "1",
75
+ "true",
76
+ "yes",
77
+ ):
78
+ return True
79
+ return False
80
+
81
+
82
+ def has_craft_host() -> bool:
83
+ """True when the host IPython session can call ``remote_run_``."""
84
+ try:
85
+ from IPython import get_ipython
86
+
87
+ ip = get_ipython()
88
+ ns = (getattr(ip, "user_ns", None) or {}) if ip is not None else {}
89
+ except Exception:
90
+ return False
91
+ return callable(ns.get("remote_run_"))
92
+
93
+
94
+ def _remote_run() -> Callable[..., str]:
95
+ from IPython import get_ipython
96
+
97
+ ip = get_ipython()
98
+ if ip is None:
99
+ raise RuntimeError("fetch_remote_payload requires IPython")
100
+ rr = (ip.user_ns or {}).get("remote_run_")
101
+ if not callable(rr):
102
+ raise RuntimeError(
103
+ "remote_run_ missing — load CRAFT and run %gpu on the host first"
104
+ )
105
+ return rr
106
+
107
+
108
+ def fetch_remote_payload(
109
+ remote_source: str,
110
+ *,
111
+ max_chars: int = 8000,
112
+ ) -> dict[str, Any]:
113
+ """Execute *remote_source* on the GPU kernel and return a PlotPayload.
114
+
115
+ *remote_source* must leave a dict named ``_plot3_payload`` in scope
116
+ (or assign the final expression result). Implementation writes JSON to a
117
+ temp file on the remote and streams it back with SSH (same pattern as
118
+ :func:`plot3.jupyter.remote_df`), so large blobs are not limited by
119
+ ``remote_run_`` stdout caps.
120
+ """
121
+ from plot3.io import ssh_bytes
122
+ from plot3.payload import validate_payload
123
+
124
+ rr = _remote_run()
125
+ tmp = f"/tmp/plot3_payload_{uuid.uuid4().hex}.json"
126
+ # Indent remote body for the embedded script.
127
+ body = remote_source.strip()
128
+ if not body.endswith("\n"):
129
+ body += "\n"
130
+ code = f"""
131
+ import json as _json
132
+ from pathlib import Path as _Path
133
+ {body}
134
+ if "_plot3_payload" not in dir() and "_plot3_payload" not in locals():
135
+ raise RuntimeError(
136
+ "remote plot source must set _plot3_payload = fig.to_payload()"
137
+ )
138
+ _p = _Path({tmp!r})
139
+ _p.write_text(_json.dumps(_plot3_payload, separators=(",", ":")), encoding="utf-8")
140
+ print(_p.as_posix())
141
+ print("plot3_payload_bytes", _p.stat().st_size)
142
+ """
143
+ out = (rr(code, max_chars=max_chars) or "").strip()
144
+ # Path line: absolute or relative *.json written by the remote helper.
145
+ lines = [ln.strip() for ln in out.splitlines() if ln.strip()]
146
+ path_line = None
147
+ for ln in reversed(lines):
148
+ if not ln.endswith(".json"):
149
+ continue
150
+ if "plot3_payload" in ln or ln.startswith("/") or ln[1:3] == ":\\":
151
+ path_line = ln
152
+ break
153
+ if not path_line:
154
+ for ln in reversed(lines):
155
+ if ln.endswith(".json") and not ln.startswith("plot3_payload"):
156
+ path_line = ln
157
+ break
158
+ if not path_line:
159
+ raise RuntimeError(
160
+ "remote payload path not found in remote_run_ output:\n"
161
+ + (out[-800:] if out else "(empty)")
162
+ )
163
+ try:
164
+ raw = ssh_bytes("cat -- " + shlex.quote(path_line))
165
+ finally:
166
+ try:
167
+ ssh_bytes("rm -f -- " + shlex.quote(path_line))
168
+ except Exception:
169
+ pass
170
+ try:
171
+ data = json.loads(raw.decode("utf-8"))
172
+ except Exception as e:
173
+ raise RuntimeError(f"invalid remote PlotPayload JSON: {e}") from e
174
+ return validate_payload(data)
175
+
176
+
177
+ def remote_ggplot_payload(
178
+ expr: str,
179
+ *,
180
+ max_chars: int = 8000,
181
+ ) -> dict[str, Any]:
182
+ """Evaluate a ggplot expression on the remote kernel; return PlotPayload.
183
+
184
+ Example::
185
+
186
+ payload = remote_ggplot_payload(
187
+ "ggplot(df, aes(x='wt', y='mpg')) + geom_point()"
188
+ )
189
+ """
190
+ expr = expr.strip()
191
+ source = f"""
192
+ _fig = ({expr})
193
+ if not hasattr(_fig, "to_payload"):
194
+ raise TypeError(
195
+ f"remote expression must produce a ggplot, got {{type(_fig).__name__}}"
196
+ )
197
+ _plot3_payload = _fig.to_payload()
198
+ """
199
+ return fetch_remote_payload(source, max_chars=max_chars)
200
+
201
+
202
+ def show_remote(
203
+ expr: str,
204
+ *,
205
+ browser: bool | None = None,
206
+ height: str | int = "480px",
207
+ hide: bool | None = None,
208
+ max_chars: int = 8000,
209
+ ):
210
+ """Run *expr* on the remote kernel and display the figure on the host.
211
+
212
+ Stats / encoding run where the data lives; only the PlotPayload crosses
213
+ the wire. Viewer HTML is built locally.
214
+ """
215
+ from plot3.payload import display_payload
216
+
217
+ payload = remote_ggplot_payload(expr, max_chars=max_chars)
218
+ return display_payload(
219
+ payload, browser=browser, height=height, hide=hide
220
+ )