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/_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)