statepatch 0.1.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.
@@ -0,0 +1,27 @@
1
+ node_modules
2
+ .next
3
+ dist
4
+ dist-test
5
+ .wrangler
6
+ .DS_Store
7
+ .env
8
+ .env.local
9
+ .dev.vars
10
+ *.tsbuildinfo
11
+ .harness-runs
12
+ *.rdb
13
+ .source
14
+ next-env.d.ts
15
+ __pycache__
16
+ .venv
17
+ .pytest_cache
18
+ *.egg-info
19
+
20
+ # agentdoc mounted docs
21
+ /d[0-9]*.md
22
+ /doc_*.md
23
+ apps/docs/.docs
24
+ /.agentdoc/
25
+ /.deepsec/
26
+ .vercel
27
+ .env*
@@ -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,17 @@
1
+ # statepatch
2
+
3
+ Immer-style ops over a plain JSON tree.
4
+
5
+ `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.
6
+
7
+ ```python
8
+ from statepatch import observable
9
+
10
+ state = observable({"runs": []})
11
+ state.subscribe(print)
12
+
13
+ run = observable({"runId": "r1", "queue": []})
14
+ state["runs"].append(run) # add op at ["runs", 0]
15
+ run["queue"].append({"id": "q1"}) # forwarded as add at ["runs", 0, "queue", 0]
16
+ state["runs"].pop(0) # remove op; run is detached and reusable
17
+ ```
@@ -0,0 +1,18 @@
1
+ [project]
2
+ name = "statepatch"
3
+ version = "0.1.0"
4
+ description = "Immer-style ops over a plain JSON tree: observables that emit every mutation as an op"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ requires-python = ">=3.11"
8
+ dependencies = []
9
+
10
+ [project.urls]
11
+ Repository = "https://github.com/assistant-ui/harness-sdk"
12
+
13
+ [build-system]
14
+ requires = ["hatchling"]
15
+ build-backend = "hatchling.build"
16
+
17
+ [tool.hatch.build.targets.wheel]
18
+ packages = ["src/statepatch"]
@@ -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,228 @@
1
+ """Contract: observables apply mutations to the tree and emit the matching op;
2
+ mounted observables forward their op streams at the mount path."""
3
+
4
+ from datetime import datetime, timezone
5
+
6
+ import pytest
7
+ from statepatch import Op, observable
8
+
9
+
10
+ def collect(obs):
11
+ ops = []
12
+ obs.subscribe(ops.append)
13
+ return ops
14
+
15
+
16
+ def test_scalar_value_set_emits_root_replace():
17
+ flag = observable(False)
18
+ ops = collect(flag)
19
+ flag.value = True
20
+ assert flag.value is True
21
+ assert ops == [Op("replace", (), True)]
22
+
23
+
24
+ def test_dict_and_list_mutations_emit_wire_shaped_ops():
25
+ state = observable({"status": "idle", "messages": []})
26
+ ops = collect(state)
27
+ state["status"] = "streaming"
28
+ state["messages"].append({"id": "m1", "content": ""})
29
+ state["messages"][0]["content"] = "hel"
30
+ state["messages"][0]["content"] = "hello" # suffix diff -> string add
31
+ state["messages"].pop(0)
32
+ assert ops == [
33
+ Op("replace", ("status",), "streaming"),
34
+ Op("add", ("messages", 0), {"id": "m1", "content": ""}),
35
+ Op("add", ("messages", 0, "content", 0), "hel"),
36
+ Op("add", ("messages", 0, "content", 3), "lo"),
37
+ Op("remove", ("messages", 0)),
38
+ ]
39
+ assert state.value == {"status": "streaming", "messages": []}
40
+
41
+
42
+ def test_values_are_dealised_on_write():
43
+ state = observable({})
44
+ held = {"a": [1]}
45
+ state["x"] = held
46
+ held["a"].append(2)
47
+ assert state.value == {"x": {"a": [1]}}
48
+
49
+
50
+ def test_invalid_values_fail_fast():
51
+ state = observable({})
52
+ with pytest.raises(ValueError):
53
+ state["x"] = float("nan")
54
+ with pytest.raises(ValueError):
55
+ state["x"] = 2**53
56
+ with pytest.raises(ValueError):
57
+ state["x"] = datetime(2026, 1, 1) # naive
58
+ with pytest.raises(TypeError):
59
+ state["x"] = {1: "non-string key"}
60
+ with pytest.raises(TypeError):
61
+ state["x"] = object()
62
+ state["x"] = datetime(2026, 1, 1, tzinfo=timezone.utc) # aware is fine
63
+
64
+
65
+ def test_mount_in_initial_value_forwards_ops():
66
+ child = observable({"n": 0})
67
+ parent = observable({"child": child, "other": 1})
68
+ ops = collect(parent)
69
+ child["n"] = 5
70
+ assert ops == [Op("replace", ("child", "n"), 5)]
71
+ assert parent.value == {"child": {"n": 5}, "other": 1}
72
+
73
+
74
+ def test_mount_via_append_and_detach_via_pop():
75
+ run = observable({"runId": "r1", "queue": []})
76
+ state = observable({"runs": []})
77
+ ops = collect(state)
78
+ state["runs"].append(run)
79
+ run["queue"].append("q1")
80
+ state["runs"].pop(0)
81
+ run["queue"].append("q2") # after detach: not forwarded
82
+ assert ops == [
83
+ Op("add", ("runs", 0), {"runId": "r1", "queue": []}),
84
+ Op("add", ("runs", 0, "queue", 0), "q1"),
85
+ Op("remove", ("runs", 0)),
86
+ ]
87
+ assert state.value == {"runs": []}
88
+ assert run.value == {"runId": "r1", "queue": ["q1", "q2"]}
89
+ # detached observables are re-mountable
90
+ state["runs"].append(run)
91
+ run["queue"].append("q3")
92
+ assert ops[-1] == Op("add", ("runs", 0, "queue", 2), "q3")
93
+
94
+
95
+ def test_mount_paths_shift_with_list_splices():
96
+ a, b = observable({"id": "a"}), observable({"id": "b"})
97
+ state = observable({"runs": []})
98
+ state["runs"].append(a)
99
+ state["runs"].append(b)
100
+ ops = collect(state)
101
+ state["runs"].pop(0)
102
+ b["id"] = "x2"
103
+ assert ops == [
104
+ Op("remove", ("runs", 0)),
105
+ Op("replace", ("runs", 0, "id"), "x2"),
106
+ ]
107
+ state["runs"].insert(0, {"id": "plain"})
108
+ b["id"] = "y3"
109
+ assert ops[-1] == Op("replace", ("runs", 1, "id"), "y3")
110
+
111
+
112
+ def test_double_mount_and_cycles_fail_fast():
113
+ child = observable({})
114
+ parent = observable({"a": child})
115
+ other = observable({})
116
+ with pytest.raises(ValueError):
117
+ other["x"] = child
118
+ with pytest.raises(ValueError):
119
+ child["up"] = parent
120
+
121
+
122
+ def test_overwrite_detaches_mount():
123
+ child = observable({"n": 0})
124
+ parent = observable({"slot": child})
125
+ ops = collect(parent)
126
+ parent["slot"] = None
127
+ child["n"] = 1
128
+ assert ops == [Op("replace", ("slot",), None)]
129
+ assert child._parent is None
130
+
131
+
132
+ def test_reads_and_mutations_cross_mounts():
133
+ child = observable({"queue": ["x"]})
134
+ parent = observable({"child": child})
135
+ child_ops = collect(child)
136
+ parent_ops = collect(parent)
137
+ # a mutation addressed through the parent is recorded by the child
138
+ parent["child"]["queue"].append("y")
139
+ assert child_ops == [Op("add", ("queue", 1), "y")]
140
+ assert parent_ops == [Op("add", ("child", "queue", 1), "y")]
141
+ assert parent["child"]["queue"][1] == "y"
142
+ assert len(parent["child"]["queue"]) == 2
143
+
144
+
145
+ def test_snapshot_ops_carry_plain_values_for_nested_mounts():
146
+ child = observable({"n": 0})
147
+ parent = observable({})
148
+ ops = collect(parent)
149
+ parent["wrap"] = {"inner": child}
150
+ assert ops == [Op("replace", ("wrap",), {"inner": {"n": 0}})]
151
+ child["n"] = 7
152
+ assert ops[-1] == Op("replace", ("wrap", "inner", "n"), 7)
153
+
154
+
155
+ def test_container_helpers():
156
+ state = observable({"d": {"a": 1}, "l": [1, 2, 3]})
157
+ ops = collect(state)
158
+ assert state["d"].setdefault("a", 9) == 1
159
+ assert state["d"].setdefault("b", 2) == 2
160
+ assert state["d"].get("missing", "dflt") == "dflt"
161
+ assert list(state["d"].keys()) == ["a", "b"]
162
+ assert state["l"].index(2) == 1
163
+ assert 3 in state["l"]
164
+ state["l"].remove(2)
165
+ state["l"].extend([4, 5])
166
+ state["l"].clear()
167
+ state["d"].update({"c": 3})
168
+ assert state.value == {"d": {"a": 1, "b": 2, "c": 3}, "l": []}
169
+ assert [op.kind for op in ops] == ["replace", "remove", "replace", "replace", "replace"]
170
+
171
+
172
+ def test_unsubscribe_stops_delivery():
173
+ state = observable({})
174
+ ops = []
175
+ unsubscribe = state.subscribe(ops.append)
176
+ state["a"] = 1
177
+ unsubscribe()
178
+ state["a"] = 2
179
+ assert len(ops) == 1
180
+
181
+
182
+ def test_failed_writes_leave_the_tree_and_mounts_untouched():
183
+ child = observable({"n": 0})
184
+ home = observable({"slot": child})
185
+ state = observable({"runs": [child2 := observable({"id": "a"})], "d": {}})
186
+ seen = []
187
+ state.subscribe(seen.append)
188
+ for bad in (
189
+ lambda: state["runs"].insert(0, object()),
190
+ lambda: state["runs"].append(child),
191
+ lambda: state["runs"].extend([1, child]),
192
+ lambda: state.update({"x": child}),
193
+ lambda: state.__setitem__("slot2", {"inner": child}),
194
+ lambda: state["d"].__setitem__(1, "x"),
195
+ lambda: state["runs"].__setitem__("x", 1),
196
+ ):
197
+ with pytest.raises((TypeError, ValueError)):
198
+ bad()
199
+ assert state.value == {"runs": [{"id": "a"}], "d": {}}
200
+ assert seen == []
201
+ child2["id"] = "b" # mount path did not shift on the failed insert
202
+ assert seen == [Op("replace", ("runs", 0, "id"), "b")]
203
+ child["n"] = 1 # child stays healthy in its own home
204
+ assert home.value == {"slot": {"n": 1}}
205
+
206
+
207
+ def test_listener_detaching_the_mount_mid_emit_is_safe():
208
+ child = observable({"n": 0})
209
+ parent = observable({})
210
+ fired = []
211
+
212
+ def detach_once(op):
213
+ fired.append(op)
214
+ if len(fired) == 1:
215
+ parent["slot"] = None
216
+
217
+ child.subscribe(detach_once)
218
+ parent["slot"] = child
219
+ child["n"] = 1
220
+ assert parent.value == {"slot": None}
221
+
222
+
223
+ def test_remounting_a_child_onto_its_own_path_works():
224
+ child = observable({"n": 0})
225
+ home = observable({"slot": child})
226
+ home["slot"] = child
227
+ child["n"] = 1
228
+ assert home.value == {"slot": {"n": 1}}