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/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