syncwave 0.2.0__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.
- {syncwave-0.2.0 → syncwave-0.2.1}/PKG-INFO +1 -1
- {syncwave-0.2.0 → syncwave-0.2.1}/pyproject.toml +18 -6
- {syncwave-0.2.0 → syncwave-0.2.1}/src/syncwave/__init__.py +5 -2
- {syncwave-0.2.0 → syncwave-0.2.1}/src/syncwave/io.py +20 -4
- syncwave-0.2.1/src/syncwave/reactive.py +188 -0
- {syncwave-0.2.0 → syncwave-0.2.1}/src/syncwave/sync_collection.py +120 -6
- {syncwave-0.2.0 → syncwave-0.2.1}/src/syncwave/sync_model.py +81 -1
- syncwave-0.2.1/src/syncwave/syncwave.py +504 -0
- {syncwave-0.2.0 → syncwave-0.2.1}/src/syncwave/tp_validation.py +4 -2
- {syncwave-0.2.0 → syncwave-0.2.1}/src/syncwave/watcher.py +3 -0
- syncwave-0.2.0/src/syncwave/reactive.py +0 -91
- syncwave-0.2.0/src/syncwave/syncwave.py +0 -239
- {syncwave-0.2.0 → syncwave-0.2.1}/LICENSE +0 -0
- {syncwave-0.2.0 → syncwave-0.2.1}/README.md +0 -0
- {syncwave-0.2.0 → syncwave-0.2.1}/src/syncwave/py.typed +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "syncwave"
|
|
3
|
-
version = "0.2.
|
|
3
|
+
version = "0.2.1"
|
|
4
4
|
description = "Make your code reactive; Turn plain JSONs into a live data store, two-way synced with Python objects."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
authors = [{ name = "Lucas Iannitello", email = "lucas.iannitello@protonmail.com" }]
|
|
@@ -26,35 +26,47 @@ build-backend = "uv_build"
|
|
|
26
26
|
[dependency-groups]
|
|
27
27
|
dev = [
|
|
28
28
|
"fastapi[standard]>=0.128.8",
|
|
29
|
+
"mkdocs-material>=9.7.5",
|
|
30
|
+
"mkdocstrings-python>=1.18.2",
|
|
29
31
|
"ruff>=0.15.4",
|
|
30
32
|
"ty>=0.0.20",
|
|
31
33
|
]
|
|
32
34
|
|
|
35
|
+
[tool.ruff.format]
|
|
36
|
+
docstring-code-format = true
|
|
37
|
+
|
|
33
38
|
[tool.ruff.lint]
|
|
34
39
|
select = [
|
|
35
40
|
"E", # pycodestyle errors
|
|
36
41
|
"F", # pyflakes
|
|
37
42
|
"I", # isort
|
|
43
|
+
"D", # pydocstyle
|
|
44
|
+
"A", # flake8-builtins
|
|
38
45
|
"B", # flake8-bugbear
|
|
39
46
|
"C4", # flake8-comprehensions
|
|
40
|
-
"SIM", # flake8-simplify
|
|
41
47
|
"ANN", # flake8-annotations
|
|
48
|
+
"FBT", # flake8-boolean-trap
|
|
49
|
+
"PYI", # flake8-pyi
|
|
50
|
+
"SIM", # flake8-simplify
|
|
42
51
|
"RUF", # ruff
|
|
43
52
|
"UP", # pyupgrade
|
|
44
53
|
]
|
|
45
|
-
|
|
54
|
+
extend-select = ["D401","D404","D413"]
|
|
46
55
|
ignore = [
|
|
47
|
-
"
|
|
56
|
+
"D105", # missing docstring in magic method
|
|
57
|
+
"ANN401", # typing.Any disallowed
|
|
48
58
|
]
|
|
49
|
-
|
|
50
|
-
|
|
51
59
|
unfixable = [
|
|
52
60
|
"F401" # disable fix for unused imports
|
|
53
61
|
]
|
|
62
|
+
pydocstyle = { convention = "google" }
|
|
54
63
|
|
|
55
64
|
[tool.ruff.lint.per-file-ignores]
|
|
56
65
|
"__init__.py" = ["I002"]
|
|
66
|
+
"!src/**.py" = ["D"]
|
|
67
|
+
"!src/__init__.py" = ["D100"]
|
|
57
68
|
|
|
58
69
|
[tool.ruff.lint.isort]
|
|
59
70
|
required-imports = ["from __future__ import annotations"]
|
|
60
71
|
extra-standard-library = ["typing_extensions"]
|
|
72
|
+
known-third-party = ["fastapi", "pydantic", "syncwave"]
|
|
@@ -1,12 +1,15 @@
|
|
|
1
|
-
|
|
1
|
+
"""Turn plain JSONs into live data stores, two-way synced with Python objects."""
|
|
2
2
|
|
|
3
|
+
__version__ = "0.2.1"
|
|
3
4
|
|
|
4
|
-
|
|
5
|
+
|
|
6
|
+
from .reactive import DeadReferenceError, Reactive
|
|
5
7
|
from .sync_collection import SyncCollection, SyncDict, SyncList, SyncSet
|
|
6
8
|
from .sync_model import SyncModel, is_sync_model_supported
|
|
7
9
|
from .syncwave import Syncwave
|
|
8
10
|
|
|
9
11
|
__all__ = [
|
|
12
|
+
"DeadReferenceError",
|
|
10
13
|
"Reactive",
|
|
11
14
|
"SyncCollection",
|
|
12
15
|
"SyncDict",
|
|
@@ -11,6 +11,9 @@ from pydantic import TypeAdapter, ValidationError
|
|
|
11
11
|
|
|
12
12
|
from .watcher import watcher
|
|
13
13
|
|
|
14
|
+
__all__ = []
|
|
15
|
+
|
|
16
|
+
|
|
14
17
|
PendingWrite = tuple[Any, TypeAdapter, Timer]
|
|
15
18
|
|
|
16
19
|
|
|
@@ -88,7 +91,7 @@ class _IO:
|
|
|
88
91
|
self._atomic_write(path, self._serialize(init_value, ta))
|
|
89
92
|
return init_value
|
|
90
93
|
|
|
91
|
-
def
|
|
94
|
+
def load(self, path: Path, ta: TypeAdapter = _any_ta) -> Any:
|
|
92
95
|
# never returns EmptyFile, it throws an error if the file is empty
|
|
93
96
|
with self._lock:
|
|
94
97
|
if path in self._pending_writes:
|
|
@@ -97,10 +100,10 @@ class _IO:
|
|
|
97
100
|
return value
|
|
98
101
|
text = self._serialize(value, previous_ta)
|
|
99
102
|
return self._deserialize(text, ta, path)
|
|
100
|
-
text = path.read_text(encoding=self.ENCODING)
|
|
103
|
+
text = path.read_text(encoding=self.ENCODING)
|
|
101
104
|
return self._deserialize(text, ta, path)
|
|
102
105
|
|
|
103
|
-
def
|
|
106
|
+
def dump(self, path: Path, value: Any, ta: TypeAdapter = _any_ta) -> None:
|
|
104
107
|
with self._lock:
|
|
105
108
|
if path in self._pending_writes:
|
|
106
109
|
self._pending_writes[path][2].cancel()
|
|
@@ -108,6 +111,20 @@ class _IO:
|
|
|
108
111
|
self._pending_writes[path] = (value, ta, timer)
|
|
109
112
|
timer.start()
|
|
110
113
|
|
|
114
|
+
def read_json(self, path: Path) -> str:
|
|
115
|
+
with self._lock:
|
|
116
|
+
if path in self._pending_writes:
|
|
117
|
+
value, ta, _ = self._pending_writes[path]
|
|
118
|
+
return self._serialize(value, ta)
|
|
119
|
+
return path.read_text(encoding=self.ENCODING)
|
|
120
|
+
|
|
121
|
+
def write_json(self, path: Path, text: str) -> None:
|
|
122
|
+
with self._lock:
|
|
123
|
+
if path in self._pending_writes:
|
|
124
|
+
_, _, timer = self._pending_writes.pop(path)
|
|
125
|
+
timer.cancel()
|
|
126
|
+
self._atomic_write(path, text)
|
|
127
|
+
|
|
111
128
|
def _scheduled_write(self, path: Path) -> None:
|
|
112
129
|
with self._lock:
|
|
113
130
|
if path not in self._pending_writes:
|
|
@@ -127,7 +144,6 @@ class _IO:
|
|
|
127
144
|
# Atomically replace the target file
|
|
128
145
|
watcher.mark_self_write(path)
|
|
129
146
|
os.replace(tmp_path, path)
|
|
130
|
-
# print(f"Wrote JSON to {path}")
|
|
131
147
|
|
|
132
148
|
except Exception as e:
|
|
133
149
|
# Attempt to remove temporary file on error
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from abc import ABCMeta, abstractmethod
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from functools import wraps
|
|
6
|
+
from threading import RLock
|
|
7
|
+
from typing import Any, Callable, NoReturn, TypeVar, final
|
|
8
|
+
from typing_extensions import ParamSpec
|
|
9
|
+
|
|
10
|
+
__all__ = ["DeadReferenceError", "Reactive"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class StoreRef:
|
|
15
|
+
lock: RLock
|
|
16
|
+
on_change: Callable[[], None]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class Context:
|
|
21
|
+
tp: type[Reactive]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ContextMap(dict[type["Reactive"], Context]): ...
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
CtxSubCls = TypeVar("CtxSubCls", bound=Context)
|
|
28
|
+
ReactiveSubCls = TypeVar("ReactiveSubCls", bound="Reactive")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Reactive(metaclass=ABCMeta):
|
|
32
|
+
"""Base class shared by all reactive values in Syncwave.
|
|
33
|
+
|
|
34
|
+
All reactive types (`SyncDict`, `SyncList`, `SyncSet` and `SyncModel`) are
|
|
35
|
+
subclasses of `Reactive`. You will mainly encounter it for type checks:
|
|
36
|
+
`isinstance(value, Reactive)`.
|
|
37
|
+
|
|
38
|
+
A reactive object can become dead when the corresponding store entry is removed or
|
|
39
|
+
replaced. Once dead, `sync_live` returns `False` and any further operation raises
|
|
40
|
+
`DeadReferenceError`.
|
|
41
|
+
|
|
42
|
+
Example:
|
|
43
|
+
```python
|
|
44
|
+
from syncwave import Reactive, SyncDict, Syncwave
|
|
45
|
+
|
|
46
|
+
syncwave = Syncwave()
|
|
47
|
+
sync_dict = syncwave.create_store(SyncDict[str, int], name="sync_dict")
|
|
48
|
+
print(isinstance(sync_dict, Reactive)) # True
|
|
49
|
+
print(issubclass(SyncDict, Reactive)) # True
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
---
|
|
53
|
+
|
|
54
|
+
Abstract: Usage Documentation
|
|
55
|
+
[Reactive](https://placeholder.dev/usage/syncwave/)
|
|
56
|
+
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
__syncwave_sref__: StoreRef
|
|
60
|
+
__syncwave_ctx__: Context
|
|
61
|
+
__syncwave_live__: bool
|
|
62
|
+
|
|
63
|
+
def __new__(cls, *args: Any, **kwargs: Any) -> NoReturn: # noqa: D102
|
|
64
|
+
raise TypeError("Reactive types can never be instantiated directly.")
|
|
65
|
+
|
|
66
|
+
@final
|
|
67
|
+
@property
|
|
68
|
+
def sync_live(self) -> bool:
|
|
69
|
+
"""Whether this reactive object is still connected to the store.
|
|
70
|
+
|
|
71
|
+
Returns `False` once the object has been removed or replaced in its parent
|
|
72
|
+
store, for example because a key was deleted from a `SyncDict`. After that, any
|
|
73
|
+
operation on the object raises `DeadReferenceError`.
|
|
74
|
+
|
|
75
|
+
Example:
|
|
76
|
+
```python
|
|
77
|
+
from pydantic import BaseModel
|
|
78
|
+
from syncwave import Syncwave
|
|
79
|
+
|
|
80
|
+
syncwave = Syncwave()
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@syncwave.register(name="customers")
|
|
84
|
+
class Customer(BaseModel):
|
|
85
|
+
name: str
|
|
86
|
+
age: int
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
customers = syncwave["customers"]
|
|
90
|
+
customers.append({"name": "Alice", "age": 30})
|
|
91
|
+
alice = customers[0]
|
|
92
|
+
print(alice.sync_live) # True
|
|
93
|
+
del customers[0]
|
|
94
|
+
print(alice.sync_live) # False
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
---
|
|
98
|
+
|
|
99
|
+
Abstract: Usage Documentation
|
|
100
|
+
[Reactive](https://placeholder.dev/usage/syncwave/)
|
|
101
|
+
|
|
102
|
+
"""
|
|
103
|
+
return self.__syncwave_live__ # atomic, no need to lock
|
|
104
|
+
|
|
105
|
+
@abstractmethod
|
|
106
|
+
def __syncwave_init__(self, sref: StoreRef, ctx: CtxSubCls) -> None:
|
|
107
|
+
raise NotImplementedError
|
|
108
|
+
|
|
109
|
+
@abstractmethod
|
|
110
|
+
def __syncwave_kill__(self) -> None:
|
|
111
|
+
raise NotImplementedError
|
|
112
|
+
|
|
113
|
+
@abstractmethod
|
|
114
|
+
def __syncwave_update__(self, new: ReactiveSubCls) -> None:
|
|
115
|
+
raise NotImplementedError
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
P = ParamSpec("P")
|
|
119
|
+
R = TypeVar("R")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def atomic(fn: Callable[P, R]) -> Callable[P, R]:
|
|
123
|
+
@wraps(fn)
|
|
124
|
+
def wrapper(self: Reactive, *args: P.args, **kwargs: P.kwargs) -> R:
|
|
125
|
+
with self.__syncwave_sref__.lock:
|
|
126
|
+
if not self.__syncwave_live__:
|
|
127
|
+
raise DeadReferenceError(reference=self)
|
|
128
|
+
return fn(self, *args, **kwargs) # ty: ignore[invalid-argument-type]
|
|
129
|
+
|
|
130
|
+
return wrapper # ty: ignore[invalid-return-type]
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def mut_atomic(fn: Callable[P, R]) -> Callable[P, None]:
|
|
134
|
+
@wraps(fn)
|
|
135
|
+
def wrapper(self: Reactive, *args: P.args, **kwargs: P.kwargs) -> None:
|
|
136
|
+
with self.__syncwave_sref__.lock:
|
|
137
|
+
if not self.__syncwave_live__:
|
|
138
|
+
raise DeadReferenceError(reference=self)
|
|
139
|
+
result = fn(self, *args, **kwargs) # ty: ignore[invalid-argument-type]
|
|
140
|
+
if result is not None:
|
|
141
|
+
unreachable()
|
|
142
|
+
self.__syncwave_sref__.on_change()
|
|
143
|
+
|
|
144
|
+
return wrapper # ty: ignore[invalid-return-type]
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class DeadReferenceError(RuntimeError):
|
|
148
|
+
"""Raised when an operation is attempted on a dead reactive object.
|
|
149
|
+
|
|
150
|
+
A reactive object becomes dead when it is removed from the store, for example
|
|
151
|
+
by deleting a key from a `SyncDict` or by deleting the store entirely. Catching
|
|
152
|
+
this error is one way to check whether a reference is still valid, though
|
|
153
|
+
checking `sync_live` first is usually cleaner.
|
|
154
|
+
|
|
155
|
+
Example:
|
|
156
|
+
```python
|
|
157
|
+
from pydantic import BaseModel
|
|
158
|
+
from syncwave import DeadReferenceError, Syncwave
|
|
159
|
+
|
|
160
|
+
syncwave = Syncwave()
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@syncwave.register(name="customers")
|
|
164
|
+
class Customer(BaseModel):
|
|
165
|
+
name: str
|
|
166
|
+
age: int
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
customers = syncwave["customers"]
|
|
170
|
+
customers.append({"name": "Alice", "age": 30})
|
|
171
|
+
alice = customers[0]
|
|
172
|
+
del customers[0]
|
|
173
|
+
|
|
174
|
+
try:
|
|
175
|
+
alice.age = 31
|
|
176
|
+
except DeadReferenceError as e:
|
|
177
|
+
print(e)
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
"""
|
|
181
|
+
|
|
182
|
+
def __init__(self, *, reference: Reactive) -> None: # noqa: D107
|
|
183
|
+
message = f"Operation attempted on a dead reference: {reference!r}"
|
|
184
|
+
super().__init__(message)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def unreachable() -> NoReturn:
|
|
188
|
+
raise RuntimeError("Internal Error")
|
|
@@ -34,12 +34,35 @@ from .reactive import (
|
|
|
34
34
|
unreachable,
|
|
35
35
|
)
|
|
36
36
|
|
|
37
|
+
__all__ = ["SyncCollection", "SyncDict", "SyncList", "SyncSet"]
|
|
38
|
+
|
|
39
|
+
|
|
37
40
|
KT = TypeVar("KT")
|
|
38
41
|
VT = TypeVar("VT", bound=Union[Reactive, Any])
|
|
39
42
|
|
|
40
43
|
|
|
41
44
|
@final
|
|
42
45
|
class SyncCollection(Reactive):
|
|
46
|
+
"""Virtual base class for Syncwave's reactive collection types.
|
|
47
|
+
|
|
48
|
+
The `SyncCollection` types are `SyncDict`, `SyncList`, and `SyncSet`.
|
|
49
|
+
|
|
50
|
+
`SyncCollection` itself cannot be instantiated or subclassed. Can be used for
|
|
51
|
+
`isinstance` and `issubclass` checks.
|
|
52
|
+
|
|
53
|
+
Example:
|
|
54
|
+
```python
|
|
55
|
+
from syncwave import SyncCollection, SyncList, Syncwave
|
|
56
|
+
|
|
57
|
+
syncwave = Syncwave()
|
|
58
|
+
sync_list = syncwave.create_store(SyncList[int], name="sync_list")
|
|
59
|
+
|
|
60
|
+
print(isinstance(sync_list, SyncCollection)) # True
|
|
61
|
+
print(issubclass(SyncList, SyncCollection)) # True
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
"""
|
|
65
|
+
|
|
43
66
|
def __init_subclass__(cls, /, **kwargs: Any) -> NoReturn:
|
|
44
67
|
raise TypeError("SyncCollection cannot be subclassed.")
|
|
45
68
|
|
|
@@ -54,13 +77,45 @@ class SyncCollection(Reactive):
|
|
|
54
77
|
|
|
55
78
|
|
|
56
79
|
@dataclass(frozen=True)
|
|
57
|
-
class SyncDictCtx(Generic[KT, VT]
|
|
80
|
+
class SyncDictCtx(Context, Generic[KT, VT]):
|
|
58
81
|
tp: type[SyncDict]
|
|
59
82
|
inner_ctx: Context | ContextMap | None
|
|
60
83
|
inner_type_adapter: TypeAdapter[VT]
|
|
61
84
|
|
|
62
85
|
|
|
63
86
|
class SyncDict(MutableMapping[KT, VT], Reactive):
|
|
87
|
+
"""A reactive dictionary.
|
|
88
|
+
|
|
89
|
+
`SyncDict` behaves like a regular `dict`. Assignments, updates, and deletions
|
|
90
|
+
trigger a write to the backing JSON file, and external changes to the file are
|
|
91
|
+
reflected in place.
|
|
92
|
+
|
|
93
|
+
References to reactive values are stable by key: a reference to `store["alice"]`
|
|
94
|
+
continues to represent whatever is stored under `"alice"` until that key is removed,
|
|
95
|
+
at which point `sync_live` is set to `False` and any further operation raises
|
|
96
|
+
`DeadReferenceError`.
|
|
97
|
+
|
|
98
|
+
Example:
|
|
99
|
+
```python
|
|
100
|
+
from syncwave import SyncDict, Syncwave
|
|
101
|
+
|
|
102
|
+
syncwave = Syncwave()
|
|
103
|
+
sync_dict = syncwave.create_store(SyncDict[str, int], name="sync_dict")
|
|
104
|
+
|
|
105
|
+
sync_dict["a"] = 1
|
|
106
|
+
sync_dict["b"] = 2
|
|
107
|
+
sync_dict["c"] = 3
|
|
108
|
+
print(sync_dict)
|
|
109
|
+
print(list(sync_dict.items()))
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
---
|
|
113
|
+
|
|
114
|
+
Abstract: Usage Documentation
|
|
115
|
+
[SyncDict](https://placeholder.dev/usage/syncwave/)
|
|
116
|
+
|
|
117
|
+
"""
|
|
118
|
+
|
|
64
119
|
__syncwave_ctx__: SyncDictCtx[KT, VT]
|
|
65
120
|
__data: dict[KT, VT]
|
|
66
121
|
|
|
@@ -221,13 +276,43 @@ class SyncDict(MutableMapping[KT, VT], Reactive):
|
|
|
221
276
|
|
|
222
277
|
|
|
223
278
|
@dataclass(frozen=True)
|
|
224
|
-
class SyncListCtx(Generic[VT]
|
|
279
|
+
class SyncListCtx(Context, Generic[VT]):
|
|
225
280
|
tp: type[SyncList]
|
|
226
281
|
inner_ctx: Context | ContextMap | None
|
|
227
282
|
inner_type_adapter: TypeAdapter[VT]
|
|
228
283
|
|
|
229
284
|
|
|
230
285
|
class SyncList(MutableSequence[VT], Reactive):
|
|
286
|
+
"""A reactive list.
|
|
287
|
+
|
|
288
|
+
`SyncList` behaves like a regular `list`. Appending, replacing, inserting, and
|
|
289
|
+
deleting items trigger a write to the backing JSON file, and external changes to the
|
|
290
|
+
file are reflected in place.
|
|
291
|
+
|
|
292
|
+
When a `SyncList` holds reactive items, references are stable by position, not by
|
|
293
|
+
value. A reference to `store[0]` represents whatever is at index `0`. Inserting a
|
|
294
|
+
new element at the beginning shifts that reference to the new element, not to the
|
|
295
|
+
one that was there before.
|
|
296
|
+
|
|
297
|
+
Example:
|
|
298
|
+
```python
|
|
299
|
+
from syncwave import SyncList, Syncwave
|
|
300
|
+
|
|
301
|
+
syncwave = Syncwave()
|
|
302
|
+
sync_list = syncwave.create_store(SyncList[int], name="sync_list")
|
|
303
|
+
|
|
304
|
+
sync_list.append(1)
|
|
305
|
+
sync_list.extend([2, 3])
|
|
306
|
+
print(sync_list)
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
---
|
|
310
|
+
|
|
311
|
+
Abstract: Usage Documentation
|
|
312
|
+
[SyncList](https://placeholder.dev/usage/syncwave/)
|
|
313
|
+
|
|
314
|
+
"""
|
|
315
|
+
|
|
231
316
|
__syncwave_ctx__: SyncListCtx[VT]
|
|
232
317
|
__data: list[VT]
|
|
233
318
|
|
|
@@ -374,7 +459,7 @@ class SyncList(MutableSequence[VT], Reactive):
|
|
|
374
459
|
return len(self.__data)
|
|
375
460
|
|
|
376
461
|
@mut_atomic
|
|
377
|
-
def insert(self, index: SupportsIndex, value: VT) -> None:
|
|
462
|
+
def insert(self, index: SupportsIndex, value: VT) -> None: # noqa: D102
|
|
378
463
|
i = _get_index(index)
|
|
379
464
|
inner_ctx = self.__syncwave_ctx__.inner_ctx
|
|
380
465
|
new_item = self.__syncwave_ctx__.inner_type_adapter.validate_python(value)
|
|
@@ -426,13 +511,42 @@ def _get_index(index: Any) -> int:
|
|
|
426
511
|
|
|
427
512
|
|
|
428
513
|
@dataclass(frozen=True)
|
|
429
|
-
class SyncSetCtx(Generic[VT]
|
|
514
|
+
class SyncSetCtx(Context, Generic[VT]):
|
|
430
515
|
tp: type[SyncSet]
|
|
431
516
|
inner_ctx: None # never holds reactive items
|
|
432
517
|
inner_type_adapter: TypeAdapter[VT]
|
|
433
518
|
|
|
434
519
|
|
|
435
520
|
class SyncSet(MutableSet[VT], Reactive):
|
|
521
|
+
"""A reactive set.
|
|
522
|
+
|
|
523
|
+
`SyncSet` behaves like a regular `set`. Adding and discarding items trigger a write
|
|
524
|
+
to the backing JSON file, and external changes to the file are reflected in place.
|
|
525
|
+
|
|
526
|
+
`SyncSet` can only hold non-reactive, hashable values such as `str`, `int`, `UUID`,
|
|
527
|
+
etc. Reactive types like `SyncCollection` or `SyncModel` are mutable and therefore
|
|
528
|
+
not supported.
|
|
529
|
+
|
|
530
|
+
Example:
|
|
531
|
+
```python
|
|
532
|
+
from syncwave import SyncSet, Syncwave
|
|
533
|
+
|
|
534
|
+
syncwave = Syncwave()
|
|
535
|
+
sync_set: SyncSet[int] = syncwave.create_store(SyncSet[int], name="sync_set")
|
|
536
|
+
|
|
537
|
+
sync_set.add(1)
|
|
538
|
+
sync_set.add(2)
|
|
539
|
+
sync_set.discard(2)
|
|
540
|
+
print(sync_set)
|
|
541
|
+
```
|
|
542
|
+
|
|
543
|
+
---
|
|
544
|
+
|
|
545
|
+
Abstract: Usage Documentation
|
|
546
|
+
[SyncSet](https://placeholder.dev/usage/syncwave/)
|
|
547
|
+
|
|
548
|
+
"""
|
|
549
|
+
|
|
436
550
|
# SyncSet cannot hold reactive items because a reactive item is mutable
|
|
437
551
|
__syncwave_ctx__: SyncSetCtx[VT]
|
|
438
552
|
__data: set[VT]
|
|
@@ -490,12 +604,12 @@ class SyncSet(MutableSet[VT], Reactive):
|
|
|
490
604
|
return len(self.__data)
|
|
491
605
|
|
|
492
606
|
@mut_atomic
|
|
493
|
-
def add(self, value: VT) -> None:
|
|
607
|
+
def add(self, value: VT) -> None: # noqa: D102
|
|
494
608
|
new_item = self.__syncwave_ctx__.inner_type_adapter.validate_python(value)
|
|
495
609
|
self.__data.add(new_item)
|
|
496
610
|
|
|
497
611
|
@mut_atomic
|
|
498
|
-
def discard(self, value: VT) -> None:
|
|
612
|
+
def discard(self, value: VT) -> None: # noqa: D102
|
|
499
613
|
if value in self.__data:
|
|
500
614
|
self.__data.discard(value)
|
|
501
615
|
|
|
@@ -38,8 +38,48 @@ if TYPE_CHECKING:
|
|
|
38
38
|
# 3. classes decorated with `@pydantic.dataclasses.dataclass`.
|
|
39
39
|
SMS = Union[BaseModel, RootModel, PydanticDataclass]
|
|
40
40
|
|
|
41
|
+
__all__ = ["SyncModel", "is_sync_model_supported"]
|
|
42
|
+
|
|
41
43
|
|
|
42
44
|
def is_sync_model_supported(cls: Any) -> TypeGuard[type[SMS]]:
|
|
45
|
+
"""Return whether `cls` can be made into a `SyncModel`.
|
|
46
|
+
|
|
47
|
+
Supported classes are:
|
|
48
|
+
|
|
49
|
+
- subclasses of `pydantic.BaseModel`
|
|
50
|
+
- subclasses of `pydantic.RootModel`
|
|
51
|
+
- classes decorated with `pydantic.dataclasses.dataclass`
|
|
52
|
+
|
|
53
|
+
Standard-library dataclasses or plain Python classes are not supported.
|
|
54
|
+
|
|
55
|
+
Example:
|
|
56
|
+
```python
|
|
57
|
+
from pydantic import BaseModel
|
|
58
|
+
from syncwave import is_sync_model_supported
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class Customer(BaseModel):
|
|
62
|
+
name: str
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
is_sync_model_supported(Customer) # True
|
|
66
|
+
is_sync_model_supported(dict) # False
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
---
|
|
70
|
+
|
|
71
|
+
Abstract: Usage Documentation
|
|
72
|
+
[is_sync_model_supported](https://placeholder.dev/usage/syncwave/)
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
cls: Object to test.
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
`True` if `cls` can be passed to [Syncwave.make_reactive](https://placeholder.dev/api/syncwave/#syncwave.Syncwave.make_reactive)
|
|
79
|
+
or used with [Syncwave.register](https://placeholder.dev/api/syncwave/#syncwave.Syncwave.register),
|
|
80
|
+
and False otherwise.
|
|
81
|
+
|
|
82
|
+
"""
|
|
43
83
|
if not isclass(cls):
|
|
44
84
|
return False
|
|
45
85
|
if issubclass(cls, SyncModel):
|
|
@@ -56,6 +96,46 @@ class SyncModelCtx(Context):
|
|
|
56
96
|
|
|
57
97
|
|
|
58
98
|
class SyncModel(Reactive):
|
|
99
|
+
"""Base class for reactive models.
|
|
100
|
+
|
|
101
|
+
Instances of `SyncModel` behave just like the original model: field access and
|
|
102
|
+
assignment work exactly the same way. The difference is that every field assignment
|
|
103
|
+
triggers a write to the backing JSON file, and external changes to the file are
|
|
104
|
+
reflected in the same object.
|
|
105
|
+
|
|
106
|
+
You will rarely interact with `SyncModel` directly. Instances appear when you access
|
|
107
|
+
reactive model values from a store, and `isinstance(value, SyncModel)` is the main
|
|
108
|
+
way to check for them.
|
|
109
|
+
|
|
110
|
+
Example:
|
|
111
|
+
```python
|
|
112
|
+
from pydantic import BaseModel
|
|
113
|
+
from syncwave import SyncModel, Syncwave
|
|
114
|
+
|
|
115
|
+
syncwave = Syncwave()
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@syncwave.register(name="customers")
|
|
119
|
+
class Customer(BaseModel):
|
|
120
|
+
name: str
|
|
121
|
+
age: int
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
customers = syncwave["customers"]
|
|
125
|
+
customers.append({"name": "Alice", "age": 30})
|
|
126
|
+
alice = customers[0]
|
|
127
|
+
isinstance(alice, SyncModel) # True
|
|
128
|
+
alice.age = 31 # writes to customers.json immediately
|
|
129
|
+
print(alice) # name='Alice' age=31
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
---
|
|
133
|
+
|
|
134
|
+
Abstract: Usage Documentation
|
|
135
|
+
[SyncModel](https://placeholder.dev/usage/syncwave/)
|
|
136
|
+
|
|
137
|
+
"""
|
|
138
|
+
|
|
59
139
|
__syncwave_ctx__: SyncModelCtx
|
|
60
140
|
__syncwave_original_cls__: type[SMS]
|
|
61
141
|
|
|
@@ -220,7 +300,7 @@ class SyncModel(Reactive):
|
|
|
220
300
|
o_setattr(self, f, n)
|
|
221
301
|
|
|
222
302
|
|
|
223
|
-
def create_sync_model(cls: type[SMS], rename: bool | str = True) -> type[SyncModel]:
|
|
303
|
+
def create_sync_model(cls: type[SMS], *, rename: bool | str = True) -> type[SyncModel]:
|
|
224
304
|
cls_name = f"Sync{cls.__name__}" if rename is True else rename or cls.__name__
|
|
225
305
|
return type(
|
|
226
306
|
cls_name,
|