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/_dom_lite.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Pure-Python DOM-lite tree builder for structile.convert()'s xml/html
|
|
2
|
+
interpreter path (see interpreters.py's run_interpreter_source and
|
|
3
|
+
dom_lite.js). Replaces jsdom's real DOM with the small subset every bundled
|
|
4
|
+
interpreter (and the documented contract in README.md's "Custom formats via
|
|
5
|
+
an interpreter" section) actually uses at the element level — tagName,
|
|
6
|
+
getAttribute, children, textContent — plus documentElement/doctype/body at
|
|
7
|
+
the document level.
|
|
8
|
+
|
|
9
|
+
NOT a general DOM implementation: no namespaces, no siblings, no comments/
|
|
10
|
+
CDATA nodes, no querySelector beyond a bare tag name (see dom_lite.js). A
|
|
11
|
+
custom interpreter that needs more than this documented subset isn't
|
|
12
|
+
supported by convert()'s Node-free path; structile.html's own
|
|
13
|
+
browser-side DOMParser (used by every renderer, including widget) is
|
|
14
|
+
unaffected by any of this and still hands interpreters a real DOM.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import xml.etree.ElementTree as ET
|
|
19
|
+
from html.parser import HTMLParser
|
|
20
|
+
from typing import Any, Dict, List, Optional
|
|
21
|
+
|
|
22
|
+
# Elements a real HTML5 parser never expects a closing tag for — without
|
|
23
|
+
# this, a bare "<br>" would swallow every sibling after it as its children,
|
|
24
|
+
# since handle_starttag would push it onto the open-element stack forever.
|
|
25
|
+
_VOID_HTML_TAGS = {
|
|
26
|
+
"area", "base", "br", "col", "embed", "hr", "img", "input",
|
|
27
|
+
"link", "meta", "param", "source", "track", "wbr",
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def build_document_payload(text: str, fmt: str) -> Dict[str, Any]:
|
|
32
|
+
"""`fmt` is "xml" or "html". Returns the {"root", "body", "hasDoctype"}
|
|
33
|
+
payload dom_lite.js's __structileBuildDocument expects. Raises `RuntimeError`
|
|
34
|
+
on genuinely malformed markup — xml only, matching a real DOMParser:
|
|
35
|
+
HTML5 parsing never rejects anything (see interpreters/html.js's own
|
|
36
|
+
header comment), so `fmt == "html"` never raises here either."""
|
|
37
|
+
return _build_html_payload(text) if fmt == "html" else _build_xml_payload(text)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _build_xml_payload(text: str) -> Dict[str, Any]:
|
|
41
|
+
try:
|
|
42
|
+
root = ET.fromstring(text)
|
|
43
|
+
except ET.ParseError as e:
|
|
44
|
+
raise RuntimeError(f"Could not parse XML: {e}") from e
|
|
45
|
+
return {"root": _xml_node(root), "body": None, "hasDoctype": False}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _xml_node(el: ET.Element) -> Dict[str, Any]:
|
|
49
|
+
# Built bottom-up in one pass: each child's own "text" is already its
|
|
50
|
+
# full recursive textContent by the time it comes back, so the parent
|
|
51
|
+
# just appends that plus the child's own tail (the text between that
|
|
52
|
+
# child's closing tag and the next sibling) rather than re-walking the
|
|
53
|
+
# whole subtree again.
|
|
54
|
+
children: List[Dict[str, Any]] = []
|
|
55
|
+
text_parts = [el.text or ""]
|
|
56
|
+
for child_el in el:
|
|
57
|
+
child_node = _xml_node(child_el)
|
|
58
|
+
children.append(child_node)
|
|
59
|
+
text_parts.append(child_node["text"])
|
|
60
|
+
text_parts.append(child_el.tail or "")
|
|
61
|
+
return {"tag": el.tag, "attrs": dict(el.attrib), "children": children, "text": "".join(text_parts)}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class _HTMLTreeBuilder(HTMLParser):
|
|
65
|
+
"""Builds a plain tag/attrs/children tree from arbitrary (possibly
|
|
66
|
+
malformed) HTML — deliberately lenient, same as a real browser's HTML5
|
|
67
|
+
parser: an unmatched end tag is ignored rather than raising, and a void
|
|
68
|
+
element (_VOID_HTML_TAGS) never expects one at all."""
|
|
69
|
+
|
|
70
|
+
def __init__(self) -> None:
|
|
71
|
+
super().__init__(convert_charrefs=True)
|
|
72
|
+
self.has_doctype = False
|
|
73
|
+
self._root: Dict[str, Any] = {"tag": "#document", "attrs": {}, "children": [], "text": ""}
|
|
74
|
+
self._stack: List[Dict[str, Any]] = [self._root]
|
|
75
|
+
|
|
76
|
+
def handle_decl(self, decl: str) -> None:
|
|
77
|
+
if decl.strip().lower().startswith("doctype"):
|
|
78
|
+
self.has_doctype = True
|
|
79
|
+
|
|
80
|
+
def handle_starttag(self, tag: str, attrs: List[Any]) -> None:
|
|
81
|
+
node = {"tag": tag, "attrs": dict(attrs), "children": [], "text": ""}
|
|
82
|
+
self._stack[-1]["children"].append(node)
|
|
83
|
+
if tag not in _VOID_HTML_TAGS:
|
|
84
|
+
self._stack.append(node)
|
|
85
|
+
|
|
86
|
+
def handle_startendtag(self, tag: str, attrs: List[Any]) -> None:
|
|
87
|
+
self._stack[-1]["children"].append({"tag": tag, "attrs": dict(attrs), "children": [], "text": ""})
|
|
88
|
+
|
|
89
|
+
def handle_endtag(self, tag: str) -> None:
|
|
90
|
+
for i in range(len(self._stack) - 1, 0, -1):
|
|
91
|
+
if self._stack[i]["tag"] == tag:
|
|
92
|
+
del self._stack[i:]
|
|
93
|
+
return
|
|
94
|
+
# Unmatched end tag (no open element with this name) — ignored, the
|
|
95
|
+
# same "just do your best" leniency a real HTML5 parser has for
|
|
96
|
+
# malformed markup, rather than raising.
|
|
97
|
+
|
|
98
|
+
def handle_data(self, data: str) -> None:
|
|
99
|
+
self._stack[-1]["text"] += data
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _find_html_node(node: Dict[str, Any], tag: str) -> Optional[Dict[str, Any]]:
|
|
103
|
+
if node["tag"] == tag:
|
|
104
|
+
return node
|
|
105
|
+
for child in node["children"]:
|
|
106
|
+
found = _find_html_node(child, tag)
|
|
107
|
+
if found is not None:
|
|
108
|
+
return found
|
|
109
|
+
return None
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _finalize_html_node(node: Dict[str, Any]) -> Dict[str, Any]:
|
|
113
|
+
children = [_finalize_html_node(c) for c in node["children"]]
|
|
114
|
+
text = node.get("text", "") + "".join(c["text"] for c in children)
|
|
115
|
+
return {"tag": node["tag"], "attrs": node["attrs"], "children": children, "text": text}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _build_html_payload(text: str) -> Dict[str, Any]:
|
|
119
|
+
builder = _HTMLTreeBuilder()
|
|
120
|
+
builder.feed(text)
|
|
121
|
+
builder.close()
|
|
122
|
+
html_node = _find_html_node(builder._root, "html") or builder._root
|
|
123
|
+
body_node = _find_html_node(builder._root, "body")
|
|
124
|
+
return {
|
|
125
|
+
"root": _finalize_html_node(html_node),
|
|
126
|
+
"body": _finalize_html_node(body_node) if body_node is not None else None,
|
|
127
|
+
"hasDoctype": builder.has_doctype,
|
|
128
|
+
}
|
structile/_log.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""The package's one logger — `logging.getLogger("structile")`, shared by
|
|
2
|
+
every module. A library only ever emits records; it never configures
|
|
3
|
+
handlers/levels for them (that's the application's call) — the
|
|
4
|
+
`NullHandler` is just to keep Python quiet about "no handlers found" if
|
|
5
|
+
nothing else in the process has configured logging at all.
|
|
6
|
+
|
|
7
|
+
Severity conventions used throughout this package:
|
|
8
|
+
DEBUG - internal decisions with no user-visible effect on their own
|
|
9
|
+
(which renderer/interpreter candidate is being tried, cache
|
|
10
|
+
hits) — useful when something downstream looks wrong and you
|
|
11
|
+
need to see why a choice was made.
|
|
12
|
+
INFO - a real action happened (a file was written, a browser tab was
|
|
13
|
+
opened, an interpreter was selected among several candidates,
|
|
14
|
+
a save was written to disk).
|
|
15
|
+
WARNING - something recoverable didn't go as expected but execution
|
|
16
|
+
continues (an interpreter candidate failed and the next one
|
|
17
|
+
in line is being tried; a value had to be coerced/dropped).
|
|
18
|
+
ERROR - logged immediately before raising, only where the exception
|
|
19
|
+
message alone would lose useful context (e.g. which of
|
|
20
|
+
several candidates were tried and how each failed) — not
|
|
21
|
+
duplicated at every raise site, since the exception itself
|
|
22
|
+
already carries its own message to the caller.
|
|
23
|
+
|
|
24
|
+
import logging
|
|
25
|
+
logging.getLogger("structile").setLevel(logging.DEBUG)
|
|
26
|
+
logging.basicConfig() # or any other handler setup
|
|
27
|
+
"""
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import logging
|
|
31
|
+
|
|
32
|
+
logger = logging.getLogger("structile")
|
|
33
|
+
logger.addHandler(logging.NullHandler())
|
structile/_paths.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Path/text-loading helpers shared between open() (__init__.py),
|
|
2
|
+
convert() (convert.py), widget.py, and render.py — kept in their own module
|
|
3
|
+
so none of those end up importing each other.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import pathlib
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import Any, Optional, Union
|
|
12
|
+
|
|
13
|
+
_PACKAGE_DIR = pathlib.Path(__file__).resolve().parent
|
|
14
|
+
_REPO_ROOT = _PACKAGE_DIR.parent
|
|
15
|
+
# The minified, identifier-renamed production build — see AGENTS.md's
|
|
16
|
+
# "Production build" section and scripts/build_production.js. Built once,
|
|
17
|
+
# checked in, and shipped as package data (`[tool.setuptools.package-data]`
|
|
18
|
+
# in pyproject.toml), NOT generated during `pip install`.
|
|
19
|
+
_BUNDLED_PROD_VIEWER_HTML = _PACKAGE_DIR / "static" / "structile.prod.html"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _viewer_html_override() -> Optional[str]:
|
|
23
|
+
return os.environ.get("STRUCTILE_VIEWER_HTML")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def find_viewer_html() -> pathlib.Path:
|
|
27
|
+
"""Locate structile.html. Tries, in order:
|
|
28
|
+
|
|
29
|
+
1. `STRUCTILE_VIEWER_HTML` env var, if set (dev/testing override).
|
|
30
|
+
2. The human-readable dev source at the repo root — present for an
|
|
31
|
+
editable, in-repo install (`pip install -e .`) or when just running
|
|
32
|
+
this repo's own tests/scripts/build_samples.py. Always preferred
|
|
33
|
+
over the bundled build below so local edits to structile.html take
|
|
34
|
+
effect immediately, without needing scripts/build_production.js
|
|
35
|
+
re-run first.
|
|
36
|
+
3. The bundled production build (see `_BUNDLED_PROD_VIEWER_HTML` above)
|
|
37
|
+
— what a real `pip install structile` resolves to, since a built
|
|
38
|
+
wheel has no repo checkout sitting next to it.
|
|
39
|
+
"""
|
|
40
|
+
override = _viewer_html_override()
|
|
41
|
+
candidates = [pathlib.Path(override)] if override else []
|
|
42
|
+
candidates.append(_REPO_ROOT / "structile.html")
|
|
43
|
+
candidates.append(_BUNDLED_PROD_VIEWER_HTML)
|
|
44
|
+
for candidate in candidates:
|
|
45
|
+
if candidate.is_file():
|
|
46
|
+
return candidate
|
|
47
|
+
raise FileNotFoundError(
|
|
48
|
+
"structile: could not find structile.html (looked at: "
|
|
49
|
+
+ ", ".join(str(c) for c in candidates)
|
|
50
|
+
+ "). This package expects either an editable install (`pip install "
|
|
51
|
+
"-e .`) run from inside the structile repository, right next to "
|
|
52
|
+
"structile.html, or a normal `pip install structile` with its "
|
|
53
|
+
"bundled production build intact. If you've moved things, point "
|
|
54
|
+
"the STRUCTILE_VIEWER_HTML environment variable at a viewer HTML "
|
|
55
|
+
"file directly."
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True)
|
|
60
|
+
class InterpreterSource:
|
|
61
|
+
"""Interpreter JS source text supplied directly, rather than read from a
|
|
62
|
+
file path — for an interpreter that lives inside an installed package
|
|
63
|
+
(e.g. loaded via `importlib.resources.files()` from an entry-point
|
|
64
|
+
plugin — see docs/advanced.md's "Distributing interpreters as a
|
|
65
|
+
package" and `structile.load_plugins`) instead of at a stable filesystem
|
|
66
|
+
path.
|
|
67
|
+
|
|
68
|
+
Accepted anywhere a path is accepted: the `interpreter=` argument of
|
|
69
|
+
`open()`, `diff()` (either side, or the `(left, right)` tuple form),
|
|
70
|
+
`convert()`, and `register_interpreter()` — alone, or inside a candidate
|
|
71
|
+
list freely mixed with paths. A bare `str` interpreter argument still
|
|
72
|
+
means a path (or, if it doesn't happen to name an existing file, literal
|
|
73
|
+
JS source text — see `read_interpreter`'s existing convention below,
|
|
74
|
+
unchanged); this wrapper is what disambiguates "this is source text, not
|
|
75
|
+
a path" for a caller (like a plugin's `register()`) that has text in
|
|
76
|
+
hand and no file to point at.
|
|
77
|
+
|
|
78
|
+
`text`: the interpreter's JS source code (`interpretXML`/`serializeXML`
|
|
79
|
+
for markup, or `interpretText`/`serializeText` for any other format
|
|
80
|
+
— exactly what a `.js` file would contain).
|
|
81
|
+
`name`: a human label used only in logs/error messages when this
|
|
82
|
+
candidate fails during selection — with a plugin, the user may have
|
|
83
|
+
no idea which candidates were even tried, so naming it there is the
|
|
84
|
+
only way to make that failure legible.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
text: str
|
|
88
|
+
name: str = "<inline>"
|
|
89
|
+
|
|
90
|
+
def __repr__(self) -> str:
|
|
91
|
+
return f"InterpreterSource(name={self.name!r}, len(text)={len(self.text)})"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def as_path_like(value: Any) -> Optional[pathlib.Path]:
|
|
95
|
+
"""`value` if it names a file to load — a `PurePath` always counts (even
|
|
96
|
+
if missing, so callers can raise a proper FileNotFoundError on it), a
|
|
97
|
+
plain `str` only if it happens to name an existing file. `None` means
|
|
98
|
+
"this is a literal value, not a path"."""
|
|
99
|
+
if isinstance(value, pathlib.PurePath):
|
|
100
|
+
return pathlib.Path(value)
|
|
101
|
+
if isinstance(value, str) and os.path.isfile(value):
|
|
102
|
+
return pathlib.Path(value)
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def read_text(p: pathlib.Path) -> str:
|
|
107
|
+
if not p.is_file():
|
|
108
|
+
raise FileNotFoundError(f"structile: no such file: {p}")
|
|
109
|
+
# "utf-8-sig" transparently strips a leading UTF-8 BOM (common in files
|
|
110
|
+
# saved by Windows tools/editors) and decodes byte-identically to plain
|
|
111
|
+
# "utf-8" when no BOM is present — a pure widening of what's accepted,
|
|
112
|
+
# never a behavior change for the (vast majority) BOM-less case. Plain
|
|
113
|
+
# "utf-8" left a BOM as a leading U+FEFF character, which made
|
|
114
|
+
# json.loads reject an otherwise-valid .json file outright (see
|
|
115
|
+
# test_structile.py's BOM test).
|
|
116
|
+
return p.read_text(encoding="utf-8-sig")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def read_interpreter(source: Union[str, pathlib.Path, InterpreterSource]) -> str:
|
|
120
|
+
"""The single point where an interpreter candidate — a path, a bare str
|
|
121
|
+
(a path if it names an existing file, else literal JS source text), or
|
|
122
|
+
an explicit `InterpreterSource` — is normalized to plain JS source text.
|
|
123
|
+
Both JS execution paths (the browser-side trial `open()`/`diff()` use,
|
|
124
|
+
and the `mini-racer` path `convert()`/`select_interpreter` use) funnel
|
|
125
|
+
through this one function, so a new candidate kind (like
|
|
126
|
+
`InterpreterSource`) only ever needs handling here."""
|
|
127
|
+
if isinstance(source, InterpreterSource):
|
|
128
|
+
return source.text
|
|
129
|
+
p = as_path_like(source)
|
|
130
|
+
return read_text(p) if p is not None else source # a bare str that isn't a file is raw JS source text
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def strict_json_loads(text: str) -> Any:
|
|
134
|
+
"""`json.loads`, but rejecting the non-standard `NaN`/`Infinity`/
|
|
135
|
+
`-Infinity` constants CPython's `json` module otherwise accepts by
|
|
136
|
+
default (`parse_constant`'s default just returns the corresponding
|
|
137
|
+
float). Those tokens aren't valid JSON (RFC 8259) and
|
|
138
|
+
structile.html's own `JSON.parse`-based parser rejects them —
|
|
139
|
+
so code that uses this (instead of a bare `json.loads`) to decide
|
|
140
|
+
whether some text genuinely IS valid JSON (e.g. `_load_path`'s
|
|
141
|
+
`parsed_as_json`, which gates forwarding literal raw text to the viewer
|
|
142
|
+
as trusted `format="json"` source) must reject them too, or it risks
|
|
143
|
+
forwarding a document the browser's own parser will refuse."""
|
|
144
|
+
|
|
145
|
+
def _reject_constant(token: str) -> Any:
|
|
146
|
+
raise ValueError(f"{token} is not valid JSON")
|
|
147
|
+
|
|
148
|
+
return json.loads(text, parse_constant=_reject_constant)
|
structile/_plugins.py
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""Entry-point-based interpreter plugin discovery — lets an organisation
|
|
2
|
+
distribute its own interpreter(s) as an ordinary, `pip install`-able Python
|
|
3
|
+
package that `structile` picks up with no import of the plugin package, no
|
|
4
|
+
registration call, and no wrapper API. See docs/advanced.md's "Distributing
|
|
5
|
+
interpreters as a package" section for the full mechanics (precedence,
|
|
6
|
+
discovery timing/caching, the `STRUCTILE_DISABLE_PLUGINS` escape hatch) and
|
|
7
|
+
README.md for the end-user-facing walkthrough; `tests/fixtures/
|
|
8
|
+
structile_demo_plugin/` is a working, copyable template.
|
|
9
|
+
|
|
10
|
+
Plugin contract (public and stable — external packages pin against this):
|
|
11
|
+
|
|
12
|
+
# pyproject.toml
|
|
13
|
+
[project.entry-points."structile.interpreters"]
|
|
14
|
+
my_plugin = "my_package:register"
|
|
15
|
+
|
|
16
|
+
# my_package/__init__.py
|
|
17
|
+
def register(registry) -> None:
|
|
18
|
+
registry.register_interpreter(".myext", "path/to/interpreter.js")
|
|
19
|
+
|
|
20
|
+
`registry` (see `_RegistryFacade` below) is a small facade exposing only
|
|
21
|
+
`register_interpreter()` — never `structile.interpreters` itself, so a
|
|
22
|
+
plugin can't reach (or break) anything else in the package.
|
|
23
|
+
"""
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import os
|
|
27
|
+
from dataclasses import dataclass, field
|
|
28
|
+
from importlib import metadata
|
|
29
|
+
from typing import Any, Dict, Iterable, List, Optional
|
|
30
|
+
|
|
31
|
+
from ._log import logger
|
|
32
|
+
from ._paths import InterpreterSource
|
|
33
|
+
|
|
34
|
+
GROUP = "structile.interpreters"
|
|
35
|
+
|
|
36
|
+
_loaded = False
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class PluginRecord:
|
|
41
|
+
"""One discovered `structile.interpreters` entry point — what
|
|
42
|
+
`structile.plugins()` reports: which entry point it was, which
|
|
43
|
+
distribution it came from (name + version), and which extensions it
|
|
44
|
+
actually registered.
|
|
45
|
+
|
|
46
|
+
`success=False` means this entry point's `ep.load()` or `register()`
|
|
47
|
+
call raised (the exception is already logged at WARNING with
|
|
48
|
+
`exc_info=True` — see `load_plugins`) — `error` holds a short
|
|
49
|
+
`"ExceptionType: message"` summary and `extensions` stays empty. A
|
|
50
|
+
record is included here either way: a plugin that silently vanished
|
|
51
|
+
from `plugins()` on failure is indistinguishable from one that was
|
|
52
|
+
never installed at all, which is exactly the case someone reaching for
|
|
53
|
+
`plugins()`/`--plugins` to debug "why didn't my plugin load" needs to
|
|
54
|
+
see."""
|
|
55
|
+
|
|
56
|
+
entry_point: str
|
|
57
|
+
distribution: str
|
|
58
|
+
version: str
|
|
59
|
+
extensions: List[str] = field(default_factory=list)
|
|
60
|
+
success: bool = True
|
|
61
|
+
error: Optional[str] = None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
_plugin_records: List[PluginRecord] = []
|
|
65
|
+
# ext -> distribution name currently owning it via PLUGIN discovery (never a
|
|
66
|
+
# real, direct register_interpreter() call — see _RegistryFacade.register_interpreter
|
|
67
|
+
# and interpreters.register_interpreter/unregister_interpreter, which clear
|
|
68
|
+
# an entry here the moment a caller registers/unregisters it directly).
|
|
69
|
+
_plugin_owner_by_ext: Dict[str, str] = {}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _entry_points_for_group() -> Iterable[Any]:
|
|
73
|
+
"""The one place entry-point iteration happens — monkeypatched by tests
|
|
74
|
+
to yield fake entry points instead of scanning real installed packages
|
|
75
|
+
(see tests/test_plugins.py). `importlib.metadata.entry_points()` returns
|
|
76
|
+
a `SelectableGroups`-like object with `.select(group=...)` on Python
|
|
77
|
+
3.10+, but only supports the older dict-like `.get(group, [])` on
|
|
78
|
+
3.8/3.9 — this repo's `requires-python` is `>=3.8`, so both are needed;
|
|
79
|
+
no `importlib_metadata` dependency required either way."""
|
|
80
|
+
eps = metadata.entry_points()
|
|
81
|
+
return eps.select(group=GROUP) if hasattr(eps, "select") else eps.get(GROUP, [])
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _env_truthy(name: str) -> bool:
|
|
85
|
+
return os.environ.get(name, "").strip().lower() not in ("", "0", "false", "no")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class _RegistryFacade:
|
|
89
|
+
"""The only object a plugin's `register(registry)` function ever sees —
|
|
90
|
+
deliberately just `register_interpreter()`, a documented, stable public
|
|
91
|
+
surface a plugin can pin against, without exposing anything else a
|
|
92
|
+
plugin could reach into or break.
|
|
93
|
+
|
|
94
|
+
Enforces the "a plugin registration never beats a real, direct
|
|
95
|
+
`register_interpreter()` call" precedence rule (see docs/advanced.md's
|
|
96
|
+
precedence table) and tracks which extensions THIS plugin registered,
|
|
97
|
+
for `structile.plugins()`. If two plugins register the same extension,
|
|
98
|
+
the last one loaded wins and a WARNING names both distributions —
|
|
99
|
+
entry-point iteration order isn't guaranteed, so the warning fires
|
|
100
|
+
regardless of which one happens to win.
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
def __init__(self, record: PluginRecord) -> None:
|
|
104
|
+
self._record = record
|
|
105
|
+
|
|
106
|
+
def register_interpreter(self, ext: str, interpreter: Any) -> None:
|
|
107
|
+
from . import interpreters as _interpreters
|
|
108
|
+
|
|
109
|
+
ext_norm = _interpreters._normalize_ext(ext)
|
|
110
|
+
if ext_norm in _interpreters._registry and ext_norm not in _plugin_owner_by_ext:
|
|
111
|
+
logger.debug(
|
|
112
|
+
"structile: plugin %r tried to register %r, but it's already registered directly — "
|
|
113
|
+
"plugin registration skipped (a direct register_interpreter() call always wins)",
|
|
114
|
+
self._record.distribution,
|
|
115
|
+
ext_norm,
|
|
116
|
+
)
|
|
117
|
+
return
|
|
118
|
+
previous_owner = _plugin_owner_by_ext.get(ext_norm)
|
|
119
|
+
if previous_owner is not None and previous_owner != self._record.distribution:
|
|
120
|
+
logger.warning(
|
|
121
|
+
"structile: two plugins both registered an interpreter for %r — %r (entry point %r) "
|
|
122
|
+
"wins, loaded after %r",
|
|
123
|
+
ext_norm,
|
|
124
|
+
self._record.distribution,
|
|
125
|
+
self._record.entry_point,
|
|
126
|
+
previous_owner,
|
|
127
|
+
)
|
|
128
|
+
if isinstance(interpreter, InterpreterSource) and interpreter.name == "<inline>":
|
|
129
|
+
logger.debug(
|
|
130
|
+
"structile: plugin %r registered %r via an InterpreterSource with no name= — pass a "
|
|
131
|
+
"name= (e.g. the distribution name) so failures naming this candidate are legible",
|
|
132
|
+
self._record.distribution,
|
|
133
|
+
ext_norm,
|
|
134
|
+
)
|
|
135
|
+
_interpreters.register_interpreter(ext, interpreter)
|
|
136
|
+
_plugin_owner_by_ext[ext_norm] = self._record.distribution
|
|
137
|
+
if ext_norm not in self._record.extensions:
|
|
138
|
+
self._record.extensions.append(ext_norm)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def load_plugins(force: bool = False) -> None:
|
|
142
|
+
"""Discover and register every installed `structile.interpreters`
|
|
143
|
+
entry-point plugin — see this module's docstring for the contract.
|
|
144
|
+
|
|
145
|
+
Lazy: called from the interpreter-resolution path
|
|
146
|
+
(`interpreters.get_registered_interpreter`), never from `import
|
|
147
|
+
structile` — importing the package must never have this side effect,
|
|
148
|
+
and a broken plugin must never break `import structile`.
|
|
149
|
+
|
|
150
|
+
Idempotent: runs at most once per process unless `force=True` (for
|
|
151
|
+
tests, and for a notebook user who just `pip install`ed a plugin
|
|
152
|
+
mid-session and wants it picked up without restarting the kernel).
|
|
153
|
+
|
|
154
|
+
Each plugin is fully isolated: a bad `ep.load()`, or a `register()` call
|
|
155
|
+
that raises, is logged at WARNING (`exc_info=True`) and never propagated
|
|
156
|
+
— one broken plugin can't break anyone else's `structile.open()` call,
|
|
157
|
+
or even registration of its own other extensions attempted before the
|
|
158
|
+
raise. It's still recorded (as a failed `PluginRecord`, see below) rather
|
|
159
|
+
than silently dropped, so `structile.plugins()`/`--plugins` can surface
|
|
160
|
+
it.
|
|
161
|
+
|
|
162
|
+
Set `STRUCTILE_DISABLE_PLUGINS` (any truthy value) to skip discovery
|
|
163
|
+
entirely — logged at DEBUG — for locked-down environments where someone
|
|
164
|
+
needs to prove a plugin isn't interfering.
|
|
165
|
+
"""
|
|
166
|
+
global _loaded
|
|
167
|
+
if _loaded and not force:
|
|
168
|
+
return
|
|
169
|
+
if force:
|
|
170
|
+
_plugin_records.clear()
|
|
171
|
+
_plugin_owner_by_ext.clear()
|
|
172
|
+
_loaded = True
|
|
173
|
+
|
|
174
|
+
if _env_truthy("STRUCTILE_DISABLE_PLUGINS"):
|
|
175
|
+
logger.debug("structile: STRUCTILE_DISABLE_PLUGINS is set, skipping interpreter plugin discovery")
|
|
176
|
+
return
|
|
177
|
+
|
|
178
|
+
for ep in _entry_points_for_group():
|
|
179
|
+
dist = getattr(ep, "dist", None)
|
|
180
|
+
distribution = dist.name if dist is not None else ep.name
|
|
181
|
+
version = dist.version if dist is not None else ""
|
|
182
|
+
try:
|
|
183
|
+
register_fn = ep.load()
|
|
184
|
+
except Exception as exc:
|
|
185
|
+
logger.warning("structile: plugin entry point %r failed to load, skipping", ep.name, exc_info=True)
|
|
186
|
+
_plugin_records.append(
|
|
187
|
+
PluginRecord(
|
|
188
|
+
entry_point=ep.name,
|
|
189
|
+
distribution=distribution,
|
|
190
|
+
version=version,
|
|
191
|
+
success=False,
|
|
192
|
+
error=f"{type(exc).__name__}: {exc}",
|
|
193
|
+
)
|
|
194
|
+
)
|
|
195
|
+
continue
|
|
196
|
+
record = PluginRecord(entry_point=ep.name, distribution=distribution, version=version)
|
|
197
|
+
try:
|
|
198
|
+
register_fn(_RegistryFacade(record))
|
|
199
|
+
except Exception as exc:
|
|
200
|
+
logger.warning(
|
|
201
|
+
"structile: plugin %r (entry point %r) raised during register(), skipping",
|
|
202
|
+
distribution,
|
|
203
|
+
ep.name,
|
|
204
|
+
exc_info=True,
|
|
205
|
+
)
|
|
206
|
+
record.success = False
|
|
207
|
+
record.error = f"{type(exc).__name__}: {exc}"
|
|
208
|
+
_plugin_records.append(record)
|
|
209
|
+
continue
|
|
210
|
+
_plugin_records.append(record)
|
|
211
|
+
logger.debug(
|
|
212
|
+
"structile: plugin %r (entry point %r) registered %r", distribution, ep.name, record.extensions
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def plugins() -> List[PluginRecord]:
|
|
217
|
+
"""Every `structile.interpreters` entry point discovered so far —
|
|
218
|
+
loaded successfully or not (check `.success`/`.error`): entry-point
|
|
219
|
+
name, distribution name/version, and which extensions it registered —
|
|
220
|
+
a support/debugging surface a user can run themselves to answer "why
|
|
221
|
+
did my file open with the wrong schema?" or "why didn't my plugin load
|
|
222
|
+
at all?" without reading any source or turning on logging. Including
|
|
223
|
+
failures here (rather than only ones that loaded) matters precisely
|
|
224
|
+
because the person most likely to call this is the one whose plugin
|
|
225
|
+
*isn't* working — for them, an empty/incomplete list is indistinguishable
|
|
226
|
+
from "never installed". Triggers discovery itself (same lazy/idempotent
|
|
227
|
+
rule as everywhere else), so it's safe to call before anything else has
|
|
228
|
+
resolved an interpreter."""
|
|
229
|
+
load_plugins()
|
|
230
|
+
return list(_plugin_records)
|