syncwave 0.1.4__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.
- syncwave-0.2.0/PKG-INFO +220 -0
- syncwave-0.2.0/README.md +183 -0
- {syncwave-0.1.4 → syncwave-0.2.0}/pyproject.toml +18 -9
- syncwave-0.2.0/src/syncwave/__init__.py +18 -0
- syncwave-0.2.0/src/syncwave/io.py +164 -0
- syncwave-0.2.0/src/syncwave/reactive.py +91 -0
- syncwave-0.2.0/src/syncwave/sync_collection.py +523 -0
- syncwave-0.2.0/src/syncwave/sync_model.py +229 -0
- syncwave-0.2.0/src/syncwave/syncwave.py +239 -0
- syncwave-0.2.0/src/syncwave/tp_validation.py +375 -0
- {syncwave-0.1.4 → syncwave-0.2.0}/src/syncwave/watcher.py +41 -34
- syncwave-0.1.4/PKG-INFO +0 -106
- syncwave-0.1.4/README.md +0 -70
- syncwave-0.1.4/src/syncwave/__init__.py +0 -6
- syncwave-0.1.4/src/syncwave/io.py +0 -98
- syncwave-0.1.4/src/syncwave/syncwave.py +0 -260
- {syncwave-0.1.4 → syncwave-0.2.0}/LICENSE +0 -0
- {syncwave-0.1.4 → syncwave-0.2.0}/src/syncwave/py.typed +0 -0
syncwave-0.2.0/PKG-INFO
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: syncwave
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Make your code reactive; Turn plain JSONs into a live data store, two-way synced with Python objects.
|
|
5
|
+
Keywords:
|
|
6
|
+
Author: Lucas Iannitello
|
|
7
|
+
Author-email: Lucas Iannitello <lucas.iannitello@protonmail.com>
|
|
8
|
+
License: MIT License
|
|
9
|
+
|
|
10
|
+
Copyright (c) 2025 Lucas Iannitello
|
|
11
|
+
|
|
12
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
13
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
14
|
+
in the Software without restriction, including without limitation the rights
|
|
15
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
16
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
17
|
+
furnished to do so, subject to the following conditions:
|
|
18
|
+
|
|
19
|
+
The above copyright notice and this permission notice shall be included in all
|
|
20
|
+
copies or substantial portions of the Software.
|
|
21
|
+
|
|
22
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
23
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
24
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
25
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
26
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
27
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
28
|
+
SOFTWARE.
|
|
29
|
+
Requires-Dist: pydantic>=2.12.5
|
|
30
|
+
Requires-Dist: typing-extensions>=4.15.0
|
|
31
|
+
Requires-Dist: watchdog>=6.0.0
|
|
32
|
+
Requires-Python: >=3.9
|
|
33
|
+
Project-URL: Homepage, https://github.com/iannitello/syncwave
|
|
34
|
+
Project-URL: Issues, https://github.com/iannitello/syncwave/issues
|
|
35
|
+
Project-URL: Repository, https://github.com/iannitello/syncwave
|
|
36
|
+
Description-Content-Type: text/markdown
|
|
37
|
+
|
|
38
|
+
# Syncwave
|
|
39
|
+
|
|
40
|
+
Make your code reactive; Turn plain JSONs into a live data store, two-way synced with Python objects.
|
|
41
|
+
|
|
42
|
+
> ⚠️ **Warning**
|
|
43
|
+
>
|
|
44
|
+
> Syncwave is under active development. Until version **1.0**, any minor release (`0.x`) may introduce breaking changes. Pin the exact version in production.
|
|
45
|
+
>
|
|
46
|
+
> Version **1.0** will be released when the library is stable, feature-complete, and tested.
|
|
47
|
+
|
|
48
|
+
## Installation
|
|
49
|
+
|
|
50
|
+
Install from [PyPI](https://pypi.org/project/syncwave/).
|
|
51
|
+
|
|
52
|
+
```shell
|
|
53
|
+
# pip
|
|
54
|
+
pip install syncwave
|
|
55
|
+
|
|
56
|
+
# uv
|
|
57
|
+
uv add syncwave
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Requires Python 3.9+.
|
|
61
|
+
|
|
62
|
+
## Quick Start
|
|
63
|
+
|
|
64
|
+
Bind in-memory data to a JSON file with `@syncwave.register`. _Syncwave_ automatically synchronizes the file with your in-memory objects.
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
from pydantic import BaseModel
|
|
68
|
+
|
|
69
|
+
from syncwave import Syncwave
|
|
70
|
+
|
|
71
|
+
syncwave = Syncwave()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# Register a model — creates `syncstores/customers.json` automatically.
|
|
75
|
+
# If the file already exists, its data is loaded into the store.
|
|
76
|
+
@syncwave.register(name="customers")
|
|
77
|
+
class Customer(BaseModel):
|
|
78
|
+
key: int
|
|
79
|
+
name: str
|
|
80
|
+
age: int
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
customers = syncwave["customers"]
|
|
84
|
+
|
|
85
|
+
# Add entries — dicts and model instances both work.
|
|
86
|
+
customers[1] = {"key": 1, "name": "John Doe", "age": 30}
|
|
87
|
+
customers[2] = Customer(key=2, name="Jane Doe", age=25)
|
|
88
|
+
|
|
89
|
+
# Grab a reference and mutate — changes are written to disk automatically.
|
|
90
|
+
john = customers[1]
|
|
91
|
+
john.age = 31 # syncstores/customers.json is updated
|
|
92
|
+
|
|
93
|
+
# The store is a dict-like object that can be printed.
|
|
94
|
+
print(customers) # {1: key=1 name='John Doe' age=31, 2: key=2 name='Jane Doe' age=25}
|
|
95
|
+
print(syncwave) # {'customers': ...}
|
|
96
|
+
|
|
97
|
+
# Delete an entry.
|
|
98
|
+
del customers[1] # removed from memory and from the file
|
|
99
|
+
|
|
100
|
+
# External edits propagate back.
|
|
101
|
+
input("Go edit syncstores/customers.json, then press Enter... ")
|
|
102
|
+
print(customers) # reflects the external changes
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## The Reactive System
|
|
106
|
+
|
|
107
|
+
See [`docs/reactivity.md`](docs/reactivity.md).
|
|
108
|
+
|
|
109
|
+
## API Reference
|
|
110
|
+
|
|
111
|
+
### `Syncwave`
|
|
112
|
+
|
|
113
|
+
The entry point. A `MutableMapping[str, Any]` where each key is a store name and each value is the store's data (which may or may not be reactive). Each store maps to a single JSON file.
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
from syncwave import Syncwave
|
|
117
|
+
|
|
118
|
+
syncwave = Syncwave(root_path="data")
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
- `root_path` (optional): Directory for JSON files. Defaults to `./syncstores`.
|
|
122
|
+
- `syncwave.root_path`: Read-only property returning the resolved path.
|
|
123
|
+
|
|
124
|
+
**Methods:**
|
|
125
|
+
|
|
126
|
+
- `syncwave.create_store(tp, *, name, default)` — Creates a store for a given type. The type can be anything supported by Pydantic, including reactive types. Creates or loads the associated JSON file. A `default` is required when the file is empty and the type has no natural empty form (e.g., `int`).
|
|
127
|
+
- `syncwave.make_reactive(cls, *, cls_name)` — Turns a Pydantic model into a `SyncModel` class.
|
|
128
|
+
- `@syncwave.register(*, name, collection)` — Class decorator that makes a model reactive and creates a store for it in one step. See [Collection Wrapping](#collection-wrapping) below.
|
|
129
|
+
|
|
130
|
+
Standard `MutableMapping` operations are supported: `syncwave[name]`, `syncwave[name] = value`, `del syncwave[name]`, `len(syncwave)`, iteration, etc.
|
|
131
|
+
|
|
132
|
+
### `SyncDict`, `SyncList`, `SyncSet`
|
|
133
|
+
|
|
134
|
+
Reactive counterparts of `dict`, `list`, and `set`. They behave like their standard equivalents (`MutableMapping`, `MutableSequence`, `MutableSet`) but every mutation is validated by Pydantic, persisted to disk, and propagated from disk.
|
|
135
|
+
|
|
136
|
+
They are parameterized like their standard counterparts:
|
|
137
|
+
|
|
138
|
+
```python
|
|
139
|
+
from syncwave import SyncDict, SyncList, SyncSet
|
|
140
|
+
|
|
141
|
+
syncwave.create_store(SyncDict[str, int], name="settings")
|
|
142
|
+
syncwave.create_store(SyncList[str], name="tags")
|
|
143
|
+
syncwave.create_store(SyncSet[int], name="ids")
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Reactive collections can be nested: `SyncDict[str, SyncList[int]]`, `SyncList[SyncDict[str, str]]`, etc. `SyncSet` cannot hold reactive items (since reactive objects are mutable and therefore not hashable).
|
|
147
|
+
|
|
148
|
+
`SyncCollection` is the abstract base for all three — useful for `isinstance` checks.
|
|
149
|
+
|
|
150
|
+
### `SyncModel`
|
|
151
|
+
|
|
152
|
+
A user-defined Pydantic model that Syncwave has made reactive. Attribute access and assignment are intercepted to keep the data in sync.
|
|
153
|
+
|
|
154
|
+
Created via `syncwave.make_reactive` or `@syncwave.register`. The supported model types are:
|
|
155
|
+
|
|
156
|
+
1. subclasses of `pydantic.BaseModel`,
|
|
157
|
+
2. subclasses of `pydantic.RootModel`,
|
|
158
|
+
3. classes decorated with `@pydantic.dataclasses.dataclass`.
|
|
159
|
+
|
|
160
|
+
`@syncwave.register` returns the **original class**, not the `SyncModel`. You can keep using it normally — Syncwave handles the reactive wrapping internally.
|
|
161
|
+
|
|
162
|
+
### `Reactive`
|
|
163
|
+
|
|
164
|
+
Abstract base class that all reactive types inherit from. Provides the `sync_live` property, which is `True` while the object is connected to a store and `False` after it has been killed (e.g., by deleting the store or replacing the entry). Operations on a dead reference raise `DeadReferenceError`.
|
|
165
|
+
|
|
166
|
+
```python
|
|
167
|
+
from syncwave import Reactive
|
|
168
|
+
|
|
169
|
+
ref = syncwave["customers"][1]
|
|
170
|
+
isinstance(ref, Reactive) # True
|
|
171
|
+
ref.sync_live # True
|
|
172
|
+
|
|
173
|
+
del syncwave["customers"][1]
|
|
174
|
+
ref.sync_live # False
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
### `is_sync_model_supported`
|
|
178
|
+
|
|
179
|
+
Type guard that returns `True` if a class can be made reactive (i.e., it is a `BaseModel`, `RootModel`, or Pydantic dataclass, and is not already a `SyncModel`).
|
|
180
|
+
|
|
181
|
+
```python
|
|
182
|
+
from syncwave import is_sync_model_supported
|
|
183
|
+
|
|
184
|
+
is_sync_model_supported(Customer) # True
|
|
185
|
+
is_sync_model_supported(int) # False
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
## Collection Wrapping
|
|
189
|
+
|
|
190
|
+
When using `@syncwave.register`, the `collection` parameter controls how the model is stored:
|
|
191
|
+
|
|
192
|
+
| Model shape | Wrapping | Access pattern |
|
|
193
|
+
| ----------------------- | ----------------------------- | --------------------------- |
|
|
194
|
+
| Has a `key` field | `SyncDict[<key type>, Model]` | `store[key]` |
|
|
195
|
+
| No `key` field | `SyncList[Model]` | `store[index]` |
|
|
196
|
+
| Subclass of `RootModel` | No wrapping | `syncwave["name"]` directly |
|
|
197
|
+
|
|
198
|
+
This is the default `collection="auto"` behavior. You can override it:
|
|
199
|
+
|
|
200
|
+
```python
|
|
201
|
+
from syncwave import SyncList
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
# Force a list even though the model has a `key` field.
|
|
205
|
+
@syncwave.register(name="items", collection=SyncList)
|
|
206
|
+
class Item(BaseModel):
|
|
207
|
+
key: int # ignored
|
|
208
|
+
name: str
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
# No wrapping — the store holds a single model instance.
|
|
212
|
+
@syncwave.register(name="config", collection=None)
|
|
213
|
+
class Config(BaseModel):
|
|
214
|
+
theme: str
|
|
215
|
+
debug: bool
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
## License
|
|
219
|
+
|
|
220
|
+
Syncwave is licensed under the MIT License.
|
syncwave-0.2.0/README.md
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# Syncwave
|
|
2
|
+
|
|
3
|
+
Make your code reactive; Turn plain JSONs into a live data store, two-way synced with Python objects.
|
|
4
|
+
|
|
5
|
+
> ⚠️ **Warning**
|
|
6
|
+
>
|
|
7
|
+
> Syncwave is under active development. Until version **1.0**, any minor release (`0.x`) may introduce breaking changes. Pin the exact version in production.
|
|
8
|
+
>
|
|
9
|
+
> Version **1.0** will be released when the library is stable, feature-complete, and tested.
|
|
10
|
+
|
|
11
|
+
## Installation
|
|
12
|
+
|
|
13
|
+
Install from [PyPI](https://pypi.org/project/syncwave/).
|
|
14
|
+
|
|
15
|
+
```shell
|
|
16
|
+
# pip
|
|
17
|
+
pip install syncwave
|
|
18
|
+
|
|
19
|
+
# uv
|
|
20
|
+
uv add syncwave
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Requires Python 3.9+.
|
|
24
|
+
|
|
25
|
+
## Quick Start
|
|
26
|
+
|
|
27
|
+
Bind in-memory data to a JSON file with `@syncwave.register`. _Syncwave_ automatically synchronizes the file with your in-memory objects.
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
from pydantic import BaseModel
|
|
31
|
+
|
|
32
|
+
from syncwave import Syncwave
|
|
33
|
+
|
|
34
|
+
syncwave = Syncwave()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# Register a model — creates `syncstores/customers.json` automatically.
|
|
38
|
+
# If the file already exists, its data is loaded into the store.
|
|
39
|
+
@syncwave.register(name="customers")
|
|
40
|
+
class Customer(BaseModel):
|
|
41
|
+
key: int
|
|
42
|
+
name: str
|
|
43
|
+
age: int
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
customers = syncwave["customers"]
|
|
47
|
+
|
|
48
|
+
# Add entries — dicts and model instances both work.
|
|
49
|
+
customers[1] = {"key": 1, "name": "John Doe", "age": 30}
|
|
50
|
+
customers[2] = Customer(key=2, name="Jane Doe", age=25)
|
|
51
|
+
|
|
52
|
+
# Grab a reference and mutate — changes are written to disk automatically.
|
|
53
|
+
john = customers[1]
|
|
54
|
+
john.age = 31 # syncstores/customers.json is updated
|
|
55
|
+
|
|
56
|
+
# The store is a dict-like object that can be printed.
|
|
57
|
+
print(customers) # {1: key=1 name='John Doe' age=31, 2: key=2 name='Jane Doe' age=25}
|
|
58
|
+
print(syncwave) # {'customers': ...}
|
|
59
|
+
|
|
60
|
+
# Delete an entry.
|
|
61
|
+
del customers[1] # removed from memory and from the file
|
|
62
|
+
|
|
63
|
+
# External edits propagate back.
|
|
64
|
+
input("Go edit syncstores/customers.json, then press Enter... ")
|
|
65
|
+
print(customers) # reflects the external changes
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## The Reactive System
|
|
69
|
+
|
|
70
|
+
See [`docs/reactivity.md`](docs/reactivity.md).
|
|
71
|
+
|
|
72
|
+
## API Reference
|
|
73
|
+
|
|
74
|
+
### `Syncwave`
|
|
75
|
+
|
|
76
|
+
The entry point. A `MutableMapping[str, Any]` where each key is a store name and each value is the store's data (which may or may not be reactive). Each store maps to a single JSON file.
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
from syncwave import Syncwave
|
|
80
|
+
|
|
81
|
+
syncwave = Syncwave(root_path="data")
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
- `root_path` (optional): Directory for JSON files. Defaults to `./syncstores`.
|
|
85
|
+
- `syncwave.root_path`: Read-only property returning the resolved path.
|
|
86
|
+
|
|
87
|
+
**Methods:**
|
|
88
|
+
|
|
89
|
+
- `syncwave.create_store(tp, *, name, default)` — Creates a store for a given type. The type can be anything supported by Pydantic, including reactive types. Creates or loads the associated JSON file. A `default` is required when the file is empty and the type has no natural empty form (e.g., `int`).
|
|
90
|
+
- `syncwave.make_reactive(cls, *, cls_name)` — Turns a Pydantic model into a `SyncModel` class.
|
|
91
|
+
- `@syncwave.register(*, name, collection)` — Class decorator that makes a model reactive and creates a store for it in one step. See [Collection Wrapping](#collection-wrapping) below.
|
|
92
|
+
|
|
93
|
+
Standard `MutableMapping` operations are supported: `syncwave[name]`, `syncwave[name] = value`, `del syncwave[name]`, `len(syncwave)`, iteration, etc.
|
|
94
|
+
|
|
95
|
+
### `SyncDict`, `SyncList`, `SyncSet`
|
|
96
|
+
|
|
97
|
+
Reactive counterparts of `dict`, `list`, and `set`. They behave like their standard equivalents (`MutableMapping`, `MutableSequence`, `MutableSet`) but every mutation is validated by Pydantic, persisted to disk, and propagated from disk.
|
|
98
|
+
|
|
99
|
+
They are parameterized like their standard counterparts:
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
from syncwave import SyncDict, SyncList, SyncSet
|
|
103
|
+
|
|
104
|
+
syncwave.create_store(SyncDict[str, int], name="settings")
|
|
105
|
+
syncwave.create_store(SyncList[str], name="tags")
|
|
106
|
+
syncwave.create_store(SyncSet[int], name="ids")
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Reactive collections can be nested: `SyncDict[str, SyncList[int]]`, `SyncList[SyncDict[str, str]]`, etc. `SyncSet` cannot hold reactive items (since reactive objects are mutable and therefore not hashable).
|
|
110
|
+
|
|
111
|
+
`SyncCollection` is the abstract base for all three — useful for `isinstance` checks.
|
|
112
|
+
|
|
113
|
+
### `SyncModel`
|
|
114
|
+
|
|
115
|
+
A user-defined Pydantic model that Syncwave has made reactive. Attribute access and assignment are intercepted to keep the data in sync.
|
|
116
|
+
|
|
117
|
+
Created via `syncwave.make_reactive` or `@syncwave.register`. The supported model types are:
|
|
118
|
+
|
|
119
|
+
1. subclasses of `pydantic.BaseModel`,
|
|
120
|
+
2. subclasses of `pydantic.RootModel`,
|
|
121
|
+
3. classes decorated with `@pydantic.dataclasses.dataclass`.
|
|
122
|
+
|
|
123
|
+
`@syncwave.register` returns the **original class**, not the `SyncModel`. You can keep using it normally — Syncwave handles the reactive wrapping internally.
|
|
124
|
+
|
|
125
|
+
### `Reactive`
|
|
126
|
+
|
|
127
|
+
Abstract base class that all reactive types inherit from. Provides the `sync_live` property, which is `True` while the object is connected to a store and `False` after it has been killed (e.g., by deleting the store or replacing the entry). Operations on a dead reference raise `DeadReferenceError`.
|
|
128
|
+
|
|
129
|
+
```python
|
|
130
|
+
from syncwave import Reactive
|
|
131
|
+
|
|
132
|
+
ref = syncwave["customers"][1]
|
|
133
|
+
isinstance(ref, Reactive) # True
|
|
134
|
+
ref.sync_live # True
|
|
135
|
+
|
|
136
|
+
del syncwave["customers"][1]
|
|
137
|
+
ref.sync_live # False
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
### `is_sync_model_supported`
|
|
141
|
+
|
|
142
|
+
Type guard that returns `True` if a class can be made reactive (i.e., it is a `BaseModel`, `RootModel`, or Pydantic dataclass, and is not already a `SyncModel`).
|
|
143
|
+
|
|
144
|
+
```python
|
|
145
|
+
from syncwave import is_sync_model_supported
|
|
146
|
+
|
|
147
|
+
is_sync_model_supported(Customer) # True
|
|
148
|
+
is_sync_model_supported(int) # False
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
## Collection Wrapping
|
|
152
|
+
|
|
153
|
+
When using `@syncwave.register`, the `collection` parameter controls how the model is stored:
|
|
154
|
+
|
|
155
|
+
| Model shape | Wrapping | Access pattern |
|
|
156
|
+
| ----------------------- | ----------------------------- | --------------------------- |
|
|
157
|
+
| Has a `key` field | `SyncDict[<key type>, Model]` | `store[key]` |
|
|
158
|
+
| No `key` field | `SyncList[Model]` | `store[index]` |
|
|
159
|
+
| Subclass of `RootModel` | No wrapping | `syncwave["name"]` directly |
|
|
160
|
+
|
|
161
|
+
This is the default `collection="auto"` behavior. You can override it:
|
|
162
|
+
|
|
163
|
+
```python
|
|
164
|
+
from syncwave import SyncList
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
# Force a list even though the model has a `key` field.
|
|
168
|
+
@syncwave.register(name="items", collection=SyncList)
|
|
169
|
+
class Item(BaseModel):
|
|
170
|
+
key: int # ignored
|
|
171
|
+
name: str
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
# No wrapping — the store holds a single model instance.
|
|
175
|
+
@syncwave.register(name="config", collection=None)
|
|
176
|
+
class Config(BaseModel):
|
|
177
|
+
theme: str
|
|
178
|
+
debug: bool
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
## License
|
|
182
|
+
|
|
183
|
+
Syncwave is licensed under the MIT License.
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "syncwave"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.2.0"
|
|
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" }]
|
|
7
7
|
requires-python = ">=3.9"
|
|
8
8
|
dependencies = [
|
|
9
|
-
"pydantic>=2.
|
|
9
|
+
"pydantic>=2.12.5",
|
|
10
|
+
"typing-extensions>=4.15.0",
|
|
10
11
|
"watchdog>=6.0.0",
|
|
11
12
|
]
|
|
12
13
|
license = { file = "LICENSE" }
|
|
@@ -24,22 +25,29 @@ build-backend = "uv_build"
|
|
|
24
25
|
|
|
25
26
|
[dependency-groups]
|
|
26
27
|
dev = [
|
|
27
|
-
"
|
|
28
|
+
"fastapi[standard]>=0.128.8",
|
|
29
|
+
"ruff>=0.15.4",
|
|
30
|
+
"ty>=0.0.20",
|
|
28
31
|
]
|
|
29
32
|
|
|
30
33
|
[tool.ruff.lint]
|
|
31
34
|
select = [
|
|
32
|
-
"E",
|
|
33
|
-
"F",
|
|
34
|
-
"I",
|
|
35
|
-
"B",
|
|
36
|
-
"C4",
|
|
35
|
+
"E", # pycodestyle errors
|
|
36
|
+
"F", # pyflakes
|
|
37
|
+
"I", # isort
|
|
38
|
+
"B", # flake8-bugbear
|
|
39
|
+
"C4", # flake8-comprehensions
|
|
37
40
|
"SIM", # flake8-simplify
|
|
38
41
|
"ANN", # flake8-annotations
|
|
39
42
|
"RUF", # ruff
|
|
40
|
-
"UP",
|
|
43
|
+
"UP", # pyupgrade
|
|
41
44
|
]
|
|
42
45
|
|
|
46
|
+
ignore = [
|
|
47
|
+
"ANN401"
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
|
|
43
51
|
unfixable = [
|
|
44
52
|
"F401" # disable fix for unused imports
|
|
45
53
|
]
|
|
@@ -49,3 +57,4 @@ unfixable = [
|
|
|
49
57
|
|
|
50
58
|
[tool.ruff.lint.isort]
|
|
51
59
|
required-imports = ["from __future__ import annotations"]
|
|
60
|
+
extra-standard-library = ["typing_extensions"]
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
__version__ = "0.2.0"
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
from .reactive import Reactive
|
|
5
|
+
from .sync_collection import SyncCollection, SyncDict, SyncList, SyncSet
|
|
6
|
+
from .sync_model import SyncModel, is_sync_model_supported
|
|
7
|
+
from .syncwave import Syncwave
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"Reactive",
|
|
11
|
+
"SyncCollection",
|
|
12
|
+
"SyncDict",
|
|
13
|
+
"SyncList",
|
|
14
|
+
"SyncModel",
|
|
15
|
+
"SyncSet",
|
|
16
|
+
"Syncwave",
|
|
17
|
+
"is_sync_model_supported",
|
|
18
|
+
]
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import contextlib
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from tempfile import TemporaryDirectory, mkstemp
|
|
7
|
+
from threading import Lock, Timer
|
|
8
|
+
from typing import Any, Final
|
|
9
|
+
|
|
10
|
+
from pydantic import TypeAdapter, ValidationError
|
|
11
|
+
|
|
12
|
+
from .watcher import watcher
|
|
13
|
+
|
|
14
|
+
PendingWrite = tuple[Any, TypeAdapter, Timer]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class EmptyFileType: ...
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
EmptyFile: Final = EmptyFileType()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class _IO:
|
|
24
|
+
ENCODING: Final[str] = "utf-8"
|
|
25
|
+
DUMPS_CONFIG: Final[dict[str, Any]] = {"indent": 2, "warnings": "error"}
|
|
26
|
+
DEBOUNCE_WINDOW: Final[float] = 0.05
|
|
27
|
+
|
|
28
|
+
_any_ta = TypeAdapter(Any)
|
|
29
|
+
|
|
30
|
+
def __init__(self) -> None:
|
|
31
|
+
self._lock = Lock()
|
|
32
|
+
self._pending_writes: dict[Path, PendingWrite] = {}
|
|
33
|
+
|
|
34
|
+
def file_name_guard(self, name: str) -> None:
|
|
35
|
+
# assumes name is already validated to be a non-empty string
|
|
36
|
+
if os.sep in name or (os.altsep is not None and os.altsep in name):
|
|
37
|
+
sep = repr(os.sep) + (f" or {os.altsep!r}" if os.altsep is not None else "")
|
|
38
|
+
raise ValueError(f"File name cannot contain path separators ({sep}).")
|
|
39
|
+
if Path(name).is_absolute():
|
|
40
|
+
raise ValueError("File name cannot be an absolute path.")
|
|
41
|
+
try:
|
|
42
|
+
with TemporaryDirectory() as td:
|
|
43
|
+
(Path(td) / name).mkdir()
|
|
44
|
+
except OSError as e:
|
|
45
|
+
raise ValueError(f"File name {name!r} is not valid: {e.strerror}") from None
|
|
46
|
+
|
|
47
|
+
def sanitize_path(self, path: Path | str) -> Path:
|
|
48
|
+
path_str = os.fspath(path)
|
|
49
|
+
path_str = os.path.expandvars(path_str)
|
|
50
|
+
path_str = os.path.expanduser(path_str)
|
|
51
|
+
return Path(path_str).resolve()
|
|
52
|
+
|
|
53
|
+
def create_dir(self, path: Path) -> None:
|
|
54
|
+
if path.exists() and not path.is_dir():
|
|
55
|
+
raise FileExistsError(f"Path '{path}' exists and is not a directory.")
|
|
56
|
+
try:
|
|
57
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
58
|
+
except PermissionError as e:
|
|
59
|
+
raise PermissionError(f"Permission denied to create dir '{path}'.") from e
|
|
60
|
+
except OSError as e:
|
|
61
|
+
raise OSError(f"Unable to create dir '{path}'.") from e
|
|
62
|
+
|
|
63
|
+
def create_file(self, path: Path) -> None:
|
|
64
|
+
self.create_dir(path.parent)
|
|
65
|
+
if path.exists() and not path.is_file():
|
|
66
|
+
raise FileExistsError(f"Path '{path}' exists and is not a file.")
|
|
67
|
+
try:
|
|
68
|
+
path.touch()
|
|
69
|
+
except PermissionError as e:
|
|
70
|
+
raise PermissionError(f"Permission denied to create file '{path}'.") from e
|
|
71
|
+
except OSError as e:
|
|
72
|
+
raise OSError(f"Unable to create file '{path}'.") from e
|
|
73
|
+
|
|
74
|
+
def remove_file(self, path: Path) -> None:
|
|
75
|
+
if path.exists() and not path.is_file():
|
|
76
|
+
raise OSError(f"Path '{path}' exists but is not a regular file.")
|
|
77
|
+
path.unlink(missing_ok=True)
|
|
78
|
+
|
|
79
|
+
def init_json(self, path: Path, ta: TypeAdapter = _any_ta) -> Any | EmptyFileType:
|
|
80
|
+
self.create_file(path)
|
|
81
|
+
content = path.read_text(encoding=self.ENCODING).strip()
|
|
82
|
+
if content == "":
|
|
83
|
+
default = self._get_default(ta)
|
|
84
|
+
if default is not EmptyFile:
|
|
85
|
+
self._atomic_write(path, self._serialize(default, ta))
|
|
86
|
+
return default
|
|
87
|
+
init_value = self._deserialize(content, ta, path)
|
|
88
|
+
self._atomic_write(path, self._serialize(init_value, ta))
|
|
89
|
+
return init_value
|
|
90
|
+
|
|
91
|
+
def read_json(self, path: Path, ta: TypeAdapter = _any_ta) -> Any:
|
|
92
|
+
# never returns EmptyFile, it throws an error if the file is empty
|
|
93
|
+
with self._lock:
|
|
94
|
+
if path in self._pending_writes:
|
|
95
|
+
value, previous_ta, _ = self._pending_writes[path]
|
|
96
|
+
if previous_ta is ta:
|
|
97
|
+
return value
|
|
98
|
+
text = self._serialize(value, previous_ta)
|
|
99
|
+
return self._deserialize(text, ta, path)
|
|
100
|
+
text = path.read_text(encoding=self.ENCODING).strip()
|
|
101
|
+
return self._deserialize(text, ta, path)
|
|
102
|
+
|
|
103
|
+
def write_json(self, path: Path, value: Any, ta: TypeAdapter = _any_ta) -> None:
|
|
104
|
+
with self._lock:
|
|
105
|
+
if path in self._pending_writes:
|
|
106
|
+
self._pending_writes[path][2].cancel()
|
|
107
|
+
timer = Timer(self.DEBOUNCE_WINDOW, self._scheduled_write, args=(path,))
|
|
108
|
+
self._pending_writes[path] = (value, ta, timer)
|
|
109
|
+
timer.start()
|
|
110
|
+
|
|
111
|
+
def _scheduled_write(self, path: Path) -> None:
|
|
112
|
+
with self._lock:
|
|
113
|
+
if path not in self._pending_writes:
|
|
114
|
+
return # defensive check, should never happen
|
|
115
|
+
value, ta, _ = self._pending_writes.pop(path)
|
|
116
|
+
self._atomic_write(path, self._serialize(value, ta))
|
|
117
|
+
|
|
118
|
+
def _atomic_write(self, path: Path, text: str) -> None:
|
|
119
|
+
fd, tmp_path = mkstemp(prefix=watcher.TMP_FILE_PREFIX, dir=path.parent)
|
|
120
|
+
try:
|
|
121
|
+
with os.fdopen(fd, "w", encoding=self.ENCODING) as tmp_file:
|
|
122
|
+
tmp_file.write(text)
|
|
123
|
+
tmp_file.write("\n")
|
|
124
|
+
tmp_file.flush()
|
|
125
|
+
os.fsync(tmp_file.fileno())
|
|
126
|
+
|
|
127
|
+
# Atomically replace the target file
|
|
128
|
+
watcher.mark_self_write(path)
|
|
129
|
+
os.replace(tmp_path, path)
|
|
130
|
+
# print(f"Wrote JSON to {path}")
|
|
131
|
+
|
|
132
|
+
except Exception as e:
|
|
133
|
+
# Attempt to remove temporary file on error
|
|
134
|
+
with contextlib.suppress(OSError):
|
|
135
|
+
os.remove(tmp_path)
|
|
136
|
+
raise OSError(
|
|
137
|
+
f"Failed to write JSON to '{path}'. "
|
|
138
|
+
f"Temporary file '{tmp_path}' removed. Original error: {e}"
|
|
139
|
+
) from e
|
|
140
|
+
|
|
141
|
+
def _serialize(self, value: Any, ta: TypeAdapter) -> str:
|
|
142
|
+
return ta.dump_json(value, **self.DUMPS_CONFIG).decode(self.ENCODING)
|
|
143
|
+
|
|
144
|
+
def _deserialize(self, text: str, ta: TypeAdapter, path: Path) -> Any:
|
|
145
|
+
try:
|
|
146
|
+
return ta.validate_json(text)
|
|
147
|
+
except ValidationError as e:
|
|
148
|
+
try:
|
|
149
|
+
self._any_ta.validate_json(text)
|
|
150
|
+
except ValidationError:
|
|
151
|
+
raise ValueError(f"File '{path}' contains malformed JSON.") from None
|
|
152
|
+
raise ValueError(f"File '{path}' contains unexpected data type.") from e
|
|
153
|
+
|
|
154
|
+
def _get_default(self, ta: TypeAdapter) -> Any | EmptyFileType:
|
|
155
|
+
defaults = [{}, [], "", None]
|
|
156
|
+
for default in defaults:
|
|
157
|
+
try:
|
|
158
|
+
return ta.validate_python(default)
|
|
159
|
+
except ValidationError:
|
|
160
|
+
continue
|
|
161
|
+
return EmptyFile
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
io = _IO()
|