Metadata-Version: 2.4
Name: structile
Version: 6.0.2
Summary: A spatial editor and diff tool for structured data, inline in Jupyter
License-Expression: Apache-2.0
Requires-Python: >=3.8
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: anywidget>=0.9
Requires-Dist: traitlets>=5
Provides-Extra: numpy
Requires-Dist: numpy; extra == "numpy"
Provides-Extra: convert
Requires-Dist: mini-racer; extra == "convert"
Provides-Extra: test
Requires-Dist: pytest; extra == "test"
Requires-Dist: pytest-xdist; extra == "test"
Requires-Dist: numpy; extra == "test"
Requires-Dist: mini-racer; extra == "test"
Requires-Dist: build; extra == "test"
Provides-Extra: browser-test
Requires-Dist: playwright; extra == "browser-test"
Provides-Extra: notebook-test
Requires-Dist: nbformat; extra == "notebook-test"
Requires-Dist: nbclient; extra == "notebook-test"
Requires-Dist: ipykernel; extra == "notebook-test"
Dynamic: license-file

# 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](#install)
- [Basic use](#basic-use)
- [Renderers](#renderers)
- [Command line](#command-line)
- [Editing and saving back to Python](#editing-and-saving-back-to-python)
- [Custom formats via an interpreter](#custom-formats-via-an-interpreter)
  - [Distributing interpreters as a package](#distributing-interpreters-as-a-package)
- [Comparing two files (`structile.diff`)](#comparing-two-files-structilediff)
- [Converting between formats](#converting-between-formats)
- [Configuring the viewer](#configuring-the-viewer)
- [Embedding the viewer in your own host](#embedding-the-viewer-in-your-own-host)
- [Logging](#logging)
- [Current limitations](#current-limitations)

## Install

```bash
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](#converting-between-formats))
— everything else works with no extra dependency beyond `anywidget`/
`traitlets`, which `pip install structile` already pulls in.

## Basic use

```python
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](#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.

```python
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):

```python
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:

```python
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

```bash
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.)

```python
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:

```python
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:

  ```javascript
  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](https://packaging.python.org/en/latest/specifications/entry-points/)
in the group `structile.interpreters`. Once installed, this just works,
with no import of the plugin package and no registration call:

```python
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()`:

```python
# 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`).

```python
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:

```python
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:

```python
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.

```python
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:

```python
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:

```python
%%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:

```python
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.
