structile 6.1.2__tar.gz → 6.3.0__tar.gz

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.
Files changed (31) hide show
  1. {structile-6.1.2/structile.egg-info → structile-6.3.0}/PKG-INFO +6 -1
  2. {structile-6.1.2 → structile-6.3.0}/README.pypi.md +5 -0
  3. structile-6.3.0/VERSION +1 -0
  4. {structile-6.1.2 → structile-6.3.0}/structile/__init__.py +9 -0
  5. {structile-6.1.2 → structile-6.3.0}/structile/_paths.py +54 -1
  6. structile-6.3.0/structile/custom_html.py +67 -0
  7. {structile-6.1.2 → structile-6.3.0}/structile/interpreters.py +31 -0
  8. structile-6.3.0/structile/manifest.py +87 -0
  9. {structile-6.1.2 → structile-6.3.0}/structile/render.py +4 -21
  10. structile-6.3.0/structile/static/structile.prod.html +183 -0
  11. {structile-6.1.2 → structile-6.3.0/structile.egg-info}/PKG-INFO +6 -1
  12. {structile-6.1.2 → structile-6.3.0}/structile.egg-info/SOURCES.txt +2 -0
  13. structile-6.1.2/VERSION +0 -1
  14. structile-6.1.2/structile/static/structile.prod.html +0 -183
  15. {structile-6.1.2 → structile-6.3.0}/LICENSE +0 -0
  16. {structile-6.1.2 → structile-6.3.0}/MANIFEST.in +0 -0
  17. {structile-6.1.2 → structile-6.3.0}/pyproject.toml +0 -0
  18. {structile-6.1.2 → structile-6.3.0}/setup.cfg +0 -0
  19. {structile-6.1.2 → structile-6.3.0}/structile/__main__.py +0 -0
  20. {structile-6.1.2 → structile-6.3.0}/structile/_dom_lite.py +0 -0
  21. {structile-6.1.2 → structile-6.3.0}/structile/_log.py +0 -0
  22. {structile-6.1.2 → structile-6.3.0}/structile/_plugins.py +0 -0
  23. {structile-6.1.2 → structile-6.3.0}/structile/config.py +0 -0
  24. {structile-6.1.2 → structile-6.3.0}/structile/convert.py +0 -0
  25. {structile-6.1.2 → structile-6.3.0}/structile/dom_lite.js +0 -0
  26. {structile-6.1.2 → structile-6.3.0}/structile/serialize.py +0 -0
  27. {structile-6.1.2 → structile-6.3.0}/structile/static/widget.js +0 -0
  28. {structile-6.1.2 → structile-6.3.0}/structile/widget.py +0 -0
  29. {structile-6.1.2 → structile-6.3.0}/structile.egg-info/dependency_links.txt +0 -0
  30. {structile-6.1.2 → structile-6.3.0}/structile.egg-info/requires.txt +0 -0
  31. {structile-6.1.2 → structile-6.3.0}/structile.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: structile
3
- Version: 6.1.2
3
+ Version: 6.3.0
4
4
  Summary: A spatial editor and diff tool for structured data, inline in Jupyter
5
5
  License-Expression: Apache-2.0
6
6
  Requires-Python: >=3.8
@@ -232,6 +232,11 @@ data:
232
232
  extension): `interpretText(text)` (and optionally `serializeText(value)`),
233
233
  given the raw text directly — no DOM involved at all.
234
234
 
235
+ An interpreter script may also declare two optional top-level string
236
+ constants, `INTERPRETER_NAME`/`INTERPRETER_VERSION`, purely to
237
+ self-identify — the standalone viewer's `[?]` About popover shows them for
238
+ whichever interpreter is currently active, when either is declared.
239
+
235
240
  `open()` never needs anything beyond its own dependencies for
236
241
  either case: every renderer (including `widget`) forwards the interpreter
237
242
  source(s) as-is and lets the browser try them client-side. `convert()`
@@ -204,6 +204,11 @@ data:
204
204
  extension): `interpretText(text)` (and optionally `serializeText(value)`),
205
205
  given the raw text directly — no DOM involved at all.
