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/config.py ADDED
@@ -0,0 +1,272 @@
1
+ """Configuration for `open()`, mirroring the viewer's own settings.
2
+
3
+ Three ways to set an option, in increasing precedence:
4
+
5
+ structile.set_option("theme", "dark") # module-level default
6
+ structile.options.gap = 8 # same, attribute style
7
+ structile.open(data, gap=8, theme="dark") # per-call override
8
+
9
+ An unset option (module default `None`, no per-call override) is simply
10
+ omitted from the config handed to the viewer, so it falls back to the
11
+ viewer's own built-in default (see SETTINGS_SCHEMA in
12
+ structile.html) rather than to some second copy of that default
13
+ kept here.
14
+
15
+ `_OPTION_TYPES` mirrors structile.html's SETTINGS_SCHEMA (the
16
+ numeric layout knobs) plus `theme`, which the viewer handles separately
17
+ (see applySettingsFile's `payload.theme` handling) since it's a
18
+ documentElement attribute, not a layout setting; `forNull`/`forEmpty`/
19
+ `values`, the viewer's "Special values" display overrides (renderMap in
20
+ structile.html — cosmetic only, never changes the underlying
21
+ data); `linkOpen`/`linkClose`, the viewer's cross-reference-value marker
22
+ (linkSettings in structile.html — a dict value/table cell wrapped
23
+ between them that names another dict/table in the document becomes a
24
+ clickable link that jumps to it; unlike forNull/forEmpty/values this one
25
+ IS interactive, not purely cosmetic, but travels the same way); and
26
+ `renderer`, which never reaches the viewer at all — it only
27
+ picks which renderer (see render.py) `open()` dispatches to on the
28
+ Python side, matplotlib-backend style (see `resolve_renderer`/`use`).
29
+
30
+ `config=` also accepts a plain dict instead of an `Options` instance —
31
+ `structile.open(data, config={"forNull": "N/A"})` — see `options_from_dict`,
32
+ used internally by `open()` to coerce one before it ever reaches
33
+ `resolve_config`/`resolve_renderer` (which still only accept `Options`).
34
+ """
35
+ from __future__ import annotations
36
+
37
+ import os
38
+ from typing import Any, Dict, Optional
39
+
40
+ from ._log import logger
41
+
42
+ _OPTION_TYPES: Dict[str, type] = {
43
+ "nameMax": int,
44
+ "valMax": int,
45
+ "headerLabelMax": int,
46
+ "n": int,
47
+ "m": int,
48
+ "N": int,
49
+ "M": int,
50
+ "detCols": int,
51
+ "detRows": int,
52
+ "dwellMs": int,
53
+ "sourceAutoRenderMs": int,
54
+ "gap": int,
55
+ "kvRows": int,
56
+ "kvCols": int,
57
+ "maxWidthFrac": float,
58
+ "theme": str,
59
+ "renderer": str,
60
+ "forNull": str,
61
+ "forEmpty": str,
62
+ "values": dict,
63
+ "linkOpen": str,
64
+ "linkClose": str,
65
+ }
66
+ _THEME_VALUES = ("light", "dark")
67
+ _RENDERER_VALUES = ("widget", "browser", "file", "none", "text")
68
+ # Options that are Python-side dispatch only — never sent to the viewer as
69
+ # part of the embedded-mode/settings-file `config` object (see resolve_config).
70
+ _NON_VIEWER_OPTIONS = ("renderer",)
71
+ # The viewer's "Special values" display overrides (structile.html's
72
+ # renderMap/buildRenderMapPanel) — cosmetic-only, string-keyed, and travel to
73
+ # the viewer as their own `renderMap` config field rather than folded into
74
+ # the numeric `settings` object (see resolve_config).
75
+ _RENDER_MAP_KEYS = ("forNull", "forEmpty", "values")
76
+ # The viewer's cross-reference-value marker (structile.html's
77
+ # linkSettings/buildLinkSettingsPanel) — a dict value/table cell wrapped
78
+ # between these that names another dict/table in the document becomes a
79
+ # clickable link. Own `links` config field (see resolve_config), keyed
80
+ # `open`/`close` there — renamed from the Python-facing `linkOpen`/
81
+ # `linkClose` (unlike _RENDER_MAP_KEYS, whose Python names already match
82
+ # the viewer's own renderMap field names 1:1) since a bare top-level
83
+ # `open`/`close` option name would be a poor, ambiguous public API.
84
+ _LINK_KEYS = ("linkOpen", "linkClose")
85
+ _LINK_KEYS_TO_FIELD = {"linkOpen": "open", "linkClose": "close"}
86
+
87
+
88
+ def _validate(key: str, value: Any) -> None:
89
+ if key not in _OPTION_TYPES:
90
+ raise AttributeError(
91
+ f"structile: unknown option {key!r} (known options: "
92
+ + ", ".join(sorted(_OPTION_TYPES))
93
+ + ")"
94
+ )
95
+ if key == "theme":
96
+ if value not in _THEME_VALUES:
97
+ raise ValueError(f"structile: theme must be one of {_THEME_VALUES!r}, got {value!r}")
98
+ return
99
+ if key == "renderer":
100
+ if value not in _RENDERER_VALUES:
101
+ raise ValueError(f"structile: renderer must be one of {_RENDERER_VALUES!r}, got {value!r}")
102
+ return
103
+ if key in ("forNull", "forEmpty", "linkOpen", "linkClose"):
104
+ if not isinstance(value, str):
105
+ raise TypeError(f"structile: option {key!r} must be a str, got {type(value).__name__}")
106
+ return
107
+ if key == "values":
108
+ if not isinstance(value, dict) or not all(isinstance(k, str) and isinstance(v, str) for k, v in value.items()):
109
+ raise TypeError(f"structile: option 'values' must be a dict of str -> str, got {value!r}")
110
+ return
111
+ expected = _OPTION_TYPES[key]
112
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
113
+ raise TypeError(f"structile: option {key!r} must be a {expected.__name__}, got {type(value).__name__}")
114
+
115
+
116
+ class Options:
117
+ """A namespace of viewer settings, matplotlib.rcParams-style.
118
+
119
+ Every attribute defaults to `None` (unset). Assigning `None` back to an
120
+ attribute restores that "use the viewer's default" state.
121
+ """
122
+
123
+ __slots__ = tuple(_OPTION_TYPES)
124
+
125
+ def __init__(self) -> None:
126
+ for key in _OPTION_TYPES:
127
+ object.__setattr__(self, key, None)
128
+
129
+ def __setattr__(self, key: str, value: Any) -> None:
130
+ if value is not None:
131
+ _validate(key, value)
132
+ object.__setattr__(self, key, value)
133
+
134
+ def as_dict(self) -> Dict[str, Any]:
135
+ """The currently-set (non-`None`) options, as a plain dict."""
136
+ return {key: getattr(self, key) for key in _OPTION_TYPES if getattr(self, key) is not None}
137
+
138
+
139
+ options = Options()
140
+ """Module-level default `Options`, shared by every `open()` call that
141
+ doesn't override a given option itself."""
142
+
143
+
144
+ def set_option(name: str, value: Any) -> None:
145
+ """Set a module-level default option.
146
+
147
+ structile.set_option("theme", "dark")
148
+
149
+ Equivalent to `structile.options.<name> = value`. Pass `value=None` to
150
+ unset it again (falls back to the viewer's built-in default).
151
+ """
152
+ setattr(options, name, value)
153
+
154
+
155
+ def get_option(name: str) -> Optional[Any]:
156
+ """Read a module-level default option (`None` if unset)."""
157
+ if name not in _OPTION_TYPES:
158
+ raise AttributeError(f"structile: unknown option {name!r}")
159
+ return getattr(options, name)
160
+
161
+
162
+ def reset_option(name: str) -> None:
163
+ """Unset a module-level default option (falls back to the viewer default)."""
164
+ setattr(options, name, None)
165
+
166
+
167
+ def replace_options(new_options: Options) -> None:
168
+ """Overwrite every module-level default option from `new_options`,
169
+ including keys left `None`/unset in it (which resets that option back to
170
+ unset here too, rather than leaving a stale previous value behind).
171
+
172
+ Used by `StructileWidget`'s "Set as default" action (see widget.py's
173
+ `_handle_settings_changed`): the browser-side settings panel's FULL
174
+ current state — not a diff — becomes the new baseline for every
175
+ `open()` call for the rest of this process, matplotlib.rcParams-
176
+ style, same as any other `set_option()` call.
177
+ """
178
+ for key in _OPTION_TYPES:
179
+ setattr(options, key, getattr(new_options, key))
180
+
181
+
182
+ def resolve_config(overrides: Dict[str, Any], extra: Optional[Options] = None) -> Optional[Dict[str, Any]]:
183
+ """Merge module defaults + an optional per-call `Options` + per-call
184
+ keyword overrides (in that increasing order of precedence) into the
185
+ `config` object the viewer's embedded-mode protocol expects:
186
+ `{settings?: {...numeric keys...}, theme?: "light"|"dark", renderMap?:
187
+ {forNull?, forEmpty?, values?}, links?: {open?, close?}}`.
188
+
189
+ Returns `None` if nothing is set anywhere (config is entirely omitted,
190
+ same effect as an empty one).
191
+ """
192
+ if extra is not None and not isinstance(extra, Options):
193
+ raise TypeError(f"structile: config= must be an Options instance, got {type(extra).__name__}")
194
+ merged: Dict[str, Any] = dict(options.as_dict())
195
+ if extra is not None:
196
+ merged.update(extra.as_dict())
197
+ for key, value in overrides.items():
198
+ if value is None:
199
+ continue
200
+ _validate(key, value)
201
+ merged[key] = value
202
+ for key in _NON_VIEWER_OPTIONS:
203
+ merged.pop(key, None)
204
+ if not merged:
205
+ return None
206
+ theme = merged.pop("theme", None)
207
+ render_map = {key: merged.pop(key) for key in _RENDER_MAP_KEYS if key in merged}
208
+ links = {
209
+ _LINK_KEYS_TO_FIELD[key]: merged.pop(key) for key in _LINK_KEYS if key in merged
210
+ }
211
+ config: Dict[str, Any] = {}
212
+ if merged:
213
+ config["settings"] = merged
214
+ if theme is not None:
215
+ config["theme"] = theme
216
+ if render_map:
217
+ config["renderMap"] = render_map
218
+ if links:
219
+ config["links"] = links
220
+ return config
221
+
222
+
223
+ def options_from_dict(mapping: Dict[str, Any]) -> Options:
224
+ """Build an `Options` instance from a plain dict — e.g. `config={"gap":
225
+ 8, "forNull": "N/A"}` instead of `config=Options()` +
226
+ attribute-assignment. Same per-key validation as setting each attribute
227
+ individually (unknown key -> `AttributeError`, wrong type -> `TypeError`),
228
+ just collected up front instead of one at a time."""
229
+ opts = Options()
230
+ for key, value in mapping.items():
231
+ setattr(opts, key, value)
232
+ return opts
233
+
234
+
235
+ def resolve_renderer(explicit: Optional[str] = None, extra: Optional[Options] = None) -> Optional[str]:
236
+ """Resolve the active renderer name, matplotlib-backend style, in
237
+ increasing precedence: an explicit `renderer=` argument -> the
238
+ `STRUCTILE_RENDERER` env var -> a per-call `config=Options()` -> the
239
+ module-level default (`structile.options.renderer` /
240
+ `set_option("renderer", ...)` / `use(...)`).
241
+
242
+ Returns `None` if nothing is set anywhere — the caller should then
243
+ auto-detect (see `render.detect_renderer`).
244
+ """
245
+ if explicit is not None:
246
+ _validate("renderer", explicit)
247
+ logger.debug("structile: renderer=%r (explicit)", explicit)
248
+ return explicit
249
+ env = os.environ.get("STRUCTILE_RENDERER")
250
+ if env:
251
+ _validate("renderer", env)
252
+ logger.debug("structile: renderer=%r (STRUCTILE_RENDERER)", env)
253
+ return env
254
+ if extra is not None and not isinstance(extra, Options):
255
+ raise TypeError(f"structile: config= must be an Options instance, got {type(extra).__name__}")
256
+ if extra is not None and extra.renderer is not None:
257
+ logger.debug("structile: renderer=%r (config=)", extra.renderer)
258
+ return extra.renderer
259
+ if options.renderer is not None:
260
+ logger.debug("structile: renderer=%r (module default)", options.renderer)
261
+ return options.renderer
262
+
263
+
264
+ def use(renderer: str) -> None:
265
+ """Set the module-level default renderer, `matplotlib.use()`-style.
266
+
267
+ structile.use("browser")
268
+
269
+ Equivalent to `set_option("renderer", renderer)` / `options.renderer =
270
+ renderer`. See `structile.render.RENDERERS` for the valid names.
271
+ """
272
+ set_option("renderer", renderer)
structile/convert.py ADDED
@@ -0,0 +1,247 @@
1
+ """structile.convert() — format conversion outside the widget/notebook.
2
+
3
+ Two built-in formats, "json" and "python" (Python-repr — the same dialect
4
+ structile.html's own parsePythonRepr/serializePythonRepr read and
5
+ write: dict keys always stringified, tuples collapse to lists, frozenset
6
+ reads back as set — see _parse_python_repr/_serialize_python_repr below),
7
+ are parsed and serialized entirely in Python: no extra dependency, no
8
+ subprocess. Any other format ("xml"/"html", or a custom format like "ini")
9
+ is not built in at all — it only exists via a caller-supplied interpreter
10
+ script (the same interpretXML(xmlDocument)/serializeXML(value) — markup —
11
+ or interpretText(text)/serializeText(value) — anything else — contract
12
+ structile.open(..., interpreter=...) and structile.html's own
13
+ embedded-mode interpreter loading use), which is JS and has to run as JS —
14
+ see .interpreters.run_interpreter_source, which runs it in an embedded V8
15
+ engine (the `mini-racer` package) rather than a real browser. That split IS
16
+ the module's whole design: built-in <-> built-in needs nothing extra; the
17
+ moment either side isn't "json"/"python", the optional 'mini-racer' package
18
+ becomes required (no Node.js, no npm, no jsdom, no system install — see
19
+ dom_lite.py/dom_lite.js for how "xml"/"html" get a DOM without a real
20
+ browser or jsdom).
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import ast
25
+ import json
26
+ import pathlib
27
+ import re
28
+ from typing import Any, Optional, Tuple, Union
29
+
30
+ from ._paths import as_path_like, read_text
31
+ from .interpreters import (
32
+ CANONICAL_EXT_FOR_FORMAT,
33
+ InterpreterSpec,
34
+ get_registered_interpreter,
35
+ resolve_candidates,
36
+ select_interpreter,
37
+ )
38
+ from .serialize import _normalize_float, normalize
39
+
40
+ _FORMAT_BY_EXT = {".json": "json", ".py": "python", ".xml": "xml", ".html": "html", ".htm": "html"}
41
+ # A bare "name.ext" string (no whitespace/braces/quotes — i.e. nothing that
42
+ # could plausibly be literal data) with one of the recognized extensions:
43
+ # almost certainly a path the caller meant to exist, so a missing file there
44
+ # should say so clearly rather than silently falling through to being
45
+ # sniffed — and likely misparsed — as literal source text instead. Only
46
+ # covers the extensions _infer_source can sniff a format for without any
47
+ # other context (see its own fallback for anything else) — a bare
48
+ # "config.ini"-shaped missing path still gets a FileNotFoundError, just via
49
+ # a different branch (no interpreter registered for ".ini" either).
50
+ _BARE_FILENAME_RE = re.compile(r'^[^\s{}\[\]()\'"<>]+\.(json|py|xml|html?)$', re.IGNORECASE)
51
+
52
+
53
+ def _canonical_ext_for(fmt: str) -> Optional[str]:
54
+ """The registry-lookup key for a format with no real file extension of
55
+ its own — `dst_format` is always just a string, never a path.
56
+ CANONICAL_EXT_FOR_FORMAT covers "xml"/"html" explicitly; any other
57
+ format falls back to "." + fmt, the same convention _infer_source's own
58
+ unrecognized-extension fallback (below) uses in the other direction."""
59
+ return CANONICAL_EXT_FOR_FORMAT.get(fmt) or (("." + fmt) if fmt else None)
60
+
61
+
62
+ def convert(
63
+ src: Union[str, pathlib.Path],
64
+ dst_format: str,
65
+ interpreter: Optional[InterpreterSpec] = None,
66
+ ) -> str:
67
+ """Convert data from one of the viewer's formats to another, entirely
68
+ outside the widget/notebook — parse `src`, then serialize the result as
69
+ `dst_format`, returning the converted text. See README.md ("Converting
70
+ between formats") for the built-in-vs-JS-runtime boundary and examples.
71
+
72
+ src: a path (format inferred from its extension: .json/.py/.xml/.html —
73
+ or, once an interpreter is attached to it, any other extension too,
74
+ e.g. .ini) or a literal string of source text (sniffed: JSON, else
75
+ Python-repr, else XML if it looks like markup — there's no
76
+ `src_format=` parameter, so a literal string for any OTHER format
77
+ needs a `.` path instead, since there's no reliable way to sniff an
78
+ arbitrary text format from content alone the way XML's leading `<`
79
+ works).
80
+ dst_format: `"json"` | `"python"` | `"xml"` | `"html"` | any other
81
+ string an interpreter is registered/provided for (e.g. `"ini"`).
82
+ interpreter: a path to a `.js` interpreter script, raw JS source, or a
83
+ list of candidates tried in order (same contract as
84
+ `structile.open(..., interpreter=...)` — see `select_interpreter`) —
85
+ required whenever `src` or `dst_format` isn't `"json"`/`"python"`
86
+ and nothing is registered for the relevant extension (see
87
+ `register_interpreter`); needs the optional `mini-racer` package
88
+ (`pip install mini-racer`) to run it — no Node.js, no jsdom, no
89
+ system install (see interpreters.run_interpreter_source). Converting
90
+ purely between "json" and "python" needs nothing extra at all.
91
+ """
92
+ text, src_format, src_ext = _infer_source(src, interpreter)
93
+ value = _parse_by_format(text, src_format, interpreter, src_ext)
94
+ return _serialize_by_format(value, dst_format, interpreter, _canonical_ext_for(dst_format))
95
+
96
+
97
+ def _infer_source(src: Union[str, pathlib.Path], interpreter: Optional[InterpreterSpec]) -> Tuple[str, str, Optional[str]]:
98
+ p = as_path_like(src)
99
+ if p is not None:
100
+ ext = p.suffix.lower()
101
+ fmt = _FORMAT_BY_EXT.get(ext)
102
+ if fmt is None:
103
+ # Not one of the built-in-recognized extensions — but a real
104
+ # format the moment an interpreter is actually attached to it
105
+ # (explicit interpreter=, or something registered), the same
106
+ # "only once something's attached" guard open()'s own
107
+ # markup-mode trigger uses. Otherwise this is almost certainly a
108
+ # genuine typo/unsupported file, so keep raising clearly rather
109
+ # than silently attempting a format nothing can ever satisfy.
110
+ if interpreter is not None or get_registered_interpreter(ext) is not None:
111
+ fmt = ext.lstrip(".")
112
+ else:
113
+ raise ValueError(f"structile.convert: can't infer a format from {p.name!r}'s extension")
114
+ return read_text(p), fmt, ext
115
+ if not isinstance(src, str):
116
+ raise TypeError("structile.convert: src must be a file path or a string of source text")
117
+ # Reached only when `src` is NOT an existing file (as_path_like already
118
+ # would have matched it above otherwise) — so a bare "name.ext"-shaped
119
+ # string here means a path that doesn't exist, not literal data.
120
+ if _BARE_FILENAME_RE.match(src.strip()):
121
+ raise FileNotFoundError(f"structile.convert: no such file: {src}")
122
+ stripped = src.lstrip()
123
+ if stripped.startswith("<"):
124
+ # Same heuristic as looksLikeHtmlDoc in structile.html.
125
+ looks_html = re.match(r"(?i)<!doctype\s+html|<html[\s>]", stripped) is not None
126
+ fmt = "html" if looks_html else "xml"
127
+ return src, fmt, CANONICAL_EXT_FOR_FORMAT[fmt]
128
+ try:
129
+ json.loads(src) # try strict JSON first (the common case), same precedence as detectAndParseDataText
130
+ return src, "json", None
131
+ except ValueError:
132
+ return src, "python", None
133
+
134
+
135
+ def _parse_by_format(text: str, fmt: str, interpreter: Optional[InterpreterSpec], ext: Optional[str]) -> Any:
136
+ if fmt == "json":
137
+ return json.loads(text)
138
+ if fmt == "python":
139
+ return _parse_python_repr(text)
140
+ # Any other format (xml/html/ini/...) runs through an interpreter —
141
+ # select_interpreter raises a clear error itself if none is available.
142
+ candidates = resolve_candidates(interpreter, ext)
143
+ return select_interpreter(candidates, fmt, op="parse", text=text, run=True).result
144
+
145
+
146
+ def _serialize_by_format(value: Any, fmt: str, interpreter: Optional[InterpreterSpec], ext: Optional[str]) -> str:
147
+ if fmt == "json":
148
+ return json.dumps(normalize(value), indent=2)
149
+ if fmt == "python":
150
+ return _serialize_python_repr(value)
151
+ candidates = resolve_candidates(interpreter, ext)
152
+ return select_interpreter(candidates, fmt, op="serialize", value=value, run=True).result
153
+
154
+
155
+ # ---- "python" (Python-repr) — parse -----------------------------------
156
+ # A hand-rolled walk over the stdlib's own `ast`, not ast.literal_eval:
157
+ # literal_eval can't parse `set()`/`frozenset()` (those are Call nodes, not
158
+ # literal syntax) even though serializePythonRepr emits exactly that for an
159
+ # empty set — so this needs to be literal_eval's node-walk plus that one
160
+ # extra case, not literal_eval itself.
161
+ def _parse_python_repr(text: str) -> Any:
162
+ try:
163
+ tree = ast.parse(text, mode="eval")
164
+ except SyntaxError as e:
165
+ raise ValueError(f"structile.convert: not valid Python literal syntax: {e}") from e
166
+ return _eval_literal_node(tree.body)
167
+
168
+
169
+ def _eval_literal_node(node: ast.AST) -> Any:
170
+ if isinstance(node, ast.Constant):
171
+ if node.value is None or isinstance(node.value, (bool, int, float, str)):
172
+ return node.value
173
+ raise ValueError(f"structile.convert: unsupported literal {node.value!r} in Python-repr data")
174
+ if isinstance(node, ast.UnaryOp) and isinstance(node.op, (ast.USub, ast.UAdd)):
175
+ v = _eval_literal_node(node.operand)
176
+ return -v if isinstance(node.op, ast.USub) else v
177
+ if isinstance(node, ast.List):
178
+ return [_eval_literal_node(e) for e in node.elts]
179
+ if isinstance(node, ast.Tuple):
180
+ return [_eval_literal_node(e) for e in node.elts] # tuples collapse to lists, matching the viewer's own dialect
181
+ if isinstance(node, ast.Set):
182
+ return {_eval_literal_node(e) for e in node.elts}
183
+ if isinstance(node, ast.Dict):
184
+ if any(k is None for k in node.keys): # a None key node means **unpacking, e.g. {**other}
185
+ raise ValueError("structile.convert: dict unpacking (**) isn't valid Python-repr literal syntax")
186
+ return {
187
+ _js_like_key(_eval_literal_node(k)): _eval_literal_node(v)
188
+ for k, v in zip(node.keys, node.values)
189
+ }
190
+ if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id in ("set", "frozenset"):
191
+ if node.keywords or len(node.args) > 1:
192
+ raise ValueError("structile.convert: set()/frozenset() takes 0 or 1 argument")
193
+ if not node.args:
194
+ return set()
195
+ inner = _eval_literal_node(node.args[0])
196
+ if isinstance(inner, (set, list)):
197
+ return set(inner)
198
+ raise ValueError("structile.convert: set()/frozenset() expects a list, tuple, or set argument")
199
+ raise ValueError(f"structile.convert: unsupported Python literal syntax ({ast.dump(node)})")
200
+
201
+
202
+ def _js_like_key(value: Any) -> str:
203
+ """Mirrors parsePythonRepr's `String(k)` dict-key coercion (structile.html)
204
+ for the scalar cases that matter in practice; a composite
205
+ key (list/dict/set used as a key) is rare enough to not chase JS's exact
206
+ String() stringification of objects — falls back to Python's str()."""
207
+ if value is None:
208
+ return "null"
209
+ if value is True:
210
+ return "true"
211
+ if value is False:
212
+ return "false"
213
+ return str(value)
214
+
215
+
216
+ # ---- "python" (Python-repr) — serialize --------------------------------
217
+ # The exact inverse of the parse above, matching serializePythonRepr
218
+ # (structile.html) — Python's own repr() would work for the scalar
219
+ # cases too, but not for None/bool (Python's None/True/False spelling
220
+ # happens to already match; kept explicit here for symmetry with the parser
221
+ # above and because it's what pins down the dict/list/set container syntax).
222
+ def _serialize_python_repr(value: Any) -> str:
223
+ if value is None:
224
+ return "None"
225
+ if isinstance(value, bool):
226
+ return "True" if value else "False"
227
+ if isinstance(value, int):
228
+ return str(value) # exact for any size, same as serializePythonRepr's String(v) for a bigint
229
+ if isinstance(value, float):
230
+ return repr(_normalize_float(value)) # non-finite -> None -> "None"; ast can't parse bare inf/nan anyway
231
+ if isinstance(value, str):
232
+ return repr(value)
233
+ if isinstance(value, (set, frozenset)):
234
+ if not value:
235
+ return "set()"
236
+ try:
237
+ items = sorted(value)
238
+ except TypeError:
239
+ items = list(value)
240
+ return "{" + ", ".join(_serialize_python_repr(v) for v in items) + "}"
241
+ if isinstance(value, (list, tuple)):
242
+ return "[" + ", ".join(_serialize_python_repr(v) for v in value) + "]"
243
+ if isinstance(value, dict):
244
+ if not value:
245
+ return "{}"
246
+ return "{" + ", ".join(f"{_serialize_python_repr(k)}: {_serialize_python_repr(v)}" for k, v in value.items()) + "}"
247
+ raise TypeError(f"structile.convert: don't know how to serialize a value of type {type(value).__name__!r} to python-repr")
structile/dom_lite.js ADDED
@@ -0,0 +1,70 @@
1
+ // dom_lite.js — minimal DOM built from a plain Python-parsed tree (see
2
+ // structile/_dom_lite.py), for structile.convert()'s xml/html
3
+ // interpreter path. Loaded into the same mini-racer context as the
4
+ // interpreter script itself, ahead of it — see interpreters.py's
5
+ // run_interpreter_source, which is the only place this ever runs (not part
6
+ // of the browser bundle: structile.html hands interpreters a REAL
7
+ // browser DOM via its own DOMParser, untouched by any of this).
8
+ //
9
+ // NOT jsdom, not a general DOM implementation — only the subset
10
+ // interpretXML/serializeXML scripts actually use: tagName, getAttribute,
11
+ // children, textContent, plus documentElement/doctype/body at the document
12
+ // level (see README.md's "Custom formats via an interpreter" section for
13
+ // the documented contract). A custom interpreter reaching for anything
14
+ // beyond this (namespaces, siblings, attributes as a NamedNodeMap,
15
+ // querySelector with a real CSS selector, ...) isn't supported here.
16
+
17
+ function __structileBuildElement(node){
18
+ const attrs = node.attrs || {};
19
+ const children = (node.children || []).map(__structileBuildElement);
20
+ return {
21
+ tagName: node.tag,
22
+ children: children,
23
+ textContent: node.text || "",
24
+ getAttribute: function(name){
25
+ return Object.prototype.hasOwnProperty.call(attrs, name) ? attrs[name] : null;
26
+ },
27
+ hasAttribute: function(name){ return Object.prototype.hasOwnProperty.call(attrs, name); },
28
+ attributes: attrs,
29
+ // Bare tag-name lookup only (no class/id/attribute selectors) — a depth-
30
+ // first search of this element's own descendants, matching the one way
31
+ // the bundled interpreters ever call this (generic_xml.js's childElementsXml
32
+ // uses .children directly instead; this exists for parity/forward use).
33
+ querySelector: function(tag){
34
+ const want = String(tag).toLowerCase();
35
+ const stack = this.children.slice();
36
+ while (stack.length){
37
+ const cur = stack.shift();
38
+ if (cur.tagName.toLowerCase() === want) return cur;
39
+ stack.push.apply(stack, cur.children);
40
+ }
41
+ return null;
42
+ },
43
+ };
44
+ }
45
+ function __structileBuildDocument(payload){
46
+ return {
47
+ documentElement: __structileBuildElement(payload.root),
48
+ doctype: payload.hasDoctype ? { name: "html" } : null,
49
+ body: payload.body ? __structileBuildElement(payload.body) : null,
50
+ };
51
+ }
52
+ // JSON has no Set — mirrors structile/interpreters.py's _mark_sets/
53
+ // _unmark_sets on the Python side of this exact same boundary (a Python
54
+ // set/frozenset going INTO this context, or a JS Set an interpreter
55
+ // constructs going back OUT — see run_interpreter_source).
56
+ const __STRUCTILE_SET_MARK = "__structile_set__";
57
+ function __structileMarkSets(v){
58
+ if (v instanceof Set) return { [__STRUCTILE_SET_MARK]: Array.from(v).map(__structileMarkSets) };
59
+ if (Array.isArray(v)) return v.map(__structileMarkSets);
60
+ if (v && typeof v === "object"){ const o = {}; for (const k of Object.keys(v)) o[k] = __structileMarkSets(v[k]); return o; }
61
+ return v;
62
+ }
63
+ function __structileUnmarkSets(v){
64
+ if (v && typeof v === "object" && !Array.isArray(v) &&
65
+ Object.keys(v).length === 1 && Array.isArray(v[__STRUCTILE_SET_MARK]))
66
+ return new Set(v[__STRUCTILE_SET_MARK].map(__structileUnmarkSets));
67
+ if (Array.isArray(v)) return v.map(__structileUnmarkSets);
68
+ if (v && typeof v === "object"){ const o = {}; for (const k of Object.keys(v)) o[k] = __structileUnmarkSets(v[k]); return o; }
69
+ return v;
70
+ }