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
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// anywidget front-end: mounts structile.html inside an iframe and
|
|
2
|
+
// drives it purely through its documented embedded-mode postMessage
|
|
3
|
+
// protocol (see the "EMBEDDED MODE" section of structile.html) —
|
|
4
|
+
// this file has no knowledge of the viewer's internals beyond that
|
|
5
|
+
// contract, so it stays correct across viewer changes as long as the
|
|
6
|
+
// protocol does. Shared by both StructileWidget ("value" mode, single
|
|
7
|
+
// document, read-write) and StructileDiffWidget ("diff" mode, two-sided,
|
|
8
|
+
// read-only graph) — see widget.py's own `mode` trait on each class.
|
|
9
|
+
function render({ model, el }) {
|
|
10
|
+
const iframe = document.createElement("iframe");
|
|
11
|
+
iframe.style.width = "100%";
|
|
12
|
+
iframe.style.height = model.get("height") + "px";
|
|
13
|
+
iframe.style.border = "0px";
|
|
14
|
+
iframe.style.borderRadius = "0px";
|
|
15
|
+
iframe.style.display = "block";
|
|
16
|
+
// srcdoc (not src) — the viewer HTML is delivered inline over the widget
|
|
17
|
+
// model, not fetched from a URL, so this works the same whether the
|
|
18
|
+
// notebook is local or hosted.
|
|
19
|
+
iframe.srcdoc = model.get("viewer_html");
|
|
20
|
+
el.appendChild(iframe);
|
|
21
|
+
model.on("change:height", () => {
|
|
22
|
+
iframe.style.height = model.get("height") + "px";
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
if (model.get("mode") === "diff") return renderDiff(model, iframe);
|
|
26
|
+
return renderValue(model, iframe);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// A model trait holding a single interpreter source, or a list of candidates
|
|
30
|
+
// the viewer tries itself (tryInterpreterCandidates in structile.html) —
|
|
31
|
+
// shared truthiness check for the three traits shaped this way
|
|
32
|
+
// (interpreter_source, left_interpreter_source, right_interpreter_source).
|
|
33
|
+
function hasInterpreterSource(source) {
|
|
34
|
+
return typeof source === "string" ? !!source : Array.isArray(source) && source.length > 0;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Forwards a viewer -> host structile-save-requested message on to Python's on_msg
|
|
38
|
+
// (see widget.py's _on_custom_msg) — shared by renderValue/renderDiff, which
|
|
39
|
+
// differ only in whether a `side` field is also present (diff mode only).
|
|
40
|
+
function forwardSaveRequested(model, msg, extra) {
|
|
41
|
+
model.send({
|
|
42
|
+
type: "structile-save-requested",
|
|
43
|
+
format: msg.format,
|
|
44
|
+
ext: msg.ext,
|
|
45
|
+
fileName: msg.fileName,
|
|
46
|
+
isSaveAs: !!msg.isSaveAs,
|
|
47
|
+
content: msg.content,
|
|
48
|
+
...extra,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Forwards a viewer -> host structile-settings-changed message on to Python's
|
|
53
|
+
// on_msg (see widget.py's _handle_settings_changed) — a one-off event, same
|
|
54
|
+
// custom-message channel as structile-save-requested (see the comment on
|
|
55
|
+
// forwardSaveRequested/onViewerMessage below for why this isn't a trait).
|
|
56
|
+
function forwardSettingsChanged(model, msg) {
|
|
57
|
+
model.send({
|
|
58
|
+
type: "structile-settings-changed",
|
|
59
|
+
settings: msg.settings,
|
|
60
|
+
renderMap: msg.renderMap,
|
|
61
|
+
links: msg.links,
|
|
62
|
+
theme: msg.theme,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function renderValue(model, iframe) {
|
|
67
|
+
function sendInit() {
|
|
68
|
+
if (!iframe.contentWindow) return;
|
|
69
|
+
// raw_format non-empty, OR a non-empty interpreter_candidates_by_format
|
|
70
|
+
// (format genuinely unknown, still TBD by the viewer itself — see
|
|
71
|
+
// tryInterpreterCandidatesAnyFormat), selects the raw_text+interpreter
|
|
72
|
+
// path (XML, HTML included, via a host-supplied interpreter script) over the
|
|
73
|
+
// normal already-parsed-value path (see StructileWidget in widget.py).
|
|
74
|
+
const rawFormat = model.get("raw_format");
|
|
75
|
+
const candidatesByFormat = model.get("interpreter_candidates_by_format");
|
|
76
|
+
const hasCandidatesByFormat = candidatesByFormat && Object.keys(candidatesByFormat).length > 0;
|
|
77
|
+
const msg = {
|
|
78
|
+
type: "structile-embed-init",
|
|
79
|
+
name: model.get("data_name"),
|
|
80
|
+
config: JSON.parse(model.get("config_json")),
|
|
81
|
+
// Shows the viewer's settings panel (normally hidden in every embed
|
|
82
|
+
// host — see enableSettingsPanel in structile.html's EMBEDDED
|
|
83
|
+
// MODE section) and its "Set as default" button, which posts
|
|
84
|
+
// structile-settings-changed back — see onViewerMessage below.
|
|
85
|
+
enableSettingsPanel: true,
|
|
86
|
+
};
|
|
87
|
+
if (rawFormat || hasCandidatesByFormat) {
|
|
88
|
+
msg.text = model.get("raw_text");
|
|
89
|
+
msg.format = rawFormat;
|
|
90
|
+
// interpreter_source is a single string or a list of candidates —
|
|
91
|
+
// the viewer tries each itself (tryInterpreterCandidates), so this
|
|
92
|
+
// widget never has to disambiguate more than one candidate.
|
|
93
|
+
const interpreterSource = model.get("interpreter_source");
|
|
94
|
+
if (hasInterpreterSource(interpreterSource)) msg.interpreterSource = interpreterSource;
|
|
95
|
+
if (hasCandidatesByFormat) msg.interpreterCandidatesByFormat = candidatesByFormat;
|
|
96
|
+
} else {
|
|
97
|
+
msg.text = model.get("value_json");
|
|
98
|
+
msg.format = "json";
|
|
99
|
+
}
|
|
100
|
+
iframe.contentWindow.postMessage(msg, "*");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// The viewer's own init() attaches its "message" listener synchronously
|
|
104
|
+
// during initial script execution, which finishes well before the
|
|
105
|
+
// iframe's "load" event (DOMContentLoaded, then load) — so it's always
|
|
106
|
+
// listening by the time this fires.
|
|
107
|
+
iframe.addEventListener("load", sendInit, { once: true });
|
|
108
|
+
model.on("change:value_json", sendInit);
|
|
109
|
+
model.on("change:raw_text", sendInit);
|
|
110
|
+
model.on("change:raw_format", sendInit);
|
|
111
|
+
model.on("change:interpreter_source", sendInit);
|
|
112
|
+
model.on("change:interpreter_candidates_by_format", sendInit);
|
|
113
|
+
model.on("change:data_name", sendInit);
|
|
114
|
+
model.on("change:config_json", sendInit);
|
|
115
|
+
|
|
116
|
+
// Viewer -> host: dirty-state is plain synced state (a trait, pushed
|
|
117
|
+
// whenever it changes); save-requested is a one-off event, so it goes
|
|
118
|
+
// over the custom-message channel instead (model.send <-> Python's
|
|
119
|
+
// on_msg) rather than a trait, since "a save just happened" isn't state
|
|
120
|
+
// that makes sense to read back later.
|
|
121
|
+
function onViewerMessage(e) {
|
|
122
|
+
if (e.source !== iframe.contentWindow || !e.data) return;
|
|
123
|
+
const msg = e.data;
|
|
124
|
+
if (msg.type === "structile-dirty-state-changed") {
|
|
125
|
+
model.set("dirty", !!msg.dirty);
|
|
126
|
+
model.set("dirty_count", msg.totalCount || 0);
|
|
127
|
+
model.save_changes();
|
|
128
|
+
} else if (msg.type === "structile-save-requested") {
|
|
129
|
+
forwardSaveRequested(model, msg);
|
|
130
|
+
} else if (msg.type === "structile-settings-changed") {
|
|
131
|
+
forwardSettingsChanged(model, msg);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
window.addEventListener("message", onViewerMessage);
|
|
135
|
+
return () => window.removeEventListener("message", onViewerMessage);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function renderDiff(model, iframe) {
|
|
139
|
+
function sendInit() {
|
|
140
|
+
if (!iframe.contentWindow) return;
|
|
141
|
+
const msg = {
|
|
142
|
+
type: "structile-embed-diff-init",
|
|
143
|
+
left: { text: model.get("left_text"), format: model.get("left_format"), name: model.get("left_name") },
|
|
144
|
+
right: { text: model.get("right_text"), format: model.get("right_format"), name: model.get("right_name") },
|
|
145
|
+
config: JSON.parse(model.get("config_json")),
|
|
146
|
+
diff: { view: model.get("diff_view") },
|
|
147
|
+
};
|
|
148
|
+
const keyColumns = model.get("key_columns");
|
|
149
|
+
if (keyColumns && keyColumns.length) msg.keyColumns = keyColumns;
|
|
150
|
+
const leftInterp = model.get("left_interpreter_source");
|
|
151
|
+
if (hasInterpreterSource(leftInterp)) msg.leftInterpreterSource = leftInterp;
|
|
152
|
+
const rightInterp = model.get("right_interpreter_source");
|
|
153
|
+
if (hasInterpreterSource(rightInterp)) msg.rightInterpreterSource = rightInterp;
|
|
154
|
+
iframe.contentWindow.postMessage(msg, "*");
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
iframe.addEventListener("load", sendInit, { once: true });
|
|
158
|
+
model.on("change:left_text", sendInit);
|
|
159
|
+
model.on("change:left_format", sendInit);
|
|
160
|
+
model.on("change:left_name", sendInit);
|
|
161
|
+
model.on("change:right_text", sendInit);
|
|
162
|
+
model.on("change:right_format", sendInit);
|
|
163
|
+
model.on("change:right_name", sendInit);
|
|
164
|
+
model.on("change:left_interpreter_source", sendInit);
|
|
165
|
+
model.on("change:right_interpreter_source", sendInit);
|
|
166
|
+
model.on("change:diff_view", sendInit);
|
|
167
|
+
model.on("change:key_columns", sendInit);
|
|
168
|
+
model.on("change:config_json", sendInit);
|
|
169
|
+
|
|
170
|
+
// No dirty-state-changed for diff mode (the viewer never emits one — see
|
|
171
|
+
// structile.html's emitDirtyStateChanged, single-value-only) —
|
|
172
|
+
// only a per-side structile-save-requested (with `side`), forwarded the same
|
|
173
|
+
// way the value widget forwards its own.
|
|
174
|
+
function onViewerMessage(e) {
|
|
175
|
+
if (e.source !== iframe.contentWindow || !e.data) return;
|
|
176
|
+
const msg = e.data;
|
|
177
|
+
if (msg.type === "structile-save-requested") {
|
|
178
|
+
forwardSaveRequested(model, msg, { side: msg.side });
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
window.addEventListener("message", onViewerMessage);
|
|
182
|
+
return () => window.removeEventListener("message", onViewerMessage);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export default { render };
|
structile/widget.py
ADDED
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
"""anywidget front-end wrapper around structile.html (embedded mode)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import pathlib
|
|
6
|
+
from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Union
|
|
7
|
+
|
|
8
|
+
import anywidget
|
|
9
|
+
import traitlets
|
|
10
|
+
|
|
11
|
+
from ._log import logger
|
|
12
|
+
from ._paths import find_viewer_html as _find_viewer_html
|
|
13
|
+
from .config import options_from_dict, replace_options
|
|
14
|
+
from .serialize import normalize
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from .render import Payload
|
|
18
|
+
|
|
19
|
+
_PACKAGE_DIR = pathlib.Path(__file__).resolve().parent
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class StructileWidget(anywidget.AnyWidget):
|
|
23
|
+
"""Renders structile.html, in embedded mode, inside an iframe.
|
|
24
|
+
|
|
25
|
+
The value is handed to the viewer over its existing host<->viewer
|
|
26
|
+
postMessage protocol (see the "EMBEDDED MODE" section of
|
|
27
|
+
structile.html) — this class never reaches into the viewer's
|
|
28
|
+
internals, it only speaks that protocol from the JS side (see
|
|
29
|
+
static/widget.js).
|
|
30
|
+
|
|
31
|
+
Two data paths, matching `structile-embed-init`'s own `value` vs `text`+
|
|
32
|
+
`format` split:
|
|
33
|
+
- `value_json` (the default): an already-normalized Python value,
|
|
34
|
+
shown/edited/saved as JSON.
|
|
35
|
+
- `raw_text` + `raw_format` ("xml"/"html", or any other format like
|
|
36
|
+
"ini") + `interpreter_source`: raw source text run through a
|
|
37
|
+
host-supplied interpreter script, for data `normalize()` has no
|
|
38
|
+
business touching. `interpreter_source` is a single source or a
|
|
39
|
+
list of candidates — the viewer tries each itself
|
|
40
|
+
(structile.html's `tryInterpreterCandidates`), so
|
|
41
|
+
disambiguating more than one never needs Node.js here either. When
|
|
42
|
+
`raw_format` is left `""` (format genuinely unknown), the viewer
|
|
43
|
+
instead tries every entry of `interpreter_candidates_by_format`
|
|
44
|
+
(`{"xml": [...], "ini": [...]}` — any mix of formats) to determine
|
|
45
|
+
the format itself.
|
|
46
|
+
|
|
47
|
+
Edits made in the viewer come back on save (Ctrl+S / Save),
|
|
48
|
+
via `structile-save-requested` — never live per-keystroke, matching the
|
|
49
|
+
viewer's own "nothing but Save commits an edit" contract. `self.value`
|
|
50
|
+
is updated in place; if a target path is known (`source_path` or an
|
|
51
|
+
explicit `save_path`), that file is written too — Python, not the
|
|
52
|
+
browser, owns the filesystem in embedded mode.
|
|
53
|
+
|
|
54
|
+
The viewer's Save As… button (embed-only — see `[data-embed-only]` in
|
|
55
|
+
structile.html) sends the same message with a new `fileName`,
|
|
56
|
+
which `_resolve_save_path` honors as a rename: the new file is written
|
|
57
|
+
*alongside* `source_path` (never overwriting it) and adopted as
|
|
58
|
+
`self._source_path` for every save after that, so there's no lingering
|
|
59
|
+
"which file is this, really" ambiguity — what's on screen and what the
|
|
60
|
+
next Save writes to always agree. A rename that would silently clobber
|
|
61
|
+
an unrelated, already-existing file is refused instead (see
|
|
62
|
+
`_handle_save`), since there's no channel back to the viewer to ask
|
|
63
|
+
"overwrite?" first.
|
|
64
|
+
|
|
65
|
+
`self.source_text` tracks the literal text of the most recent save (or
|
|
66
|
+
the initial load) even when it doesn't parse — the viewer's source pane
|
|
67
|
+
(source_code_view_claude.md) can save a mid-edit/invalid buffer on
|
|
68
|
+
purpose (its own "Save source (graph is stale)" case), and that literal
|
|
69
|
+
text is still written to disk. `self.value` never reflects that broken
|
|
70
|
+
intermediate state — it only ever updates on a SUCCESSFUL parse, so
|
|
71
|
+
anything reading `.value` between saves never sees a broken one.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
_esm = _PACKAGE_DIR / "static" / "widget.js"
|
|
75
|
+
|
|
76
|
+
# Shared with StructileDiffWidget's own "diff" — static/widget.js's
|
|
77
|
+
# render() branches on this to decide which embed-init message (single-
|
|
78
|
+
# value vs two-sided) to send, since both classes point at the same ESM
|
|
79
|
+
# file.
|
|
80
|
+
mode = traitlets.Unicode("value").tag(sync=True)
|
|
81
|
+
value_json = traitlets.Unicode("null").tag(sync=True)
|
|
82
|
+
raw_text = traitlets.Unicode("").tag(sync=True)
|
|
83
|
+
raw_format = traitlets.Unicode("").tag(sync=True)
|
|
84
|
+
# A single source, or a list of candidates the viewer tries itself —
|
|
85
|
+
# see the class docstring. traitlets.Union tries each trait in order,
|
|
86
|
+
# same as the plain-string case has always worked.
|
|
87
|
+
interpreter_source = traitlets.Union(
|
|
88
|
+
[traitlets.Unicode(), traitlets.List(traitlets.Unicode())], default_value=""
|
|
89
|
+
).tag(sync=True)
|
|
90
|
+
# {"xml": [...], "ini": [...]} — any mix of formats — only used when raw_format is "".
|
|
91
|
+
interpreter_candidates_by_format = traitlets.Dict().tag(sync=True)
|
|
92
|
+
data_name = traitlets.Unicode("data").tag(sync=True)
|
|
93
|
+
viewer_html = traitlets.Unicode("").tag(sync=True)
|
|
94
|
+
height = traitlets.Int(600).tag(sync=True)
|
|
95
|
+
# JSON-encoded embedded-mode `config` object (settings/theme), or the
|
|
96
|
+
# literal string "null" for none — see `structile-embed-init` in
|
|
97
|
+
# structile.html's EMBEDDED MODE section.
|
|
98
|
+
config_json = traitlets.Unicode("null").tag(sync=True)
|
|
99
|
+
# Pushed from the viewer on every `structile-dirty-state-changed`.
|
|
100
|
+
dirty = traitlets.Bool(False).tag(sync=True)
|
|
101
|
+
dirty_count = traitlets.Int(0).tag(sync=True)
|
|
102
|
+
|
|
103
|
+
def __init__(
|
|
104
|
+
self,
|
|
105
|
+
*,
|
|
106
|
+
value_json: str = "null",
|
|
107
|
+
raw_text: str = "",
|
|
108
|
+
raw_format: str = "",
|
|
109
|
+
interpreter_source: Union[str, List[str]] = "",
|
|
110
|
+
interpreter_candidates_by_format: Optional[Dict[str, List[str]]] = None,
|
|
111
|
+
data_name: str = "data",
|
|
112
|
+
height: int = 600,
|
|
113
|
+
config_json: str = "null",
|
|
114
|
+
value: Any = None,
|
|
115
|
+
source_path: Optional[pathlib.Path] = None,
|
|
116
|
+
save_path: Optional[pathlib.Path] = None,
|
|
117
|
+
**kwargs: Any,
|
|
118
|
+
) -> None:
|
|
119
|
+
# Read fresh on every construction (not once at import time) so
|
|
120
|
+
# local edits to structile.html show up in the next
|
|
121
|
+
# open(...) call without reinstalling the package.
|
|
122
|
+
viewer_html = _find_viewer_html().read_text(encoding="utf-8")
|
|
123
|
+
super().__init__(
|
|
124
|
+
value_json=value_json,
|
|
125
|
+
raw_text=raw_text,
|
|
126
|
+
raw_format=raw_format,
|
|
127
|
+
interpreter_source=interpreter_source,
|
|
128
|
+
interpreter_candidates_by_format=interpreter_candidates_by_format or {},
|
|
129
|
+
data_name=data_name,
|
|
130
|
+
height=height,
|
|
131
|
+
config_json=config_json,
|
|
132
|
+
viewer_html=viewer_html,
|
|
133
|
+
**kwargs,
|
|
134
|
+
)
|
|
135
|
+
# Plain instance state, not a synced trait — Python-only, mirrors
|
|
136
|
+
# whatever was last shown/saved rather than something the JS side
|
|
137
|
+
# reads or drives.
|
|
138
|
+
self.value = value
|
|
139
|
+
# The literal text of the most recent save (or the initial load),
|
|
140
|
+
# regardless of whether it parsed — see _handle_save. Separate from
|
|
141
|
+
# .value, which only ever reflects the last SUCCESSFULLY parsed
|
|
142
|
+
# value (source_code_view_claude.md Phase 6's ".value"/raw-text
|
|
143
|
+
# distinction for invalid-buffer saves).
|
|
144
|
+
self.source_text = raw_text or value_json
|
|
145
|
+
self._source_path = source_path
|
|
146
|
+
self._save_path = save_path
|
|
147
|
+
self.on_msg(self._on_custom_msg)
|
|
148
|
+
|
|
149
|
+
def set_value(self, obj: Any, *, name: Optional[str] = None, default: Optional[Callable[[Any], Any]] = None) -> None:
|
|
150
|
+
"""Replace the displayed value in place (re-renders the viewer).
|
|
151
|
+
|
|
152
|
+
`default`: same `normalize()` escape hatch as `open()`'s own
|
|
153
|
+
`default=` — called on any value `normalize()` doesn't otherwise
|
|
154
|
+
support, in place of raising `TypeError`.
|
|
155
|
+
"""
|
|
156
|
+
normalized = normalize(obj, default)
|
|
157
|
+
self.raw_format = ""
|
|
158
|
+
self.value_json = json.dumps(normalized, indent=2)
|
|
159
|
+
self.value = normalized
|
|
160
|
+
self.source_text = self.value_json
|
|
161
|
+
if name is not None:
|
|
162
|
+
self.data_name = name
|
|
163
|
+
|
|
164
|
+
# NOT named _handle_msg — ipywidgets.Widget already defines a method by
|
|
165
|
+
# that exact name (the real comm-message router, wired to the comm via
|
|
166
|
+
# on_msg during open(): handles state "update" — the sync a plain
|
|
167
|
+
# model.set()+save_changes() in JS produces, e.g. dirty/dirty_count —
|
|
168
|
+
# AND dispatches "custom" messages, e.g. our own model.send(...), to
|
|
169
|
+
# on_msg-registered callbacks like this one). Reusing that name here
|
|
170
|
+
# would silently override it instead of adding to it, breaking both.
|
|
171
|
+
def _on_custom_msg(self, widget: "StructileWidget", content: Any, buffers: Any) -> None:
|
|
172
|
+
if not isinstance(content, dict):
|
|
173
|
+
return
|
|
174
|
+
if content.get("type") == "structile-save-requested":
|
|
175
|
+
logger.debug("structile: widget received structile-save-requested")
|
|
176
|
+
self._handle_save(content)
|
|
177
|
+
elif content.get("type") == "structile-settings-changed":
|
|
178
|
+
logger.debug("structile: widget received structile-settings-changed")
|
|
179
|
+
self._handle_settings_changed(content)
|
|
180
|
+
|
|
181
|
+
def _handle_save(self, content: dict) -> None:
|
|
182
|
+
text = content.get("content")
|
|
183
|
+
if not isinstance(text, str):
|
|
184
|
+
logger.warning("structile: structile-save-requested had no usable content, ignoring (%r)", content.get("error"))
|
|
185
|
+
return
|
|
186
|
+
self.source_text = text # the literal buffer, saved as-is below regardless of validity
|
|
187
|
+
if self.raw_format:
|
|
188
|
+
self.value = text # xml/html/etc — already serialized text, nothing to parse
|
|
189
|
+
else:
|
|
190
|
+
try:
|
|
191
|
+
self.value = json.loads(text)
|
|
192
|
+
except ValueError:
|
|
193
|
+
# Mid-edit/invalid JSON — source_code_view_claude.md Phase
|
|
194
|
+
# 3b's "Save source (graph is stale)" case, saveable on
|
|
195
|
+
# purpose. .value keeps reflecting the last successfully
|
|
196
|
+
# parsed value (so anything reading it between saves never
|
|
197
|
+
# sees a broken parse) rather than becoming the raw,
|
|
198
|
+
# unparseable text itself; the literal buffer is still
|
|
199
|
+
# captured above (self.source_text) and still written to
|
|
200
|
+
# disk below.
|
|
201
|
+
logger.debug("structile: structile-save-requested content isn't valid JSON, .value left unchanged")
|
|
202
|
+
target = self._resolve_save_path(content)
|
|
203
|
+
if target is None:
|
|
204
|
+
logger.debug("structile: save updated self.value only (no source_path/save_path known)")
|
|
205
|
+
return
|
|
206
|
+
if target != self._source_path and target.exists():
|
|
207
|
+
# Save As colliding with an existing, unrelated file — refuse
|
|
208
|
+
# rather than silently clobbering it. There's no ack/error
|
|
209
|
+
# channel back to the viewer for this message (see
|
|
210
|
+
# structile-save-requested in the "EMBEDDED MODE" protocol doc), so the
|
|
211
|
+
# only signal is this log line; self.value/self.source_text above
|
|
212
|
+
# already reflect the saved buffer either way, and a plain Save
|
|
213
|
+
# right after this still targets the ORIGINAL file, unaffected by
|
|
214
|
+
# the rejected rename (self._source_path is left untouched).
|
|
215
|
+
logger.warning(
|
|
216
|
+
"structile: save-as target %s already exists, refusing to overwrite it — data kept in-memory only",
|
|
217
|
+
target,
|
|
218
|
+
)
|
|
219
|
+
return
|
|
220
|
+
target.write_text(text, encoding="utf-8")
|
|
221
|
+
self._source_path = target
|
|
222
|
+
logger.info("structile: saved %s", target)
|
|
223
|
+
|
|
224
|
+
def _resolve_save_path(self, content: dict) -> Optional[pathlib.Path]:
|
|
225
|
+
"""Where to write on save: a caller-fixed `save_path` always wins;
|
|
226
|
+
otherwise the file this data was loaded from. `fileName` normally just
|
|
227
|
+
equals that file's stem (an ordinary Save/Ctrl+S), but a differing one
|
|
228
|
+
— from the viewer's own embed-only Save As… popover (performSaveAs in
|
|
229
|
+
structile.html), or some other future host mechanism — is
|
|
230
|
+
honored as a rename: written alongside the original (which is never
|
|
231
|
+
itself touched) and adopted as `self._source_path` for every save
|
|
232
|
+
after this one (see _handle_save). `None` (no known path — an
|
|
233
|
+
in-memory object with no `path=` override) means: update `self.value`
|
|
234
|
+
only and write nothing — deliberately true for Save As too, since
|
|
235
|
+
without an existing source_path there's no directory to anchor a new
|
|
236
|
+
file's location to."""
|
|
237
|
+
if self._save_path is not None:
|
|
238
|
+
return self._save_path
|
|
239
|
+
if self._source_path is None:
|
|
240
|
+
return None
|
|
241
|
+
file_name = content.get("fileName")
|
|
242
|
+
ext = content.get("ext") or self._source_path.suffix.lstrip(".")
|
|
243
|
+
if file_name and file_name != self._source_path.stem:
|
|
244
|
+
try:
|
|
245
|
+
return self._source_path.with_name(file_name + "." + ext)
|
|
246
|
+
except ValueError:
|
|
247
|
+
# An invalid name (e.g. containing a path separator) reached
|
|
248
|
+
# here despite the viewer's own inline validation
|
|
249
|
+
# (sanitizeSaveAsName in structile.html) — some other
|
|
250
|
+
# protocol producer, or a stale cached build. Fall back to the
|
|
251
|
+
# current path rather than raising out of a comm message
|
|
252
|
+
# handler.
|
|
253
|
+
logger.warning("structile: ignoring invalid save-as file name %r", file_name)
|
|
254
|
+
return self._source_path
|
|
255
|
+
return self._source_path
|
|
256
|
+
|
|
257
|
+
def _handle_settings_changed(self, content: dict) -> None:
|
|
258
|
+
"""The viewer's embed-only "Set as default" button (see
|
|
259
|
+
enableSettingsPanel/structile-settings-changed in structile.html's
|
|
260
|
+
EMBEDDED MODE section) — `content` is the FULL current settings-panel
|
|
261
|
+
state (settings/renderMap/links/theme), not a diff, same as Export
|
|
262
|
+
Settings would produce. Promotes it into `structile.options`
|
|
263
|
+
(matplotlib.rcParams-style — see config.replace_options), so it
|
|
264
|
+
becomes the new baseline for every `open()` call for the rest
|
|
265
|
+
of this process. This widget instance itself is unaffected — it
|
|
266
|
+
already shows these values."""
|
|
267
|
+
payload = dict(content.get("settings") or {})
|
|
268
|
+
payload.update(content.get("renderMap") or {})
|
|
269
|
+
# `links` carries the viewer's own {open, close} field names (see
|
|
270
|
+
# structile.html's linkSettings) — renamed to the
|
|
271
|
+
# Python-facing linkOpen/linkClose option names (see
|
|
272
|
+
# config._LINK_KEYS_TO_FIELD for the reverse direction).
|
|
273
|
+
links = content.get("links") or {}
|
|
274
|
+
if "open" in links:
|
|
275
|
+
payload["linkOpen"] = links["open"]
|
|
276
|
+
if "close" in links:
|
|
277
|
+
payload["linkClose"] = links["close"]
|
|
278
|
+
if "theme" in content:
|
|
279
|
+
payload["theme"] = content["theme"]
|
|
280
|
+
try:
|
|
281
|
+
opts = options_from_dict(payload)
|
|
282
|
+
except (AttributeError, TypeError, ValueError) as e:
|
|
283
|
+
logger.warning("structile: structile-settings-changed had an invalid payload, ignoring (%s)", e)
|
|
284
|
+
return
|
|
285
|
+
replace_options(opts)
|
|
286
|
+
logger.info("structile: settings panel state promoted to structile.options (module default)")
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
class StructileDiffWidget(anywidget.AnyWidget):
|
|
290
|
+
"""Renders structile.html in embedded two-sided diff mode
|
|
291
|
+
(`structile-embed-diff-init` — see the "EMBEDDED MODE"/"DIFF MODE" sections of
|
|
292
|
+
structile.html), for `structile.diff(renderer="widget")`.
|
|
293
|
+
|
|
294
|
+
Shares `static/widget.js` with `StructileWidget` (its `mode` trait is
|
|
295
|
+
what tells the front end which embed-init message to build/send) rather
|
|
296
|
+
than duplicating the iframe-mounting boilerplate for a second file.
|
|
297
|
+
|
|
298
|
+
The diff graph itself is always read-only (same as every other diff
|
|
299
|
+
renderer), but each side's own source pane is independently editable and
|
|
300
|
+
saveable — see structile.html's `performDiffSideSave`, which
|
|
301
|
+
posts `structile-save-requested` with a `side: "left" | "right"` field. Saving
|
|
302
|
+
a side updates `self.left_value`/`self.right_value` in place; there is
|
|
303
|
+
no `left_path`/`right_path` to write back to yet (`structile.diff()`
|
|
304
|
+
doesn't take one), so — mirroring `StructileWidget`'s own "no
|
|
305
|
+
source_path/save_path known" case — a save only ever updates the
|
|
306
|
+
in-memory value, never a file.
|
|
307
|
+
"""
|
|
308
|
+
|
|
309
|
+
_esm = _PACKAGE_DIR / "static" / "widget.js"
|
|
310
|
+
|
|
311
|
+
mode = traitlets.Unicode("diff").tag(sync=True)
|
|
312
|
+
left_text = traitlets.Unicode("").tag(sync=True)
|
|
313
|
+
left_format = traitlets.Unicode("").tag(sync=True)
|
|
314
|
+
left_name = traitlets.Unicode("left").tag(sync=True)
|
|
315
|
+
right_text = traitlets.Unicode("").tag(sync=True)
|
|
316
|
+
right_format = traitlets.Unicode("").tag(sync=True)
|
|
317
|
+
right_name = traitlets.Unicode("right").tag(sync=True)
|
|
318
|
+
# Same Union-of-str-or-list shape as StructileWidget.interpreter_source,
|
|
319
|
+
# once per side (a diff's two sides can be different, mutually
|
|
320
|
+
# incompatible markup schemas — see structile.diff()'s own
|
|
321
|
+
# interpreter=/format= docs).
|
|
322
|
+
left_interpreter_source = traitlets.Union(
|
|
323
|
+
[traitlets.Unicode(), traitlets.List(traitlets.Unicode())], default_value=""
|
|
324
|
+
).tag(sync=True)
|
|
325
|
+
right_interpreter_source = traitlets.Union(
|
|
326
|
+
[traitlets.Unicode(), traitlets.List(traitlets.Unicode())], default_value=""
|
|
327
|
+
).tag(sync=True)
|
|
328
|
+
diff_view = traitlets.Unicode("unified").tag(sync=True)
|
|
329
|
+
key_columns = traitlets.List(traitlets.Unicode()).tag(sync=True)
|
|
330
|
+
viewer_html = traitlets.Unicode("").tag(sync=True)
|
|
331
|
+
height = traitlets.Int(600).tag(sync=True)
|
|
332
|
+
config_json = traitlets.Unicode("null").tag(sync=True)
|
|
333
|
+
|
|
334
|
+
def __init__(
|
|
335
|
+
self,
|
|
336
|
+
*,
|
|
337
|
+
left: "Payload",
|
|
338
|
+
right: "Payload",
|
|
339
|
+
view: str = "unified",
|
|
340
|
+
key_columns: Optional[list] = None,
|
|
341
|
+
height: int = 600,
|
|
342
|
+
**kwargs: Any,
|
|
343
|
+
) -> None:
|
|
344
|
+
viewer_html = _find_viewer_html().read_text(encoding="utf-8")
|
|
345
|
+
config_json = json.dumps(left.config) if left.config is not None else "null"
|
|
346
|
+
super().__init__(
|
|
347
|
+
left_text=left.text,
|
|
348
|
+
left_format=left.format,
|
|
349
|
+
left_name=left.name,
|
|
350
|
+
right_text=right.text,
|
|
351
|
+
right_format=right.format,
|
|
352
|
+
right_name=right.name,
|
|
353
|
+
left_interpreter_source=left.interpreter_source or "",
|
|
354
|
+
right_interpreter_source=right.interpreter_source or "",
|
|
355
|
+
diff_view=view if view == "split" else "unified",
|
|
356
|
+
key_columns=list(key_columns or []),
|
|
357
|
+
viewer_html=viewer_html,
|
|
358
|
+
height=height,
|
|
359
|
+
config_json=config_json,
|
|
360
|
+
**kwargs,
|
|
361
|
+
)
|
|
362
|
+
# Plain instance state (not synced traits) — mirrors StructileWidget's
|
|
363
|
+
# own .value: Python-only, updated on each side's own Save. Parsed
|
|
364
|
+
# up front for a "json" side (matching open()'s own
|
|
365
|
+
# already-normalized initial .value) — every other format (no
|
|
366
|
+
# Python-side parser here — see _handle_save) keeps the raw text.
|
|
367
|
+
self.left_value: Any = json.loads(left.text) if left.format == "json" else left.text
|
|
368
|
+
self.right_value: Any = json.loads(right.text) if right.format == "json" else right.text
|
|
369
|
+
self.on_msg(self._on_custom_msg)
|
|
370
|
+
|
|
371
|
+
def _on_custom_msg(self, widget: "StructileDiffWidget", content: Any, buffers: Any) -> None:
|
|
372
|
+
if isinstance(content, dict) and content.get("type") == "structile-save-requested":
|
|
373
|
+
logger.debug("structile.diff: widget received structile-save-requested")
|
|
374
|
+
self._handle_save(content)
|
|
375
|
+
|
|
376
|
+
def _handle_save(self, content: dict) -> None:
|
|
377
|
+
text = content.get("content")
|
|
378
|
+
side = content.get("side")
|
|
379
|
+
if not isinstance(text, str) or side not in ("left", "right"):
|
|
380
|
+
logger.warning(
|
|
381
|
+
"structile.diff: widget structile-save-requested had no usable content/side, ignoring (%r)",
|
|
382
|
+
content.get("error"),
|
|
383
|
+
)
|
|
384
|
+
return
|
|
385
|
+
# Unlike StructileWidget.raw_format (only truthy for markup/other
|
|
386
|
+
# non-JSON formats — "" means "plain JSON value"), a diff side's own
|
|
387
|
+
# `format` is always a real string, "json" included (see
|
|
388
|
+
# _resolve_diff_side) — so the "already-serialized, nothing to
|
|
389
|
+
# parse" check has to name "json" explicitly rather than testing
|
|
390
|
+
# for emptiness. "python" text has no Python-side repr parser here
|
|
391
|
+
# (same gap `_describe_payload_for_text` already has), so it's kept
|
|
392
|
+
# as raw text too, same as xml/html/any other format.
|
|
393
|
+
fmt = self.left_format if side == "left" else self.right_format
|
|
394
|
+
if fmt == "json":
|
|
395
|
+
try:
|
|
396
|
+
value: Any = json.loads(text)
|
|
397
|
+
except ValueError:
|
|
398
|
+
logger.debug("structile.diff: %s side saved non-JSON text, value left unchanged", side)
|
|
399
|
+
return
|
|
400
|
+
else:
|
|
401
|
+
value = text
|
|
402
|
+
if side == "left":
|
|
403
|
+
self.left_value = value
|
|
404
|
+
else:
|
|
405
|
+
self.right_value = value
|
|
406
|
+
logger.info("structile.diff: %s side saved (in-memory only — diff() has no source path to write back to)", side)
|