statepatch 0.1.1__tar.gz → 0.2.1__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: statepatch
3
- Version: 0.1.1
3
+ Version: 0.2.1
4
4
  Summary: Immer-style ops over a plain JSON tree: observables that emit every mutation as an op
5
5
  Project-URL: Repository, https://github.com/assistant-ui/harness-sdk
6
6
  License-Expression: MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "statepatch"
3
- version = "0.1.1"
3
+ version = "0.2.1"
4
4
  description = "Immer-style ops over a plain JSON tree: observables that emit every mutation as an op"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -0,0 +1,22 @@
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
+ from ._core import Observable, Proxy, append, observable
10
+ from ._wire import Listener, Op, Path, Segment, plain
11
+
12
+ __all__ = [
13
+ "observable",
14
+ "Observable",
15
+ "Proxy",
16
+ "plain",
17
+ "append",
18
+ "Op",
19
+ "Path",
20
+ "Segment",
21
+ "Listener",
22
+ ]
@@ -1,96 +1,6 @@
1
- """Immer-style ops over a plain JSON tree.
1
+ from typing import Any, Callable, Iterator
2
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
3
+ from ._wire import _MISSING, Listener, Op, Path, Segment, _adopt, _index, _snapshot, plain
94
4
 
95
5
 
96
6
  class Observable:
@@ -220,6 +130,8 @@ class Observable:
220
130
  return node._target(())
221
131
  return self, path, node
222
132
 
133
+ # container delegators
134
+
223
135
  def __getitem__(self, key: Any) -> Any:
224
136
  return Proxy(self, ())[key]
225
137
 
@@ -0,0 +1,95 @@
1
+ import math
2
+ from datetime import datetime
3
+ from typing import TYPE_CHECKING, Any, Callable, NamedTuple
4
+
5
+ if TYPE_CHECKING:
6
+ from ._core import Observable
7
+
8
+ Segment = str | int
9
+ Path = tuple[Segment, ...]
10
+
11
+
12
+ class Op(NamedTuple):
13
+ kind: str # "replace" | "add" | "remove"
14
+ path: Path
15
+ value: Any = None
16
+
17
+
18
+ Listener = Callable[[Op], None]
19
+
20
+ _MAX_SAFE_INT = 2**53 - 1
21
+ _MISSING: Any = object()
22
+
23
+
24
+ def _snapshot(node: Any) -> Any:
25
+ from ._core import Observable
26
+
27
+ if isinstance(node, Observable):
28
+ return _snapshot(node._state)
29
+ if isinstance(node, dict):
30
+ return {key: _snapshot(item) for key, item in node.items()}
31
+ if isinstance(node, list):
32
+ return [_snapshot(item) for item in node]
33
+ return node
34
+
35
+
36
+ def plain(value: Any) -> Any:
37
+ """Resolve a proxy or observable to its live stored node (mutating it emits no ops); pass anything else through."""
38
+ from ._core import Observable, Proxy
39
+
40
+ if isinstance(value, Observable):
41
+ value = Proxy(value, ())
42
+ if not isinstance(value, Proxy):
43
+ return value
44
+ owner, rel, node = value._target()
45
+ mounted = any(path[: len(rel)] == rel for _child, path, _unsub in owner._mounts.values())
46
+ return _snapshot(node) if mounted else node
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
+ from ._core import Observable, Proxy
53
+
54
+ mounts: list[tuple[Path, Observable]] = []
55
+
56
+ def walk(value: Any, path: Path) -> Any:
57
+ if isinstance(value, Observable):
58
+ mounts.append((path, value))
59
+ return value
60
+ if isinstance(value, Proxy):
61
+ value = plain(value)
62
+ if isinstance(value, bool) or value is None or isinstance(value, str):
63
+ return value
64
+ if isinstance(value, float):
65
+ if math.isnan(value) or math.isinf(value):
66
+ raise ValueError(f"{value!r} is not wire-encodable; floats must be finite")
67
+ return value
68
+ if isinstance(value, int):
69
+ if abs(value) > _MAX_SAFE_INT:
70
+ raise ValueError(
71
+ f"integer {value} exceeds the JS safe range (|n| <= 2**53-1); send it as a string"
72
+ )
73
+ return value
74
+ if isinstance(value, datetime):
75
+ if value.tzinfo is None or value.tzinfo.utcoffset(value) is None:
76
+ raise ValueError("naive datetime is not wire-encodable; attach a timezone")
77
+ return value
78
+ if isinstance(value, (list, tuple)):
79
+ return [walk(item, path + (i,)) for i, item in enumerate(value)]
80
+ if isinstance(value, dict):
81
+ out: dict[str, Any] = {}
82
+ for key, item in value.items():
83
+ if not isinstance(key, str):
84
+ raise TypeError(f"object keys must be strings, got {type(key).__name__}")
85
+ out[key] = walk(item, path + (key,))
86
+ return out
87
+ raise TypeError(f"{type(value).__name__} is not wire-encodable")
88
+
89
+ return walk(value, base), mounts
90
+
91
+
92
+ def _index(container: Any, key: Any) -> Any:
93
+ if isinstance(container, list) and isinstance(key, int) and key < 0:
94
+ return key + len(container)
95
+ return key
@@ -4,7 +4,7 @@ mounted observables forward their op streams at the mount path."""
4
4
  from datetime import datetime, timezone
5
5
 
6
6
  import pytest
7
- from statepatch import Op, append, observable
7
+ from statepatch import Observable, Op, append, observable, plain
8
8
 
9
9
 
10
10
  def collect(obs):
@@ -180,6 +180,26 @@ def test_snapshot_ops_carry_plain_values_for_nested_mounts():
180
180
  assert ops[-1] == Op("replace", ("wrap", "inner", "n"), 7)
181
181
 
182
182
 
183
+ def test_plain_returns_the_live_node():
184
+ state = observable({"runs": [{"id": "r1"}]})
185
+ ops = collect(state)
186
+ assert plain(state) is state._state
187
+ assert plain(state["runs"]) is state._state["runs"]
188
+ assert plain(state["runs"][0]["id"]) == "r1"
189
+ plain(state["runs"]).append({"id": "r2"})
190
+ assert state.value == {"runs": [{"id": "r1"}, {"id": "r2"}]}
191
+ assert ops == []
192
+
193
+
194
+ def test_plain_unwraps_mounts_into_a_snapshot():
195
+ child = observable({"n": 0})
196
+ parent = observable({"wrap": {"inner": child}, "other": [1]})
197
+ assert plain(parent["wrap"]) == {"inner": {"n": 0}}
198
+ assert not isinstance(plain(parent["wrap"])["inner"], Observable)
199
+ assert plain(parent["wrap"]["inner"]) is child._state
200
+ assert plain(parent["other"]) is parent._state["other"]
201
+
202
+
183
203
  def test_container_helpers():
184
204
  state = observable({"d": {"a": 1}, "l": [1, 2, 3]})
185
205
  ops = collect(state)
File without changes
File without changes