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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any