syncwave 0.1.3__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.3 → syncwave-0.1.4}/PKG-INFO +1 -1
- {syncwave-0.1.3 → syncwave-0.1.4}/pyproject.toml +18 -2
- {syncwave-0.1.3 → syncwave-0.1.4}/src/syncwave/__init__.py +1 -1
- {syncwave-0.1.3 → syncwave-0.1.4}/src/syncwave/io.py +11 -10
- {syncwave-0.1.3 → syncwave-0.1.4}/src/syncwave/syncwave.py +32 -30
- {syncwave-0.1.3 → syncwave-0.1.4}/src/syncwave/watcher.py +7 -7
- {syncwave-0.1.3 → syncwave-0.1.4}/LICENSE +0 -0
- {syncwave-0.1.3 → syncwave-0.1.4}/README.md +0 -0
- {syncwave-0.1.3 → 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:
|
|
@@ -83,12 +86,10 @@ class _IO:
|
|
|
83
86
|
|
|
84
87
|
except Exception as e:
|
|
85
88
|
# Attempt to remove temporary file on error
|
|
86
|
-
|
|
89
|
+
with contextlib.suppress(OSError):
|
|
87
90
|
os.remove(tmp_path)
|
|
88
|
-
except OSError:
|
|
89
|
-
pass
|
|
90
91
|
# Raise a clear error message including context
|
|
91
|
-
raise
|
|
92
|
+
raise OSError(
|
|
92
93
|
f"Failed to write JSON to '{path}'. "
|
|
93
94
|
f"Temporary file '{tmp_path}' removed. Original error: {e}"
|
|
94
95
|
) from e
|
|
@@ -1,9 +1,11 @@
|
|
|
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
10
|
from pydantic import BaseModel, ValidationError, create_model, model_validator
|
|
9
11
|
|
|
@@ -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,17 +33,17 @@ 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],
|
|
36
38
|
/,
|
|
37
|
-
name:
|
|
38
|
-
key:
|
|
39
|
+
name: str | None = None,
|
|
40
|
+
key: str | None = None,
|
|
39
41
|
*,
|
|
40
42
|
skip_key_validation: bool = False,
|
|
41
|
-
file_name:
|
|
42
|
-
sub_dir:
|
|
43
|
-
file_path:
|
|
44
|
-
new_cls_name:
|
|
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,
|
|
45
47
|
# in_memory: bool = True, # TODO: implement in_memory
|
|
46
48
|
) -> None:
|
|
47
49
|
if not isinstance(syncwave, Syncwave):
|
|
@@ -123,8 +125,8 @@ class SyncStore(Mapping[JSONKey, BaseModel]):
|
|
|
123
125
|
def _get_path(
|
|
124
126
|
self,
|
|
125
127
|
file_name: str,
|
|
126
|
-
sub_dir:
|
|
127
|
-
file_path:
|
|
128
|
+
sub_dir: Path | str | None,
|
|
129
|
+
file_path: Path | str | None,
|
|
128
130
|
) -> Path:
|
|
129
131
|
if file_path:
|
|
130
132
|
return Path(file_path).resolve()
|
|
@@ -133,7 +135,7 @@ class SyncStore(Mapping[JSONKey, BaseModel]):
|
|
|
133
135
|
return (self.syncwave.data_dir / sub_dir / file_name).resolve()
|
|
134
136
|
return (self.syncwave.data_dir / file_name).resolve()
|
|
135
137
|
|
|
136
|
-
def _patch_cls(self, cls:
|
|
138
|
+
def _patch_cls(self, cls: type[BaseModel], new_cls_name: str) -> type[BaseModel]:
|
|
137
139
|
# This is a validator that acts as a post-initialization hook.
|
|
138
140
|
# Using a validator instead of `__init__` ensures every instance is tracked.
|
|
139
141
|
# Libraries such as FastAPI may create instances indirectly with methods like
|
|
@@ -157,7 +159,7 @@ class SyncStore(Mapping[JSONKey, BaseModel]):
|
|
|
157
159
|
original_setattr = cls.__setattr__
|
|
158
160
|
original_delattr = cls.__delattr__
|
|
159
161
|
|
|
160
|
-
def __setattr__(self_model: BaseModel, attr: str, value: Any) -> None:
|
|
162
|
+
def __setattr__(self_model: BaseModel, attr: str, value: Any) -> None: # noqa: ANN401
|
|
161
163
|
# TODO check for edge cases
|
|
162
164
|
old_value = getattr(self_model, attr)
|
|
163
165
|
original_setattr(self_model, attr, value)
|
|
@@ -183,7 +185,7 @@ class SyncStore(Mapping[JSONKey, BaseModel]):
|
|
|
183
185
|
|
|
184
186
|
|
|
185
187
|
class Syncwave(Mapping[str, SyncStore]):
|
|
186
|
-
def __init__(self, data_dir:
|
|
188
|
+
def __init__(self, data_dir: str | Path) -> None:
|
|
187
189
|
self.data_dir = Path(data_dir).resolve()
|
|
188
190
|
self._swd: dict[str, SyncStore] = {} # actual mapping, swd = SyncWaveDict
|
|
189
191
|
|
|
@@ -216,28 +218,28 @@ class Syncwave(Mapping[str, SyncStore]):
|
|
|
216
218
|
def register(
|
|
217
219
|
self,
|
|
218
220
|
*,
|
|
219
|
-
name:
|
|
220
|
-
key:
|
|
221
|
+
name: str | None = None,
|
|
222
|
+
key: str | None = None,
|
|
221
223
|
skip_key_validation: bool = False,
|
|
222
|
-
file_name:
|
|
223
|
-
sub_dir:
|
|
224
|
-
file_path:
|
|
225
|
-
) -> Callable[[
|
|
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]]: ...
|
|
226
228
|
|
|
227
229
|
def register(
|
|
228
230
|
self,
|
|
229
|
-
_cls:
|
|
231
|
+
_cls: type[BaseModel] | None = None,
|
|
230
232
|
/,
|
|
231
233
|
*,
|
|
232
|
-
name:
|
|
233
|
-
key:
|
|
234
|
+
name: str | None = None,
|
|
235
|
+
key: str | None = None,
|
|
234
236
|
skip_key_validation: bool = False,
|
|
235
|
-
file_name:
|
|
236
|
-
sub_dir:
|
|
237
|
-
file_path:
|
|
237
|
+
file_name: str | None = None,
|
|
238
|
+
sub_dir: Path | str | None = None,
|
|
239
|
+
file_path: Path | str | None = None,
|
|
238
240
|
# in_memory: bool = True, # TODO: implement in_memory
|
|
239
|
-
) ->
|
|
240
|
-
def decorator(cls:
|
|
241
|
+
) -> type[BaseModel] | Callable[[type[BaseModel]], type[BaseModel]]:
|
|
242
|
+
def decorator(cls: type[BaseModel]) -> type[BaseModel]:
|
|
241
243
|
store = SyncStore(
|
|
242
244
|
self,
|
|
243
245
|
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
|