structile 6.0.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.
- structile/__init__.py +544 -0
- structile/__main__.py +57 -0
- structile/_dom_lite.py +128 -0
- structile/_log.py +33 -0
- structile/_paths.py +148 -0
- structile/_plugins.py +230 -0
- structile/config.py +272 -0
- structile/convert.py +247 -0
- structile/dom_lite.js +70 -0
- structile/interpreters.py +430 -0
- structile/render.py +535 -0
- structile/serialize.py +88 -0
- structile/static/structile.prod.html +188 -0
- structile/static/widget.js +185 -0
- structile/widget.py +406 -0
- structile-6.0.0.dist-info/METADATA +471 -0
- structile-6.0.0.dist-info/RECORD +20 -0
- structile-6.0.0.dist-info/WHEEL +5 -0
- structile-6.0.0.dist-info/licenses/LICENSE +201 -0
- structile-6.0.0.dist-info/top_level.txt +1 -0
structile/render.py
ADDED
|
@@ -0,0 +1,535 @@
|
|
|
1
|
+
"""The renderer layer — matplotlib-backend style: where an `open()`
|
|
2
|
+
call actually shows up (an inline widget, a browser tab, a written file, a
|
|
3
|
+
terminal summary, or nowhere) is decided once, here, rather than being
|
|
4
|
+
hardcoded to "inline Jupyter widget" the way the original single-path
|
|
5
|
+
version of this package was.
|
|
6
|
+
|
|
7
|
+
Every renderer produces the same thing: a `RenderHandle` — never `None`,
|
|
8
|
+
never the raw ipywidgets object — so callers always have `.path`, `.value`,
|
|
9
|
+
`.open()`, `.to_html()`, `.save(path)` available regardless of which
|
|
10
|
+
renderer actually ran. `.to_html()` (and everything built on it: `.open()`,
|
|
11
|
+
`.save()`, the `out=` kwarg) works the same way for every renderer, because
|
|
12
|
+
it doesn't depend on which one was picked — it always rebuilds the
|
|
13
|
+
STANDALONE viewer (settings bar, Phase A editing, undo/redo, client-side
|
|
14
|
+
Save) as one self-contained HTML file, by reusing structile.html
|
|
15
|
+
verbatim and injecting a small `window.__STRUCTILE_SNAPSHOT__` bootstrap before its
|
|
16
|
+
main <script> tag (see applyInitialPayload in structile.html) — the
|
|
17
|
+
exact same {text, format, name, interpreterSource?, config?} shape the
|
|
18
|
+
embedded-mode protocol already uses, just consumed without ever calling
|
|
19
|
+
enterEmbedMode(), so none of the standalone chrome gets hidden.
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import html as _html
|
|
24
|
+
import json
|
|
25
|
+
import os
|
|
26
|
+
import sys
|
|
27
|
+
import tempfile
|
|
28
|
+
import uuid
|
|
29
|
+
import webbrowser
|
|
30
|
+
from dataclasses import dataclass
|
|
31
|
+
from typing import Any, Dict, List, Optional, Union
|
|
32
|
+
|
|
33
|
+
from ._log import logger
|
|
34
|
+
from ._paths import find_viewer_html
|
|
35
|
+
|
|
36
|
+
RENDERERS = ("widget", "browser", "file", "none", "text")
|
|
37
|
+
|
|
38
|
+
# The one place in structile.html this module depends on the exact
|
|
39
|
+
# shape of: the main <script> tag, identified by the "use strict" that
|
|
40
|
+
# immediately follows it on the next line (the bare substring "<script>"
|
|
41
|
+
# alone also appears inside a couple of comments elsewhere in the file — see
|
|
42
|
+
# AGENTS.md's note on that CSP-injection lesson from the VS Code extension).
|
|
43
|
+
_SNAPSHOT_ANCHOR = '\n<script>\n"use strict";'
|
|
44
|
+
|
|
45
|
+
_temp_dir: Optional[str] = None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _session_temp_dir() -> str:
|
|
49
|
+
"""A stable per-process temp directory for generated snapshot HTML —
|
|
50
|
+
created lazily, on first use, and never cleaned up: the whole point of
|
|
51
|
+
`browser`/`file` rendering is that the file outlives this call (the
|
|
52
|
+
browser may not even have finished loading it by the time the process
|
|
53
|
+
exits), so auto-deleting it would be actively hostile."""
|
|
54
|
+
global _temp_dir
|
|
55
|
+
if _temp_dir is None:
|
|
56
|
+
_temp_dir = tempfile.mkdtemp(prefix="structile_")
|
|
57
|
+
return _temp_dir
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _write_session_temp(html_text: str) -> "os.PathLike":
|
|
61
|
+
import pathlib
|
|
62
|
+
|
|
63
|
+
p = pathlib.Path(_session_temp_dir()) / (uuid.uuid4().hex[:8] + ".html")
|
|
64
|
+
p.write_text(html_text, encoding="utf-8")
|
|
65
|
+
logger.debug("structile: wrote standalone HTML to session temp file %s", p)
|
|
66
|
+
return p
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# ---- auto-detection ----------------------------------------------------
|
|
70
|
+
def _in_rich_ipython_kernel() -> bool:
|
|
71
|
+
try:
|
|
72
|
+
from IPython import get_ipython
|
|
73
|
+
except ImportError:
|
|
74
|
+
return False
|
|
75
|
+
ip = get_ipython()
|
|
76
|
+
if ip is None:
|
|
77
|
+
return False
|
|
78
|
+
if type(ip).__name__ == "ZMQInteractiveShell": # Jupyter classic/lab, VS Code notebooks
|
|
79
|
+
return True
|
|
80
|
+
return "google.colab" in sys.modules # Colab's own shell class isn't ZMQInteractiveShell, but is just as rich
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _headless() -> bool:
|
|
84
|
+
if os.environ.get("SSH_CONNECTION"):
|
|
85
|
+
return True
|
|
86
|
+
if sys.platform.startswith("linux") and not (os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY")):
|
|
87
|
+
return True
|
|
88
|
+
try:
|
|
89
|
+
webbrowser.get()
|
|
90
|
+
except webbrowser.Error:
|
|
91
|
+
return True
|
|
92
|
+
return False
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def detect_renderer() -> str:
|
|
96
|
+
"""Auto-detect which renderer to use when nothing resolved one
|
|
97
|
+
explicitly (see `config.resolve_renderer`)."""
|
|
98
|
+
if os.environ.get("CI") or os.environ.get("PYTEST_CURRENT_TEST"):
|
|
99
|
+
logger.debug("structile: CI/PYTEST_CURRENT_TEST set, auto-detected renderer=none")
|
|
100
|
+
return "none" # stray debug calls in a test suite should never spawn browser tabs
|
|
101
|
+
if _in_rich_ipython_kernel():
|
|
102
|
+
logger.debug("structile: rich IPython kernel detected, auto-detected renderer=widget")
|
|
103
|
+
return "widget"
|
|
104
|
+
resolved = "file" if _headless() else "browser"
|
|
105
|
+
logger.debug("structile: auto-detected renderer=%r (headless=%s)", resolved, resolved == "file")
|
|
106
|
+
return resolved
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# ---- payload / standalone HTML ------------------------------------------
|
|
110
|
+
@dataclass
|
|
111
|
+
class Payload:
|
|
112
|
+
"""Everything needed to (re)build the standalone HTML for a render, or
|
|
113
|
+
to describe it in a repr/text summary — independent of which renderer
|
|
114
|
+
actually ran."""
|
|
115
|
+
|
|
116
|
+
text: str
|
|
117
|
+
format: str # "json" | "python" | "xml" | "html" | any other interpreter format | "" (format unknown — see interpreter_candidates_by_format)
|
|
118
|
+
name: str
|
|
119
|
+
# A single interpreter source, or a list of candidates for the browser
|
|
120
|
+
# to try itself (tryInterpreterCandidates in structile.html) —
|
|
121
|
+
# see open()'s markup-mode branch for when a list is built.
|
|
122
|
+
interpreter_source: Optional[Union[str, List[str]]] = None
|
|
123
|
+
# Set instead of interpreter_source (format left "") when the format
|
|
124
|
+
# itself isn't known ahead of time — a dict of {"xml": [...], "ini":
|
|
125
|
+
# [...], ...} (any mix of markup and other formats), tried by the
|
|
126
|
+
# browser itself via tryInterpreterCandidatesAnyFormat, which also
|
|
127
|
+
# determines the winning format. Deliberately never resolved in
|
|
128
|
+
# Python: doing so would need Node.js just to decide which format
|
|
129
|
+
# applies, and open() must not depend on Node for anything a
|
|
130
|
+
# browser can decide for itself.
|
|
131
|
+
interpreter_candidates_by_format: Optional[Dict[str, List[str]]] = None
|
|
132
|
+
config: Optional[Dict[str, Any]] = None
|
|
133
|
+
|
|
134
|
+
def to_snapshot_dict(self) -> Dict[str, Any]:
|
|
135
|
+
d: Dict[str, Any] = {"text": self.text, "format": self.format, "name": self.name}
|
|
136
|
+
if self.interpreter_source:
|
|
137
|
+
d["interpreterSource"] = self.interpreter_source
|
|
138
|
+
if self.interpreter_candidates_by_format:
|
|
139
|
+
d["interpreterCandidatesByFormat"] = self.interpreter_candidates_by_format
|
|
140
|
+
if self.config is not None:
|
|
141
|
+
d["config"] = self.config
|
|
142
|
+
return d
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _inject_snapshot(snapshot: Dict[str, Any]) -> str:
|
|
146
|
+
"""Splice an arbitrary `window.__STRUCTILE_SNAPSHOT__` payload into a fresh
|
|
147
|
+
read of structile.html, right before its main <script> tag.
|
|
148
|
+
Shared by `build_standalone_html` (single-value) and
|
|
149
|
+
`build_standalone_diff_html` (two-value, below) — both just build a
|
|
150
|
+
JSON-safe dict in the shape `applyInitialPayload`/`loadDiffPayload`
|
|
151
|
+
already know how to consume and hand it here."""
|
|
152
|
+
viewer_html = find_viewer_html().read_text(encoding="utf-8")
|
|
153
|
+
idx = viewer_html.find(_SNAPSHOT_ANCHOR)
|
|
154
|
+
if idx == -1:
|
|
155
|
+
raise RuntimeError(
|
|
156
|
+
"structile: could not find structile.html's main <script> tag "
|
|
157
|
+
"to inject the snapshot before — has the viewer's structure changed?"
|
|
158
|
+
)
|
|
159
|
+
# `<` -> <, same escaping the VS Code extension's jsonForScript uses,
|
|
160
|
+
# so a "</script"-shaped substring anywhere in the data can't break out
|
|
161
|
+
# of this <script> block.
|
|
162
|
+
snapshot_json = json.dumps(snapshot).replace("<", "\\u003c")
|
|
163
|
+
snippet = "\n<script>\nwindow.__STRUCTILE_SNAPSHOT__ = " + snapshot_json + ";\n</script>\n"
|
|
164
|
+
return viewer_html[:idx] + snippet + viewer_html[idx:]
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def build_standalone_html(payload: Payload) -> str:
|
|
168
|
+
return _inject_snapshot(payload.to_snapshot_dict())
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def build_standalone_diff_html(
|
|
172
|
+
left: Payload,
|
|
173
|
+
right: Payload,
|
|
174
|
+
*,
|
|
175
|
+
view: str = "unified",
|
|
176
|
+
key_columns: Optional[list] = None,
|
|
177
|
+
) -> str:
|
|
178
|
+
"""The diff-mode counterpart to `build_standalone_html` — a
|
|
179
|
+
`mode: "diff"` snapshot with both sides, per diff_plan.md's "Payload And
|
|
180
|
+
Protocol Shape". This is deliberately narrow: just enough to build the
|
|
181
|
+
standalone diff HTML from two already-prepared `Payload`s (used by
|
|
182
|
+
`scripts/build_samples.py` to regenerate `samples/diff_demo*.html`
|
|
183
|
+
without ever hand-copying viewer code into them) — not the full
|
|
184
|
+
`structile.diff()` Python entry point (renderer resolution, a
|
|
185
|
+
`DiffRenderHandle`, `key_columns=` auto-detection, ...), which is
|
|
186
|
+
diff_plan.md Phase 7 and not implemented yet.
|
|
187
|
+
|
|
188
|
+
`left`/`right` can be different formats and each carries its own
|
|
189
|
+
`interpreter_source` independently (structile.html's
|
|
190
|
+
`leftInterpreterSource`/`rightInterpreterSource` — see
|
|
191
|
+
`loadDiffPayload`) — there is no requirement that they match, or that
|
|
192
|
+
only one side is markup.
|
|
193
|
+
"""
|
|
194
|
+
snapshot: Dict[str, Any] = {
|
|
195
|
+
"mode": "diff",
|
|
196
|
+
"left": {"text": left.text, "format": left.format, "name": left.name},
|
|
197
|
+
"right": {"text": right.text, "format": right.format, "name": right.name},
|
|
198
|
+
}
|
|
199
|
+
if left.interpreter_source:
|
|
200
|
+
snapshot["leftInterpreterSource"] = left.interpreter_source
|
|
201
|
+
if right.interpreter_source:
|
|
202
|
+
snapshot["rightInterpreterSource"] = right.interpreter_source
|
|
203
|
+
if left.config is not None:
|
|
204
|
+
snapshot["config"] = left.config
|
|
205
|
+
if key_columns:
|
|
206
|
+
snapshot["keyColumns"] = list(key_columns)
|
|
207
|
+
if view and view != "unified":
|
|
208
|
+
snapshot["diff"] = {"view": view}
|
|
209
|
+
return _inject_snapshot(snapshot)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _describe_value(value: Any, fmt: str) -> str:
|
|
213
|
+
if fmt and fmt not in ("json", "python"):
|
|
214
|
+
return f"{fmt}, {len(value)} chars"
|
|
215
|
+
if isinstance(value, dict):
|
|
216
|
+
return f"dict, {len(value)} key{'' if len(value) == 1 else 's'}"
|
|
217
|
+
if isinstance(value, (list, tuple, set, frozenset)):
|
|
218
|
+
return f"{type(value).__name__}, {len(value)} item{'' if len(value) == 1 else 's'}"
|
|
219
|
+
if isinstance(value, str):
|
|
220
|
+
return f"str, {len(value)} chars"
|
|
221
|
+
return type(value).__name__
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _text_summary(value: Any, name: str, fmt: str, *, max_items: int = 12, max_depth: int = 2) -> str:
|
|
225
|
+
"""A compact terminal summary — type counts, top-level keys, a
|
|
226
|
+
truncated tree. Deliberately NOT a text rendering of the viewer's own 2D
|
|
227
|
+
packed layout (that's a browser-only concern); this is closer to what
|
|
228
|
+
`du -h --max-depth` or `df.info()` gives you: enough to sanity-check
|
|
229
|
+
what would be shown, not a substitute for actually looking at it."""
|
|
230
|
+
lines = [f"structile: {name or 'data'} ({_describe_value(value, fmt)})"]
|
|
231
|
+
if fmt and fmt not in ("json", "python"):
|
|
232
|
+
snippet = value.strip().replace("\n", " ")
|
|
233
|
+
lines.append(" " + (snippet[:200] + "..." if len(snippet) > 200 else snippet))
|
|
234
|
+
return "\n".join(lines)
|
|
235
|
+
|
|
236
|
+
def scalar(v: Any) -> str:
|
|
237
|
+
return repr(v) if not isinstance(v, (dict, list, tuple, set, frozenset)) else _describe_value(v, "json")
|
|
238
|
+
|
|
239
|
+
def walk(v: Any, depth: int, prefix: str) -> None:
|
|
240
|
+
if depth > max_depth:
|
|
241
|
+
return
|
|
242
|
+
if isinstance(v, dict):
|
|
243
|
+
keys = list(v.keys())
|
|
244
|
+
for k in keys[:max_items]:
|
|
245
|
+
child = v[k]
|
|
246
|
+
lines.append(f"{prefix}{k}: {scalar(child)}")
|
|
247
|
+
if isinstance(child, (dict, list, tuple, set, frozenset)):
|
|
248
|
+
walk(child, depth + 1, prefix + " ")
|
|
249
|
+
if len(keys) > max_items:
|
|
250
|
+
lines.append(f"{prefix}... (+{len(keys) - max_items} more keys)")
|
|
251
|
+
elif isinstance(v, (list, tuple, set, frozenset)):
|
|
252
|
+
items = list(v)
|
|
253
|
+
for i, child in enumerate(items[:max_items]):
|
|
254
|
+
lines.append(f"{prefix}[{i}]: {scalar(child)}")
|
|
255
|
+
if isinstance(child, (dict, list, tuple, set, frozenset)):
|
|
256
|
+
walk(child, depth + 1, prefix + " ")
|
|
257
|
+
if len(items) > max_items:
|
|
258
|
+
lines.append(f"{prefix}... (+{len(items) - max_items} more items)")
|
|
259
|
+
|
|
260
|
+
walk(value, 0, " ")
|
|
261
|
+
return "\n".join(lines)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
# ---- the handle ----------------------------------------------------------
|
|
265
|
+
class _RenderHandleBase:
|
|
266
|
+
"""Shared `.path`/HTML-caching/save/open/repr plumbing for `RenderHandle`
|
|
267
|
+
and `DiffRenderHandle` — every renderer path funnels through the same
|
|
268
|
+
"lazily build the standalone HTML once, then write-or-open it" sequence
|
|
269
|
+
regardless of whether there's one document or two; only the HTML-
|
|
270
|
+
building itself and the live-value/repr semantics genuinely differ (see
|
|
271
|
+
each subclass's `_build_html`/`__repr__`/`.value`-or-`.left_value`).
|
|
272
|
+
`_log_prefix` is the one piece of per-subclass text every log line below
|
|
273
|
+
needs ("structile" vs "structile.diff")."""
|
|
274
|
+
|
|
275
|
+
_log_prefix = "structile"
|
|
276
|
+
|
|
277
|
+
def __init__(self) -> None:
|
|
278
|
+
self.path: Optional["os.PathLike"] = None
|
|
279
|
+
self._html: Optional[str] = None
|
|
280
|
+
|
|
281
|
+
def _build_html(self) -> str:
|
|
282
|
+
raise NotImplementedError
|
|
283
|
+
|
|
284
|
+
def to_html(self) -> str:
|
|
285
|
+
"""The standalone viewer HTML for this render (settings bar, Phase
|
|
286
|
+
A editing, undo/redo, client-side Save) — built once, cached."""
|
|
287
|
+
if self._html is None:
|
|
288
|
+
self._html = self._build_html()
|
|
289
|
+
return self._html
|
|
290
|
+
|
|
291
|
+
def save(self, path) -> "os.PathLike":
|
|
292
|
+
"""Write the standalone HTML to `path` (any renderer). Returns
|
|
293
|
+
`path` as a `pathlib.Path`; also becomes `.path` if nothing had been
|
|
294
|
+
written yet."""
|
|
295
|
+
import pathlib
|
|
296
|
+
|
|
297
|
+
p = pathlib.Path(path)
|
|
298
|
+
p.write_text(self.to_html(), encoding="utf-8")
|
|
299
|
+
logger.info("%s: wrote standalone HTML to %s", self._log_prefix, p)
|
|
300
|
+
if self.path is None:
|
|
301
|
+
self.path = p
|
|
302
|
+
return p
|
|
303
|
+
|
|
304
|
+
def open(self) -> "os.PathLike":
|
|
305
|
+
"""Open the standalone HTML in a browser tab, generating it into
|
|
306
|
+
the session temp dir first if nothing's been written yet."""
|
|
307
|
+
if self.path is None:
|
|
308
|
+
self.path = _write_session_temp(self.to_html())
|
|
309
|
+
logger.info("%s: opening %s in a browser tab", self._log_prefix, self.path)
|
|
310
|
+
webbrowser.open(self.path.resolve().as_uri())
|
|
311
|
+
return self.path
|
|
312
|
+
|
|
313
|
+
def _repr_mimebundle_(self, include=None, exclude=None):
|
|
314
|
+
widget = getattr(self, "widget", None)
|
|
315
|
+
if widget is not None and hasattr(widget, "_repr_mimebundle_"):
|
|
316
|
+
return widget._repr_mimebundle_(include=include, exclude=exclude)
|
|
317
|
+
return {"text/plain": repr(self), "text/html": self._repr_html_()}
|
|
318
|
+
|
|
319
|
+
def _repr_html_(self) -> str:
|
|
320
|
+
text = _html.escape(repr(self))
|
|
321
|
+
if self.path is not None:
|
|
322
|
+
href = _html.escape(self.path.resolve().as_uri())
|
|
323
|
+
return f'<pre style="margin:0">{text}</pre><a href="{href}">{href}</a>'
|
|
324
|
+
return f'<pre style="margin:0">{text}</pre>'
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
class RenderHandle(_RenderHandleBase):
|
|
328
|
+
"""What every `open()` call returns, regardless of renderer.
|
|
329
|
+
|
|
330
|
+
`.value` is the displayed value (JSON-safe data, or raw XML text
|
|
331
|
+
when an `interpreter=` was used) — for the `widget` renderer this stays
|
|
332
|
+
live, updated on every Save (same as `StructileWidget.value` always
|
|
333
|
+
was); for every other renderer it's a static snapshot of what was
|
|
334
|
+
rendered, since there's no channel back from a plain browser tab/file to
|
|
335
|
+
Python. `.widget` is the underlying `StructileWidget` for the `widget`
|
|
336
|
+
renderer, `None` otherwise — an escape hatch for anywidget-specific
|
|
337
|
+
features (`.dirty`, `.dirty_count`, `.on_msg`, ...) this handle doesn't
|
|
338
|
+
itself proxy.
|
|
339
|
+
"""
|
|
340
|
+
|
|
341
|
+
_log_prefix = "structile"
|
|
342
|
+
|
|
343
|
+
def __init__(self, *, renderer: str, payload: Payload, value: Any, widget: Any = None):
|
|
344
|
+
super().__init__()
|
|
345
|
+
self.renderer = renderer
|
|
346
|
+
self._value = value
|
|
347
|
+
self.widget = widget
|
|
348
|
+
self._payload = payload
|
|
349
|
+
|
|
350
|
+
@property
|
|
351
|
+
def value(self) -> Any:
|
|
352
|
+
# Reads through to the live widget for the `widget` renderer, so a
|
|
353
|
+
# save in the browser (which updates StructileWidget.value — see
|
|
354
|
+
# widget.py's _handle_save) is visible here immediately, with no
|
|
355
|
+
# separate copy of this handle's own to fall out of sync. Every
|
|
356
|
+
# other renderer has no such live source, so it falls back to the
|
|
357
|
+
# snapshot captured at render() time.
|
|
358
|
+
if self.widget is not None:
|
|
359
|
+
return self.widget.value
|
|
360
|
+
return self._value
|
|
361
|
+
|
|
362
|
+
@value.setter
|
|
363
|
+
def value(self, new_value: Any) -> None:
|
|
364
|
+
self._value = new_value
|
|
365
|
+
|
|
366
|
+
def _build_html(self) -> str:
|
|
367
|
+
return build_standalone_html(self._payload)
|
|
368
|
+
|
|
369
|
+
def set_value(self, obj: Any, *, name: Optional[str] = None) -> None:
|
|
370
|
+
"""Replace the displayed value — only meaningful for the `widget`
|
|
371
|
+
renderer (every other renderer is a static, already-written
|
|
372
|
+
artifact)."""
|
|
373
|
+
if self.widget is None:
|
|
374
|
+
raise RuntimeError(
|
|
375
|
+
f"structile: set_value() needs the widget renderer (this call used renderer={self.renderer!r})"
|
|
376
|
+
)
|
|
377
|
+
self.widget.set_value(obj, name=name) # self.value reads through to this
|
|
378
|
+
|
|
379
|
+
def __repr__(self) -> str:
|
|
380
|
+
desc = _describe_value(self.value, self._payload.format)
|
|
381
|
+
return f"<structile: {desc} -> {self.path}>" if self.path else f"<structile: {desc}>"
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
class DiffRenderHandle(_RenderHandleBase):
|
|
385
|
+
"""Returned by `structile.diff()` — the two-sided counterpart to
|
|
386
|
+
`RenderHandle`, deliberately narrow: the diff GRAPH is always read-only
|
|
387
|
+
(there's no single `.value` the way a plain document has one — see
|
|
388
|
+
`.left_payload`/`.right_payload` for the static snapshot each side
|
|
389
|
+
started from), but for the `widget` renderer, `.left_value`/
|
|
390
|
+
`.right_value` read through to the live `StructileDiffWidget` the same
|
|
391
|
+
way `RenderHandle.value` reads through to `StructileWidget` — each
|
|
392
|
+
side's own source pane is independently editable/saveable in embedded
|
|
393
|
+
diff mode (structile.html's `performDiffSideSave`), even though
|
|
394
|
+
the graph itself never is."""
|
|
395
|
+
|
|
396
|
+
_log_prefix = "structile.diff"
|
|
397
|
+
|
|
398
|
+
def __init__(
|
|
399
|
+
self,
|
|
400
|
+
*,
|
|
401
|
+
left: Payload,
|
|
402
|
+
right: Payload,
|
|
403
|
+
view: str = "unified",
|
|
404
|
+
key_columns: Optional[list] = None,
|
|
405
|
+
widget: Any = None,
|
|
406
|
+
):
|
|
407
|
+
super().__init__()
|
|
408
|
+
self.left_payload = left
|
|
409
|
+
self.right_payload = right
|
|
410
|
+
self.view = view
|
|
411
|
+
self.key_columns = key_columns
|
|
412
|
+
self.widget = widget
|
|
413
|
+
|
|
414
|
+
@property
|
|
415
|
+
def left_value(self) -> Any:
|
|
416
|
+
return self.widget.left_value if self.widget is not None else None
|
|
417
|
+
|
|
418
|
+
@property
|
|
419
|
+
def right_value(self) -> Any:
|
|
420
|
+
return self.widget.right_value if self.widget is not None else None
|
|
421
|
+
|
|
422
|
+
def _build_html(self) -> str:
|
|
423
|
+
return build_standalone_diff_html(
|
|
424
|
+
self.left_payload, self.right_payload, view=self.view, key_columns=self.key_columns
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
def __repr__(self) -> str:
|
|
428
|
+
desc = f"{self.left_payload.name} vs {self.right_payload.name}"
|
|
429
|
+
return f"<structile diff: {desc} -> {self.path}>" if self.path else f"<structile diff: {desc}>"
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _resolve_and_validate_renderer(explicit: Optional[str], *, log_prefix: str) -> str:
|
|
433
|
+
resolved = explicit or detect_renderer()
|
|
434
|
+
if resolved not in RENDERERS:
|
|
435
|
+
raise ValueError(f"{log_prefix}: unknown renderer {resolved!r} (expected one of {RENDERERS!r})")
|
|
436
|
+
return resolved
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def _write_or_open_standalone(handle: _RenderHandleBase, resolved: str, auto_open: bool) -> None:
|
|
440
|
+
"""The `browser`/`file` half of `render()`/`render_diff()`'s dispatch —
|
|
441
|
+
identical for both (a `RenderHandle` and a `DiffRenderHandle` both know
|
|
442
|
+
how to lazily build+cache their own HTML via `to_html()`), so it's
|
|
443
|
+
shared rather than duplicated per caller. `widget`/`text`/`none` each
|
|
444
|
+
need caller-specific handling (a live widget, a value- or two-value-
|
|
445
|
+
shaped text summary, or nothing) and stay inline in each dispatcher."""
|
|
446
|
+
log_prefix = handle._log_prefix
|
|
447
|
+
if resolved == "browser":
|
|
448
|
+
if handle.path is None:
|
|
449
|
+
handle.path = _write_session_temp(handle.to_html())
|
|
450
|
+
if auto_open:
|
|
451
|
+
logger.info("%s: opening %s in a browser tab", log_prefix, handle.path)
|
|
452
|
+
webbrowser.open(handle.path.resolve().as_uri())
|
|
453
|
+
else:
|
|
454
|
+
logger.debug("%s: auto_open=False, wrote %s without opening it", log_prefix, handle.path)
|
|
455
|
+
elif resolved == "file":
|
|
456
|
+
if handle.path is None:
|
|
457
|
+
handle.path = _write_session_temp(handle.to_html())
|
|
458
|
+
logger.info("%s: wrote %s", log_prefix, handle.path)
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def render(
|
|
462
|
+
payload: Payload,
|
|
463
|
+
*,
|
|
464
|
+
value: Any,
|
|
465
|
+
renderer: Optional[str],
|
|
466
|
+
widget: Any = None,
|
|
467
|
+
out=None,
|
|
468
|
+
auto_open: bool = True,
|
|
469
|
+
) -> RenderHandle:
|
|
470
|
+
"""Dispatch a prepared `Payload` through the resolved renderer, always
|
|
471
|
+
returning a `RenderHandle`."""
|
|
472
|
+
resolved = _resolve_and_validate_renderer(renderer, log_prefix="structile")
|
|
473
|
+
logger.debug("structile: rendering %r (%s) via renderer=%r", payload.name, payload.format, resolved)
|
|
474
|
+
handle = RenderHandle(renderer=resolved, payload=payload, value=value, widget=widget if resolved == "widget" else None)
|
|
475
|
+
|
|
476
|
+
if out is not None:
|
|
477
|
+
handle.save(out)
|
|
478
|
+
|
|
479
|
+
# "widget": the widget itself is what actually renders; see
|
|
480
|
+
# _repr_mimebundle_. "none": render nothing beyond an explicit out=
|
|
481
|
+
# above.
|
|
482
|
+
_write_or_open_standalone(handle, resolved, auto_open)
|
|
483
|
+
if resolved == "text":
|
|
484
|
+
print(_text_summary(value, payload.name, payload.format))
|
|
485
|
+
|
|
486
|
+
return handle
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def _describe_payload_for_text(payload: Payload) -> str:
|
|
490
|
+
"""`_describe_value`, but for a `Payload` that might not have had its
|
|
491
|
+
text parsed back into a Python value yet (`diff()` never parses markup
|
|
492
|
+
text in Python — that's the browser's job) — falls back to a raw
|
|
493
|
+
char-count description for xml/html, same as `_text_summary` does."""
|
|
494
|
+
if payload.format and payload.format not in ("json", "python"):
|
|
495
|
+
return f"{payload.format}, {len(payload.text)} chars"
|
|
496
|
+
try:
|
|
497
|
+
value = json.loads(payload.text)
|
|
498
|
+
except (ValueError, TypeError):
|
|
499
|
+
return f"{payload.format}, {len(payload.text)} chars"
|
|
500
|
+
return _describe_value(value, payload.format)
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def render_diff(
|
|
504
|
+
left: Payload,
|
|
505
|
+
right: Payload,
|
|
506
|
+
*,
|
|
507
|
+
renderer: Optional[str],
|
|
508
|
+
view: str = "unified",
|
|
509
|
+
key_columns: Optional[list] = None,
|
|
510
|
+
widget: Any = None,
|
|
511
|
+
out=None,
|
|
512
|
+
auto_open: bool = True,
|
|
513
|
+
) -> DiffRenderHandle:
|
|
514
|
+
"""The `structile.diff()` counterpart to `render()` — dispatches a
|
|
515
|
+
prepared pair of `Payload`s through the resolved renderer, always
|
|
516
|
+
returning a `DiffRenderHandle`. `widget` (a `StructileDiffWidget`,
|
|
517
|
+
built by `diff()` itself — mirrors `render()`'s own `widget` param) is
|
|
518
|
+
only meaningful when `renderer == "widget"`."""
|
|
519
|
+
resolved = _resolve_and_validate_renderer(renderer, log_prefix="structile.diff")
|
|
520
|
+
logger.debug("structile.diff: rendering %r vs %r via renderer=%r", left.name, right.name, resolved)
|
|
521
|
+
handle = DiffRenderHandle(
|
|
522
|
+
left=left, right=right, view=view, key_columns=key_columns, widget=widget if resolved == "widget" else None
|
|
523
|
+
)
|
|
524
|
+
|
|
525
|
+
if out is not None:
|
|
526
|
+
handle.save(out)
|
|
527
|
+
|
|
528
|
+
# "widget": the widget itself is what actually renders; see
|
|
529
|
+
# _repr_mimebundle_. "none": render nothing beyond an explicit out=
|
|
530
|
+
# above.
|
|
531
|
+
_write_or_open_standalone(handle, resolved, auto_open)
|
|
532
|
+
if resolved == "text":
|
|
533
|
+
print(f"structile diff: {left.name} ({_describe_payload_for_text(left)}) vs {right.name} ({_describe_payload_for_text(right)})")
|
|
534
|
+
|
|
535
|
+
return handle
|
structile/serialize.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Convert Python values into the JSON-safe form structile.html expects.
|
|
2
|
+
|
|
3
|
+
structile.html keeps integers outside JavaScript's safe range
|
|
4
|
+
(|v| >= 2**53) exact by re-scanning the *text* of a JSON document for bare
|
|
5
|
+
integer literals as it parses (see jsonParsePreserveBigInt in
|
|
6
|
+
structile.html) and reviving them as BigInt. json.dumps already
|
|
7
|
+
emits Python's arbitrary-precision int as a bare numeric literal — exactly
|
|
8
|
+
the shape that scan is built to catch — so this module needs no marker of
|
|
9
|
+
its own for big integers; it only has to avoid the JSON-illegal shapes
|
|
10
|
+
(non-finite floats, non-string dict keys, unsupported types) that would
|
|
11
|
+
stop the text from parsing as JSON at all.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import math
|
|
16
|
+
import warnings
|
|
17
|
+
from typing import Any, Callable, Optional
|
|
18
|
+
|
|
19
|
+
try:
|
|
20
|
+
import numpy as _np
|
|
21
|
+
except ImportError: # numpy is optional
|
|
22
|
+
_np = None
|
|
23
|
+
|
|
24
|
+
_SCALAR_KEY_TYPES = (str, int, float, bool, type(None))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def normalize(value: Any, default: Optional[Callable[[Any], Any]] = None) -> Any:
|
|
28
|
+
"""Recursively convert a Python value into a JSON-safe structure.
|
|
29
|
+
|
|
30
|
+
Supported inputs: None, bool, int (any size), float, str, dict, list,
|
|
31
|
+
tuple, set, frozenset, and numpy scalars (if numpy is installed). Any
|
|
32
|
+
other type is passed to `default` (if given) and its return value
|
|
33
|
+
normalized in its place — same escape hatch as `json.dumps(...,
|
|
34
|
+
default=...)`, so e.g. `default=str` turns an otherwise-unsupported
|
|
35
|
+
object into whatever `str()` shows for it, instead of raising.
|
|
36
|
+
`default` can itself raise (or simply not be given) to keep today's
|
|
37
|
+
behavior for anything it doesn't recognize. Raises TypeError for
|
|
38
|
+
anything neither this nor `default` handles.
|
|
39
|
+
"""
|
|
40
|
+
if _np is not None and isinstance(value, _np.generic):
|
|
41
|
+
return normalize(value.item(), default)
|
|
42
|
+
if value is None or isinstance(value, bool):
|
|
43
|
+
return value
|
|
44
|
+
if isinstance(value, int):
|
|
45
|
+
return value
|
|
46
|
+
if isinstance(value, float):
|
|
47
|
+
return _normalize_float(value)
|
|
48
|
+
if isinstance(value, str):
|
|
49
|
+
return value
|
|
50
|
+
if isinstance(value, dict):
|
|
51
|
+
return {_normalize_key(k): normalize(v, default) for k, v in value.items()}
|
|
52
|
+
if isinstance(value, (list, tuple)):
|
|
53
|
+
return [normalize(v, default) for v in value]
|
|
54
|
+
if isinstance(value, (set, frozenset)):
|
|
55
|
+
try:
|
|
56
|
+
items = sorted(value)
|
|
57
|
+
except TypeError:
|
|
58
|
+
items = list(value) # unorderable elements — fall back to iteration order
|
|
59
|
+
return [normalize(v, default) for v in items]
|
|
60
|
+
if default is not None:
|
|
61
|
+
return normalize(default(value), default)
|
|
62
|
+
raise TypeError(
|
|
63
|
+
f"structile: don't know how to display a value of type {type(value).__name__!r} "
|
|
64
|
+
"(supported: None, bool, int, float, str, dict, list, tuple, set, "
|
|
65
|
+
"frozenset, and numpy scalars)"
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _normalize_key(key: Any) -> Any:
|
|
70
|
+
"""JSON object keys are always strings; json.dumps already stringifies
|
|
71
|
+
str/int/float/bool/None keys itself (exactly, via str() — no precision
|
|
72
|
+
loss even for a huge int key), so only genuinely non-scalar keys (e.g.
|
|
73
|
+
a tuple) need converting here."""
|
|
74
|
+
if _np is not None and isinstance(key, _np.generic):
|
|
75
|
+
return _normalize_key(key.item())
|
|
76
|
+
if isinstance(key, _SCALAR_KEY_TYPES):
|
|
77
|
+
return key
|
|
78
|
+
return str(key)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _normalize_float(value: float) -> Any:
|
|
82
|
+
if math.isfinite(value):
|
|
83
|
+
return value
|
|
84
|
+
warnings.warn(
|
|
85
|
+
f"structile: {value!r} has no JSON representation; showing null instead",
|
|
86
|
+
stacklevel=3,
|
|
87
|
+
)
|
|
88
|
+
return None
|