Structile
A spatial editor and diff tool for structured data.
A viewer for JSON (and Python-repr, and XML/HTML, and any other text format you attach a small interpreter script to) data — inline editing, undo/redo, search, and a spatial "packing" layout that lays dicts and tables out as nested boxes instead of an indented tree. Renders wherever you're running: an inline Jupyter widget, a browser tab, a written HTML file, or a terminal summary.
Table of contents
- Install
- Basic use
- Renderers
- Command line
- Editing and saving back to Python
- Custom formats via an interpreter
- Comparing two files (
structile.diff) - Converting between formats
- Configuring the viewer
- Embedding the viewer in your own host
- Logging
- Current limitations
Install
pip install structile
Add pip install "structile[numpy]" if you also want numpy scalar
support, or pip install mini-racer if you plan to use convert()'s
non-JSON/Python directions (see Converting between formats)
— everything else works with no extra dependency beyond anywidget/
traitlets, which pip install structile already pulls in.
Basic use
import structile as st
st.open({"a": 1, "nested": {"b": 2, "tags": ["x", "y"]}}) # a literal value
st.open("results.json") # or load a file by path
st.__version__ # package/viewer version
Call it as the last expression in a cell (or wrap it in
IPython.display.display(...)) to render it. It always returns a
RenderHandle — see Renderers for what that is.
obj can be any mix of dict, list, tuple, set/frozenset, None,
bool, int (any size — huge ints are preserved exactly), float, str,
and numpy scalars (if numpy is installed). If obj is a pathlib.Path, or
a plain str that happens to name an existing file, it's read from disk
and parsed instead of shown as a literal value — as JSON if that succeeds,
otherwise as plain text. When it does succeed, the source pane (see below)
shows/saves the file's own literal text, preserving its original
formatting, rather than a re-serialization.
structile.normalize(obj) runs just that same value-preparation step —
big-int/set handling included — and returns the resulting JSON-safe
structure directly, with no rendering at all, if that's all you want.
Renderers
open() doesn't hardcode "inline Jupyter widget" — it renders
through whichever renderer is active, matplotlib-backend style
(matplotlib.use(...) ⟷ structile.use(...)).
| Renderer | What it does | Auto-selected when |
|---|---|---|
widget |
The original inline anywidget path (editable, embedded mode). | An IPython kernel with a rich frontend (ZMQInteractiveShell) — Jupyter classic/lab, VS Code notebooks, Colab. |
browser |
Writes one self-contained HTML file (viewer + data + interpreter, all inlined — no server, no CDN) and opens it with webbrowser.open(). Settings bar, editing, undo/redo, and client-side Save all work directly in that file. |
Outside a notebook, with a display available. |
file |
Same HTML as browser, but just writes it and returns/prints the path — never opens a tab. |
Headless: no DISPLAY/WAYLAND_DISPLAY on Linux, SSH_CONNECTION set, or webbrowser.get() raises. |
none |
Renders nothing. | CI or PYTEST_CURRENT_TEST is set — so stray debug calls in a test suite never spawn browser tabs. |
text |
A compact terminal summary (type/counts, top-level keys, a truncated tree). | Never auto-selected; opt in with renderer="text". |
Resolved in increasing precedence: renderer= (per call) → the
STRUCTILE_RENDERER env var → config=/
structile.options.renderer (set_option("renderer", ...) /
structile.use(...)) → auto-detect.
st.open(my_data, renderer="browser") # force it, this call only
st.use("file") # module default, matplotlib.use()-style
browser/file write into a stable per-process temp directory that is
never auto-deleted (the browser may not have finished loading the file
by the time the process exits). out= writes the same standalone HTML
somewhere specific instead of/in addition to that temp file, for any
renderer; auto_open=False stops the browser renderer from opening a tab
(it still writes the file):
st.open(my_data, out="snapshot.html") # write only, don't open
st.open(my_data, renderer="browser", auto_open=False) # write to the temp dir, don't open
Every call returns a RenderHandle — never None — regardless of
renderer:
h = st.open(my_data)
h.path # where the standalone HTML was last written, or None
h.value # the displayed value — live (updates on Save) for `widget`, a static snapshot otherwise
h.widget # the underlying StructileWidget for `widget`, else None
h.open() # open a browser tab (builds+writes to the temp dir first if nothing has been written yet)
h.to_html() # the standalone HTML as a string, built lazily and cached
h.save(path) # write that HTML to `path`
repr(h) # e.g. <structile: dict, 9 keys -> C:\...\a3f2b91c.html>
h.to_html()/.open()/.save() work the same way regardless of which
renderer actually ran — a widget render can still be popped out into a
full standalone browser tab via h.open().
Command line
python -m structile data.json
python -m structile data.json --renderer file --out snapshot.html
python -m structile data.json --no-open # write, don't open a browser tab
python -m structile --version
Same renderer resolution/auto-detection as calling open() from a
script. There's no --format/registry flag on the CLI: --interpreter is
inferred from the file's own extension the same way open() infers
it.
Editing and saving back to Python
The widget renderer is read-write. Edit inline as usual, then Save
(or Ctrl+S) sends the edited content back to Python instead of downloading
it. (The browser/file renderers use the standalone viewer instead,
where Save prompts for a new data file directly from the browser — there's
no Python-side channel for those to report back through, so h.value for
them stays a static snapshot of what was originally rendered.)
h = st.open("results.json") # loaded from a path: Save writes back to it
h = st.open(my_data, path="out.json") # in-memory obj: Save writes to out.json
h = st.open(my_data) # in-memory, no path: Save only updates h.value
h.value holds the last-saved (parsed) value and is updated on every save;
h.widget.dirty / h.widget.dirty_count mirror the viewer's own
unsaved-edit indicator if you want to poll it. Nothing is synced live
per-keystroke — only an explicit Save commits.
Double-clicking a value or table cell opens a reviewable Edit diff (original on the left, your edit on the right) — Save commits it back to Python (as above); Cancel discards it. Key/column/name renames are the one exception: they still commit immediately, live (no review step).
Gotcha: editing never mutates the object you passed in. st.open(my_dict)
normalizes my_dict into a separate structure the widget owns; nothing
ever writes back into my_dict itself. Read h.value after saving to get
the edited result, or reassign my_dict = h.value.
Custom formats via an interpreter
Pass interpreter= — a path to a .js file, raw JS source, or a list of
candidates tried in order — to hand the viewer raw text instead of a
Python value; it's parsed (and, on save, serialized back) with that script
client-side. Register one once at import time (register_interpreter) so
later calls don't need interpreter= at all:
st.register_interpreter(".xml", "my_interpreter.js")
st.open("config.xml") # auto-selected from the registry, no interpreter= needed
interpreter= (or a register_interpreter() registration) can also be a
list of candidates, tried in order — useful when you have more than
one schema in play and don't want to say up front which file is which; the
first candidate that doesn't raise on a given file wins, independently per
file.
An interpreter script implements one of two contracts, depending on the data:
-
Markup (
format="xml"/"html", or a.xml/.html/.htmpath):interpretXML(xmlDocument)(and optionallyserializeXML(value)to support saving back), given a browser-parsed DOM document. A minimal example, reading a flat<config>element's attributes as key/value pairs:function interpretXML(doc) { const root = doc.documentElement; if (root.tagName !== "config") throw new Error("expected a <config> root element"); const result = {}; for (const attr of root.attributes) result[attr.name] = attr.value; return result; }
-
Anything else (any other
format=, e.g."ini", or a matching file extension):interpretText(text)(and optionallyserializeText(value)), given the raw text directly — no DOM involved at all.
open() never needs anything beyond its own dependencies for
either case: every renderer (including widget) forwards the interpreter
source(s) as-is and lets the browser try them client-side. convert()
(below), and calling select_interpreter/select_interpreter_any_format
directly, are the only two cases that need the optional mini-racer
package (pip install mini-racer) — both need the actual parsed Python
value back, which only running the interpreter (in an embedded JS engine —
a real V8, via a prebuilt wheel; no Node.js, no npm, no system install) can
produce.
Distributing interpreters as a package
An organisation with its own in-house schema can distribute its
interpreter(s) as an ordinary pip install-able package instead of a
.js file callers have to know the location of — via a standard
entry point
in the group structile.interpreters. Once installed, this just works,
with no import of the plugin package and no registration call:
import structile as st
st.open("blotter.axml") # correct interpreter chosen automatically
The plugin package's entry point resolves to a callable taking one
argument, a small facade exposing only register_interpreter():
# pyproject.toml: [project.entry-points."structile.interpreters"]
# blotter = "acme_structile_formats:register"
import importlib.resources
from structile import InterpreterSource
def register(registry) -> None:
text = (importlib.resources.files("acme_structile_formats") / "interpreters" / "blotter.js").read_text(encoding="utf-8")
registry.register_interpreter(".axml", InterpreterSource(text, name="acme_structile_formats"))
InterpreterSource(text, name=) wraps JS source text loaded directly
(rather than a path) — accepted anywhere interpreter= is. Discovery is
lazy (never triggered by import structile) and runs at most once per
process; a broken plugin logs a warning and is skipped rather than
breaking anyone else's call. An explicit interpreter= or your own
register_interpreter() call always takes precedence over a plugin's.
st.plugins() (or python -m structile --plugins) lists what's actually
installed and what it registered.
Comparing two files (structile.diff)
structile.diff(left, right, ...) shows a field-by-field diff —
left/right each accept anything open()'s obj does, and it
returns a DiffRenderHandle (.path/.open()/.to_html()/.save(path),
same shape as open()'s own RenderHandle).
import structile as st
st.diff({"a": 1, "b": 2}, {"a": 1, "b": 3})
interpreter=/format=/name= each accept a single value (applied to
both sides — two files in the same format/schema, the common case) or a
(left, right) tuple, so the two sides can be genuinely different, even
mutually-incompatible, schemas — each resolved through its own
interpreter. Give interpreter=/format= as a (value, None) tuple so
only one side is treated as markup, when comparing e.g. an XML file
against a plain JSON value:
st.diff(
"left.xml", "right.json",
interpreter=("my_interpreter.js", None),
format=("xml", None),
)
renderer=/out=/auto_open= work the same as open()'s (height=
too — iframe height, widget only). view= ("unified"/"split")
switches between the two diff presentations. key_columns= sets initial
per-table row-key overrides for table alignment.
renderer="widget" renders an inline two-sided diff widget in Jupyter. The
diff GRAPH is always read-only, but each side's own source pane can still
be independently edited and saved — h.left_value/h.right_value read
through to the live widget, updated on that side's own Save:
h = st.diff({"a": 1}, {"a": 2}, renderer="widget")
h.left_value # {"a": 1} — updates if the left side is edited+saved in the widget
h.right_value # {"a": 2} — likewise for the right side
Converting between formats
structile.convert(src, dst_format, interpreter=None) parses src and
re-serializes it as dst_format, returning the converted text — no widget,
no notebook, just a format conversion.
import structile as st
st.convert("data.json", "python") # -> Python-repr text
st.convert("data.py", "json") # -> JSON text
st.convert("{'a': 1, 'b': {1, 2, 3}}", "json") # literal text works too
st.convert("data.xml", "json", interpreter="my_interpreter.js")
src is a path (format inferred from its extension: .json/.py/.xml/
.html — or, once an interpreter is attached to it, any other extension
too) or a literal string (a literal string only works for "json"/
"python"/"xml"/"html", since there's no way to reliably sniff an
arbitrary text format from content alone). dst_format is "json",
"python", "xml", "html", or any other string an interpreter is
registered/provided for. "json" ↔ "python" needs no extra dependency;
anything else needs the optional mini-racer package
(pip install mini-racer) — a real embedded V8 engine via a prebuilt
wheel, no Node.js, no npm, no system install at all.
Configuring the viewer
The viewer's own settings (gap, theme, and the packing-layout knobs)
can be set three ways, in increasing order of precedence:
import structile as st
st.set_option("theme", "dark") # module-level default, applies to every call after this
st.options.gap = 8 # equivalent, attribute style
st.get_option("theme") # read a module-level default back ("dark") — None if unset
st.reset_option("theme") # unset it again, back to the viewer's own built-in default
st.open(my_data, gap=8, theme="dark") # per-call override
st.open(my_data, config=st.Options()) # ...or pass an Options instance
Any setting left unset everywhere (module default and per-call both
None) falls back to the viewer's own built-in default. Passing an
unknown option name, or a value of the wrong type, raises immediately.
Precedence for viewer settings (gap/theme/layout knobs): per-call
keyword (st.open(data, gap=8)) > per-call config=Options() >
module-level default (set_option/options.gap = 8) > the viewer's own
built-in default. Precedence for renderer: per-call renderer= >
STRUCTILE_RENDERER env var > per-call config=Options() >
module-level default (use()/set_option("renderer", ...)) >
auto-detect. The two precedence chains are resolved completely
independently — renderer is Python-side dispatch only and has no
corresponding concept in the viewer itself.
VS Code's Jupyter renderer may draw a white output background around ipywidgets even in a dark theme. Add this notebook cell before rendering widgets if you want that wrapper to be transparent:
%%html
<style>
.cell-output-ipywidget-background { background-color: transparent !important; }
.jp-OutputArea-output { background-color: transparent !important; }
</style>
Embedding the viewer in your own host
The widget renderer's underlying iframe protocol is available directly
if you want to embed the viewer as a controlled editing surface inside
your own page (an <iframe>, or a srcdoc document) — the host supplies
the data/config instead of the user picking a file, and the viewer reports
state back instead of downloading files itself.
Turning it on — either works, and either activates it independent of the other:
- Load the page with
?embed=1in the URL, or - Just post a
structile-embed-initmessage to it.
Message protocol (plain objects, matched on type):
| Direction | Type | Payload |
|---|---|---|
| host to viewer | structile-embed-init |
{ value? | text?+format?, name?, interpreterSource?, config?, hostManagesSave?, disableSourceView? } |
| host to viewer | structile-embed-diff-init |
{ left:{text,format,name?}, right:{text,format,name?}, leftInterpreterSource?, rightInterpreterSource?, config?, keyColumns?, diff?:{view}, hostManagesSave?, disableSourceView? } — two-sided diff mode |
| host to viewer | structile-embed-interpreter |
{ source, name? } — send a companion interpreter after the fact |
| host to viewer | structile-embed-request-save |
no payload — pull current content on the host's own initiative |
| viewer to host | structile-dirty-state-changed |
{ dirty, totalCount, containers: [{path, label, count}] } — single-value mode only, never sent for diff mode |
| viewer to host | structile-save-requested |
{ format, ext, fileName, isSaveAs:false, content } | { ..., side } (diff mode — side is "left"/"right") | { error } |
For structile-embed-init: pass either an already-parsed value, or raw text
plus format — "json" and "python" use the two built-in parsers;
anything else ("xml", "html", or a custom format) runs through an
interpreter. config is a settings payload (e.g.
{ settings: { valMax: 22, n: 3 } }), plus an optional top-level
theme: "light" | "dark". hostManagesSave: true hides the Save button
and stops Ctrl/Cmd+S from being handled inside the iframe at all — for a
host that wants its own save keybinding to win instead; that host is
expected to pull current content on demand via structile-embed-request-save
rather than waiting for a push. disableSourceView: true hides the
read-only raw-source-text toggle/pane entirely.
In embedded mode, Save never touches the filesystem or opens a tab — it
only posts structile-save-requested with the serialized content, and it's the
host's job to persist it.
Note: the viewer posts with
targetOrigin: "*"(it doesn't know the host's origin ahead of time) and listens for messages from any origin — fine for a same-app iframe/widget, but don't embed untrusted third-party pages this way without adding your own origin checks on both ends.
Logging
structile emits to logging.getLogger("structile") — DEBUG for
internal decisions (which renderer/interpreter was picked and why), INFO
for real actions (a file written, a browser tab opened), WARNING for
recoverable hiccups (an interpreter candidate failed, trying the next
one). It never configures handlers/levels itself:
import logging
logging.basicConfig(level=logging.DEBUG)
Current limitations
- Fixed iframe height (
height=, default600) — no auto-resize to content yet. - Plain numpy arrays aren't handled (only numpy scalars) — call
.tolist()first if you need to show one. - A source dict with keys that collide once stringified (e.g. both
1and"1"as separate keys) will collide in the displayed result too — this is a fundamental JSON limitation (object keys are always strings), not somethingstructileworks around. browser/file/none/textrenders are one-way — there's no channel back to the Python process the waywidget's embedded-mode protocol has, soh.valuefor those stays whatever was originally rendered, regardless of what happens later in the browser tab or file.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file structile-6.0.2.tar.gz.
File metadata
- Download URL: structile-6.0.2.tar.gz
- Upload date:
- Size: 144.5 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/7.0.0 CPython/3.12.13
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
159159f77dd5a33a485f944f5f30c7b9af20241e699b103a75b20062a9c30485
|
|
| MD5 |
92265cfea76ccde722f301c0b162b546
|
|
| BLAKE2b-256 |
0ebdf103208c841a97d7dfedc3040d64ca0cb01b5874d6d06356ab99b429a99d
|
File details
Details for the file structile-6.0.2-py3-none-any.whl.
File metadata
- Download URL: structile-6.0.2-py3-none-any.whl
- Upload date:
- Size: 143.4 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/7.0.0 CPython/3.12.13
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
d7069400a3a6ce798fead47dc2f42f282abeeb58e8b25aab29e3610933bf2e13
|
|
| MD5 |
145ec8ed6a28b238d0127a288b961eb3
|
|
| BLAKE2b-256 |
c445bf65c22f75eb43db5c328df4a1dd45700e6ae02205b7a9ad74cdade3935a
|