syncwave 0.1.2__tar.gz → 0.1.4__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.1.2 → syncwave-0.1.4}/PKG-INFO +1 -1
- {syncwave-0.1.2 → syncwave-0.1.4}/pyproject.toml +18 -2
- {syncwave-0.1.2 → syncwave-0.1.4}/src/syncwave/__init__.py +1 -1
- {syncwave-0.1.2 → syncwave-0.1.4}/src/syncwave/io.py +12 -10
- {syncwave-0.1.2 → syncwave-0.1.4}/src/syncwave/syncwave.py +98 -61
- {syncwave-0.1.2 → syncwave-0.1.4}/src/syncwave/watcher.py +7 -7
- {syncwave-0.1.2 → syncwave-0.1.4}/LICENSE +0 -0
- {syncwave-0.1.2 → syncwave-0.1.4}/README.md +0 -0
- {syncwave-0.1.2 → syncwave-0.1.4}/src/syncwave/py.typed +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "syncwave"
|
|
3
|
-
version = "0.1.
|
|
3
|
+
version = "0.1.4"
|
|
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" }]
|
|
@@ -28,8 +28,24 @@ dev = [
|
|
|
28
28
|
]
|
|
29
29
|
|
|
30
30
|
[tool.ruff.lint]
|
|
31
|
-
select = [
|
|
31
|
+
select = [
|
|
32
|
+
"E", # pycodestyle errors
|
|
33
|
+
"F", # pyflakes
|
|
34
|
+
"I", # isort
|
|
35
|
+
"B", # flake8-bugbear
|
|
36
|
+
"C4", # flake8-comprehensions
|
|
37
|
+
"SIM", # flake8-simplify
|
|
38
|
+
"ANN", # flake8-annotations
|
|
39
|
+
"RUF", # ruff
|
|
40
|
+
"UP", # pyupgrade
|
|
41
|
+
]
|
|
32
42
|
|
|
33
43
|
unfixable = [
|
|
34
44
|
"F401" # disable fix for unused imports
|
|
35
45
|
]
|
|
46
|
+
|
|
47
|
+
[tool.ruff.lint.per-file-ignores]
|
|
48
|
+
"__init__.py" = ["I002"]
|
|
49
|
+
|
|
50
|
+
[tool.ruff.lint.isort]
|
|
51
|
+
required-imports = ["from __future__ import annotations"]
|
|
@@ -1,9 +1,12 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import contextlib
|
|
1
4
|
import json
|
|
2
5
|
import os
|
|
3
6
|
from pathlib import Path
|
|
4
7
|
from tempfile import mkstemp
|
|
5
8
|
from threading import Lock, Timer
|
|
6
|
-
from typing import Any, Callable
|
|
9
|
+
from typing import Any, Callable, Final
|
|
7
10
|
|
|
8
11
|
from .watcher import watcher
|
|
9
12
|
|
|
@@ -12,10 +15,10 @@ DataProvider = Callable[[], JSONData]
|
|
|
12
15
|
|
|
13
16
|
|
|
14
17
|
class _IO:
|
|
15
|
-
ENCODING = "utf-8"
|
|
16
|
-
DEFAULT_JSON_CONTENT = {}
|
|
17
|
-
DUMPS_CONFIG = {"indent": 2, "ensure_ascii": False}
|
|
18
|
-
DEBOUNCE_WINDOW = 0.05
|
|
18
|
+
ENCODING: Final[str] = "utf-8"
|
|
19
|
+
DEFAULT_JSON_CONTENT: Final[JSONData] = {}
|
|
20
|
+
DUMPS_CONFIG: Final[dict[str, Any]] = {"indent": 2, "ensure_ascii": False}
|
|
21
|
+
DEBOUNCE_WINDOW: Final[float] = 0.05
|
|
19
22
|
|
|
20
23
|
def __init__(self) -> None:
|
|
21
24
|
self._lock = Lock()
|
|
@@ -37,7 +40,7 @@ class _IO:
|
|
|
37
40
|
with path.open(encoding=self.ENCODING) as f:
|
|
38
41
|
json.load(f)
|
|
39
42
|
except json.JSONDecodeError as e:
|
|
40
|
-
raise
|
|
43
|
+
raise OSError(f"File '{path}' exists but is not a valid JSON file.") from e
|
|
41
44
|
|
|
42
45
|
def read_json(self, path: Path) -> JSONData:
|
|
43
46
|
with self._lock:
|
|
@@ -79,15 +82,14 @@ class _IO:
|
|
|
79
82
|
# Atomically replace the target file
|
|
80
83
|
watcher.mark_self_write(path)
|
|
81
84
|
os.replace(tmp_path, path)
|
|
85
|
+
# print(f"Wrote JSON to {path}")
|
|
82
86
|
|
|
83
87
|
except Exception as e:
|
|
84
88
|
# Attempt to remove temporary file on error
|
|
85
|
-
|
|
89
|
+
with contextlib.suppress(OSError):
|
|
86
90
|
os.remove(tmp_path)
|
|
87
|
-
except OSError:
|
|
88
|
-
pass
|
|
89
91
|
# Raise a clear error message including context
|
|
90
|
-
raise
|
|
92
|
+
raise OSError(
|
|
91
93
|
f"Failed to write JSON to '{path}'. "
|
|
92
94
|
f"Temporary file '{tmp_path}' removed. Original error: {e}"
|
|
93
95
|
) from e
|
|
@@ -1,11 +1,13 @@
|
|
|
1
|
-
from
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterator, Mapping
|
|
2
4
|
from inspect import isclass
|
|
3
5
|
from json import JSONDecodeError
|
|
4
6
|
from pathlib import Path
|
|
5
7
|
from threading import RLock
|
|
6
|
-
from typing import Any, Callable,
|
|
8
|
+
from typing import Any, Callable, Union, get_args, overload
|
|
7
9
|
|
|
8
|
-
from pydantic import BaseModel, ValidationError
|
|
10
|
+
from pydantic import BaseModel, ValidationError, create_model, model_validator
|
|
9
11
|
|
|
10
12
|
from .io import io
|
|
11
13
|
from .watcher import watcher
|
|
@@ -14,7 +16,7 @@ from .watcher import watcher
|
|
|
14
16
|
JSONKey = Union[str, int, float, bool, None]
|
|
15
17
|
|
|
16
18
|
|
|
17
|
-
def _validate_key_type(cls:
|
|
19
|
+
def _validate_key_type(cls: type[BaseModel], key: str) -> None:
|
|
18
20
|
# This will work if the key field is a basic type (str, int, float, bool, or None),
|
|
19
21
|
# but will fail for more complex types (Union[str, int], Optional[str], etc.).
|
|
20
22
|
# In that case skip the validation.
|
|
@@ -31,39 +33,46 @@ def _validate_key_type(cls: Type[BaseModel], key: str) -> None:
|
|
|
31
33
|
class SyncStore(Mapping[JSONKey, BaseModel]):
|
|
32
34
|
def __init__(
|
|
33
35
|
self,
|
|
34
|
-
syncwave:
|
|
35
|
-
cls:
|
|
36
|
+
syncwave: Syncwave,
|
|
37
|
+
cls: type[BaseModel],
|
|
38
|
+
/,
|
|
39
|
+
name: str | None = None,
|
|
40
|
+
key: str | None = None,
|
|
36
41
|
*,
|
|
37
|
-
name: Optional[str] = None,
|
|
38
|
-
key: str,
|
|
39
42
|
skip_key_validation: bool = False,
|
|
40
|
-
file_name:
|
|
41
|
-
sub_dir:
|
|
42
|
-
file_path:
|
|
43
|
+
file_name: str | None = None,
|
|
44
|
+
sub_dir: Path | str | None = None,
|
|
45
|
+
file_path: Path | str | None = None,
|
|
46
|
+
new_cls_name: str | None = None,
|
|
43
47
|
# in_memory: bool = True, # TODO: implement in_memory
|
|
44
48
|
) -> None:
|
|
45
49
|
if not isinstance(syncwave, Syncwave):
|
|
46
|
-
raise
|
|
50
|
+
raise TypeError("The first argument must be an instance of Syncwave.")
|
|
47
51
|
if not (isclass(cls) and issubclass(cls, BaseModel)):
|
|
48
|
-
raise
|
|
52
|
+
raise TypeError("Only subclasses of Pydantic's BaseModel can be used.")
|
|
53
|
+
# `store.cls` is created with `create_model`, with `cls` as its only base class
|
|
54
|
+
# in other words, this checks that `cls` has not been registered already
|
|
55
|
+
if any(store.cls.__bases__[0] is cls for store in syncwave.values()):
|
|
56
|
+
raise ValueError(f"The class '{cls.__name__}' has already been registered.")
|
|
57
|
+
# `name` defaults to the lowercased class name if not provided
|
|
49
58
|
if (name_ := name or cls.__name__.lower()) in syncwave:
|
|
50
|
-
raise ValueError(f"The name '{name_}'
|
|
51
|
-
if
|
|
52
|
-
raise ValueError(f"
|
|
53
|
-
|
|
54
|
-
|
|
59
|
+
raise ValueError(f"The name '{name_}' has already been registered.")
|
|
60
|
+
if not cls.model_fields:
|
|
61
|
+
raise ValueError(f"Class '{cls.__name__}' has no fields defined.")
|
|
62
|
+
# `key` defaults to the first field in the model if not provided
|
|
63
|
+
if (key_ := key or next(iter(cls.model_fields))) not in cls.model_fields:
|
|
64
|
+
raise ValueError(f"'{cls.__name__}' does not have a field named '{key_}'.")
|
|
55
65
|
if not skip_key_validation:
|
|
56
|
-
_validate_key_type(cls,
|
|
66
|
+
_validate_key_type(cls, key_)
|
|
57
67
|
|
|
58
68
|
self.syncwave = syncwave
|
|
59
|
-
self.cls = cls
|
|
69
|
+
self.cls = self._patch_cls(cls, new_cls_name or f"Syncwave{cls.__name__}")
|
|
60
70
|
self.name = name_
|
|
61
|
-
self.key =
|
|
71
|
+
self.key = key_
|
|
62
72
|
self.path = self._get_path(file_name or name_, sub_dir, file_path)
|
|
63
73
|
|
|
64
74
|
self._ssd: dict[JSONKey, BaseModel] = {} # actual mapping, ssd = SyncStoreDict
|
|
65
75
|
self._lock = RLock()
|
|
66
|
-
self._patch_cls()
|
|
67
76
|
io.init_json_file(self.path)
|
|
68
77
|
self.load()
|
|
69
78
|
watcher.watch(self.path, self.load)
|
|
@@ -75,7 +84,7 @@ class SyncStore(Mapping[JSONKey, BaseModel]):
|
|
|
75
84
|
|
|
76
85
|
def __iter__(self) -> Iterator[JSONKey]:
|
|
77
86
|
with self._lock:
|
|
78
|
-
# first
|
|
87
|
+
# first convert to a list so the iterator is over a frozen object
|
|
79
88
|
return iter(list(self._ssd))
|
|
80
89
|
|
|
81
90
|
def __len__(self) -> int:
|
|
@@ -91,10 +100,7 @@ class SyncStore(Mapping[JSONKey, BaseModel]):
|
|
|
91
100
|
|
|
92
101
|
def to_dict(self) -> dict[JSONKey, dict[str, Any]]:
|
|
93
102
|
with self._lock:
|
|
94
|
-
return {
|
|
95
|
-
key: model_instance.model_dump()
|
|
96
|
-
for key, model_instance in self._ssd.items()
|
|
97
|
-
}
|
|
103
|
+
return {key: instance.model_dump() for key, instance in self._ssd.items()}
|
|
98
104
|
|
|
99
105
|
def delete(self, key: JSONKey) -> None:
|
|
100
106
|
with self._lock:
|
|
@@ -119,8 +125,8 @@ class SyncStore(Mapping[JSONKey, BaseModel]):
|
|
|
119
125
|
def _get_path(
|
|
120
126
|
self,
|
|
121
127
|
file_name: str,
|
|
122
|
-
sub_dir:
|
|
123
|
-
file_path:
|
|
128
|
+
sub_dir: Path | str | None,
|
|
129
|
+
file_path: Path | str | None,
|
|
124
130
|
) -> Path:
|
|
125
131
|
if file_path:
|
|
126
132
|
return Path(file_path).resolve()
|
|
@@ -129,45 +135,57 @@ class SyncStore(Mapping[JSONKey, BaseModel]):
|
|
|
129
135
|
return (self.syncwave.data_dir / sub_dir / file_name).resolve()
|
|
130
136
|
return (self.syncwave.data_dir / file_name).resolve()
|
|
131
137
|
|
|
132
|
-
def _patch_cls(self) ->
|
|
133
|
-
#
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
original_init(model_instance, *args, **kwargs)
|
|
140
|
-
key_value = getattr(model_instance, self.key)
|
|
138
|
+
def _patch_cls(self, cls: type[BaseModel], new_cls_name: str) -> type[BaseModel]:
|
|
139
|
+
# This is a validator that acts as a post-initialization hook.
|
|
140
|
+
# Using a validator instead of `__init__` ensures every instance is tracked.
|
|
141
|
+
# Libraries such as FastAPI may create instances indirectly with methods like
|
|
142
|
+
# `validate_python` or `model_validate` and `__init__` may never be called.
|
|
143
|
+
def init(self_model: BaseModel) -> BaseModel:
|
|
144
|
+
key_value = getattr(self_model, self.key)
|
|
141
145
|
with self._lock:
|
|
142
|
-
self._ssd[key_value]
|
|
146
|
+
if key_value in self._ssd and self._ssd[key_value] == self_model:
|
|
147
|
+
return self_model # no changes, no need to write
|
|
148
|
+
self._ssd[key_value] = self_model
|
|
143
149
|
io.write_json(self.path, self.to_dict)
|
|
150
|
+
return self_model
|
|
151
|
+
|
|
152
|
+
new_cls = create_model(
|
|
153
|
+
new_cls_name,
|
|
154
|
+
__base__=cls,
|
|
155
|
+
__validators__={"__syncwave_init__": model_validator(mode="after")(init)},
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
# preserve original methods
|
|
159
|
+
original_setattr = cls.__setattr__
|
|
160
|
+
original_delattr = cls.__delattr__
|
|
144
161
|
|
|
145
|
-
def __setattr__(
|
|
162
|
+
def __setattr__(self_model: BaseModel, attr: str, value: Any) -> None: # noqa: ANN401
|
|
146
163
|
# TODO check for edge cases
|
|
147
|
-
old_value = getattr(
|
|
148
|
-
original_setattr(
|
|
164
|
+
old_value = getattr(self_model, attr)
|
|
165
|
+
original_setattr(self_model, attr, value)
|
|
149
166
|
with self._lock:
|
|
150
167
|
if attr == self.key:
|
|
151
168
|
del self._ssd[old_value]
|
|
152
|
-
self._ssd[value] =
|
|
169
|
+
self._ssd[value] = self_model
|
|
153
170
|
io.write_json(self.path, self.to_dict)
|
|
154
171
|
|
|
155
|
-
def __delattr__(
|
|
156
|
-
old_value = getattr(
|
|
157
|
-
original_delattr(
|
|
172
|
+
def __delattr__(self_model: BaseModel, attr: str) -> None:
|
|
173
|
+
old_value = getattr(self_model, attr)
|
|
174
|
+
original_delattr(self_model, attr)
|
|
158
175
|
with self._lock:
|
|
159
176
|
if attr == self.key:
|
|
160
177
|
del self._ssd[old_value]
|
|
161
178
|
io.write_json(self.path, self.to_dict)
|
|
162
179
|
|
|
163
|
-
#
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
180
|
+
# assign the new methods
|
|
181
|
+
new_cls.__setattr__ = __setattr__
|
|
182
|
+
new_cls.__delattr__ = __delattr__
|
|
183
|
+
|
|
184
|
+
return new_cls
|
|
167
185
|
|
|
168
186
|
|
|
169
187
|
class Syncwave(Mapping[str, SyncStore]):
|
|
170
|
-
def __init__(self, data_dir:
|
|
188
|
+
def __init__(self, data_dir: str | Path) -> None:
|
|
171
189
|
self.data_dir = Path(data_dir).resolve()
|
|
172
190
|
self._swd: dict[str, SyncStore] = {} # actual mapping, swd = SyncWaveDict
|
|
173
191
|
|
|
@@ -196,19 +214,33 @@ class Syncwave(Mapping[str, SyncStore]):
|
|
|
196
214
|
def stop(self) -> None:
|
|
197
215
|
watcher.stop()
|
|
198
216
|
|
|
217
|
+
@overload
|
|
218
|
+
def register(
|
|
219
|
+
self,
|
|
220
|
+
*,
|
|
221
|
+
name: str | None = None,
|
|
222
|
+
key: str | None = None,
|
|
223
|
+
skip_key_validation: bool = False,
|
|
224
|
+
file_name: str | None = None,
|
|
225
|
+
sub_dir: Path | str | None = None,
|
|
226
|
+
file_path: Path | str | None = None,
|
|
227
|
+
) -> Callable[[type[BaseModel]], type[BaseModel]]: ...
|
|
228
|
+
|
|
199
229
|
def register(
|
|
200
230
|
self,
|
|
231
|
+
_cls: type[BaseModel] | None = None,
|
|
232
|
+
/,
|
|
201
233
|
*,
|
|
202
|
-
name:
|
|
203
|
-
key: str,
|
|
234
|
+
name: str | None = None,
|
|
235
|
+
key: str | None = None,
|
|
204
236
|
skip_key_validation: bool = False,
|
|
205
|
-
file_name:
|
|
206
|
-
sub_dir:
|
|
207
|
-
file_path:
|
|
237
|
+
file_name: str | None = None,
|
|
238
|
+
sub_dir: Path | str | None = None,
|
|
239
|
+
file_path: Path | str | None = None,
|
|
208
240
|
# in_memory: bool = True, # TODO: implement in_memory
|
|
209
|
-
) -> Callable[[
|
|
210
|
-
def decorator(cls:
|
|
211
|
-
SyncStore(
|
|
241
|
+
) -> type[BaseModel] | Callable[[type[BaseModel]], type[BaseModel]]:
|
|
242
|
+
def decorator(cls: type[BaseModel]) -> type[BaseModel]:
|
|
243
|
+
store = SyncStore(
|
|
212
244
|
self,
|
|
213
245
|
cls,
|
|
214
246
|
name=name,
|
|
@@ -217,7 +249,12 @@ class Syncwave(Mapping[str, SyncStore]):
|
|
|
217
249
|
file_name=file_name,
|
|
218
250
|
sub_dir=sub_dir,
|
|
219
251
|
file_path=file_path,
|
|
252
|
+
new_cls_name=cls.__name__,
|
|
220
253
|
)
|
|
221
|
-
return cls
|
|
254
|
+
return store.cls
|
|
222
255
|
|
|
223
|
-
|
|
256
|
+
if _cls is None:
|
|
257
|
+
# called with parenthesis: @syncwave.register(...)
|
|
258
|
+
return decorator
|
|
259
|
+
# called without parenthesis: @syncwave.register
|
|
260
|
+
return decorator(_cls)
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
1
3
|
from pathlib import Path
|
|
2
4
|
from threading import Lock, Timer
|
|
3
5
|
from time import monotonic, sleep
|
|
4
|
-
from typing import Callable
|
|
6
|
+
from typing import Callable, Final
|
|
5
7
|
|
|
6
8
|
from watchdog.events import (
|
|
7
9
|
DirDeletedEvent,
|
|
@@ -18,7 +20,7 @@ Callback = Callable[[], None]
|
|
|
18
20
|
|
|
19
21
|
|
|
20
22
|
class _Watcher:
|
|
21
|
-
TMP_FILE_PREFIX = ".tmp_"
|
|
23
|
+
TMP_FILE_PREFIX: Final[str] = ".tmp_"
|
|
22
24
|
|
|
23
25
|
def __init__(self) -> None:
|
|
24
26
|
self._lock = Lock()
|
|
@@ -96,8 +98,8 @@ class _Watcher:
|
|
|
96
98
|
|
|
97
99
|
|
|
98
100
|
class _EventHandler(FileSystemEventHandler):
|
|
99
|
-
DEBOUNCE_WINDOW = 0.05
|
|
100
|
-
SELF_WRITE_WINDOW = 0.5
|
|
101
|
+
DEBOUNCE_WINDOW: Final[float] = 0.05
|
|
102
|
+
SELF_WRITE_WINDOW: Final[float] = 0.5
|
|
101
103
|
|
|
102
104
|
def __init__(self, watcher: _Watcher) -> None:
|
|
103
105
|
self._watcher = watcher
|
|
@@ -121,9 +123,7 @@ class _EventHandler(FileSystemEventHandler):
|
|
|
121
123
|
def _is_self_write(self, file_path: FilePath) -> bool:
|
|
122
124
|
# not protected by the lock because it's only called internally
|
|
123
125
|
ts = self._self_writes_ts.get(file_path)
|
|
124
|
-
|
|
125
|
-
return True
|
|
126
|
-
return False
|
|
126
|
+
return ts is not None and monotonic() - ts < self.SELF_WRITE_WINDOW
|
|
127
127
|
|
|
128
128
|
def dispatch(self, event: FileSystemEvent) -> None:
|
|
129
129
|
if event.is_directory:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|