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