statepatch 0.1.1__tar.gz → 0.2.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.
- {statepatch-0.1.1 → statepatch-0.2.0}/PKG-INFO +1 -1
- {statepatch-0.1.1 → statepatch-0.2.0}/pyproject.toml +1 -1
- statepatch-0.2.0/src/statepatch/__init__.py +22 -0
- statepatch-0.1.1/src/statepatch/__init__.py → statepatch-0.2.0/src/statepatch/_core.py +4 -92
- statepatch-0.2.0/src/statepatch/_wire.py +94 -0
- {statepatch-0.1.1 → statepatch-0.2.0}/.gitignore +0 -0
- {statepatch-0.1.1 → statepatch-0.2.0}/README.md +0 -0
- {statepatch-0.1.1 → statepatch-0.2.0}/tests/test_observable.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: statepatch
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
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
|
|
@@ -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
|
-
|
|
1
|
+
from typing import Any, Callable, Iterator
|
|
2
2
|
|
|
3
|
-
|
|
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,94 @@
|
|
|
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 a plain snapshot; pass anything else through."""
|
|
38
|
+
from ._core import Observable, Proxy
|
|
39
|
+
|
|
40
|
+
if isinstance(value, Proxy):
|
|
41
|
+
_owner, _rel, node = value._target()
|
|
42
|
+
return _snapshot(node)
|
|
43
|
+
if isinstance(value, Observable):
|
|
44
|
+
return _snapshot(value._state)
|
|
45
|
+
return value
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _adopt(value: Any, base: Path) -> tuple[Any, list[tuple[Path, "Observable"]]]:
|
|
49
|
+
"""Validated deep copy keeping ``Observable`` nodes in place; returns the
|
|
50
|
+
stored tree and the mount points found inside it (paths rooted at ``base``)."""
|
|
51
|
+
from ._core import Observable, Proxy
|
|
52
|
+
|
|
53
|
+
mounts: list[tuple[Path, Observable]] = []
|
|
54
|
+
|
|
55
|
+
def walk(value: Any, path: Path) -> Any:
|
|
56
|
+
if isinstance(value, Observable):
|
|
57
|
+
mounts.append((path, value))
|
|
58
|
+
return value
|
|
59
|
+
if isinstance(value, Proxy):
|
|
60
|
+
value = plain(value)
|
|
61
|
+
if isinstance(value, bool) or value is None or isinstance(value, str):
|
|
62
|
+
return value
|
|
63
|
+
if isinstance(value, float):
|
|
64
|
+
if math.isnan(value) or math.isinf(value):
|
|
65
|
+
raise ValueError(f"{value!r} is not wire-encodable; floats must be finite")
|
|
66
|
+
return value
|
|
67
|
+
if isinstance(value, int):
|
|
68
|
+
if abs(value) > _MAX_SAFE_INT:
|
|
69
|
+
raise ValueError(
|
|
70
|
+
f"integer {value} exceeds the JS safe range (|n| <= 2**53-1); send it as a string"
|
|
71
|
+
)
|
|
72
|
+
return value
|
|
73
|
+
if isinstance(value, datetime):
|
|
74
|
+
if value.tzinfo is None or value.tzinfo.utcoffset(value) is None:
|
|
75
|
+
raise ValueError("naive datetime is not wire-encodable; attach a timezone")
|
|
76
|
+
return value
|
|
77
|
+
if isinstance(value, (list, tuple)):
|
|
78
|
+
return [walk(item, path + (i,)) for i, item in enumerate(value)]
|
|
79
|
+
if isinstance(value, dict):
|
|
80
|
+
out: dict[str, Any] = {}
|
|
81
|
+
for key, item in value.items():
|
|
82
|
+
if not isinstance(key, str):
|
|
83
|
+
raise TypeError(f"object keys must be strings, got {type(key).__name__}")
|
|
84
|
+
out[key] = walk(item, path + (key,))
|
|
85
|
+
return out
|
|
86
|
+
raise TypeError(f"{type(value).__name__} is not wire-encodable")
|
|
87
|
+
|
|
88
|
+
return walk(value, base), mounts
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _index(container: Any, key: Any) -> Any:
|
|
92
|
+
if isinstance(container, list) and isinstance(key, int) and key < 0:
|
|
93
|
+
return key + len(container)
|
|
94
|
+
return key
|
|
File without changes
|
|
File without changes
|
|
File without changes
|