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 ADDED
@@ -0,0 +1,544 @@
1
+ """Display Python data through Structile, wherever you're running.
2
+
3
+ import structile as st
4
+ st.open({"a": 1, "b": [1, 2, 3]})
5
+ st.open("results.json")
6
+
7
+ `open()` renders through whichever renderer is active — an inline
8
+ Jupyter widget, a browser tab, a written HTML file, a terminal summary, or
9
+ nothing at all — matplotlib-backend style (see `render.py`). See README.md
10
+ for the full picture (renderers, install steps, supported types, current
11
+ limitations).
12
+ """
13
+ from __future__ import annotations
14
+
15
+ from importlib import metadata
16
+ import json
17
+ import pathlib
18
+ from typing import Any, Callable, Dict, Optional, Tuple, Union
19
+
20
+ from ._log import logger as _logger
21
+ from ._paths import (
22
+ InterpreterSource,
23
+ as_path_like as _as_path_like,
24
+ read_interpreter as _read_interpreter,
25
+ read_text as _read_text,
26
+ strict_json_loads as _strict_json_loads,
27
+ )
28
+ from .config import Options, get_option, options, options_from_dict, replace_options, reset_option, resolve_config, resolve_renderer, set_option, use
29
+ from .convert import convert
30
+ from .interpreters import (
31
+ CANONICAL_EXT_FOR_FORMAT as _CANONICAL_EXT_FOR_FORMAT,
32
+ InterpreterSpec,
33
+ MARKUP_FORMATS,
34
+ _normalize_ext,
35
+ get_registered_interpreter,
36
+ register_interpreter,
37
+ resolve_candidates as _resolve_interpreter_candidates,
38
+ unregister_interpreter,
39
+ )
40
+ from ._plugins import PluginRecord, load_plugins, plugins
41
+ from .render import (
42
+ DiffRenderHandle,
43
+ Payload as _Payload,
44
+ RENDERERS,
45
+ RenderHandle,
46
+ detect_renderer as _detect_renderer,
47
+ render as _render,
48
+ render_diff as _render_diff,
49
+ )
50
+ from .serialize import normalize
51
+ from .widget import (
52
+ StructileDiffWidget,
53
+ StructileWidget,
54
+ )
55
+
56
+ _VERSION_FILE = pathlib.Path(__file__).resolve().parent.parent / "VERSION"
57
+ if _VERSION_FILE.is_file():
58
+ __version__ = _VERSION_FILE.read_text(encoding="utf-8").strip()
59
+ else:
60
+ __version__ = metadata.version("structile")
61
+
62
+ __all__ = [
63
+ "open",
64
+ "diff",
65
+ "convert",
66
+ "normalize",
67
+ "StructileWidget",
68
+ "StructileDiffWidget",
69
+ "RenderHandle",
70
+ "DiffRenderHandle",
71
+ "RENDERERS",
72
+ "Options",
73
+ "options",
74
+ "options_from_dict",
75
+ "set_option",
76
+ "get_option",
77
+ "reset_option",
78
+ "replace_options",
79
+ "use",
80
+ "register_interpreter",
81
+ "unregister_interpreter",
82
+ "get_registered_interpreter",
83
+ "InterpreterSource",
84
+ "load_plugins",
85
+ "plugins",
86
+ "PluginRecord",
87
+ "__version__",
88
+ ]
89
+
90
+
91
+ def _load_path(path: Any) -> Tuple[Any, str, str, bool]:
92
+ """Returns `(data, name, raw_text, parsed_as_json)` — `raw_text` is the
93
+ file's literal, unmodified content; `parsed_as_json` tells the caller
94
+ whether `raw_text` itself IS a JSON document representing `data` (safe
95
+ to forward verbatim as source text) or whether `data` is really just
96
+ `raw_text` wrapped as a plain string (the "unrecognized extension falls
97
+ back to plain text" case below) — in which case `raw_text` is arbitrary
98
+ non-JSON content and must NOT be handed to the viewer as `format="json"`
99
+ source text (see `open()`'s own use of this)."""
100
+ p = pathlib.Path(path)
101
+ text = _read_text(p)
102
+ if p.suffix.lower() == ".json":
103
+ # strict_json_loads (not a bare json.loads): a NaN/Infinity/-Infinity
104
+ # literal anywhere in the document is something CPython's json
105
+ # module tolerates but structile.html's own JSON.parse-
106
+ # based parser does not — letting one through here would mean
107
+ # forwarding text below as trusted format="json" source that the
108
+ # viewer then fails to parse. Treating it as malformed (raising, same
109
+ # as any other invalid .json file) is consistent with this branch's
110
+ # existing "let a malformed .json file raise" rule below.
111
+ data = _strict_json_loads(text) # let a malformed .json file raise, rather than silently showing raw text
112
+ parsed_as_json = True
113
+ else:
114
+ try:
115
+ data = _strict_json_loads(text)
116
+ parsed_as_json = True
117
+ except ValueError:
118
+ data = text
119
+ parsed_as_json = False
120
+ return data, p.stem, text, parsed_as_json
121
+
122
+
123
+ def _is_markup_mode(ext: Optional[str], interpreter: Optional[InterpreterSpec], fmt: Optional[str]) -> bool:
124
+ """True when `obj`/a diff side is markup/interpreter mode rather than a
125
+ plain JSON/Python value: an explicit `interpreter=`, an explicit
126
+ `format=` other than "json"/"python", or a registered interpreter for
127
+ the inferred extension (so nothing registered + no `interpreter=` stays
128
+ plain-value mode)."""
129
+ if interpreter is not None or (fmt is not None and fmt not in ("json", "python")):
130
+ return True
131
+ return bool(ext) and get_registered_interpreter(ext) is not None
132
+
133
+
134
+ def _resolve_markup_source(
135
+ obj: Any,
136
+ *,
137
+ source_path: Optional[pathlib.Path],
138
+ ext: Optional[str],
139
+ interpreter: Optional[InterpreterSpec],
140
+ format: Optional[str],
141
+ fallback_name: str,
142
+ allow_format_unknown: bool,
143
+ error_prefix: str,
144
+ bad_obj_message: str,
145
+ unknown_format_message: str,
146
+ ) -> _Payload:
147
+ """Resolve one markup/interpreter-mode source — `open()`'s own
148
+ `obj`, or one side of `diff()` (`_resolve_diff_side`, below) — into a
149
+ `Payload` (`config` unset; only `open()` has one to set, after
150
+ the call). Shared so "classify -> load text -> infer format -> resolve
151
+ interpreter candidate(s)" lives once instead of twice.
152
+ `allow_format_unknown` is the one real fork between the callers: only
153
+ `open()` lets an interpreter dict spanning several extensions
154
+ resolve its format client-side (a diff side must already be
155
+ classifiable on its own)."""
156
+ if source_path is not None:
157
+ text = _read_text(source_path)
158
+ registered = bool(ext) and get_registered_interpreter(ext) is not None
159
+ # ".ini" -> "ini" once something's actually attached to it — see
160
+ # _is_markup_mode above.
161
+ fmt = format or MARKUP_FORMATS.get(ext) or (ext.lstrip(".") if ext and (interpreter is not None or registered) else None)
162
+ inferred_name = source_path.stem
163
+ elif isinstance(obj, str):
164
+ text, fmt, inferred_name = obj, format, fallback_name
165
+ else:
166
+ raise TypeError(bad_obj_message)
167
+
168
+ if fmt:
169
+ candidates = _resolve_interpreter_candidates(interpreter, ext or _CANONICAL_EXT_FOR_FORMAT.get(fmt))
170
+ if not candidates:
171
+ raise ValueError(
172
+ f"{error_prefix}: no interpreter registered or provided for {fmt!r} data — pass interpreter=, "
173
+ "or call register_interpreter(ext, ...) once at import time"
174
+ )
175
+ # Every renderer (including widget) only ever forwards the
176
+ # interpreter source(s) on to be run client-side — none of them need
177
+ # Python to have actually RUN it, so no Node is needed here
178
+ # regardless of candidate count.
179
+ sources = [_read_interpreter(c) for c in candidates]
180
+ interpreter_source = sources[0] if len(sources) == 1 else sources
181
+ return _Payload(text=text, format=fmt, name=inferred_name, interpreter_source=interpreter_source)
182
+
183
+ if allow_format_unknown and isinstance(interpreter, dict) and interpreter and source_path is None:
184
+ # No format= and nothing to infer one from a path — build a
185
+ # {format: [sources...]} map for every entry in the dict and let the
186
+ # BROWSER itself figure out which one applies
187
+ # (tryInterpreterCandidatesAnyFormat in structile.html),
188
+ # instead of resolving it here. Doing this in Python would need
189
+ # Node.js just to decide which format applies — open() must
190
+ # never need Node for anything a browser can just as well decide for
191
+ # itself. A markup extension's format comes from MARKUP_FORMATS (its
192
+ # DOM mode); any other extension's format is just itself (".ini" ->
193
+ # "ini") — every entry is a candidate now, not just recognized-markup
194
+ # ones.
195
+ candidates_by_format: Dict[str, list] = {}
196
+ for ext_key in interpreter.keys():
197
+ ext_norm = _normalize_ext(ext_key)
198
+ fmt_for_ext = MARKUP_FORMATS.get(ext_norm) or ext_norm.lstrip(".")
199
+ cands = _resolve_interpreter_candidates(interpreter, ext_norm)
200
+ if cands:
201
+ candidates_by_format.setdefault(fmt_for_ext, []).extend(_read_interpreter(c) for c in cands)
202
+ if not candidates_by_format:
203
+ raise ValueError(f"{error_prefix}: interpreter dict has no usable entries")
204
+ return _Payload(text=text, format="", name=inferred_name, interpreter_candidates_by_format=candidates_by_format)
205
+
206
+ raise ValueError(unknown_format_message)
207
+
208
+
209
+ def open(
210
+ obj: Any,
211
+ *,
212
+ name: Optional[str] = None,
213
+ height: int = 600,
214
+ config: Optional[Union[Options, Dict[str, Any]]] = None,
215
+ interpreter: Optional[InterpreterSpec] = None,
216
+ format: Optional[str] = None,
217
+ path: Optional[Union[str, pathlib.Path]] = None,
218
+ renderer: Optional[str] = None,
219
+ out: Optional[Union[str, pathlib.Path]] = None,
220
+ auto_open: bool = True,
221
+ default: Optional[Callable[[Any], Any]] = None,
222
+ **settings: Any,
223
+ ) -> RenderHandle:
224
+ """Display a Python value through the active renderer. Always returns a
225
+ `RenderHandle` (never `None`) with `.path`, `.value`, `.open()`,
226
+ `.to_html()`, `.save(path)`, and a one-line `repr()` — evaluate it as
227
+ the last expression in a Jupyter cell, or pass it to
228
+ `IPython.display.display`, and it renders itself richly there too.
229
+
230
+ See README.md ("Python / Jupyter (structile.open)") for examples and the
231
+ full picture — this is a parameter reference, not a tutorial. Shadows
232
+ the `open` builtin — always call this namespace-qualified
233
+ (`structile.open(...)`), never `from structile import open`.
234
+
235
+ obj: the value to display — any mix of dict/list/tuple/set/None/bool/
236
+ int (any size)/float/str/numpy scalar — or a `pathlib.Path`/`str`
237
+ naming an existing file to load it from (JSON, falling back to
238
+ plain text). When loaded from a path whose content IS valid JSON,
239
+ the file's own literal bytes (not a re-serialization) are what the
240
+ viewer's source pane shows/edits/saves — so original formatting
241
+ (whitespace, key order, ...) survives untouched until an edit
242
+ actually touches it. There's no such "original text" for an
243
+ in-memory `obj` (no path) or for a path that falls back to plain
244
+ text (below) — both show a freshly-serialized JSON view instead, as
245
+ they always have. A path whose extension is markup-like (`.xml`,
246
+ `.html`/`.htm` — see `interpreters.MARKUP_FORMATS`), or has an
247
+ interpreter attached to it (`interpreter=`, or something already
248
+ registered via `register_interpreter`), or `format=` set explicitly
249
+ to anything other than `"json"`/`"python"`, switches to interpreter
250
+ mode: `obj` (or a literal string, with `format=` then required) is
251
+ handed to an interpreter instead of being read as a Python value —
252
+ see `interpreter=` below. `.xml`/`.html` use the DOM-based
253
+ `interpretXML(xmlDocument)` contract; any other format (`.ini`,
254
+ `.toml`, ...) uses the raw-text `interpretText(text)` contract — see
255
+ interpreters/generic_xml.js vs interpreters/generic_ini.js.
256
+ name: display name. Defaults to the loaded file's stem, else "data".
257
+ height: iframe height in px (`widget` renderer only).
258
+ config: viewer settings for this call — an `Options` instance, or a
259
+ plain dict of the same keys (`config={"gap": 8, "forNull": "N/A"}`),
260
+ whichever's more convenient; a dict is just turned into an `Options`
261
+ internally (see `options_from_dict`).
262
+ **settings: individual viewer settings for this call — the numeric
263
+ layout knobs (`gap`, `n`, `m`, `N`, `M`, `nameMax`, `valMax`,
264
+ `headerLabelMax`, `detCols`, `detRows`, `dwellMs`, `kvRows`,
265
+ `kvCols`, `maxWidthFrac`), `theme`, the "Special values" display
266
+ overrides the viewer's Settings panel edits under that name —
267
+ `forNull`/`forEmpty` (strings shown in place of `None`/`""`) and
268
+ `values` (a `{exact_string: replacement}` table) — cosmetic only,
269
+ never changes the underlying data; and `linkOpen`/`linkClose`, the
270
+ viewer's cross-reference-value marker — a dict value/table cell
271
+ wrapped between them, verbatim (delimiters included), that names
272
+ another dict/table anywhere in the document becomes a clickable
273
+ link that jumps to it (see `Options`). `config=` and
274
+ `**settings` both take precedence over `structile.options` /
275
+ `set_option(...)`; anything left unset everywhere falls back to the
276
+ viewer's default. There's no Settings panel in embedded (widget)
277
+ mode, so this is the only way to reach these from Python there —
278
+ e.g. `structile.open(data, forNull="N/A")` to show `None` as `"N/A"`
279
+ everywhere it appears, including nested inside dicts/lists, or
280
+ `structile.open(data, linkOpen="<link.id", linkClose=">")` so a value
281
+ like `"<link.id.20>"` links to a same-named dict/table.
282
+ interpreter: a path to a `.js` interpreter script, raw JS source
283
+ (`interpretXML(xmlDocument)` + optional `serializeXML(value)` for
284
+ markup; `interpretText(text)` + optional `serializeText(value)` for
285
+ any other text format — see interpreters/generic_xml.js,
286
+ interpreters/html.js, interpreters/generic_ini.js), or an
287
+ `InterpreterSource` (source text supplied directly rather than read
288
+ from a path — what an installed plugin package hands
289
+ `register_interpreter`; see `structile.plugins()`); a list mixing
290
+ any of the above; or a dict keyed by file extension (`{".xml": [...],
291
+ ".html": [...]}`) — only the entry matching the actual file/format
292
+ is tried, never another extension's candidates (see
293
+ `resolve_candidates`). Multiple candidates are never disambiguated
294
+ in Python — the source(s) are forwarded as-is to whichever renderer
295
+ is active, which tries each one itself (`tryInterpreterCandidates`
296
+ in structile.html) and reports which one worked. This
297
+ needs no Node.js, for any renderer including `widget`. Not required
298
+ if something's already registered for the extension
299
+ (`register_interpreter`, set up once at import time) — this only
300
+ overrides that for one call.
301
+ format: `"xml"` | `"html"` | any other string (e.g. `"ini"`) —
302
+ required alongside `interpreter=`/a registered interpreter when
303
+ `obj` isn't a path (no extension to infer it from) — *unless*
304
+ `interpreter=` is a dict spanning more than one extension, in which
305
+ case omitting `format=` forwards every entry (each under its own
306
+ extension's format) to the renderer, which determines the format
307
+ itself the same no-Node way (`tryInterpreterCandidatesAnyFormat`) —
308
+ Python never has to run anything just to decide which format
309
+ applies, or which contract (DOM vs. raw-text) it uses. Any format
310
+ other than `"xml"`/`"html"` runs through the raw-text
311
+ `interpretText(text)` contract instead of DOM parsing.
312
+ path: save destination, overriding `obj`'s own source path (if any) —
313
+ `widget` renderer only (the other renderers never write back to a
314
+ source file; see the `browser`/`file`/`none`/`text` docs).
315
+ renderer: `"widget"` | `"browser"` | `"file"` | `"none"` | `"text"` —
316
+ overrides resolution for this call only. Otherwise resolved via
317
+ `STRUCTILE_RENDERER` -> `config`/`structile.options.renderer`
318
+ (`set_option`/`use`) -> auto-detect (see `render.detect_renderer`).
319
+ out: also write the standalone HTML snapshot here (any renderer).
320
+ auto_open: set `False` to suppress the `browser` renderer's automatic
321
+ `webbrowser.open()` (it still writes the file; call `.open()` later
322
+ to open it manually).
323
+ default: called on any value that isn't one of `obj`'s own supported
324
+ types (see above); its return value is normalized in its place,
325
+ same escape hatch as `json.dumps(..., default=...)` — e.g.
326
+ `default=str` displays an otherwise-unsupported object as whatever
327
+ `str()` shows for it, instead of raising `TypeError`. See
328
+ `structile.normalize`.
329
+ """
330
+ if isinstance(config, dict):
331
+ config = options_from_dict(config)
332
+ embed_config = resolve_config(settings, config)
333
+ config_json = json.dumps(embed_config) if embed_config is not None else "null"
334
+ resolved_renderer = resolve_renderer(renderer, config) or _detect_renderer()
335
+ save_path = pathlib.Path(path) if path is not None else None
336
+
337
+ source_path = _as_path_like(obj)
338
+ ext = source_path.suffix.lower() if source_path is not None else None
339
+ if _is_markup_mode(ext, interpreter, format):
340
+ payload = _resolve_markup_source(
341
+ obj,
342
+ source_path=source_path,
343
+ ext=ext,
344
+ interpreter=interpreter,
345
+ format=format,
346
+ fallback_name="data",
347
+ allow_format_unknown=True,
348
+ error_prefix="structile",
349
+ bad_obj_message=(
350
+ "structile: with interpreter=/format=/a registered extension, "
351
+ "obj must be a file path or a raw string"
352
+ ),
353
+ unknown_format_message=(
354
+ "structile: could not infer a format for this data; pass format=... "
355
+ '(e.g. format="xml", format="html", or a custom format like format="ini")'
356
+ ),
357
+ )
358
+ payload.name = name or payload.name
359
+ payload.config = embed_config
360
+ _logger.debug("structile: markup mode (%s)", payload.format)
361
+ widget = None
362
+ if resolved_renderer == "widget":
363
+ widget = StructileWidget(
364
+ raw_text=payload.text,
365
+ raw_format=payload.format or "",
366
+ interpreter_source=payload.interpreter_source or "",
367
+ interpreter_candidates_by_format=payload.interpreter_candidates_by_format or {},
368
+ data_name=payload.name,
369
+ height=height,
370
+ config_json=config_json,
371
+ value=payload.text,
372
+ source_path=source_path,
373
+ save_path=save_path,
374
+ )
375
+ # Markup mode's own "value" is just the raw text (nothing to parse
376
+ # in Python — see payload.text above).
377
+ return _render(payload, value=payload.text, renderer=resolved_renderer, widget=widget, out=out, auto_open=auto_open)
378
+
379
+ if source_path is not None:
380
+ data, inferred_name, raw_text, parsed_as_json = _load_path(source_path)
381
+ else:
382
+ # "" (not "data"): an in-memory value has no real file/save identity
383
+ # behind it, and the viewer's #fileNameLabel treats a falsy name as
384
+ # exactly that signal — showing the bare display name instead of a
385
+ # fabricated "name.json" that would misleadingly read as an actual
386
+ # loaded file (see updateFileNameLabel/hasRealFileName in
387
+ # structile.html). name= still wins below if the caller
388
+ # gave one explicitly.
389
+ data, inferred_name, raw_text, parsed_as_json = obj, "", None, False
390
+ normalized = normalize(data, default)
391
+ display_name = name or inferred_name
392
+ # source_code_view_claude.md Phase 6: when this was loaded from a real
393
+ # file that IS a JSON document, forward its literal bytes as the source
394
+ # text — preserving the file's own formatting (whitespace, key order,
395
+ # ...) for the viewer's source pane — instead of a re-serialization that
396
+ # was never what was actually on disk. No such "original text" exists
397
+ # for an in-memory value (raw_text is None) or for a path whose content
398
+ # isn't valid JSON (parsed_as_json is False — the historical "unrecognized
399
+ # extension falls back to plain text" case, where raw_text is arbitrary
400
+ # non-JSON content and would break the viewer if sent as format="json"
401
+ # source text) — both keep today's re-serialize-as-text behavior, pretty-
402
+ # printed (indent=2, matching the viewer's own JSON.stringify(v, null, 2)
403
+ # elsewhere) so the source pane never shows a single minified line.
404
+ text = raw_text if (raw_text is not None and parsed_as_json) else json.dumps(normalized, indent=2)
405
+ widget = None
406
+ if resolved_renderer == "widget":
407
+ widget = StructileWidget(
408
+ value_json=text,
409
+ data_name=display_name,
410
+ height=height,
411
+ config_json=config_json,
412
+ value=normalized,
413
+ source_path=source_path,
414
+ save_path=save_path,
415
+ )
416
+ payload = _Payload(text=text, format="json", name=display_name, config=embed_config)
417
+ return _render(payload, value=normalized, renderer=resolved_renderer, widget=widget, out=out, auto_open=auto_open)
418
+
419
+
420
+ def _resolve_diff_side(
421
+ obj: Any,
422
+ *,
423
+ interpreter: Optional[InterpreterSpec],
424
+ fmt: Optional[str],
425
+ side_label: str,
426
+ default: Optional[Callable[[Any], Any]] = None,
427
+ ) -> _Payload:
428
+ """Resolve one side of a `diff()` call into a `Payload` — mirrors
429
+ `open()`'s own markup-mode branch via the shared
430
+ `_resolve_markup_source` helper, scoped to what a single diff side
431
+ needs: the format-unknown "dict spanning several extensions, let the
432
+ browser pick the format" case doesn't apply here, since each side must
433
+ already be classifiable (markup or plain value) before the two sides
434
+ can even be compared."""
435
+ source_path = _as_path_like(obj)
436
+ ext = source_path.suffix.lower() if source_path is not None else None
437
+ if _is_markup_mode(ext, interpreter, fmt):
438
+ return _resolve_markup_source(
439
+ obj,
440
+ source_path=source_path,
441
+ ext=ext,
442
+ interpreter=interpreter,
443
+ format=fmt,
444
+ fallback_name=side_label,
445
+ allow_format_unknown=False,
446
+ error_prefix=f"structile.diff ({side_label} side)",
447
+ bad_obj_message=(
448
+ f"structile.diff: with interpreter=/format= given for the {side_label} side, "
449
+ "it must be a file path or a raw string"
450
+ ),
451
+ unknown_format_message=(
452
+ f"structile.diff: could not infer a format for the {side_label} side; pass "
453
+ "format=... (or a (left, right) tuple if only one side is markup) for it"
454
+ ),
455
+ )
456
+
457
+ if source_path is not None:
458
+ data, inferred_name, _raw_text, _parsed_as_json = _load_path(source_path)
459
+ else:
460
+ data, inferred_name = obj, side_label
461
+ normalized = normalize(data, default)
462
+ return _Payload(text=json.dumps(normalized, indent=2), format="json", name=inferred_name)
463
+
464
+
465
+ def diff(
466
+ left: Any,
467
+ right: Any,
468
+ *,
469
+ interpreter: Optional[Union[InterpreterSpec, Tuple[Optional[InterpreterSpec], Optional[InterpreterSpec]]]] = None,
470
+ format: Optional[Union[str, Tuple[Optional[str], Optional[str]]]] = None,
471
+ name: Optional[Tuple[Optional[str], Optional[str]]] = None,
472
+ view: str = "unified",
473
+ key_columns: Optional[list] = None,
474
+ height: int = 600,
475
+ renderer: Optional[str] = None,
476
+ out: Optional[Union[str, pathlib.Path]] = None,
477
+ auto_open: bool = True,
478
+ default: Optional[Union[Callable[[Any], Any], Tuple[Optional[Callable[[Any], Any]], Optional[Callable[[Any], Any]]]]] = None,
479
+ ) -> DiffRenderHandle:
480
+ """Display a two-sided diff of `left` vs `right` through the active
481
+ renderer. Always returns a `DiffRenderHandle` with `.path`,
482
+ `.left_payload`/`.right_payload`, `.left_value`/`.right_value`,
483
+ `.open()`, `.to_html()`, `.save(path)`.
484
+
485
+ See README.md ("Comparing two files (structile.diff)") for examples.
486
+ Deliberately narrower than `open()`: no per-side viewer
487
+ `config=`/`**settings`, and the diff graph itself is always read-only
488
+ for every renderer (including `widget`) — only each side's own source
489
+ pane can be independently edited/saved (see `.left_value`/
490
+ `.right_value`, which read through to the live `StructileDiffWidget`
491
+ for the `widget` renderer, same as `RenderHandle.value` does for
492
+ `open()`).
493
+
494
+ left, right: each independently accepts anything `open()`'s
495
+ `obj` does — a Python value, a path, or (with `interpreter=`/
496
+ `format=`) a raw XML string.
497
+ interpreter, format: a single value applies to BOTH sides (the common
498
+ case: two files in the same format/schema); a `(left, right)`
499
+ 2-tuple gives each side its own — this is what lets `left` and
500
+ `right` be two genuinely different, mutually-incompatible XML
501
+ schemas, each with its own interpreter. See `open()`'s
502
+ `interpreter=`/`format=` docs for what a single value can be.
503
+ name: `(left_name, right_name)` — defaults to each side's own inferred
504
+ name (its file stem, or "left"/"right" for a raw string/value).
505
+ view: `"unified"` | `"split"`.
506
+ key_columns: table key-column overrides — same shape the standalone
507
+ viewer's own diff header accepts.
508
+ height: iframe height in px (`widget` renderer only).
509
+ renderer, out, auto_open: same as `open()`.
510
+ default: same escape hatch as `open()`'s `default=`, for
511
+ whichever side(s) are plain Python values (a markup/interpreter-mode
512
+ side never reaches `normalize()`, so this has no effect there) — a
513
+ single value applies to both sides; a `(left, right)` 2-tuple gives
514
+ each side its own, same convention as `interpreter=`/`format=`.
515
+ """
516
+ resolved_renderer = resolve_renderer(renderer, None) or _detect_renderer()
517
+ left_interp, right_interp = interpreter if isinstance(interpreter, tuple) else (interpreter, interpreter)
518
+ left_fmt, right_fmt = format if isinstance(format, tuple) else (format, format)
519
+ left_name, right_name = name if name is not None else (None, None)
520
+ left_default, right_default = default if isinstance(default, tuple) else (default, default)
521
+
522
+ left_payload = _resolve_diff_side(left, interpreter=left_interp, fmt=left_fmt, side_label="left", default=left_default)
523
+ right_payload = _resolve_diff_side(right, interpreter=right_interp, fmt=right_fmt, side_label="right", default=right_default)
524
+ if left_name:
525
+ left_payload.name = left_name
526
+ if right_name:
527
+ right_payload.name = right_name
528
+
529
+ widget = None
530
+ if resolved_renderer == "widget":
531
+ widget = StructileDiffWidget(
532
+ left=left_payload, right=right_payload, view=view, key_columns=key_columns, height=height
533
+ )
534
+
535
+ return _render_diff(
536
+ left_payload,
537
+ right_payload,
538
+ renderer=resolved_renderer,
539
+ view=view,
540
+ key_columns=key_columns,
541
+ widget=widget,
542
+ out=out,
543
+ auto_open=auto_open,
544
+ )
structile/__main__.py ADDED
@@ -0,0 +1,57 @@
1
+ """`python -m structile data.json [--interpreter x.js] [--renderer ...] [--out f.html]`
2
+
3
+ A thin CLI over `open()` — same renderer resolution/auto-detect as
4
+ calling it from a script (see render.py), just from a terminal instead.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import argparse
9
+ import sys
10
+ from typing import Optional, Sequence
11
+
12
+ from . import __version__
13
+ from . import open as _open
14
+ from . import plugins as _plugins
15
+ from .render import RENDERERS
16
+
17
+
18
+ def main(argv: Optional[Sequence[str]] = None) -> int:
19
+ parser = argparse.ArgumentParser(prog="python -m structile", description="View a JSON / Python-repr / XML / HTML file with Structile.")
20
+ parser.add_argument("path", nargs="?", help="file to view (.json, .py, .xml, .html/.htm)")
21
+ parser.add_argument("--interpreter", metavar="FILE", help="a .js interpreter script (required for .xml/.html)")
22
+ parser.add_argument("--renderer", choices=RENDERERS, help="override renderer auto-detection")
23
+ parser.add_argument("--out", metavar="FILE", help="also write the standalone HTML snapshot here")
24
+ parser.add_argument("--no-open", action="store_true", help="don't open a browser tab (browser renderer only)")
25
+ parser.add_argument(
26
+ "--plugins",
27
+ action="store_true",
28
+ help="list installed structile.interpreters entry-point plugins (including any that failed to load) and exit",
29
+ )
30
+ parser.add_argument("--version", action="version", version=f"structile {__version__}")
31
+ args = parser.parse_args(argv)
32
+
33
+ if args.plugins:
34
+ records = _plugins()
35
+ if not records:
36
+ print("no structile.interpreters plugins found")
37
+ for record in records:
38
+ if not record.success:
39
+ print(f"{record.entry_point} ({record.distribution} {record.version}): FAILED — {record.error}")
40
+ continue
41
+ exts = ", ".join(record.extensions) if record.extensions else "(no extensions registered)"
42
+ print(f"{record.entry_point} ({record.distribution} {record.version}): {exts}")
43
+ return 0
44
+
45
+ if not args.path:
46
+ parser.error("the following arguments are required: path")
47
+
48
+ kwargs = dict(renderer=args.renderer, out=args.out, auto_open=not args.no_open)
49
+ if args.interpreter:
50
+ kwargs["interpreter"] = args.interpreter
51
+ handle = _open(args.path, **kwargs)
52
+ print(repr(handle))
53
+ return 0
54
+
55
+
56
+ if __name__ == "__main__":
57
+ sys.exit(main())