statepatch 0.1.0__py3-none-any.whl
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.
statepatch/__init__.py
ADDED
|
@@ -0,0 +1,524 @@
|
|
|
1
|
+
"""Immer-style ops over a plain JSON tree.
|
|
2
|
+
|
|
3
|
+
An ``Observable`` wraps one value: ``.value`` reads a plain snapshot, every
|
|
4
|
+
mutation applies to the tree and emits the matching ``Op`` to subscribers.
|
|
5
|
+
Observables compose: an observable stored inside another mounts its op
|
|
6
|
+
stream at that path; removing it detaches it, live and reusable.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import math
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from typing import Any, Callable, Iterator, NamedTuple
|
|
12
|
+
|
|
13
|
+
Segment = str | int
|
|
14
|
+
Path = tuple[Segment, ...]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Op(NamedTuple):
|
|
18
|
+
kind: str # "replace" | "add" | "remove"
|
|
19
|
+
path: Path
|
|
20
|
+
value: Any = None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
Listener = Callable[[Op], None]
|
|
24
|
+
|
|
25
|
+
_MAX_SAFE_INT = 2**53 - 1
|
|
26
|
+
_MISSING: Any = object()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _snapshot(node: Any) -> Any:
|
|
30
|
+
if isinstance(node, Observable):
|
|
31
|
+
return _snapshot(node._state)
|
|
32
|
+
if isinstance(node, dict):
|
|
33
|
+
return {key: _snapshot(item) for key, item in node.items()}
|
|
34
|
+
if isinstance(node, list):
|
|
35
|
+
return [_snapshot(item) for item in node]
|
|
36
|
+
return node
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def plain(value: Any) -> Any:
|
|
40
|
+
"""Resolve a proxy or observable to a plain snapshot; pass anything else through."""
|
|
41
|
+
if isinstance(value, Proxy):
|
|
42
|
+
_owner, _rel, node = value._target()
|
|
43
|
+
return _snapshot(node)
|
|
44
|
+
if isinstance(value, Observable):
|
|
45
|
+
return _snapshot(value._state)
|
|
46
|
+
return value
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _adopt(value: Any, base: Path) -> tuple[Any, list[tuple[Path, "Observable"]]]:
|
|
50
|
+
"""Validated deep copy keeping ``Observable`` nodes in place; returns the
|
|
51
|
+
stored tree and the mount points found inside it (paths rooted at ``base``)."""
|
|
52
|
+
mounts: list[tuple[Path, Observable]] = []
|
|
53
|
+
|
|
54
|
+
def walk(value: Any, path: Path) -> Any:
|
|
55
|
+
if isinstance(value, Observable):
|
|
56
|
+
mounts.append((path, value))
|
|
57
|
+
return value
|
|
58
|
+
if isinstance(value, Proxy):
|
|
59
|
+
value = plain(value)
|
|
60
|
+
if isinstance(value, bool) or value is None or isinstance(value, str):
|
|
61
|
+
return value
|
|
62
|
+
if isinstance(value, float):
|
|
63
|
+
if math.isnan(value) or math.isinf(value):
|
|
64
|
+
raise ValueError(f"{value!r} is not wire-encodable; floats must be finite")
|
|
65
|
+
return value
|
|
66
|
+
if isinstance(value, int):
|
|
67
|
+
if abs(value) > _MAX_SAFE_INT:
|
|
68
|
+
raise ValueError(
|
|
69
|
+
f"integer {value} exceeds the JS safe range (|n| <= 2**53-1); send it as a string"
|
|
70
|
+
)
|
|
71
|
+
return value
|
|
72
|
+
if isinstance(value, datetime):
|
|
73
|
+
if value.tzinfo is None or value.tzinfo.utcoffset(value) is None:
|
|
74
|
+
raise ValueError("naive datetime is not wire-encodable; attach a timezone")
|
|
75
|
+
return value
|
|
76
|
+
if isinstance(value, (list, tuple)):
|
|
77
|
+
return [walk(item, path + (i,)) for i, item in enumerate(value)]
|
|
78
|
+
if isinstance(value, dict):
|
|
79
|
+
out: dict[str, Any] = {}
|
|
80
|
+
for key, item in value.items():
|
|
81
|
+
if not isinstance(key, str):
|
|
82
|
+
raise TypeError(f"object keys must be strings, got {type(key).__name__}")
|
|
83
|
+
out[key] = walk(item, path + (key,))
|
|
84
|
+
return out
|
|
85
|
+
raise TypeError(f"{type(value).__name__} is not wire-encodable")
|
|
86
|
+
|
|
87
|
+
return walk(value, base), mounts
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _index(container: Any, key: Any) -> Any:
|
|
91
|
+
if isinstance(container, list) and isinstance(key, int) and key < 0:
|
|
92
|
+
return key + len(container)
|
|
93
|
+
return key
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class Observable:
|
|
97
|
+
__slots__ = ("_state", "_listeners", "_mounts", "_parent")
|
|
98
|
+
|
|
99
|
+
def __init__(self, initial: Any = None) -> None:
|
|
100
|
+
if isinstance(initial, Observable):
|
|
101
|
+
raise TypeError("an observable cannot wrap another observable; nest it inside a container")
|
|
102
|
+
self._listeners: list[Listener] = []
|
|
103
|
+
self._mounts: dict[int, tuple[Observable, Path, Callable[[], None]]] = {}
|
|
104
|
+
self._parent: Observable | None = None
|
|
105
|
+
stored, mounts = _adopt(initial, ())
|
|
106
|
+
self._admit(mounts)
|
|
107
|
+
self._state = stored
|
|
108
|
+
for path, child in mounts:
|
|
109
|
+
self._attach(child, path)
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def value(self) -> Any:
|
|
113
|
+
return _snapshot(self._state)
|
|
114
|
+
|
|
115
|
+
@value.setter
|
|
116
|
+
def value(self, value: Any) -> None:
|
|
117
|
+
if isinstance(value, Observable):
|
|
118
|
+
raise TypeError("an observable cannot wrap another observable; nest it inside a container")
|
|
119
|
+
stored, mounts = _adopt(value, ())
|
|
120
|
+
self._admit(mounts, ())
|
|
121
|
+
for child, _path, unsubscribe in list(self._mounts.values()):
|
|
122
|
+
unsubscribe()
|
|
123
|
+
child._parent = None
|
|
124
|
+
self._mounts.clear()
|
|
125
|
+
self._state = stored
|
|
126
|
+
for path, child in mounts:
|
|
127
|
+
self._attach(child, path)
|
|
128
|
+
self._emit(Op("replace", (), _snapshot(stored)))
|
|
129
|
+
|
|
130
|
+
def subscribe(self, listener: Listener) -> Callable[[], None]:
|
|
131
|
+
self._listeners.append(listener)
|
|
132
|
+
|
|
133
|
+
def unsubscribe() -> None:
|
|
134
|
+
if listener in self._listeners:
|
|
135
|
+
self._listeners.remove(listener)
|
|
136
|
+
|
|
137
|
+
return unsubscribe
|
|
138
|
+
|
|
139
|
+
def _emit(self, op: Op) -> None:
|
|
140
|
+
for listener in list(self._listeners):
|
|
141
|
+
listener(op)
|
|
142
|
+
|
|
143
|
+
def _admit(
|
|
144
|
+
self,
|
|
145
|
+
mounts: list[tuple[Path, "Observable"]],
|
|
146
|
+
detach_root: Path | None = None,
|
|
147
|
+
seen: set[int] | None = None,
|
|
148
|
+
) -> None:
|
|
149
|
+
"""Validate pending mounts before any tree mutation; ``detach_root``
|
|
150
|
+
exempts children this write is about to detach from ``self``."""
|
|
151
|
+
seen = seen if seen is not None else set()
|
|
152
|
+
for _path, child in mounts:
|
|
153
|
+
if id(child) in seen:
|
|
154
|
+
raise ValueError("observable appears more than once in one write")
|
|
155
|
+
seen.add(id(child))
|
|
156
|
+
if child._parent is not None:
|
|
157
|
+
current = self._mounts.get(id(child))
|
|
158
|
+
freed = (
|
|
159
|
+
detach_root is not None
|
|
160
|
+
and current is not None
|
|
161
|
+
and current[1][: len(detach_root)] == detach_root
|
|
162
|
+
)
|
|
163
|
+
if not freed:
|
|
164
|
+
raise ValueError("observable is already mounted; detach it first")
|
|
165
|
+
node: Observable | None = self
|
|
166
|
+
while node is not None:
|
|
167
|
+
if node is child:
|
|
168
|
+
raise ValueError("mounting this observable would create a cycle")
|
|
169
|
+
node = node._parent
|
|
170
|
+
|
|
171
|
+
def _attach(self, child: "Observable", path: Path) -> None:
|
|
172
|
+
if child._parent is not None:
|
|
173
|
+
raise ValueError("observable is already mounted; detach it first")
|
|
174
|
+
node: Observable | None = self
|
|
175
|
+
while node is not None:
|
|
176
|
+
if node is child:
|
|
177
|
+
raise ValueError("mounting this observable would create a cycle")
|
|
178
|
+
node = node._parent
|
|
179
|
+
child._parent = self
|
|
180
|
+
unsubscribe = child.subscribe(lambda op, child=child: self._forward(child, op))
|
|
181
|
+
self._mounts[id(child)] = (child, path, unsubscribe)
|
|
182
|
+
|
|
183
|
+
def _forward(self, child: "Observable", op: Op) -> None:
|
|
184
|
+
entry = self._mounts.get(id(child))
|
|
185
|
+
if entry is None:
|
|
186
|
+
return
|
|
187
|
+
_child, path, _unsubscribe = entry
|
|
188
|
+
self._emit(Op(op.kind, path + op.path, op.value))
|
|
189
|
+
|
|
190
|
+
def _detach_under(self, path: Path) -> None:
|
|
191
|
+
for key, (child, mount_path, unsubscribe) in list(self._mounts.items()):
|
|
192
|
+
if mount_path[: len(path)] == path:
|
|
193
|
+
unsubscribe()
|
|
194
|
+
child._parent = None
|
|
195
|
+
del self._mounts[key]
|
|
196
|
+
|
|
197
|
+
def _shift(self, parent: Path, start: int, delta: int) -> None:
|
|
198
|
+
depth = len(parent)
|
|
199
|
+
for key, (child, path, unsubscribe) in list(self._mounts.items()):
|
|
200
|
+
if (
|
|
201
|
+
len(path) > depth
|
|
202
|
+
and path[:depth] == parent
|
|
203
|
+
and isinstance(path[depth], int)
|
|
204
|
+
and path[depth] >= start
|
|
205
|
+
):
|
|
206
|
+
self._mounts[key] = (
|
|
207
|
+
child,
|
|
208
|
+
parent + (path[depth] + delta,) + path[depth + 1 :],
|
|
209
|
+
unsubscribe,
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
def _target(self, path: Path) -> tuple["Observable", Path, Any]:
|
|
213
|
+
"""Deepest observable owning ``path``, the path relative to it, and the node there."""
|
|
214
|
+
node = self._state
|
|
215
|
+
for i, segment in enumerate(path):
|
|
216
|
+
if isinstance(node, Observable):
|
|
217
|
+
return node._target(path[i:])
|
|
218
|
+
node = node[segment]
|
|
219
|
+
if isinstance(node, Observable):
|
|
220
|
+
return node._target(())
|
|
221
|
+
return self, path, node
|
|
222
|
+
|
|
223
|
+
def __getitem__(self, key: Any) -> Any:
|
|
224
|
+
return Proxy(self, ())[key]
|
|
225
|
+
|
|
226
|
+
def __setitem__(self, key: Any, value: Any) -> None:
|
|
227
|
+
Proxy(self, ())[key] = value
|
|
228
|
+
|
|
229
|
+
def __delitem__(self, key: Any) -> None:
|
|
230
|
+
proxy = Proxy(self, ())
|
|
231
|
+
del proxy[key]
|
|
232
|
+
|
|
233
|
+
def __contains__(self, item: Any) -> bool:
|
|
234
|
+
return item in Proxy(self, ())
|
|
235
|
+
|
|
236
|
+
def __iter__(self) -> Iterator[Any]:
|
|
237
|
+
return iter(Proxy(self, ()))
|
|
238
|
+
|
|
239
|
+
def __len__(self) -> int:
|
|
240
|
+
return len(Proxy(self, ()))
|
|
241
|
+
|
|
242
|
+
def __repr__(self) -> str:
|
|
243
|
+
return f"observable({self.value!r})"
|
|
244
|
+
|
|
245
|
+
def append(self, item: Any) -> None:
|
|
246
|
+
Proxy(self, ()).append(item)
|
|
247
|
+
|
|
248
|
+
def insert(self, index: int, item: Any) -> None:
|
|
249
|
+
Proxy(self, ()).insert(index, item)
|
|
250
|
+
|
|
251
|
+
def pop(self, *args: Any) -> Any:
|
|
252
|
+
return Proxy(self, ()).pop(*args)
|
|
253
|
+
|
|
254
|
+
def remove(self, item: Any) -> None:
|
|
255
|
+
Proxy(self, ()).remove(item)
|
|
256
|
+
|
|
257
|
+
def clear(self) -> None:
|
|
258
|
+
Proxy(self, ()).clear()
|
|
259
|
+
|
|
260
|
+
def extend(self, items: Any) -> None:
|
|
261
|
+
Proxy(self, ()).extend(items)
|
|
262
|
+
|
|
263
|
+
def update(self, other: Any) -> None:
|
|
264
|
+
Proxy(self, ()).update(other)
|
|
265
|
+
|
|
266
|
+
def get(self, key: Any, default: Any = None) -> Any:
|
|
267
|
+
return Proxy(self, ()).get(key, default)
|
|
268
|
+
|
|
269
|
+
def setdefault(self, key: Any, default: Any = None) -> Any:
|
|
270
|
+
return Proxy(self, ()).setdefault(key, default)
|
|
271
|
+
|
|
272
|
+
def keys(self):
|
|
273
|
+
return Proxy(self, ()).keys()
|
|
274
|
+
|
|
275
|
+
def values(self):
|
|
276
|
+
return Proxy(self, ()).values()
|
|
277
|
+
|
|
278
|
+
def items(self):
|
|
279
|
+
return Proxy(self, ()).items()
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def observable(initial: Any = None) -> Observable:
|
|
283
|
+
"""A mini statewire: ``.value`` reads it, every mutation is an op."""
|
|
284
|
+
return Observable(initial)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
class Proxy:
|
|
288
|
+
"""Mutation cursor into an observable's tree; reads resolve live, writes
|
|
289
|
+
apply and record ops on the owning (deepest mounted) observable."""
|
|
290
|
+
|
|
291
|
+
__slots__ = ("_root", "_path")
|
|
292
|
+
|
|
293
|
+
def __init__(self, root: Observable, path: Path) -> None:
|
|
294
|
+
self._root = root
|
|
295
|
+
self._path = path
|
|
296
|
+
|
|
297
|
+
def _target(self) -> tuple[Observable, Path, Any]:
|
|
298
|
+
return self._root._target(self._path)
|
|
299
|
+
|
|
300
|
+
def __getitem__(self, key: Any) -> Any:
|
|
301
|
+
_owner, _rel, node = self._target()
|
|
302
|
+
key = _index(node, key)
|
|
303
|
+
value = node[key]
|
|
304
|
+
if isinstance(value, (dict, list, Observable)):
|
|
305
|
+
return Proxy(self._root, self._path + (key,))
|
|
306
|
+
return value
|
|
307
|
+
|
|
308
|
+
def __setitem__(self, key: Any, value: Any) -> None:
|
|
309
|
+
owner, rel, container = self._target()
|
|
310
|
+
key = _index(container, key)
|
|
311
|
+
if isinstance(container, dict) and not isinstance(key, str):
|
|
312
|
+
raise TypeError(f"object keys must be strings, got {type(key).__name__}")
|
|
313
|
+
if isinstance(container, list) and (
|
|
314
|
+
isinstance(key, bool) or not isinstance(key, int)
|
|
315
|
+
):
|
|
316
|
+
raise TypeError(f"list indices must be integers, got {type(key).__name__}")
|
|
317
|
+
old = (
|
|
318
|
+
container.get(key, _MISSING)
|
|
319
|
+
if isinstance(container, dict)
|
|
320
|
+
else container[key]
|
|
321
|
+
)
|
|
322
|
+
path = rel + (key,)
|
|
323
|
+
if isinstance(old, str) and isinstance(value, str) and value.startswith(old):
|
|
324
|
+
if value == old:
|
|
325
|
+
return
|
|
326
|
+
container[key] = value
|
|
327
|
+
owner._emit(Op("add", path + (len(old),), value[len(old) :]))
|
|
328
|
+
return
|
|
329
|
+
stored, mounts = _adopt(value, path)
|
|
330
|
+
owner._admit(mounts, path)
|
|
331
|
+
owner._detach_under(path)
|
|
332
|
+
container[key] = stored
|
|
333
|
+
for mount_path, child in mounts:
|
|
334
|
+
owner._attach(child, mount_path)
|
|
335
|
+
owner._emit(Op("replace", path, _snapshot(stored)))
|
|
336
|
+
|
|
337
|
+
def append(self, item: Any) -> None:
|
|
338
|
+
owner, rel, container = self._target()
|
|
339
|
+
if not isinstance(container, list):
|
|
340
|
+
raise TypeError(f"append on non-list at path {list(self._path)!r}")
|
|
341
|
+
index = len(container)
|
|
342
|
+
path = rel + (index,)
|
|
343
|
+
stored, mounts = _adopt(item, path)
|
|
344
|
+
owner._admit(mounts)
|
|
345
|
+
container.append(stored)
|
|
346
|
+
for mount_path, child in mounts:
|
|
347
|
+
owner._attach(child, mount_path)
|
|
348
|
+
owner._emit(Op("add", path, _snapshot(stored)))
|
|
349
|
+
|
|
350
|
+
def insert(self, index: int, item: Any) -> None:
|
|
351
|
+
owner, rel, container = self._target()
|
|
352
|
+
if not isinstance(container, list):
|
|
353
|
+
raise TypeError(f"insert on non-list at path {list(self._path)!r}")
|
|
354
|
+
index = max(0, min(index + len(container) if index < 0 else index, len(container)))
|
|
355
|
+
path = rel + (index,)
|
|
356
|
+
stored, mounts = _adopt(item, path)
|
|
357
|
+
owner._admit(mounts)
|
|
358
|
+
owner._shift(rel, index, 1)
|
|
359
|
+
container.insert(index, stored)
|
|
360
|
+
for mount_path, child in mounts:
|
|
361
|
+
owner._attach(child, mount_path)
|
|
362
|
+
owner._emit(Op("add", path, _snapshot(stored)))
|
|
363
|
+
|
|
364
|
+
def _remove_at(
|
|
365
|
+
self, owner: Observable, rel: Path, container: list, index: int
|
|
366
|
+
) -> Any:
|
|
367
|
+
value = _snapshot(container[index])
|
|
368
|
+
path = rel + (index,)
|
|
369
|
+
owner._detach_under(path)
|
|
370
|
+
del container[index]
|
|
371
|
+
owner._shift(rel, index + 1, -1)
|
|
372
|
+
owner._emit(Op("remove", path))
|
|
373
|
+
return value
|
|
374
|
+
|
|
375
|
+
def __delitem__(self, key: Any) -> None:
|
|
376
|
+
owner, rel, container = self._target()
|
|
377
|
+
key = _index(container, key)
|
|
378
|
+
if isinstance(container, list):
|
|
379
|
+
container[key]
|
|
380
|
+
self._remove_at(owner, rel, container, key)
|
|
381
|
+
return
|
|
382
|
+
if key not in container:
|
|
383
|
+
raise KeyError(key)
|
|
384
|
+
path = rel + (key,)
|
|
385
|
+
owner._detach_under(path)
|
|
386
|
+
del container[key]
|
|
387
|
+
owner._emit(Op("remove", path))
|
|
388
|
+
|
|
389
|
+
def pop(self, *args: Any) -> Any:
|
|
390
|
+
owner, rel, container = self._target()
|
|
391
|
+
if isinstance(container, list):
|
|
392
|
+
index = _index(container, args[0] if args else -1)
|
|
393
|
+
container[index]
|
|
394
|
+
return self._remove_at(owner, rel, container, index)
|
|
395
|
+
if not args:
|
|
396
|
+
raise TypeError("pop expected at least 1 argument, got 0")
|
|
397
|
+
if args[1:] and args[0] not in container:
|
|
398
|
+
return args[1]
|
|
399
|
+
key = args[0]
|
|
400
|
+
value = _snapshot(container[key])
|
|
401
|
+
path = rel + (key,)
|
|
402
|
+
owner._detach_under(path)
|
|
403
|
+
del container[key]
|
|
404
|
+
owner._emit(Op("remove", path))
|
|
405
|
+
return value
|
|
406
|
+
|
|
407
|
+
def remove(self, item: Any) -> None:
|
|
408
|
+
owner, rel, container = self._target()
|
|
409
|
+
if not isinstance(container, list):
|
|
410
|
+
raise TypeError(f"remove on non-list at path {list(self._path)!r}")
|
|
411
|
+
target = plain(item)
|
|
412
|
+
index = next(
|
|
413
|
+
(i for i, node in enumerate(container) if _snapshot(node) == target), None
|
|
414
|
+
)
|
|
415
|
+
if index is None:
|
|
416
|
+
raise ValueError(f"{target!r} not in list")
|
|
417
|
+
self._remove_at(owner, rel, container, index)
|
|
418
|
+
|
|
419
|
+
def clear(self) -> None:
|
|
420
|
+
owner, rel, container = self._target()
|
|
421
|
+
owner._detach_under(rel)
|
|
422
|
+
container.clear()
|
|
423
|
+
owner._emit(Op("replace", rel, _snapshot(container)))
|
|
424
|
+
|
|
425
|
+
def extend(self, items: Any) -> None:
|
|
426
|
+
owner, rel, container = self._target()
|
|
427
|
+
if not isinstance(container, list):
|
|
428
|
+
raise TypeError(f"extend on non-list at path {list(self._path)!r}")
|
|
429
|
+
adopted = [
|
|
430
|
+
_adopt(item, rel + (index,))
|
|
431
|
+
for index, item in enumerate(items, start=len(container))
|
|
432
|
+
]
|
|
433
|
+
seen: set[int] = set()
|
|
434
|
+
for _stored, mounts in adopted:
|
|
435
|
+
owner._admit(mounts, seen=seen)
|
|
436
|
+
for stored, mounts in adopted:
|
|
437
|
+
container.append(stored)
|
|
438
|
+
for mount_path, child in mounts:
|
|
439
|
+
owner._attach(child, mount_path)
|
|
440
|
+
owner._emit(Op("replace", rel, _snapshot(container)))
|
|
441
|
+
|
|
442
|
+
def update(self, other: Any) -> None:
|
|
443
|
+
owner, rel, container = self._target()
|
|
444
|
+
if not isinstance(container, dict):
|
|
445
|
+
raise TypeError(f"update on non-dict at path {list(self._path)!r}")
|
|
446
|
+
adopted = []
|
|
447
|
+
for key, item in dict(plain(other) if isinstance(other, Proxy) else other).items():
|
|
448
|
+
if not isinstance(key, str):
|
|
449
|
+
raise TypeError(f"object keys must be strings, got {type(key).__name__}")
|
|
450
|
+
adopted.append((key, *_adopt(item, rel + (key,))))
|
|
451
|
+
seen: set[int] = set()
|
|
452
|
+
for key, _stored, mounts in adopted:
|
|
453
|
+
owner._admit(mounts, rel + (key,), seen)
|
|
454
|
+
for key, stored, mounts in adopted:
|
|
455
|
+
owner._detach_under(rel + (key,))
|
|
456
|
+
container[key] = stored
|
|
457
|
+
for mount_path, child in mounts:
|
|
458
|
+
owner._attach(child, mount_path)
|
|
459
|
+
owner._emit(Op("replace", rel, _snapshot(container)))
|
|
460
|
+
|
|
461
|
+
def get(self, key: Any, default: Any = None) -> Any:
|
|
462
|
+
try:
|
|
463
|
+
return self[key]
|
|
464
|
+
except (KeyError, IndexError):
|
|
465
|
+
return default
|
|
466
|
+
|
|
467
|
+
def setdefault(self, key: Any, default: Any = None) -> Any:
|
|
468
|
+
_owner, _rel, container = self._target()
|
|
469
|
+
if not isinstance(container, dict):
|
|
470
|
+
raise TypeError(f"setdefault on non-dict at path {list(self._path)!r}")
|
|
471
|
+
if key not in container:
|
|
472
|
+
self[key] = default
|
|
473
|
+
return self[key]
|
|
474
|
+
|
|
475
|
+
def keys(self):
|
|
476
|
+
_owner, _rel, node = self._target()
|
|
477
|
+
return node.keys()
|
|
478
|
+
|
|
479
|
+
def values(self):
|
|
480
|
+
_owner, _rel, node = self._target()
|
|
481
|
+
return (self[key] for key in node.keys())
|
|
482
|
+
|
|
483
|
+
def items(self):
|
|
484
|
+
_owner, _rel, node = self._target()
|
|
485
|
+
return ((key, self[key]) for key in node.keys())
|
|
486
|
+
|
|
487
|
+
def index(self, item: Any) -> int:
|
|
488
|
+
_owner, _rel, node = self._target()
|
|
489
|
+
target = plain(item)
|
|
490
|
+
found = next(
|
|
491
|
+
(i for i, entry in enumerate(node) if _snapshot(entry) == target), None
|
|
492
|
+
)
|
|
493
|
+
if found is None:
|
|
494
|
+
raise ValueError(f"{target!r} not in list")
|
|
495
|
+
return found
|
|
496
|
+
|
|
497
|
+
def __contains__(self, item: Any) -> bool:
|
|
498
|
+
_owner, _rel, node = self._target()
|
|
499
|
+
target = plain(item)
|
|
500
|
+
if isinstance(node, list):
|
|
501
|
+
return any(_snapshot(entry) == target for entry in node)
|
|
502
|
+
return target in node
|
|
503
|
+
|
|
504
|
+
def __iter__(self) -> Iterator[Any]:
|
|
505
|
+
_owner, _rel, node = self._target()
|
|
506
|
+
if isinstance(node, list):
|
|
507
|
+
return (self[i] for i in range(len(node)))
|
|
508
|
+
return iter(list(node))
|
|
509
|
+
|
|
510
|
+
def __len__(self) -> int:
|
|
511
|
+
_owner, _rel, node = self._target()
|
|
512
|
+
return len(node)
|
|
513
|
+
|
|
514
|
+
def __eq__(self, other: Any) -> bool:
|
|
515
|
+
_owner, _rel, node = self._target()
|
|
516
|
+
return _snapshot(node) == plain(other)
|
|
517
|
+
|
|
518
|
+
def __bool__(self) -> bool:
|
|
519
|
+
_owner, _rel, node = self._target()
|
|
520
|
+
return bool(node)
|
|
521
|
+
|
|
522
|
+
def __repr__(self) -> str:
|
|
523
|
+
_owner, _rel, node = self._target()
|
|
524
|
+
return repr(_snapshot(node))
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: statepatch
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Immer-style ops over a plain JSON tree: observables that emit every mutation as an op
|
|
5
|
+
Project-URL: Repository, https://github.com/assistant-ui/harness-sdk
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Requires-Python: >=3.11
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
|
|
10
|
+
# statepatch
|
|
11
|
+
|
|
12
|
+
Immer-style ops over a plain JSON tree.
|
|
13
|
+
|
|
14
|
+
`observable(initial)` wraps one value: `.value` reads it, every mutation applies to the tree and emits the matching op (`replace` / `add` / `remove`) to subscribers. Observables compose: an observable stored inside another mounts its op stream at that path.
|
|
15
|
+
|
|
16
|
+
```python
|
|
17
|
+
from statepatch import observable
|
|
18
|
+
|
|
19
|
+
state = observable({"runs": []})
|
|
20
|
+
state.subscribe(print)
|
|
21
|
+
|
|
22
|
+
run = observable({"runId": "r1", "queue": []})
|
|
23
|
+
state["runs"].append(run) # add op at ["runs", 0]
|
|
24
|
+
run["queue"].append({"id": "q1"}) # forwarded as add at ["runs", 0, "queue", 0]
|
|
25
|
+
state["runs"].pop(0) # remove op; run is detached and reusable
|
|
26
|
+
```
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
statepatch/__init__.py,sha256=1-wj-_iSAHyQOtmTMhQAB5kGvN_rU35FNCNbnx-VcGY,19091
|
|
2
|
+
statepatch-0.1.0.dist-info/METADATA,sha256=sKJZBQe1VsqZo3FbG0gc5CmfUJYL3JPMHB6Yoeo-vXY,991
|
|
3
|
+
statepatch-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
4
|
+
statepatch-0.1.0.dist-info/RECORD,,
|