Skip to main content

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

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/.htm path): interpretXML(xmlDocument) (and optionally serializeXML(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 optionally serializeText(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=1 in the URL, or
  • Just post a structile-embed-init message 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=, default 600) — 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 1 and "1" as separate keys) will collide in the displayed result too — this is a fundamental JSON limitation (object keys are always strings), not something structile works around.
  • browser/file/none/text renders are one-way — there's no channel back to the Python process the way widget's embedded-mode protocol has, so h.value for 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

structile-6.0.2.tar.gz (144.5 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

structile-6.0.2-py3-none-any.whl (143.4 kB view details)

Uploaded Python 3

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

Hashes for structile-6.0.2.tar.gz
Algorithm Hash digest
SHA256 159159f77dd5a33a485f944f5f30c7b9af20241e699b103a75b20062a9c30485
MD5 92265cfea76ccde722f301c0b162b546
BLAKE2b-256 0ebdf103208c841a97d7dfedc3040d64ca0cb01b5874d6d06356ab99b429a99d

See more details on using hashes here.

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

Hashes for structile-6.0.2-py3-none-any.whl
Algorithm Hash digest
SHA256 d7069400a3a6ce798fead47dc2f42f282abeeb58e8b25aab29e3610933bf2e13
MD5 145ec8ed6a28b238d0127a288b961eb3
BLAKE2b-256 c445bf65c22f75eb43db5c328df4a1dd45700e6ae02205b7a9ad74cdade3935a

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page