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/__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())
|