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.
An interpreter script may also declare two optional top-level string
constants, INTERPRETER_NAME/INTERPRETER_VERSION, purely to
self-identify — the standalone viewer's [?] About popover shows them for
whichever interpreter is currently active, when either is declared.
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.
Metadata
Release files for structile 6.8.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| structile-6.8.1.tar.gz | 154.2 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| structile-6.8.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 308.3 kB
Release files / structile-6.8.1.tar.gz
| Download URL | structile-6.8.1.tar.gz |
|---|---|
| Size | 154.2 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
e8dc35a041e0ae7c384f77f42c1338677106a03dd5befef28c7cfe51eba76201
|
|
BLAKE2b-256 checksum How to use checksums |
a22185666bb42a9e2190289a35e04296e4a33fbbad6337c67f0f8e59ba4a16d5
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.12.13
|
Release files / structile-6.8.1-py3-none-any.whl
| Download URL | structile-6.8.1-py3-none-any.whl |
|---|---|
| Size | 154.1 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
a8565b77f85fecc4a309ad43e05be3bb30e5f4c578a5edd6a6bf70ba77d86777
|
|
BLAKE2b-256 checksum How to use checksums |
960be8b4a2ca1611fac6256639ede15e7c850a72786de207404aa989353617a9
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.12.13
|