structile 6.28.0__tar.gz → 6.30.0__tar.gz

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.
Files changed (33) hide show
  1. {structile-6.28.0/structile.egg-info → structile-6.30.0}/PKG-INFO +34 -3
  2. {structile-6.28.0 → structile-6.30.0}/README.pypi.md +33 -2
  3. structile-6.30.0/VERSION +1 -0
  4. {structile-6.28.0 → structile-6.30.0}/structile/__init__.py +32 -11
  5. structile-6.30.0/structile/_pyobjects.py +254 -0
  6. {structile-6.28.0 → structile-6.30.0}/structile/config.py +12 -0
  7. {structile-6.28.0 → structile-6.30.0}/structile/custom_html.py +12 -8
  8. {structile-6.28.0 → structile-6.30.0}/structile/manifest.py +16 -1
  9. {structile-6.28.0 → structile-6.30.0}/structile/render.py +14 -1
  10. {structile-6.28.0 → structile-6.30.0}/structile/serialize.py +30 -20
  11. structile-6.30.0/structile/static/structile.prod.html +215 -0
  12. {structile-6.28.0 → structile-6.30.0}/structile/static/widget.js +26 -2
  13. {structile-6.28.0 → structile-6.30.0}/structile/widget.py +56 -8
  14. {structile-6.28.0 → structile-6.30.0/structile.egg-info}/PKG-INFO +34 -3
  15. {structile-6.28.0 → structile-6.30.0}/structile.egg-info/SOURCES.txt +1 -0
  16. structile-6.28.0/VERSION +0 -1
  17. structile-6.28.0/structile/static/structile.prod.html +0 -210
  18. {structile-6.28.0 → structile-6.30.0}/LICENSE +0 -0
  19. {structile-6.28.0 → structile-6.30.0}/MANIFEST.in +0 -0
  20. {structile-6.28.0 → structile-6.30.0}/pyproject.toml +0 -0
  21. {structile-6.28.0 → structile-6.30.0}/setup.cfg +0 -0
  22. {structile-6.28.0 → structile-6.30.0}/structile/__main__.py +0 -0
  23. {structile-6.28.0 → structile-6.30.0}/structile/_csv.py +0 -0
  24. {structile-6.28.0 → structile-6.30.0}/structile/_dom_lite.py +0 -0
  25. {structile-6.28.0 → structile-6.30.0}/structile/_log.py +0 -0
  26. {structile-6.28.0 → structile-6.30.0}/structile/_paths.py +0 -0
  27. {structile-6.28.0 → structile-6.30.0}/structile/_plugins.py +0 -0
  28. {structile-6.28.0 → structile-6.30.0}/structile/convert.py +0 -0
  29. {structile-6.28.0 → structile-6.30.0}/structile/dom_lite.js +0 -0
  30. {structile-6.28.0 → structile-6.30.0}/structile/interpreters.py +0 -0
  31. {structile-6.28.0 → structile-6.30.0}/structile.egg-info/dependency_links.txt +0 -0
  32. {structile-6.28.0 → structile-6.30.0}/structile.egg-info/requires.txt +0 -0
  33. {structile-6.28.0 → structile-6.30.0}/structile.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: structile
3
- Version: 6.28.0
3
+ Version: 6.30.0
4
4
  Summary: A spatial editor and diff tool for structured data, inline in Jupyter
5
5
  License-Expression: Apache-2.0
6
6
  Requires-Python: >=3.8
@@ -94,6 +94,31 @@ formatting, rather than a re-serialization.
94
94
  big-int/set handling included — and returns the resulting JSON-safe
95
95
  structure directly, with no rendering at all, if that's all you want.
96
96
 
97
+ Any other object — `datetime.date`, `Decimal`, `UUID`, an `Enum` member,
98
+ your own dataclass — is shown as its `repr()` text (an enum member as
99
+ `Color.RED`) in its own color, and can be edited in place as that text:
100
+ double-click `datetime.date(2020, 1, 1)`, type `datetime.date(2021, 2, 3)`.
101
+ After Save, `h.value` holds real objects again: the original object
102
+ wherever you didn't touch it, and a new one built from your text where you
103
+ did. Your text is never run with `eval()`. Only the standard-library types
104
+ whose repr rebuilds them (`datetime.*`, `Decimal`, `Fraction`, `UUID`,
105
+ `pathlib` paths, `zoneinfo.ZoneInfo`, `complex`) and the classes already
106
+ present in your data are allowed. If an edit can't be turned into an
107
+ object (`datetime.date(2020, 13, 1)`, an unknown name), the viewer says so
108
+ and `h.value` keeps its previous value.
109
+
110
+ Pass `default=` (to `open()`, `diff()`, or `normalize()` itself) to show
111
+ such objects some other way instead, same escape hatch as
112
+ `json.dumps(..., default=...)`: it's called with the object and its return
113
+ value takes the object's place (normalized in turn, so it can return
114
+ anything `normalize()` already understands, not just a JSON scalar).
115
+ `default=str`, for instance, shows a date as the plain string
116
+ `"2020-01-01"`:
117
+
118
+ ```python
119
+ st.open(my_data, default=str) # objects -> str(value), a plain string
120
+ ```
121
+
97
122
  ## Renderers
98
123
 
99
124
  `open()` doesn't hardcode "inline Jupyter widget" — it renders
@@ -474,8 +499,14 @@ logging.basicConfig(level=logging.DEBUG)
474
499
 
475
500
  - Fixed iframe height (`height=`, default `600`) — no auto-resize to content
476
501
  yet.
477
- - Plain numpy arrays aren't handled (only numpy *scalars*) — call
478
- `.tolist()` first if you need to show one.
502
+ - Plain numpy arrays are shown as their `repr()` text, not as a table, and
503
+ that text can't be edited back into an array — call `.tolist()` first if
504
+ you need to see or edit the values.
505
+ - An object used as a dict *key* is shown as its `str()` and comes back as
506
+ that string — only values are revived into objects.
507
+ - A class defined inside a function has a repr like
508
+ `f.<locals>.Point(x=1)`, which isn't Python syntax, so its objects can be
509
+ shown and handed back untouched but not edited.
479
510
  - A source dict with keys that collide once stringified (e.g. both `1` and
480
511
  `"1"` as separate keys) will collide in the displayed result too — this
481
512
  is a fundamental JSON limitation (object keys are always strings), not
@@ -66,6 +66,31 @@ formatting, rather than a re-serialization.
66
66
  big-int/set handling included — and returns the resulting JSON-safe
67
67
  structure directly, with no rendering at all, if that's all you want.
68
68
 
69
+ Any other object — `datetime.date`, `Decimal`, `UUID`, an `Enum` member,
70
+ your own dataclass — is shown as its `repr()` text (an enum member as
71
+ `Color.RED`) in its own color, and can be edited in place as that text:
72
+ double-click `datetime.date(2020, 1, 1)`, type `datetime.date(2021, 2, 3)`.
73
+ After Save, `h.value` holds real objects again: the original object
74
+ wherever you didn't touch it, and a new one built from your text where you
75
+ did. Your text is never run with `eval()`. Only the standard-library types
76
+ whose repr rebuilds them (`datetime.*`, `Decimal`, `Fraction`, `UUID`,
77
+ `pathlib` paths, `zoneinfo.ZoneInfo`, `complex`) and the classes already
78
+ present in your data are allowed. If an edit can't be turned into an
79
+ object (`datetime.date(2020, 13, 1)`, an unknown name), the viewer says so
80
+ and `h.value` keeps its previous value.
81
+
82
+ Pass `default=` (to `open()`, `diff()`, or `normalize()` itself) to show
83
+ such objects some other way instead, same escape hatch as
84
+ `json.dumps(..., default=...)`: it's called with the object and its return
85
+ value takes the object's place (normalized in turn, so it can return
86
+ anything `normalize()` already understands, not just a JSON scalar).
87
+ `default=str`, for instance, shows a date as the plain string
88
+ `"2020-01-01"`:
89
+
90
+ ```python
91
+ st.open(my_data, default=str) # objects -> str(value), a plain string
92
+ ```
93
+
69
94
  ## Renderers
70
95
 
71
96
  `open()` doesn't hardcode "inline Jupyter widget" — it renders
@@ -446,8 +471,14 @@ logging.basicConfig(level=logging.DEBUG)
446
471
 
447
472
  - Fixed iframe height (`height=`, default `600`) — no auto-resize to content
448
473
  yet.
449
- - Plain numpy arrays aren't handled (only numpy *scalars*) — call
450
- `.tolist()` first if you need to show one.
474
+ - Plain numpy arrays are shown as their `repr()` text, not as a table, and
475
+ that text can't be edited back into an array — call `.tolist()` first if
476
+ you need to see or edit the values.
477
+ - An object used as a dict *key* is shown as its `str()` and comes back as
478
+ that string — only values are revived into objects.
479
+ - A class defined inside a function has a repr like
480
+ `f.<locals>.Point(x=1)`, which isn't Python syntax, so its objects can be
481
+ shown and handed back untouched but not edited.
451
482
  - A source dict with keys that collide once stringified (e.g. both `1` and
452
483
  `"1"` as separate keys) will collide in the displayed result too — this
453
484
  is a fundamental JSON limitation (object keys are always strings), not
@@ -0,0 +1 @@
1
+ 6.30.0
@@ -28,6 +28,7 @@ from ._paths import (
28
28
  set_viewer_html,
29
29
  strict_json_loads as _strict_json_loads,
30
30
  )
31
+ from ._pyobjects import PyObjects as _PyObjects
31
32
  from .config import Options, get_option, options, options_from_dict, replace_options, reset_option, resolve_config, resolve_renderer, set_option, use
32
33
  from .convert import convert
33
34
  from .custom_html import build_custom_html
@@ -291,7 +292,11 @@ def open(
291
292
  **settings: individual viewer settings for this call — the numeric
292
293
  layout knobs (`gap`, `n`, `m`, `N`, `M`, `nameMax`, `valMax`,
293
294
  `headerLabelMax`, `detCols`, `detRows`, `dwellMs`, `kvRows`,
294
- `kvCols`, `maxWidthFrac`), `theme`, the "Special values" display
295
+ `kvCols`, `maxWidthFrac`, and the nested-container cap's
296
+ `childCap`/`childStep` with its `limitChildren` on/off switch —
297
+ a bool, like `cellContainerBadges` — and `warnElements`, the
298
+ page-element count past which a redraw shows a "large view"
299
+ warning, 0 = never), `theme`, the "Special values" display
295
300
  overrides the viewer's Settings panel edits under that name —
296
301
  `forNull`/`forEmpty` (strings shown in place of `None`/`""`) and
297
302
  `values` (a `{exact_string: replacement}` table) — cosmetic only,
@@ -349,12 +354,15 @@ def open(
349
354
  auto_open: set `False` to suppress the `browser` renderer's automatic
350
355
  `webbrowser.open()` (it still writes the file; call `.open()` later
351
356
  to open it manually).
352
- default: called on any value that isn't one of `obj`'s own supported
357
+ default: called on any value that isn't one of `obj`'s own JSON-mappable
353
358
  types (see above); its return value is normalized in its place,
354
359
  same escape hatch as `json.dumps(..., default=...)` — e.g.
355
- `default=str` displays an otherwise-unsupported object as whatever
356
- `str()` shows for it, instead of raising `TypeError`. See
357
- `structile.normalize`.
360
+ `default=str` displays a date as the plain string `"2020-01-01"`.
361
+ Without it, such an object (`datetime.date`, `Decimal`, your own
362
+ dataclass, ...) is shown as its `repr()` text, editable in place,
363
+ and `.value` holds a real object again after Save: the original
364
+ one if its text wasn't touched, otherwise one rebuilt from the
365
+ edited text. See `structile.normalize`.
358
366
  """
359
367
  if isinstance(config, dict):
360
368
  config = options_from_dict(config)
@@ -385,6 +393,7 @@ def open(
385
393
  ),
386
394
  )
387
395
  payload.name = name or payload.name
396
+ payload.source_file_name = source_path.name if source_path is not None else None
388
397
  payload.config = embed_config
389
398
  _logger.debug("structile: markup mode (%s)", payload.format)
390
399
  widget = None
@@ -395,6 +404,7 @@ def open(
395
404
  interpreter_source=payload.interpreter_source or "",
396
405
  interpreter_candidates_by_format=payload.interpreter_candidates_by_format or {},
397
406
  data_name=payload.name,
407
+ source_file_name=payload.source_file_name or "",
398
408
  height=height,
399
409
  config_json=config_json,
400
410
  value=payload.text,
@@ -416,7 +426,14 @@ def open(
416
426
  # structile.html). name= still wins below if the caller
417
427
  # gave one explicitly.
418
428
  data, inferred_name, raw_text, parsed_as_json = obj, "", None, False
419
- normalized = normalize(data, default)
429
+ py_objects = _PyObjects()
430
+ normalized = normalize(data, default, objects=py_objects)
431
+ # Same revive step a Save runs (see StructileWidget._handle_save), so
432
+ # .value holds the same kind of thing before and after an untouched
433
+ # Save — the original objects, never their {"__pyrepr__": ...} tags.
434
+ # Lenient here: a tag in a loaded FILE may name a type this process
435
+ # has never seen, which is no reason to refuse to show it.
436
+ value = py_objects.revive(normalized, strict=False)
420
437
  display_name = name or inferred_name
421
438
  # source_code_view_claude.md Phase 6: when this was loaded from a real
422
439
  # file that IS a JSON document, forward its literal bytes as the source
@@ -431,19 +448,22 @@ def open(
431
448
  # printed (indent=2, matching the viewer's own JSON.stringify(v, null, 2)
432
449
  # elsewhere) so the source pane never shows a single minified line.
433
450
  text = raw_text if (raw_text is not None and parsed_as_json) else json.dumps(normalized, indent=2)
451
+ source_file_name = source_path.name if source_path is not None else None
434
452
  widget = None
435
453
  if resolved_renderer == "widget":
436
454
  widget = StructileWidget(
437
455
  value_json=text,
438
456
  data_name=display_name,
457
+ source_file_name=source_file_name or "",
439
458
  height=height,
440
459
  config_json=config_json,
441
- value=normalized,
460
+ value=value,
442
461
  source_path=source_path,
443
462
  save_path=save_path,
463
+ py_objects=py_objects,
444
464
  )
445
- payload = _Payload(text=text, format="json", name=display_name, config=embed_config)
446
- return _render(payload, value=normalized, renderer=resolved_renderer, widget=widget, out=out, auto_open=auto_open)
465
+ payload = _Payload(text=text, format="json", name=display_name, config=embed_config, source_file_name=source_file_name)
466
+ return _render(payload, value=value, renderer=resolved_renderer, widget=widget, out=out, auto_open=auto_open)
447
467
 
448
468
 
449
469
  def _resolve_diff_side(
@@ -487,8 +507,9 @@ def _resolve_diff_side(
487
507
  data, inferred_name, _raw_text, _parsed_as_json = _load_path(source_path)
488
508
  else:
489
509
  data, inferred_name = obj, side_label
490
- normalized = normalize(data, default)
491
- return _Payload(text=json.dumps(normalized, indent=2), format="json", name=inferred_name)
510
+ py_objects = _PyObjects()
511
+ normalized = normalize(data, default, objects=py_objects)
512
+ return _Payload(text=json.dumps(normalized, indent=2), format="json", name=inferred_name, py_objects=py_objects)
492
513
 
493
514
 
494
515
  def diff(
@@ -0,0 +1,254 @@
1
+ """Python objects JSON has no slot for (`datetime.date`, `Decimal`, a
2
+ user's own dataclass, ...) — shown and edited in the viewer as their
3
+ `repr()` text, and turned back into real objects when edited data comes
4
+ home on Save.
5
+
6
+ Wire format: `normalize()` replaces such an object with a one-key tag,
7
+ `{"__pyrepr__": "datetime.date(2020, 1, 1)"}`. The viewer revives that tag
8
+ into its own opaque leaf type (`PyExpr` in structile.html) — displayed as
9
+ the bare text, edited as the bare text — and writes the same tag back out on
10
+ Save, so JSON stays the only thing that ever crosses the host<->viewer
11
+ boundary. `PyObjects.revive()` then walks the saved JSON and turns each tag
12
+ back into an object:
13
+
14
+ - A tag whose text is unchanged from what `normalize()` produced resolves
15
+ to the ORIGINAL object (not a rebuilt copy), so an untouched value keeps
16
+ its identity — even one whose repr can't be rebuilt at all, like
17
+ `<Foo object at 0x7f...>`. Several originals sharing one text are
18
+ handed out in order, so two equal-but-distinct mutable objects don't
19
+ come back as one aliased instance.
20
+ - An edited (or newly typed) text is rebuilt by a restricted AST walk —
21
+ never `eval()`: the text comes from a browser page or a file on disk.
22
+ Only literals, containers, and calls/names found in the constructor
23
+ registry are allowed. The registry starts with the stdlib types whose
24
+ repr round-trips (`_BUILTIN_TYPES`) and learns every type `normalize()`
25
+ wrapped whose repr is shaped like a call to that type itself
26
+ (`Point(x=1, y=2)` for a class named `Point`) — so the classes already
27
+ present in the data are editable with no registration step.
28
+
29
+ Enum members are the one deliberate exception to "the text is `repr()`":
30
+ `repr(Color.RED)` is `<Color.RED: 1>`, which isn't Python syntax, so a
31
+ member is written as `Color.RED` instead, and every member of its class is
32
+ registered under that spelling (so editing it to `Color.GREEN` works).
33
+ """
34
+ from __future__ import annotations
35
+
36
+ import ast
37
+ import datetime
38
+ import decimal
39
+ import enum
40
+ import fractions
41
+ import pathlib
42
+ import uuid
43
+ from typing import Any, Dict, List, Mapping, Optional
44
+
45
+ PYREPR_KEY = "__pyrepr__"
46
+
47
+
48
+ class PyReprError(ValueError):
49
+ """An edited Python-object text couldn't be turned back into an object
50
+ (bad syntax, a name outside the registry, or the constructor itself
51
+ refused its arguments)."""
52
+
53
+
54
+ def _builtin_types() -> Dict[str, Any]:
55
+ types: Dict[str, Any] = {
56
+ "datetime.date": datetime.date,
57
+ "datetime.datetime": datetime.datetime,
58
+ "datetime.time": datetime.time,
59
+ "datetime.timedelta": datetime.timedelta,
60
+ "datetime.timezone": datetime.timezone,
61
+ "datetime.timezone.utc": datetime.timezone.utc,
62
+ "Decimal": decimal.Decimal,
63
+ "Fraction": fractions.Fraction,
64
+ "UUID": uuid.UUID,
65
+ "complex": complex,
66
+ "PurePosixPath": pathlib.PurePosixPath,
67
+ "PureWindowsPath": pathlib.PureWindowsPath,
68
+ # Only this platform's concrete Path class — the other one refuses
69
+ # to instantiate here anyway (PosixPath on Windows or vice versa).
70
+ type(pathlib.Path()).__name__: type(pathlib.Path()),
71
+ }
72
+ try:
73
+ import zoneinfo # Python 3.9+
74
+ except ImportError:
75
+ pass
76
+ else:
77
+ types["zoneinfo.ZoneInfo"] = zoneinfo.ZoneInfo
78
+ return types
79
+
80
+
81
+ _BUILTIN_TYPES = _builtin_types()
82
+
83
+
84
+ def is_tag(value: Any) -> bool:
85
+ return isinstance(value, dict) and len(value) == 1 and isinstance(value.get(PYREPR_KEY), str)
86
+
87
+
88
+ def object_text(obj: Any) -> str:
89
+ """The text a Python object is shown and edited as — its `repr()`,
90
+ except for enum members (see the module docstring). A `__repr__` that
91
+ raises falls back to `object`'s own `<Foo object at 0x...>` rather than
92
+ failing the whole `open()` call over one unprintable value."""
93
+ if isinstance(obj, enum.Enum):
94
+ return f"{type(obj).__qualname__}.{obj.name}"
95
+ try:
96
+ text = repr(obj)
97
+ except Exception:
98
+ text = object.__repr__(obj)
99
+ return text if isinstance(text, str) else object.__repr__(obj)
100
+
101
+
102
+ def _dotted_name(node: ast.AST) -> Optional[str]:
103
+ if isinstance(node, ast.Name):
104
+ return node.id
105
+ if isinstance(node, ast.Attribute):
106
+ base = _dotted_name(node.value)
107
+ return None if base is None else base + "." + node.attr
108
+ return None
109
+
110
+
111
+ def _callee_name(text: str) -> Optional[str]:
112
+ """`"datetime.date"` for `"datetime.date(2020, 1, 1)"`; `None` when the
113
+ text isn't a single call expression."""
114
+ try:
115
+ tree = ast.parse(text.strip(), mode="eval")
116
+ except (SyntaxError, ValueError):
117
+ return None
118
+ return _dotted_name(tree.body.func) if isinstance(tree.body, ast.Call) else None
119
+
120
+
121
+ def parse_object_text(text: str, types: Mapping[str, Any]) -> Any:
122
+ """Rebuild an object from its text, allowing nothing beyond literals,
123
+ containers, and the names/calls listed in `types`. Raises
124
+ `PyReprError` for anything else, and for a constructor that refuses its
125
+ arguments (`datetime.date(2020, 13, 1)`)."""
126
+ try:
127
+ tree = ast.parse(text.strip(), mode="eval")
128
+ except (SyntaxError, ValueError) as e:
129
+ raise PyReprError(f"{text!r} is not a valid Python expression ({e.msg if isinstance(e, SyntaxError) else e})") from e
130
+ try:
131
+ return _eval_node(tree.body, types)
132
+ except PyReprError:
133
+ raise
134
+ except Exception as e: # the constructor itself refused (ValueError/TypeError/...)
135
+ raise PyReprError(f"can't rebuild {text!r}: {type(e).__name__}: {e}") from e
136
+
137
+
138
+ def _lookup(name: Optional[str], types: Mapping[str, Any]) -> Any:
139
+ if name is None or name not in types:
140
+ known = ", ".join(sorted(types))
141
+ raise PyReprError(f"{name or 'this expression'!r} is not a known Python type here (known: {known})")
142
+ return types[name]
143
+
144
+
145
+ def _eval_node(node: ast.AST, types: Mapping[str, Any]) -> Any:
146
+ if isinstance(node, ast.Constant):
147
+ if node.value is Ellipsis:
148
+ # A summarized repr (numpy's `array([0, 1, ..., 999])`) — rebuilding
149
+ # it would silently produce something other than what was shown.
150
+ raise PyReprError("a summarized repr containing '...' can't be rebuilt")
151
+ return node.value
152
+ if isinstance(node, ast.Call):
153
+ fn = _lookup(_dotted_name(node.func), types)
154
+ if not callable(fn):
155
+ raise PyReprError(f"{_dotted_name(node.func)!r} is not callable")
156
+ args: List[Any] = []
157
+ for a in node.args:
158
+ if isinstance(a, ast.Starred):
159
+ raise PyReprError("*args unpacking isn't supported")
160
+ args.append(_eval_node(a, types))
161
+ kwargs: Dict[str, Any] = {}
162
+ for kw in node.keywords:
163
+ if kw.arg is None:
164
+ raise PyReprError("**kwargs unpacking isn't supported")
165
+ kwargs[kw.arg] = _eval_node(kw.value, types)
166
+ return fn(*args, **kwargs)
167
+ if isinstance(node, (ast.Name, ast.Attribute)):
168
+ return _lookup(_dotted_name(node), types) # e.g. datetime.timezone.utc, Color.RED
169
+ if isinstance(node, ast.UnaryOp) and isinstance(node.op, (ast.USub, ast.UAdd)):
170
+ v = _eval_node(node.operand, types)
171
+ return -v if isinstance(node.op, ast.USub) else +v
172
+ if isinstance(node, ast.BinOp) and isinstance(node.op, (ast.Add, ast.Sub)):
173
+ # Only complex literals like (1+2j) — never general arithmetic.
174
+ left, right = _eval_node(node.left, types), _eval_node(node.right, types)
175
+ if isinstance(left, (int, float)) and isinstance(right, complex):
176
+ return left + right if isinstance(node.op, ast.Add) else left - right
177
+ raise PyReprError("arithmetic isn't supported, only complex literals like (1+2j)")
178
+ # Real Python containers here, not the viewer's JSON dialect: a tuple
179
+ # argument to a constructor has to stay a tuple.
180
+ if isinstance(node, ast.List):
181
+ return [_eval_node(e, types) for e in node.elts]
182
+ if isinstance(node, ast.Tuple):
183
+ return tuple(_eval_node(e, types) for e in node.elts)
184
+ if isinstance(node, ast.Set):
185
+ return {_eval_node(e, types) for e in node.elts}
186
+ if isinstance(node, ast.Dict):
187
+ if any(k is None for k in node.keys):
188
+ raise PyReprError("dict unpacking (**) isn't supported")
189
+ return {_eval_node(k, types): _eval_node(v, types) for k, v in zip(node.keys, node.values)}
190
+ raise PyReprError(f"unsupported Python syntax: {type(node).__name__}")
191
+
192
+
193
+ class PyObjects:
194
+ """One `open()`/`set_value()` call's worth of wrapped objects — the
195
+ originals (for identity-preserving revival of untouched values) and the
196
+ constructor registry (stdlib types plus every type learned from the
197
+ data itself). See the module docstring."""
198
+
199
+ def __init__(self) -> None:
200
+ self._originals: Dict[str, List[Any]] = {}
201
+ self.types: Dict[str, Any] = dict(_BUILTIN_TYPES)
202
+
203
+ def wrap(self, obj: Any) -> Dict[str, str]:
204
+ text = object_text(obj)
205
+ self._originals.setdefault(text, []).append(obj)
206
+ self._learn_type(obj, text)
207
+ return {PYREPR_KEY: text}
208
+
209
+ def _learn_type(self, obj: Any, text: str) -> None:
210
+ cls = type(obj)
211
+ if isinstance(obj, enum.Enum):
212
+ for member in cls:
213
+ self.types.setdefault(f"{cls.__qualname__}.{member.name}", member)
214
+ return
215
+ name = _callee_name(text)
216
+ # Only when the repr names this very class (`Point(x=1)` for class
217
+ # Point, `datetime.date(...)` for date) — never guess a constructor
218
+ # for a repr like numpy's `array([...])` (class ndarray).
219
+ if name is not None and name.rsplit(".", 1)[-1] == cls.__name__:
220
+ self.types.setdefault(name, cls)
221
+
222
+ def revive(self, value: Any, *, strict: bool = True) -> Any:
223
+ """Turn every `{"__pyrepr__": text}` tag in a JSON value back into
224
+ a Python object. `strict=False` keeps a tag that can't be rebuilt
225
+ as-is instead of raising — for data loaded from a file, which may
226
+ name types this process has never seen."""
227
+ pool = {text: list(objs) for text, objs in self._originals.items()}
228
+ return self._revive(value, pool, strict)
229
+
230
+ def _revive(self, value: Any, pool: Dict[str, List[Any]], strict: bool) -> Any:
231
+ if isinstance(value, dict):
232
+ if is_tag(value):
233
+ return self._from_text(value[PYREPR_KEY], pool, value, strict)
234
+ return {k: self._revive(v, pool, strict) for k, v in value.items()}
235
+ if isinstance(value, list):
236
+ return [self._revive(v, pool, strict) for v in value]
237
+ return value
238
+
239
+ def _from_text(self, text: str, pool: Dict[str, List[Any]], tag: Dict[str, str], strict: bool) -> Any:
240
+ originals = pool.get(text)
241
+ if originals:
242
+ return originals.pop(0)
243
+ try:
244
+ return parse_object_text(text, self.types)
245
+ except PyReprError:
246
+ seen = self._originals.get(text)
247
+ if seen:
248
+ # More copies than originals (a duplicated row) of an object
249
+ # that can't be rebuilt from its text — reuse the original
250
+ # rather than refuse an edit that never touched its text.
251
+ return seen[-1]
252
+ if strict:
253
+ raise
254
+ return tag
@@ -55,6 +55,11 @@ _OPTION_TYPES: Dict[str, type] = {
55
55
  "kvRows": int,
56
56
  "kvCols": int,
57
57
  "maxWidthFrac": float,
58
+ "cellContainerBadges": bool,
59
+ "limitChildren": bool,
60
+ "childCap": int,
61
+ "childStep": int,
62
+ "warnElements": int,
58
63
  "theme": str,
59
64
  "renderer": str,
60
65
  "forNull": str,
@@ -109,6 +114,13 @@ def _validate(key: str, value: Any) -> None:
109
114
  raise TypeError(f"structile: option 'values' must be a dict of str -> str, got {value!r}")
110
115
  return
111
116
  expected = _OPTION_TYPES[key]
117
+ if expected is bool:
118
+ # The viewer's checkbox settings (SETTINGS_SCHEMA `type: "checkbox"`)
119
+ # — a real bool only, never 0/1: the viewer's own
120
+ # mergeSettingsWithEmbedded accepts nothing else for these keys.
121
+ if not isinstance(value, bool):
122
+ raise TypeError(f"structile: option {key!r} must be a bool, got {type(value).__name__}")
123
+ return
112
124
  if isinstance(value, bool) or not isinstance(value, (int, float)):
113
125
  raise TypeError(f"structile: option {key!r} must be a {expected.__name__}, got {type(value).__name__}")
114
126
 
@@ -31,7 +31,7 @@ runs this function, instead of by whoever later opens the file.
31
31
  from __future__ import annotations
32
32
 
33
33
  import pathlib
34
- from typing import Dict, Optional, Union
34
+ from typing import Any, Dict, Optional, Union
35
35
 
36
36
  from ._paths import find_viewer_html, inject_global_script, read_interpreter
37
37
  from .manifest import ManifestSource, load_manifest
@@ -59,11 +59,15 @@ def build_custom_html(
59
59
  NOT the wrapping object. Each entry names its own `extensions` (one or
60
60
  several) and `format`, and one build freely mixes the two contracts
61
61
  (`"xml"`/`"html"` use `interpretXML`/`serializeXML`, every other
62
- `format` string uses `interpretText`/`serializeText`). If two entries
63
- claim the same extension the later one silently wins — no error — so
64
- keep the list unambiguous. Extensions absent from the manifest behave
65
- exactly as in the stock viewer, and the built-in JSON/Python-repr/CSV
66
- formats need no entry at all.
62
+ `format` string uses `interpretText`/`serializeText`). An entry with
63
+ `binary: true` (implied by `format: "bin"`) reads BYTES: the viewer
64
+ routes a dropped file of that extension straight to its
65
+ `interpretText`, one character per byte, without ever sniffing or
66
+ decoding it as text — see `structile.manifest.InterpreterManifestEntry`.
67
+ If two entries claim the same extension the later one silently wins —
68
+ no error — so keep the list unambiguous. Extensions absent from the
69
+ manifest behave exactly as in the stock viewer, and the built-in
70
+ JSON/Python-repr/CSV formats need no entry at all.
67
71
 
68
72
  `source_html`: the base HTML to bake into — defaults to
69
73
  `find_viewer_html()`'s own resolution (the dev source next to this
@@ -78,11 +82,11 @@ def build_custom_html(
78
82
  if source_html is not None
79
83
  else find_viewer_html().read_text(encoding="utf-8")
80
84
  )
81
- baked: Dict[str, Dict[str, str]] = {}
85
+ baked: Dict[str, Dict[str, Any]] = {}
82
86
  for entry in entries:
83
87
  text = read_interpreter(entry.path)
84
88
  for ext in entry.extensions:
85
- baked[_normalize_ext(ext)] = {"format": entry.format, "source": text, "name": entry.name}
89
+ baked[_normalize_ext(ext)] = {"format": entry.format, "source": text, "name": entry.name, "binary": entry.binary}
86
90
  html_text = inject_global_script(base_html, "__STRUCTILE_BAKED_INTERPRETERS__", baked)
87
91
  if output is not None:
88
92
  pathlib.Path(output).write_text(html_text, encoding="utf-8")
@@ -44,12 +44,25 @@ class InterpreterManifestEntry:
44
44
  `format`: `"xml"`/`"html"` (the DOM contract) or any other string (the
45
45
  `interpretText` contract) — same meaning as `structile.open()`'s
46
46
  own `format=` argument.
47
+ `binary`: `True` when this interpreter reads BYTES rather than text —
48
+ the viewer then hands a dropped file of one of these `extensions`
49
+ to `interpretText` one character per byte (structile.html's
50
+ `runBinaryInterpreter`), never decoded, never line-ending-
51
+ normalized, and regardless of whether the bytes happen to sniff as
52
+ text. Defaults to `format == "bin"` (what the viewer's own "Save
53
+ viewer with interpreter…" writes for a binary session), so an
54
+ explicit flag is only needed for a binary format that wants a
55
+ different `format` label. An explicit flag rather than "whatever
56
+ the byte sniff says": a bespoke format's bytes can look like text
57
+ (no NUL, few control bytes) and would otherwise reach the
58
+ interpreter decoded and CRLF-normalized — i.e. corrupted.
47
59
  """
48
60
 
49
61
  extensions: List[str]
50
62
  path: ManifestInterpreterPath
51
63
  name: str
52
64
  format: str
65
+ binary: bool = False
53
66
 
54
67
 
55
68
  def _entry_from_dict(d: Dict[str, Any], *, base_dir: Optional[pathlib.Path]) -> InterpreterManifestEntry:
@@ -59,7 +72,9 @@ def _entry_from_dict(d: Dict[str, Any], *, base_dir: Optional[pathlib.Path]) ->
59
72
  path = d["path"]
60
73
  if base_dir is not None and isinstance(path, str) and not pathlib.Path(path).is_absolute():
61
74
  path = base_dir / path
62
- return InterpreterManifestEntry(extensions=list(extensions), path=path, name=d["name"], format=d["format"])
75
+ fmt = d["format"]
76
+ binary = d["binary"] if "binary" in d else fmt == "bin"
77
+ return InterpreterManifestEntry(extensions=list(extensions), path=path, name=d["name"], format=fmt, binary=bool(binary))
63
78
 
64
79
 
65
80
  ManifestSource = Union[str, pathlib.Path, Sequence[Union[Dict[str, Any], InterpreterManifestEntry]]]
@@ -27,7 +27,7 @@ import sys
27
27
  import tempfile
28
28
  import uuid
29
29
  import webbrowser
30
- from dataclasses import dataclass
30
+ from dataclasses import dataclass, field
31
31
  from typing import Any, Dict, List, Optional, Union
32
32
 
33
33
  from ._log import logger
@@ -123,9 +123,22 @@ class Payload:
123
123
  # browser can decide for itself.
124
124
  interpreter_candidates_by_format: Optional[Dict[str, List[str]]] = None
125
125
  config: Optional[Dict[str, Any]] = None
126
+ # The real on-disk name (`source_path.name`, extension and all) when
127
+ # this was loaded from an actual file; None for an in-memory value or a
128
+ # literal string. `name` is only the display stem, so without this the
129
+ # viewer can't tell `open("x.xml")` from `open("<x/>", format="xml")`
130
+ # and shows both as "no file on disk" (see currentSourceFileName in
131
+ # structile.html).
132
+ source_file_name: Optional[str] = None
133
+ # The Python objects normalize() tagged while building `text` (see
134
+ # _pyobjects.py) — Python-side only, never part of the snapshot, so a
135
+ # widget built from this payload can revive saved tags back into them.
136
+ py_objects: Any = field(default=None, repr=False, compare=False)
126
137
 
127
138
  def to_snapshot_dict(self) -> Dict[str, Any]:
128
139
  d: Dict[str, Any] = {"text": self.text, "format": self.format, "name": self.name}
140
+ if self.source_file_name:
141
+ d["sourceFileName"] = self.source_file_name
129
142
  if self.interpreter_source:
130
143
  d["interpreterSource"] = self.interpreter_source
131
144
  if self.interpreter_candidates_by_format: