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.
@@ -0,0 +1,430 @@
1
+ """Interpreter registry + selection — markup (XML, HTML included) and any
2
+ other text format alike — see README.md's "Custom formats via an
3
+ interpreter" section and docs/advanced.md's "Interpreter selection" section
4
+ for the full picture.
5
+
6
+ An interpreter is caller-supplied JS: `interpretXML(xmlDocument)` (optionally
7
+ `serializeXML(value)`) for markup, given a DOM document; or `interpretText(text)`
8
+ (optionally `serializeText(value)`) for any other format, given the raw text
9
+ directly — see interpreters/generic_xml.js / interpreters/html.js for the
10
+ former and interpreters/generic_ini.js for the latter, and their header
11
+ comments for the "fail loudly on the wrong shape" contract this module's
12
+ selection depends on. `xmlDocument` is a REAL browser DOM when open()/
13
+ diff() resolve this client-side (structile.html's own DOMParser);
14
+ when this module runs an interpreter itself (`run_interpreter_source`, used
15
+ by `convert()` and whenever more than one candidate needs disambiguating —
16
+ see `select_interpreter` below), it's a much smaller DOM-lite object built
17
+ from a Python-parsed tree instead (see dom_lite.py/dom_lite.js) — no Node.js,
18
+ no jsdom, just an embedded JS engine (`mini-racer`).
19
+
20
+ This module is generic across every extension AND across which contract an
21
+ interpreter uses, on purpose: HTML isn't treated as a different *kind* of
22
+ thing from XML anywhere below, only as a different DOMParser *mode*
23
+ (`MARKUP_FORMATS`, the one place that distinction exists at all) — the
24
+ registry, candidate-list resolution, and try-in-order selection never
25
+ branch on "is this XML or HTML", let alone "is this markup at all". Which
26
+ of the two JS contracts actually applies is decided client-side
27
+ (structile.html's `tryInterpreterCandidates`/`structuredFormat`),
28
+ never here.
29
+ """
30
+ from __future__ import annotations
31
+
32
+ import json
33
+ import pathlib
34
+ from dataclasses import dataclass
35
+ from typing import Any, Dict, List, Optional, Sequence, Union
36
+
37
+ from ._dom_lite import build_document_payload
38
+ from ._log import logger
39
+ from ._paths import InterpreterSource, read_interpreter
40
+
41
+ # A single candidate: a path, a bare str (a path if it names an existing
42
+ # file, else literal JS source text — see read_interpreter), or an explicit
43
+ # InterpreterSource (source text supplied directly, e.g. from an installed
44
+ # plugin package — see _paths.py's InterpreterSource docstring).
45
+ InterpreterCandidate = Union[str, pathlib.Path, InterpreterSource]
46
+ # A single candidate, or a list of candidates tried in order for ONE format.
47
+ InterpreterCandidateOrList = Union[InterpreterCandidate, Sequence[InterpreterCandidate]]
48
+ # The above, OR a dict keyed by file extension (".xml", ".html", ...) — each
49
+ # entry tried only for a file/format matching THAT extension, never mixed
50
+ # with another entry's candidates (see resolve_candidates /
51
+ # select_interpreter_any_format).
52
+ InterpreterSpec = Union[InterpreterCandidateOrList, Dict[str, InterpreterCandidateOrList]]
53
+
54
+ # The only xml/html-specific knowledge in this whole module: which DOMParser
55
+ # mode a markup extension implies. A file extension not listed here can
56
+ # still be used for ANY format — markup (pass format="xml"/"html"
57
+ # explicitly) or otherwise (open() infers a format straight from the
58
+ # extension once an interpreter is attached to it, e.g. ".ini" -> "ini") —
59
+ # this dict only grows the set of extensions a format is inferred from via
60
+ # the markup/DOM path specifically; it's not a gate on what's usable at all.
61
+ MARKUP_FORMATS: Dict[str, str] = {".xml": "xml", ".html": "html", ".htm": "html"}
62
+ # The reverse: which extension to use as a registry lookup key when only a
63
+ # format (not a real file/extension) is known — e.g. structile.open(xml_text,
64
+ # format="xml", ...) with no path to infer an extension from.
65
+ CANONICAL_EXT_FOR_FORMAT: Dict[str, str] = {"xml": ".xml", "html": ".html"}
66
+
67
+ _DOM_LITE_JS_PATH = pathlib.Path(__file__).resolve().parent / "dom_lite.js"
68
+ _SET_MARK = "__structile_set__"
69
+
70
+ _registry: Dict[str, InterpreterSpec] = {}
71
+
72
+
73
+ def _normalize_ext(ext: str) -> str:
74
+ ext = ext.lower()
75
+ return ext if ext.startswith(".") else "." + ext
76
+
77
+
78
+ # ---- registry: set up once at import time --------------------------------
79
+ def register_interpreter(ext: str, interpreter: Optional[InterpreterSpec]) -> None:
80
+ """Register one or more interpreters for a file extension, once, at
81
+ import time — so later `structile.open(path)` / `convert(path, ...)` calls
82
+ don't need `interpreter=` at all:
83
+
84
+ structile.register_interpreter(".xml", "interpreters/generic_xml.js")
85
+ structile.register_interpreter(".html", ["interpreters/html.js", "interpreters/generic_xml.js"])
86
+
87
+ `interpreter` is a single path/raw JS source/`InterpreterSource`, or a
88
+ list/tuple of candidates tried in order — the first one that
89
+ successfully parses the data wins (see `select_interpreter`). `None`
90
+ unregisters `ext` (same as `unregister_interpreter`).
91
+
92
+ A direct call here always wins over a plugin's own registration for the
93
+ same `ext` (see `_plugins.py`'s precedence rule) — calling this
94
+ "forgets" any plugin ownership of `ext` recorded so far, so a later
95
+ `load_plugins(force=True)` can't silently re-overwrite what was just set
96
+ directly.
97
+ """
98
+ ext = _normalize_ext(ext)
99
+ from . import _plugins as _plugins_mod
100
+
101
+ _plugins_mod._plugin_owner_by_ext.pop(ext, None)
102
+ if interpreter is None:
103
+ _registry.pop(ext, None)
104
+ logger.debug("structile: unregistered interpreter(s) for %r", ext)
105
+ return
106
+ _registry[ext] = interpreter
107
+ logger.debug("structile: registered interpreter(s) for %r: %r", ext, interpreter)
108
+
109
+
110
+ def unregister_interpreter(ext: str) -> None:
111
+ """Remove any interpreter(s) registered for `ext`."""
112
+ register_interpreter(ext, None)
113
+
114
+
115
+ def get_registered_interpreter(ext: str) -> Optional[InterpreterSpec]:
116
+ """The raw spec registered for `ext` — a single candidate, a list, or
117
+ `None` if nothing's registered. See `resolve_candidates` for the
118
+ normalized list form callers actually use.
119
+
120
+ Triggers entry-point plugin discovery first (lazy, at most once per
121
+ process — see `_plugins.load_plugins`): every renderer's resolution
122
+ path (`open()`/`diff()`'s markup-mode detection, `resolve_candidates`'s
123
+ own registry fallback below, `convert()`'s format inference) reaches the
124
+ registry through this one function, so a plugin's registration becomes
125
+ visible here with no import of the plugin package and no explicit
126
+ registration call needed.
127
+ """
128
+ from . import _plugins as _plugins_mod
129
+
130
+ _plugins_mod.load_plugins()
131
+ return _registry.get(_normalize_ext(ext))
132
+
133
+
134
+ def resolve_candidates(explicit: Optional[InterpreterSpec], ext: Optional[str]) -> List[InterpreterCandidate]:
135
+ """Normalize `interpreter=` (explicit, wins if given) or the registry
136
+ entry for `ext` (fallback — `ext` may be `None` when there's no file/
137
+ extension to key off of, e.g. a literal markup string) into a plain
138
+ list of candidates, in the order they should be tried. Empty means
139
+ "nothing available for this format".
140
+
141
+ If `explicit` is a `dict` (keyed by file extension — e.g.
142
+ `{".xml": [...], ".html": [...]}`), only the entry for THIS `ext` is
143
+ used — candidates registered for a different extension are never tried
144
+ against a file/format they weren't meant for. A `dict` with no entry
145
+ for `ext` (or given with `ext=None`) resolves to nothing, same as no
146
+ interpreter at all — see `select_interpreter_any_format` for the
147
+ "format itself is unknown, try every entry" case this deliberately
148
+ doesn't cover.
149
+ """
150
+ if isinstance(explicit, dict):
151
+ normalized = {_normalize_ext(k): v for k, v in explicit.items()}
152
+ spec = normalized.get(_normalize_ext(ext)) if ext else None
153
+ else:
154
+ spec = explicit if explicit is not None else (get_registered_interpreter(ext) if ext else None)
155
+ if spec is None:
156
+ return []
157
+ if isinstance(spec, (str, pathlib.Path, InterpreterSource)):
158
+ return [spec]
159
+ return list(spec)
160
+
161
+
162
+ # ---- running one candidate via an embedded JS engine (parse or serialize) -
163
+ def _mark_sets(value: Any) -> Any:
164
+ """JSON has no Set — mark one the same reversible way dom_lite.js's
165
+ __structileMarkSets/__structileUnmarkSets do on its side, so `value instanceof Set`
166
+ still works inside the interpreter."""
167
+ if isinstance(value, (set, frozenset)):
168
+ try:
169
+ items = sorted(value)
170
+ except TypeError:
171
+ items = list(value)
172
+ return {_SET_MARK: [_mark_sets(v) for v in items]}
173
+ if isinstance(value, (list, tuple)):
174
+ return [_mark_sets(v) for v in value]
175
+ if isinstance(value, dict):
176
+ return {k: _mark_sets(v) for k, v in value.items()}
177
+ return value
178
+
179
+
180
+ def _unmark_sets(value: Any) -> Any:
181
+ if isinstance(value, dict) and list(value.keys()) == [_SET_MARK] and isinstance(value[_SET_MARK], list):
182
+ return {_unmark_sets(v) for v in value[_SET_MARK]}
183
+ if isinstance(value, list):
184
+ return [_unmark_sets(v) for v in value]
185
+ if isinstance(value, dict):
186
+ return {k: _unmark_sets(v) for k, v in value.items()}
187
+ return value
188
+
189
+
190
+ def _mini_racer_available() -> bool:
191
+ try:
192
+ import py_mini_racer # noqa: F401
193
+ except ImportError:
194
+ return False
195
+ return True
196
+
197
+
198
+ def run_interpreter_source(
199
+ op: str,
200
+ interpreter_source: str,
201
+ fmt: str,
202
+ *,
203
+ text: Optional[str] = None,
204
+ value: Any = None,
205
+ ) -> Any:
206
+ """Run one already-resolved interpreter script, as actual JS, inside an
207
+ embedded V8 engine (the `mini-racer` package — see dom_lite.js/
208
+ _dom_lite.py) — the only way to either get a real parsed/serialized
209
+ result out of an interpreter in Python (used by `convert()`), or to
210
+ test whether a *candidate* interpreter accepts a given document at all
211
+ (used by `select_interpreter` below). No Node.js, no jsdom, no
212
+ subprocess: for `fmt` `"xml"`/`"html"` the DOM handed to `interpretXML`
213
+ is built directly from a Python-parsed tree (stdlib `ElementTree`/
214
+ `html.parser` — see dom_lite.py) rather than a real browser DOM; see
215
+ dom_lite.js's header comment for exactly what subset of the DOM this
216
+ provides. Raises `RuntimeError` if the `mini-racer` package is missing,
217
+ if `text` isn't well-formed enough to parse as `fmt` (xml only — HTML5
218
+ parsing never rejects anything, matching a real browser's own lenient
219
+ behavior), or if the interpreter itself fails (throws, or has no
220
+ matching function for `op`)."""
221
+ if not _mini_racer_available():
222
+ raise RuntimeError(
223
+ "structile: this needs the 'mini-racer' package (run `pip install "
224
+ "mini-racer`) — an optional capability, not a hard dependency of "
225
+ 'structile itself. See README.md\'s "Custom formats via an '
226
+ 'interpreter" section.'
227
+ )
228
+ from py_mini_racer import JSEvalException, MiniRacer
229
+
230
+ is_markup = fmt in ("xml", "html")
231
+ doc_payload = build_document_payload(text, fmt) if is_markup and op == "parse" else None
232
+ marked_value_json = json.dumps(_mark_sets(value)) if op == "serialize" else None
233
+ dom_lite_js = _DOM_LITE_JS_PATH.read_text(encoding="utf-8")
234
+
235
+ # Mirrors buildInterpreterFns's function-extraction in
236
+ # structile.html, and (before it) convert_runner.js's `new
237
+ # Function(...)` call — same execution shape regardless of which of the
238
+ # three actually drives an interpreter script. The whole thing is one
239
+ # try/catch so an interpreter's own thrown Error (the "fail loudly on
240
+ # the wrong shape" contract every bundled interpreter documents) comes
241
+ # back as a clean {ok:false, error} instead of a noisy JS stack trace.
242
+ driver = f"""
243
+ (function(){{
244
+ try {{
245
+ var __structileFns = {{
246
+ interpretXML: (typeof interpretXML === "function") ? interpretXML : null,
247
+ serializeXML: (typeof serializeXML === "function") ? serializeXML : null,
248
+ interpretText: (typeof interpretText === "function") ? interpretText : null,
249
+ serializeText: (typeof serializeText === "function") ? serializeText : null,
250
+ }};
251
+ var __structileResult;
252
+ if ({json.dumps(op)} === "parse") {{
253
+ if ({json.dumps(is_markup)}) {{
254
+ if (!__structileFns.interpretXML) throw new Error("Interpreter script must define function interpretXML(xmlDocument)");
255
+ __structileResult = __structileFns.interpretXML(__structileBuildDocument({json.dumps(doc_payload)}));
256
+ }} else {{
257
+ if (!__structileFns.interpretText) throw new Error("Interpreter script must define function interpretText(text)");
258
+ __structileResult = __structileFns.interpretText({json.dumps(text)});
259
+ }}
260
+ return JSON.stringify({{ ok: true, value: __structileMarkSets(__structileResult) }});
261
+ }} else {{
262
+ var __structileValue = __structileUnmarkSets(JSON.parse({json.dumps(marked_value_json)}));
263
+ if ({json.dumps(is_markup)}) {{
264
+ if (!__structileFns.serializeXML) throw new Error("Interpreter script has no serializeXML — can't serialize to this format");
265
+ __structileResult = __structileFns.serializeXML(__structileValue);
266
+ }} else {{
267
+ if (!__structileFns.serializeText) throw new Error("Interpreter script has no serializeText — can't serialize to this format");
268
+ __structileResult = __structileFns.serializeText(__structileValue);
269
+ }}
270
+ return JSON.stringify({{ ok: true, value: __structileResult }});
271
+ }}
272
+ }} catch (e) {{
273
+ return JSON.stringify({{ ok: false, error: String((e && e.message) || e) }});
274
+ }}
275
+ }})()
276
+ """
277
+
278
+ logger.debug("structile: running interpreter via mini-racer (%s, format=%r)", op, fmt)
279
+ mr = MiniRacer() # fresh isolate per call — candidates never share JS state
280
+ try:
281
+ raw = mr.eval(dom_lite_js + "\n" + interpreter_source + "\n" + driver)
282
+ except JSEvalException as e:
283
+ raise RuntimeError(f"structile: interpreter script threw while loading: {e}") from e
284
+ outcome = json.loads(raw)
285
+ if not outcome["ok"]:
286
+ raise RuntimeError(outcome["error"])
287
+ return _unmark_sets(outcome["value"]) if op == "parse" else outcome["value"]
288
+
289
+
290
+ @dataclass
291
+ class SelectedInterpreter:
292
+ candidate: InterpreterCandidate
293
+ source: str
294
+ result: Any # the parsed/serialized value if it was actually run to select it, else None
295
+
296
+
297
+ def select_interpreter(
298
+ candidates: Sequence[InterpreterCandidate],
299
+ fmt: str,
300
+ *,
301
+ op: str = "parse",
302
+ text: Optional[str] = None,
303
+ value: Any = None,
304
+ run: bool = True,
305
+ ) -> SelectedInterpreter:
306
+ """Pick the interpreter that actually handles this data out of
307
+ `candidates`, tried in order — the first one that doesn't raise wins
308
+ (see interpreters/generic_xml.js's header comment for why a *correct*
309
+ interpreter is expected to throw on the wrong shape rather than return
310
+ something meaningless).
311
+
312
+ A single candidate is returned untested when `run=False` — the
313
+ `open()` `widget`/`browser`/`file` renderers only ever forward
314
+ the interpreter source to the viewer, which does the real parse
315
+ client-side, so with nothing to disambiguate there's no reason to run
316
+ anything here either. `convert()` always needs the actual parsed/
317
+ serialized Python value back, so it passes `run=True` unconditionally —
318
+ even a single candidate has to actually run there. Whenever there's
319
+ more than one candidate, disambiguating them always requires actually
320
+ running each in turn (via mini-racer), regardless of `run`.
321
+
322
+ Raises `ValueError` if `candidates` is empty, or `RuntimeError` (after
323
+ trying every candidate, and naming how each one failed) if none of them
324
+ accept the data.
325
+ """
326
+ if not candidates:
327
+ raise ValueError(
328
+ f"structile: no interpreter registered or provided for {fmt!r} data — pass interpreter=, "
329
+ "or call register_interpreter(ext, ...) once at import time"
330
+ )
331
+ if len(candidates) == 1 and not run:
332
+ source = read_interpreter(candidates[0])
333
+ logger.debug("structile: single interpreter candidate for %r, using it without testing", fmt)
334
+ return SelectedInterpreter(candidate=candidates[0], source=source, result=None)
335
+
336
+ # From here on every candidate actually has to run (run=True, or more
337
+ # than one candidate needs disambiguating) — checked once up front so a
338
+ # missing 'mini-racer' package surfaces as ONE clear error, not this same
339
+ # message repeated (and then buried in an aggregate "none of N worked")
340
+ # once per candidate as run_interpreter_source's own identical check
341
+ # would give.
342
+ if not _mini_racer_available():
343
+ reason = (
344
+ f"choosing between {len(candidates)} interpreter candidates"
345
+ if len(candidates) > 1
346
+ else "running this interpreter"
347
+ )
348
+ raise RuntimeError(
349
+ f"structile: {reason} for {fmt!r} data needs the 'mini-racer' package "
350
+ "(run `pip install mini-racer`) — an optional capability, not a hard "
351
+ 'dependency of structile itself. See README.md\'s "Custom formats via '
352
+ 'an interpreter" section.'
353
+ )
354
+ if len(candidates) > 1:
355
+ logger.debug("structile: %d interpreter candidates for %r, trying each in order", len(candidates), fmt)
356
+
357
+ errors = []
358
+ for i, candidate in enumerate(candidates):
359
+ source = read_interpreter(candidate)
360
+ try:
361
+ result = run_interpreter_source(op, source, fmt, text=text, value=value)
362
+ except RuntimeError as e:
363
+ logger.warning("structile: interpreter candidate %r failed (%s), trying the next one", candidate, e)
364
+ errors.append(f"{candidate}: {e}")
365
+ continue
366
+ if len(candidates) > 1:
367
+ logger.info("structile: selected interpreter candidate %r (%d of %d)", candidate, i + 1, len(candidates))
368
+ return SelectedInterpreter(candidate=candidate, source=source, result=result)
369
+
370
+ logger.error("structile: no interpreter candidate could handle this %r data (tried %d)", fmt, len(candidates))
371
+ raise RuntimeError(
372
+ f"structile: none of {len(candidates)} interpreter candidate(s) could handle this {fmt} data:\n"
373
+ + "\n".join(f" - {e}" for e in errors)
374
+ )
375
+
376
+
377
+ def select_interpreter_any_format(
378
+ interpreter_dict: Dict[str, InterpreterCandidateOrList],
379
+ *,
380
+ op: str = "parse",
381
+ text: Optional[str] = None,
382
+ value: Any = None,
383
+ ) -> tuple[str, SelectedInterpreter]:
384
+ """Like `select_interpreter`, but for when the *format* itself isn't
385
+ known ahead of time either — no `format=`, no path to infer an
386
+ extension from. Tries every `(ext, candidates)` entry in
387
+ `interpreter_dict` in turn (each under its own format — `MARKUP_FORMATS`'
388
+ DOM mode for a markup extension, or the extension itself for anything
389
+ else, e.g. `.ini` -> `"ini"`), stopping at the first candidate — from
390
+ any entry — that doesn't raise. Returns `(fmt, SelectedInterpreter)`
391
+ since the winning format wasn't known ahead of time.
392
+
393
+ Always actually runs candidates (needs the 'mini-racer' package) — unlike
394
+ `select_interpreter`'s `run=False` escape hatch (a single, *known*-
395
+ format candidate can be forwarded to a browser untested, since there's
396
+ nothing to disambiguate), determining which of several *formats* even
397
+ applies is inherently a "parse it to find out" question, so there's no
398
+ untested shortcut here.
399
+
400
+ Raises `ValueError` if `interpreter_dict` is empty, `RuntimeError`
401
+ (naming every attempt, across every extension) if nothing in it can
402
+ handle the data.
403
+ """
404
+ if not interpreter_dict:
405
+ raise ValueError(
406
+ "structile: interpreter dict is empty — nothing to try (pass at least one "
407
+ '{"ext": interpreter} entry)'
408
+ )
409
+ errors: List[str] = []
410
+ tried_any = False
411
+ for ext, spec in interpreter_dict.items():
412
+ ext_norm = _normalize_ext(ext)
413
+ fmt = MARKUP_FORMATS.get(ext_norm) or ext_norm.lstrip(".")
414
+ candidates = resolve_candidates(spec, ext_norm)
415
+ if not candidates:
416
+ continue
417
+ tried_any = True
418
+ try:
419
+ selected = select_interpreter(candidates, fmt, op=op, text=text, value=value, run=True)
420
+ except (ValueError, RuntimeError) as e:
421
+ errors.append(f"{ext} ({fmt}): {e}")
422
+ continue
423
+ return fmt, selected
424
+ if not tried_any and not errors:
425
+ raise ValueError(f"structile: no usable interpreter entries in {interpreter_dict!r}")
426
+ logger.error("structile: no interpreter (across %d extension(s)) could handle this data", len(interpreter_dict))
427
+ raise RuntimeError(
428
+ f"structile: none of the provided interpreters (across {len(interpreter_dict)} extension(s)) "
429
+ "could handle this data:\n" + "\n".join(f" - {e}" for e in errors)
430
+ )