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.
- {structile-6.28.0/structile.egg-info → structile-6.30.0}/PKG-INFO +34 -3
- {structile-6.28.0 → structile-6.30.0}/README.pypi.md +33 -2
- structile-6.30.0/VERSION +1 -0
- {structile-6.28.0 → structile-6.30.0}/structile/__init__.py +32 -11
- structile-6.30.0/structile/_pyobjects.py +254 -0
- {structile-6.28.0 → structile-6.30.0}/structile/config.py +12 -0
- {structile-6.28.0 → structile-6.30.0}/structile/custom_html.py +12 -8
- {structile-6.28.0 → structile-6.30.0}/structile/manifest.py +16 -1
- {structile-6.28.0 → structile-6.30.0}/structile/render.py +14 -1
- {structile-6.28.0 → structile-6.30.0}/structile/serialize.py +30 -20
- structile-6.30.0/structile/static/structile.prod.html +215 -0
- {structile-6.28.0 → structile-6.30.0}/structile/static/widget.js +26 -2
- {structile-6.28.0 → structile-6.30.0}/structile/widget.py +56 -8
- {structile-6.28.0 → structile-6.30.0/structile.egg-info}/PKG-INFO +34 -3
- {structile-6.28.0 → structile-6.30.0}/structile.egg-info/SOURCES.txt +1 -0
- structile-6.28.0/VERSION +0 -1
- structile-6.28.0/structile/static/structile.prod.html +0 -210
- {structile-6.28.0 → structile-6.30.0}/LICENSE +0 -0
- {structile-6.28.0 → structile-6.30.0}/MANIFEST.in +0 -0
- {structile-6.28.0 → structile-6.30.0}/pyproject.toml +0 -0
- {structile-6.28.0 → structile-6.30.0}/setup.cfg +0 -0
- {structile-6.28.0 → structile-6.30.0}/structile/__main__.py +0 -0
- {structile-6.28.0 → structile-6.30.0}/structile/_csv.py +0 -0
- {structile-6.28.0 → structile-6.30.0}/structile/_dom_lite.py +0 -0
- {structile-6.28.0 → structile-6.30.0}/structile/_log.py +0 -0
- {structile-6.28.0 → structile-6.30.0}/structile/_paths.py +0 -0
- {structile-6.28.0 → structile-6.30.0}/structile/_plugins.py +0 -0
- {structile-6.28.0 → structile-6.30.0}/structile/convert.py +0 -0
- {structile-6.28.0 → structile-6.30.0}/structile/dom_lite.js +0 -0
- {structile-6.28.0 → structile-6.30.0}/structile/interpreters.py +0 -0
- {structile-6.28.0 → structile-6.30.0}/structile.egg-info/dependency_links.txt +0 -0
- {structile-6.28.0 → structile-6.30.0}/structile.egg-info/requires.txt +0 -0
- {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.
|
|
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
|
|
478
|
-
`.tolist()` first if
|
|
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
|
|
450
|
-
`.tolist()` first if
|
|
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
|
structile-6.30.0/VERSION
ADDED
|
@@ -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
|
|
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
|
|
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
|
|
356
|
-
|
|
357
|
-
`
|
|
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
|
-
|
|
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=
|
|
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=
|
|
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
|
-
|
|
491
|
-
|
|
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`).
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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,
|
|
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
|
-
|
|
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:
|