206
206
 
207
+ An interpreter script may also declare two optional top-level string
208
+ constants, `INTERPRETER_NAME`/`INTERPRETER_VERSION`, purely to
209
+ self-identify — the standalone viewer's `[?]` About popover shows them for
210
+ whichever interpreter is currently active, when either is declared.
211
+
207
212
  `open()` never needs anything beyond its own dependencies for
208
213
  either case: every renderer (including `widget`) forwards the interpreter
209
214
  source(s) as-is and lets the browser try them client-side. `convert()`
@@ -0,0 +1 @@
1
+ 6.3.0
@@ -24,10 +24,12 @@ from ._paths import (
24
24
  as_path_like as _as_path_like,
25
25
  read_interpreter as _read_interpreter,
26
26
  read_text as _read_text,
27
+ set_viewer_html,
27
28
  strict_json_loads as _strict_json_loads,
28
29
  )
29
30
  from .config import Options, get_option, options, options_from_dict, replace_options, reset_option, resolve_config, resolve_renderer, set_option, use
30
31
  from .convert import convert
32
+ from .custom_html import build_custom_html
31
33
  from .interpreters import (
32
34
  CANONICAL_EXT_FOR_FORMAT as _CANONICAL_EXT_FOR_FORMAT,
33
35
  InterpreterSpec,
@@ -35,9 +37,11 @@ from .interpreters import (
35
37
  _normalize_ext,
36
38
  get_registered_interpreter,
37
39
  register_interpreter,
40
+ register_interpreters_from_manifest,
38
41
  resolve_candidates as _resolve_interpreter_candidates,
39
42
  unregister_interpreter,
40
43
  )
44
+ from .manifest import InterpreterManifestEntry, load_manifest
41
45
  from ._plugins import PluginRecord, load_plugins, plugins
42
46
  from .render import (
43
47
  DiffRenderHandle,
@@ -85,6 +89,11 @@ __all__ = [
85
89
  "load_plugins",
86
90
  "plugins",
87
91
  "PluginRecord",
92
+ "build_custom_html",
93
+ "InterpreterManifestEntry",
94
+ "load_manifest",
95
+ "register_interpreters_from_manifest",
96
+ "set_viewer_html",
88
97
  "__version__",
89
98
  ]
90
99
 
@@ -26,7 +26,9 @@ def _viewer_html_override() -> Optional[str]:
26
26
  def find_viewer_html() -> pathlib.Path:
27
27
  """Locate structile.html. Tries, in order:
28
28
 
29
- 1. `STRUCTILE_VIEWER_HTML` env var, if set (dev/testing override).
29
+ 1. `STRUCTILE_VIEWER_HTML` env var, if set — a dev/testing override,
30
+ or how a custom-distribution wrapper package (see `set_viewer_html`
31
+ below) points every renderer at its own bundled, customized HTML.
30
32
  2. The human-readable dev source at the repo root — present for an
31
33
  editable, in-repo install (`pip install -e .`) or when just running
32
34
  this repo's own tests/scripts/build_samples.py. Always preferred
@@ -56,6 +58,26 @@ def find_viewer_html() -> pathlib.Path:
56
58
  )
57
59
 
58
60
 
61
+ def set_viewer_html(path: Optional[Union[str, pathlib.Path]]) -> None:
62
+ """Point every `structile` renderer (`open()`, `diff()`, the `widget`/
63
+ `browser`/`file` renderers, `.to_html()`) at a specific viewer HTML
64
+ file — `None` reverts to `find_viewer_html()`'s normal resolution.
65
+
66
+ This is the documented way for a thin wrapper package to ship its own
67
+ customized `structile.html` (e.g. one built with interpreters baked in
68
+ via `structile.build_custom_html`) without forking `structile` itself:
69
+ call this once, from the wrapper package's own `__init__.py`, with the
70
+ path to its bundled HTML. It's a thin wrapper around the
71
+ `STRUCTILE_VIEWER_HTML` env var (read fresh by `find_viewer_html()` on
72
+ every call, never cached), just under a name that documents this as a
73
+ supported integration point rather than only a dev/testing knob.
74
+ """
75
+ if path is None:
76
+ os.environ.pop("STRUCTILE_VIEWER_HTML", None)
77
+ else:
78
+ os.environ["STRUCTILE_VIEWER_HTML"] = str(path)
79
+
80
+
59
81
  @dataclass(frozen=True)
60
82
  class InterpreterSource:
61
83
  """Interpreter JS source text supplied directly, rather than read from a
@@ -131,6 +153,37 @@ def read_interpreter(source: Union[str, pathlib.Path, InterpreterSource]) -> str
131
153
  return read_text(p) if p is not None else source # a bare str that isn't a file is raw JS source text
132
154
 
133
155
 
156
+ # The one place this module depends on the exact shape of structile.html's
157
+ # main <script> tag, identified by the "use strict" that immediately
158
+ # follows it on the next line (the bare substring "<script>" alone also
159
+ # appears inside a couple of comments elsewhere in the file — see AGENTS.md's
160
+ # note on that CSP-injection lesson from the VS Code extension). Shared by
161
+ # render.py's `window.__STRUCTILE_SNAPSHOT__` bootstrap and
162
+ # custom_html.py's baked-interpreter registry — both just need an arbitrary
163
+ # JSON-safe value visible to the main script before it runs.
164
+ SNAPSHOT_ANCHOR = '\n<script>\n"use strict";'
165
+
166
+
167
+ def inject_global_script(html_text: str, var_name: str, value: Any, *, anchor: str = SNAPSHOT_ANCHOR) -> str:
168
+ """Splice `window.<var_name> = <value as JSON>;` into `html_text`,
169
+ right before `anchor`. Repeated calls compose correctly regardless of
170
+ order: each insertion lands before the same anchor text, which is
171
+ never itself touched, so every injected script still precedes the main
172
+ <script> that reads it."""
173
+ idx = html_text.find(anchor)
174
+ if idx == -1:
175
+ raise RuntimeError(
176
+ "structile: could not find structile.html's main <script> tag "
177
+ "to inject before — has the viewer's structure changed?"
178
+ )
179
+ # `<` -> <, same escaping the VS Code extension's jsonForScript
180
+ # uses, so a "</script"-shaped substring anywhere in the data can't
181
+ # break out of this <script> block.
182
+ value_json = json.dumps(value).replace("<", "\\u003c")
183
+ snippet = "\n<script>\nwindow." + var_name + " = " + value_json + ";\n</script>\n"
184
+ return html_text[:idx] + snippet + html_text[idx:]
185
+
186
+
134
187
  def strict_json_loads(text: str) -> Any:
135
188
  """`json.loads`, but rejecting the non-standard `NaN`/`Infinity`/
136
189
  `-Infinity` constants CPython's `json` module otherwise accepts by
@@ -0,0 +1,67 @@
1
+ """Build a customized `structile.html` with a curated set of interpreters
2
+ baked directly in, at build time — see README.md's "Building a custom/
3
+ branded distribution" section for the full story and
4
+ `tests/fixtures/structile_demo_custom_distribution/` for a working
5
+ template.
6
+
7
+ Opening the resulting HTML and dropping only a data file of a baked
8
+ extension resolves it immediately — no companion `.js` file ever needed,
9
+ for the extensions this specific build chose to bundle. Stock
10
+ `structile.html` (no manifest applied) is unaffected: the client-side
11
+ registry this bakes into (`window.__STRUCTILE_BAKED_INTERPRETERS__`) is
12
+ empty by default, and `structile.html`'s own extension list
13
+ (`STRUCTURED_EXT_FORMAT`) is only ever merged with it, never replaced by
14
+ it — see the comment above `BAKED_INTERPRETERS` in structile.html.
15
+
16
+ The trust boundary here is deliberately at build time, not runtime: this
17
+ function reads and embeds whatever JS the manifest points at, verbatim —
18
+ the same trust a user pasting/dropping an interpreter into a running
19
+ session extends to it for that session, just decided once, by whoever
20
+ runs this function, instead of by whoever later opens the file.
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import pathlib
25
+ from typing import Dict, Optional, Union
26
+
27
+ from ._paths import find_viewer_html, inject_global_script, read_interpreter
28
+ from .manifest import ManifestSource, load_manifest
29
+
30
+
31
+ def _normalize_ext(ext: str) -> str:
32
+ ext = ext.lower()
33
+ return ext if ext.startswith(".") else "." + ext
34
+
35
+
36
+ def build_custom_html(
37
+ manifest: ManifestSource,
38
+ *,
39
+ source_html: Optional[Union[str, pathlib.Path]] = None,
40
+ output: Optional[Union[str, pathlib.Path]] = None,
41
+ ) -> str:
42
+ """Bake `manifest`'s `{extensions, path, name, format}` entries (see
43
+ `structile.manifest.load_manifest` for accepted `manifest` shapes) into
44
+ a copy of `structile.html`, and return the resulting HTML text.
45
+
46
+ `source_html`: the base HTML to bake into — defaults to
47
+ `find_viewer_html()`'s own resolution (the dev source next to this
48
+ repo, or the bundled production build). Pass an explicit path to
49
+ bake into a specific build instead (e.g. an already-minified
50
+ `structile.prod.html`).
51
+ `output`: if given, the resulting HTML is also written there.
52
+ """
53
+ entries = load_manifest(manifest)
54
+ base_html = (
55
+ pathlib.Path(source_html).read_text(encoding="utf-8")
56
+ if source_html is not None
57
+ else find_viewer_html().read_text(encoding="utf-8")
58
+ )
59
+ baked: Dict[str, Dict[str, str]] = {}
60
+ for entry in entries:
61
+ text = read_interpreter(entry.path)
62
+ for ext in entry.extensions:
63
+ baked[_normalize_ext(ext)] = {"format": entry.format, "source": text, "name": entry.name}
64
+ html_text = inject_global_script(base_html, "__STRUCTILE_BAKED_INTERPRETERS__", baked)
65
+ if output is not None:
66
+ pathlib.Path(output).write_text(html_text, encoding="utf-8")
67
+ return html_text
@@ -19,6 +19,14 @@ see `select_interpreter` below), it's a much smaller DOM-lite object built
19
19
  from a Python-parsed tree instead (see dom_lite.py/dom_lite.js) — no Node.js,
20
20
  no jsdom, just an embedded JS engine (`mini-racer`).
21
21
 
22
+ An interpreter script may also declare two optional top-level string
23
+ constants, `INTERPRETER_NAME`/`INTERPRETER_VERSION`, purely for
24
+ self-identification — never read or required by anything in this module
25
+ (name/version resolution happens entirely client-side, in
26
+ structile.html's `buildInterpreterFns`, the same place the
27
+ `interpretXML`/`serializeXML`/etc. functions themselves get pulled out),
28
+ surfaced in the standalone viewer's `[?]` About popover.
29
+
22
30
  This module is generic across every extension AND across which contract an
23
31
  interpreter uses, on purpose: HTML isn't treated as a different *kind* of
24
32
  thing from XML anywhere below, only as a different DOMParser *mode*
@@ -39,6 +47,7 @@ from typing import Any, Dict, List, Optional, Sequence, Union
39
47
  from ._dom_lite import build_document_payload
40
48
  from ._log import logger
41
49
  from ._paths import InterpreterSource, read_interpreter
50
+ from .manifest import ManifestSource, load_manifest
42
51
 
43
52
  # A single candidate: a path, a bare str (a path if it names an existing
44
53
  # file, else literal JS source text — see read_interpreter), or an explicit
@@ -114,6 +123,28 @@ def unregister_interpreter(ext: str) -> None:
114
123
  register_interpreter(ext, None)
115
124
 
116
125
 
126
+ def register_interpreters_from_manifest(manifest: ManifestSource) -> None:
127
+ """Call `register_interpreter()` once per entry in `manifest` (see
128
+ `structile.manifest.load_manifest` for accepted shapes) — the
129
+ Python-side counterpart to `structile.custom_html.build_custom_html`,
130
+ so a custom distribution can drive both the standalone-HTML baking and
131
+ the Python registry from the exact same manifest instead of
132
+ maintaining two separate lists.
133
+
134
+ A plugin's own `register(registry)` callback can't call this directly
135
+ — the facade it receives only exposes `register_interpreter()` (see
136
+ `_plugins.py`'s `_RegistryFacade`) — so loop over
137
+ `structile.manifest.load_manifest(...)` and call
138
+ `registry.register_interpreter(ext, InterpreterSource(text, name=...))`
139
+ per entry there instead; see README.md's "Building a custom/branded
140
+ distribution" section for a worked example.
141
+ """
142
+ for entry in load_manifest(manifest):
143
+ text = read_interpreter(entry.path)
144
+ for ext in entry.extensions:
145
+ register_interpreter(ext, InterpreterSource(text, name=entry.name))
146
+
147
+
117
148
  def get_registered_interpreter(ext: str) -> Optional[InterpreterSpec]:
118
149
  """The raw spec registered for `ext` — a single candidate, a list, or
119
150
  `None` if nothing's registered. See `resolve_candidates` for the
@@ -0,0 +1,87 @@
1
+ """Shared interpreter manifest — the one list a company's custom
2
+ distribution re-reads for two different things: baking interpreters into a
3
+ standalone `structile.html` (see `custom_html.build_custom_html`) and
4
+ registering them with the Python-side registry, either directly
5
+ (`interpreters.register_interpreters_from_manifest`) or from inside an
6
+ entry-point plugin's own `register(registry)` callback (see
7
+ `_plugins.py`'s `_RegistryFacade` — a plugin only ever gets
8
+ `register_interpreter()`, so it loops over `load_manifest(...)` itself
9
+ rather than calling `register_interpreters_from_manifest` directly).
10
+
11
+ A manifest entry mirrors the VS Code extension's
12
+ `contributes.structileInterpreters` contribution shape (`{extensions,
13
+ path, name, format}` — see
14
+ `vscode-extension/src/interpreterContributions.ts`) so the same mental
15
+ model, and often the same JSON, works across every surface this project
16
+ ships.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ import pathlib
22
+ from dataclasses import dataclass
23
+ from typing import Any, Dict, List, Optional, Sequence, Union
24
+
25
+ from ._paths import InterpreterSource
26
+
27
+ # Anything read_interpreter() already accepts: a path, a bare str (a path
28
+ # if it names an existing file, else literal JS source text), or an
29
+ # explicit InterpreterSource.
30
+ ManifestInterpreterPath = Union[str, pathlib.Path, InterpreterSource]
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class InterpreterManifestEntry:
35
+ """One `{extensions, path, name, format}` entry — see module docstring.
36
+
37
+ `extensions`: file extensions this interpreter handles (e.g. [".axml"]).
38
+ `path`: anything `read_interpreter()` accepts. When loaded from a JSON
39
+ manifest file, a relative path is resolved against that file's own
40
+ directory (see `load_manifest`), not the process's working
41
+ directory.
42
+ `name`: a human label used in logs/error messages when this candidate
43
+ fails during selection.
44
+ `format`: `"xml"`/`"html"` (the DOM contract) or any other string (the
45
+ `interpretText` contract) — same meaning as `structile.open()`'s
46
+ own `format=` argument.
47
+ """
48
+
49
+ extensions: List[str]
50
+ path: ManifestInterpreterPath
51
+ name: str
52
+ format: str
53
+
54
+
55
+ def _entry_from_dict(d: Dict[str, Any], *, base_dir: Optional[pathlib.Path]) -> InterpreterManifestEntry:
56
+ extensions = d["extensions"]
57
+ if isinstance(extensions, str):
58
+ extensions = [extensions]
59
+ path = d["path"]
60
+ if base_dir is not None and isinstance(path, str) and not pathlib.Path(path).is_absolute():
61
+ path = base_dir / path
62
+ return InterpreterManifestEntry(extensions=list(extensions), path=path, name=d["name"], format=d["format"])
63
+
64
+
65
+ ManifestSource = Union[str, pathlib.Path, Sequence[Union[Dict[str, Any], InterpreterManifestEntry]]]
66
+
67
+
68
+ def load_manifest(source: ManifestSource) -> List[InterpreterManifestEntry]:
69
+ """Normalize `source` into a list of `InterpreterManifestEntry`.
70
+
71
+ `source` is either:
72
+ - a path to a JSON manifest file — `{"interpreters": [...]}`, each item
73
+ shaped like `InterpreterManifestEntry`'s fields, with `path` values
74
+ resolved relative to the manifest file's own directory; or
75
+ - a plain list of dicts (that same shape) or `InterpreterManifestEntry`
76
+ instances directly — `path` values are used as-is, since there's no
77
+ manifest file to resolve a relative path against.
78
+ """
79
+ if isinstance(source, (str, pathlib.Path)):
80
+ manifest_path = pathlib.Path(source)
81
+ data = json.loads(manifest_path.read_text(encoding="utf-8"))
82
+ base_dir = manifest_path.resolve().parent
83
+ return [_entry_from_dict(item, base_dir=base_dir) for item in data["interpreters"]]
84
+ entries: List[InterpreterManifestEntry] = []
85
+ for item in source:
86
+ entries.append(item if isinstance(item, InterpreterManifestEntry) else _entry_from_dict(item, base_dir=None))
87
+ return entries
@@ -31,17 +31,10 @@ from dataclasses import dataclass
31
31
  from typing import Any, Dict, List, Optional, Union
32
32
 
33
33
  from ._log import logger
34
- from ._paths import find_viewer_html
34
+ from ._paths import find_viewer_html, inject_global_script
35
35
 
36
36
  RENDERERS = ("widget", "browser", "file", "none", "text")
37
37
 
38
- # The one place in structile.html this module depends on the exact
39
- # shape of: the main <script> tag, identified by the "use strict" that
40
- # immediately follows it on the next line (the bare substring "<script>"
41
- # alone also appears inside a couple of comments elsewhere in the file — see
42
- # AGENTS.md's note on that CSP-injection lesson from the VS Code extension).
43
- _SNAPSHOT_ANCHOR = '\n<script>\n"use strict";'
44
-
45
38
  _temp_dir: Optional[str] = None
46
39
 
47
40
 
@@ -144,24 +137,14 @@ class Payload:
144
137
 
145
138
  def _inject_snapshot(snapshot: Dict[str, Any]) -> str:
146
139
  """Splice an arbitrary `window.__STRUCTILE_SNAPSHOT__` payload into a fresh
147
- read of structile.html, right before its main <script> tag.
140
+ read of structile.html, right before its main <script> tag (see
141
+ `_paths.inject_global_script`, the shared low-level splicer).
148
142
  Shared by `build_standalone_html` (single-value) and
149
143
  `build_standalone_diff_html` (two-value, below) — both just build a
150
144
  JSON-safe dict in the shape `applyInitialPayload`/`loadDiffPayload`
151
145
  already know how to consume and hand it here."""
152
146
  viewer_html = find_viewer_html().read_text(encoding="utf-8")
153
- idx = viewer_html.find(_SNAPSHOT_ANCHOR)
154
- if idx == -1:
155
- raise RuntimeError(
156
- "structile: could not find structile.html's main <script> tag "
157
- "to inject the snapshot before — has the viewer's structure changed?"
158
- )
159
- # `<` -> <, same escaping the VS Code extension's jsonForScript uses,
160
- # so a "</script"-shaped substring anywhere in the data can't break out
161
- # of this <script> block.
162
- snapshot_json = json.dumps(snapshot).replace("<", "\\u003c")
163
- snippet = "\n<script>\nwindow.__STRUCTILE_SNAPSHOT__ = " + snapshot_json + ";\n</script>\n"
164
- return viewer_html[:idx] + snippet + viewer_html[idx:]
147
+ return inject_global_script(viewer_html, "__STRUCTILE_SNAPSHOT__", snapshot)
165
148
 
166
149
 
167
150
  def build_standalone_html(payload: Payload) -> str: