syncwave 0.1.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.1.0/LICENSE +21 -0
- syncwave-0.1.0/PKG-INFO +106 -0
- syncwave-0.1.0/README.md +69 -0
- syncwave-0.1.0/pyproject.toml +32 -0
- syncwave-0.1.0/src/syncwave/__init__.py +6 -0
- syncwave-0.1.0/src/syncwave/io.py +98 -0
- syncwave-0.1.0/src/syncwave/py.typed +0 -0
- syncwave-0.1.0/src/syncwave/syncwave.py +223 -0
- syncwave-0.1.0/src/syncwave/watcher.py +139 -0
syncwave-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Lucas Iannitello
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
syncwave-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: syncwave
|
|
3
|
+
Version: 0.1.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.11.7
|
|
30
|
+
Requires-Dist: watchdog>=6.0.0
|
|
31
|
+
Requires-Python: >=3.9
|
|
32
|
+
Project-URL: Documentation, https://github.com/iannitello/syncwave
|
|
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
|
+
> Syncwave is under active development. Until version **1.0**, any minor release (`0.x`) may introduce breaking API changes. Pin the exact version to use in production.
|
|
44
|
+
>
|
|
45
|
+
> Version **1.0** will be released when the library is stable, feature-complete, and tested.
|
|
46
|
+
|
|
47
|
+
## Getting Started
|
|
48
|
+
|
|
49
|
+
Install from [PyPI](https://pypi.org):
|
|
50
|
+
|
|
51
|
+
```shell
|
|
52
|
+
# pip
|
|
53
|
+
pip install syncwave
|
|
54
|
+
|
|
55
|
+
# uv
|
|
56
|
+
uv add syncwave
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Bind a Pydantic model to a JSON file with `@syncwave.register`. Syncwave automatically synchronizes the file with your in-memory objects.
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
from pydantic import BaseModel
|
|
63
|
+
|
|
64
|
+
from syncwave import Syncwave
|
|
65
|
+
|
|
66
|
+
# 1. Initialise Syncwave with a directory to hold the JSON files.
|
|
67
|
+
syncwave = Syncwave(data_dir="data")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# 2. Define and register a model
|
|
71
|
+
# This also creates a JSON file at `data/customers.json` if it doesn't exist.
|
|
72
|
+
# Otherwise, the data is loaded into the `syncwave` instance.
|
|
73
|
+
@syncwave.register(name="customers", key="id")
|
|
74
|
+
class Customer(BaseModel):
|
|
75
|
+
id: int
|
|
76
|
+
name: str
|
|
77
|
+
age: int
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# syncwave acts as a read-only dict with:
|
|
81
|
+
# keys -> collection name (e.g. "customers")
|
|
82
|
+
# values -> corresponding SyncStore
|
|
83
|
+
# A SyncStore also acts as a read-only dict:
|
|
84
|
+
# keys -> model's key field (e.g. `id`)
|
|
85
|
+
# values -> live Pydantic model instances
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# 3. Create and mutate instances like you would with normal Pydantic objects.
|
|
89
|
+
john = Customer(id=1, name="John Doe", age=30) # Automatically inserted in JSON
|
|
90
|
+
john.age = 31 # Automatically updated in JSON
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# 4. Delete instances with `SyncStore.delete(key)`.
|
|
94
|
+
syncwave["customers"].delete(1) # Removed from syncwave and JSON
|
|
95
|
+
|
|
96
|
+
# 5. External edits propagate automatically.
|
|
97
|
+
# First, let's add a new customer to populate the JSON file.
|
|
98
|
+
Customer(id=2, name="Jane Doe", age=25)
|
|
99
|
+
|
|
100
|
+
input("Now edit 'data/customers.json' and press Enter…")
|
|
101
|
+
print(syncwave)
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## License
|
|
105
|
+
|
|
106
|
+
Syncwave is licensed under the MIT License.
|
syncwave-0.1.0/README.md
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
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
|
+
> Syncwave is under active development. Until version **1.0**, any minor release (`0.x`) may introduce breaking API changes. Pin the exact version to use in production.
|
|
7
|
+
>
|
|
8
|
+
> Version **1.0** will be released when the library is stable, feature-complete, and tested.
|
|
9
|
+
|
|
10
|
+
## Getting Started
|
|
11
|
+
|
|
12
|
+
Install from [PyPI](https://pypi.org):
|
|
13
|
+
|
|
14
|
+
```shell
|
|
15
|
+
# pip
|
|
16
|
+
pip install syncwave
|
|
17
|
+
|
|
18
|
+
# uv
|
|
19
|
+
uv add syncwave
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Bind a Pydantic model to a JSON file with `@syncwave.register`. Syncwave automatically synchronizes the file with your in-memory objects.
|
|
23
|
+
|
|
24
|
+
```python
|
|
25
|
+
from pydantic import BaseModel
|
|
26
|
+
|
|
27
|
+
from syncwave import Syncwave
|
|
28
|
+
|
|
29
|
+
# 1. Initialise Syncwave with a directory to hold the JSON files.
|
|
30
|
+
syncwave = Syncwave(data_dir="data")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# 2. Define and register a model
|
|
34
|
+
# This also creates a JSON file at `data/customers.json` if it doesn't exist.
|
|
35
|
+
# Otherwise, the data is loaded into the `syncwave` instance.
|
|
36
|
+
@syncwave.register(name="customers", key="id")
|
|
37
|
+
class Customer(BaseModel):
|
|
38
|
+
id: int
|
|
39
|
+
name: str
|
|
40
|
+
age: int
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# syncwave acts as a read-only dict with:
|
|
44
|
+
# keys -> collection name (e.g. "customers")
|
|
45
|
+
# values -> corresponding SyncStore
|
|
46
|
+
# A SyncStore also acts as a read-only dict:
|
|
47
|
+
# keys -> model's key field (e.g. `id`)
|
|
48
|
+
# values -> live Pydantic model instances
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# 3. Create and mutate instances like you would with normal Pydantic objects.
|
|
52
|
+
john = Customer(id=1, name="John Doe", age=30) # Automatically inserted in JSON
|
|
53
|
+
john.age = 31 # Automatically updated in JSON
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# 4. Delete instances with `SyncStore.delete(key)`.
|
|
57
|
+
syncwave["customers"].delete(1) # Removed from syncwave and JSON
|
|
58
|
+
|
|
59
|
+
# 5. External edits propagate automatically.
|
|
60
|
+
# First, let's add a new customer to populate the JSON file.
|
|
61
|
+
Customer(id=2, name="Jane Doe", age=25)
|
|
62
|
+
|
|
63
|
+
input("Now edit 'data/customers.json' and press Enter…")
|
|
64
|
+
print(syncwave)
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## License
|
|
68
|
+
|
|
69
|
+
Syncwave is licensed under the MIT License.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "syncwave"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Make your code reactive; Turn plain JSONs into a live data store, two-way synced with Python objects."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [{ name = "Lucas Iannitello", email = "lucas.iannitello@protonmail.com" }]
|
|
7
|
+
requires-python = ">=3.9"
|
|
8
|
+
dependencies = [
|
|
9
|
+
"pydantic>=2.11.7",
|
|
10
|
+
"watchdog>=6.0.0",
|
|
11
|
+
]
|
|
12
|
+
license = { file = "LICENSE" }
|
|
13
|
+
keywords = []
|
|
14
|
+
classifiers = []
|
|
15
|
+
|
|
16
|
+
[project.urls]
|
|
17
|
+
Homepage = "https://github.com/iannitello/syncwave"
|
|
18
|
+
Documentation = "https://github.com/iannitello/syncwave"
|
|
19
|
+
Repository = "https://github.com/iannitello/syncwave"
|
|
20
|
+
Issues = "https://github.com/iannitello/syncwave/issues"
|
|
21
|
+
|
|
22
|
+
[build-system]
|
|
23
|
+
requires = ["uv_build>=0.8.4,<0.9.0"]
|
|
24
|
+
build-backend = "uv_build"
|
|
25
|
+
|
|
26
|
+
[dependency-groups]
|
|
27
|
+
dev = [
|
|
28
|
+
"ruff>=0.12.7",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
[tool.ruff.lint]
|
|
32
|
+
extend-select = ["E501"]
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from tempfile import mkstemp
|
|
5
|
+
from threading import Lock, Timer
|
|
6
|
+
from typing import Any, Callable
|
|
7
|
+
|
|
8
|
+
from .watcher import watcher
|
|
9
|
+
|
|
10
|
+
JSONData = Any
|
|
11
|
+
DataProvider = Callable[[], JSONData]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class _IO:
|
|
15
|
+
ENCODING = "utf-8"
|
|
16
|
+
DEFAULT_JSON_CONTENT = {}
|
|
17
|
+
DUMPS_CONFIG = {"indent": 2, "ensure_ascii": False}
|
|
18
|
+
DEBOUNCE_WINDOW = 0.05
|
|
19
|
+
|
|
20
|
+
def __init__(self) -> None:
|
|
21
|
+
self._lock = Lock()
|
|
22
|
+
self._debounce_timers: dict[Path, Timer] = {}
|
|
23
|
+
self._pending_data_providers: dict[Path, DataProvider] = {}
|
|
24
|
+
|
|
25
|
+
def json_dumps(self, data: JSONData) -> str:
|
|
26
|
+
return json.dumps(data, **self.DUMPS_CONFIG)
|
|
27
|
+
|
|
28
|
+
def init_json_file(self, path: Path) -> None:
|
|
29
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
30
|
+
|
|
31
|
+
if path.is_dir():
|
|
32
|
+
raise IsADirectoryError(f"Path '{path}' is a directory, not a file.")
|
|
33
|
+
if not path.exists() or path.stat().st_size == 0:
|
|
34
|
+
self._atomic_write(path, self.DEFAULT_JSON_CONTENT)
|
|
35
|
+
return
|
|
36
|
+
try:
|
|
37
|
+
with path.open(encoding=self.ENCODING) as f:
|
|
38
|
+
json.load(f)
|
|
39
|
+
except json.JSONDecodeError as e:
|
|
40
|
+
raise IOError(f"File '{path}' exists but is not a valid JSON file.") from e
|
|
41
|
+
|
|
42
|
+
def read_json(self, path: Path) -> JSONData:
|
|
43
|
+
with self._lock:
|
|
44
|
+
if path in self._pending_data_providers:
|
|
45
|
+
return self._pending_data_providers[path]()
|
|
46
|
+
with path.open(encoding=self.ENCODING) as f:
|
|
47
|
+
return json.load(f)
|
|
48
|
+
|
|
49
|
+
def write_json(self, path: Path, data_provider: DataProvider) -> None:
|
|
50
|
+
with self._lock:
|
|
51
|
+
if path in self._debounce_timers:
|
|
52
|
+
self._debounce_timers[path].cancel()
|
|
53
|
+
|
|
54
|
+
self._pending_data_providers[path] = data_provider
|
|
55
|
+
|
|
56
|
+
timer = Timer(
|
|
57
|
+
self.DEBOUNCE_WINDOW,
|
|
58
|
+
self._scheduled_write,
|
|
59
|
+
args=(path, data_provider),
|
|
60
|
+
)
|
|
61
|
+
self._debounce_timers[path] = timer
|
|
62
|
+
timer.start()
|
|
63
|
+
|
|
64
|
+
def _scheduled_write(self, path: Path, data_provider: DataProvider) -> None:
|
|
65
|
+
try:
|
|
66
|
+
self._atomic_write(path, data_provider())
|
|
67
|
+
finally:
|
|
68
|
+
with self._lock:
|
|
69
|
+
self._debounce_timers.pop(path, None)
|
|
70
|
+
self._pending_data_providers.pop(path, None)
|
|
71
|
+
|
|
72
|
+
def _atomic_write(self, path: Path, data: JSONData) -> None:
|
|
73
|
+
fd, tmp_path = mkstemp(prefix=".tmp_", suffix=".json", dir=path.parent)
|
|
74
|
+
try:
|
|
75
|
+
with os.fdopen(fd, "w", encoding=self.ENCODING) as tmp_file:
|
|
76
|
+
json.dump(data, tmp_file, **self.DUMPS_CONFIG)
|
|
77
|
+
tmp_file.write("\n")
|
|
78
|
+
tmp_file.flush()
|
|
79
|
+
os.fsync(tmp_file.fileno())
|
|
80
|
+
|
|
81
|
+
# Atomically replace the target file
|
|
82
|
+
watcher.mark_self_write(path)
|
|
83
|
+
os.replace(tmp_path, path)
|
|
84
|
+
|
|
85
|
+
except Exception as e:
|
|
86
|
+
# Attempt to remove temporary file on error
|
|
87
|
+
try:
|
|
88
|
+
os.remove(tmp_path)
|
|
89
|
+
except OSError:
|
|
90
|
+
pass
|
|
91
|
+
# Raise a clear error message including context
|
|
92
|
+
raise IOError(
|
|
93
|
+
f"Failed to write JSON to '{path}'. "
|
|
94
|
+
f"Temporary file '{tmp_path}' removed. Original error: {e}"
|
|
95
|
+
) from e
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
io = _IO()
|
|
File without changes
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import threading
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from inspect import isclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, Callable, Iterator, Optional, Type, Union, get_args
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, ValidationError
|
|
9
|
+
|
|
10
|
+
from .io import io
|
|
11
|
+
from .watcher import watcher
|
|
12
|
+
|
|
13
|
+
# Only acceptable types for the key field to be used as a JSON key
|
|
14
|
+
JSONKey = Union[str, int, float, bool, None]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _validate_key_type(cls: Type[BaseModel], key: str) -> None:
|
|
18
|
+
# This will work if the key field is a basic type (str, int, float, bool, or None),
|
|
19
|
+
# but will fail for more complex types (Union[str, int], Optional[str], etc.).
|
|
20
|
+
# In that case skip the validation.
|
|
21
|
+
key_type = cls.model_fields[key].annotation
|
|
22
|
+
if key_type in get_args(JSONKey):
|
|
23
|
+
return
|
|
24
|
+
raise TypeError(
|
|
25
|
+
f"The key field '{key}' in class '{cls.__module__}.{cls.__qualname__}' will be "
|
|
26
|
+
"used as a JSON key. It must be a basic type (str, int, float, bool, or None), "
|
|
27
|
+
f"not {key_type.__name__ if hasattr(key_type, '__name__') else str(key_type)}."
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class SyncStore(Mapping[JSONKey, BaseModel]):
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
syncwave: "Syncwave",
|
|
35
|
+
cls: Type[BaseModel],
|
|
36
|
+
*,
|
|
37
|
+
name: Optional[str] = None,
|
|
38
|
+
key: str,
|
|
39
|
+
skip_key_validation: bool = False,
|
|
40
|
+
file_name: Optional[str] = None,
|
|
41
|
+
sub_dir: Optional[Union[Path, str]] = None,
|
|
42
|
+
file_path: Optional[Union[Path, str]] = None,
|
|
43
|
+
# in_memory: bool = True, # TODO: implement in_memory
|
|
44
|
+
) -> None:
|
|
45
|
+
if not isinstance(syncwave, Syncwave):
|
|
46
|
+
raise ValueError("The syncwave argument must be an instance of Syncwave.")
|
|
47
|
+
if not (isclass(cls) and issubclass(cls, BaseModel)):
|
|
48
|
+
raise ValueError("Only subclasses of BaseModel can be used.")
|
|
49
|
+
if (name_ := name or cls.__name__.lower()) in syncwave:
|
|
50
|
+
raise ValueError(f"The name '{name_}' is already registered.")
|
|
51
|
+
if cls in [store.cls for store in syncwave.values()]:
|
|
52
|
+
raise ValueError(f"The class '{cls.__name__}' is already registered.")
|
|
53
|
+
if key not in cls.model_fields:
|
|
54
|
+
raise ValueError(f"'{cls.__name__}' does not have a field '{key}'.")
|
|
55
|
+
if not skip_key_validation:
|
|
56
|
+
_validate_key_type(cls, key)
|
|
57
|
+
|
|
58
|
+
self.syncwave = syncwave
|
|
59
|
+
self.cls = cls
|
|
60
|
+
self.name = name_
|
|
61
|
+
self.key = key
|
|
62
|
+
self.path = self._get_path(file_name or name_, sub_dir, file_path)
|
|
63
|
+
|
|
64
|
+
self._ssd: dict[JSONKey, BaseModel] = {} # actual mapping, ssd = SyncStoreDict
|
|
65
|
+
self._lock = threading.RLock()
|
|
66
|
+
self._patch_cls()
|
|
67
|
+
io.init_json_file(self.path)
|
|
68
|
+
self.load()
|
|
69
|
+
watcher.watch(self.path, self.load)
|
|
70
|
+
self.syncwave._swd[self.name] = self
|
|
71
|
+
|
|
72
|
+
def __getitem__(self, key: JSONKey) -> BaseModel:
|
|
73
|
+
with self._lock:
|
|
74
|
+
return self._ssd[key]
|
|
75
|
+
|
|
76
|
+
def __iter__(self) -> Iterator[JSONKey]:
|
|
77
|
+
with self._lock:
|
|
78
|
+
# first, convert to a list so the iterator is over a frozen object
|
|
79
|
+
return iter(list(self._ssd))
|
|
80
|
+
|
|
81
|
+
def __len__(self) -> int:
|
|
82
|
+
with self._lock:
|
|
83
|
+
return len(self._ssd)
|
|
84
|
+
|
|
85
|
+
def __repr__(self) -> str:
|
|
86
|
+
with self._lock:
|
|
87
|
+
return repr(self._ssd)
|
|
88
|
+
|
|
89
|
+
def __str__(self) -> str:
|
|
90
|
+
return io.json_dumps(self.to_dict())
|
|
91
|
+
|
|
92
|
+
def to_dict(self) -> dict[JSONKey, dict[str, Any]]:
|
|
93
|
+
with self._lock:
|
|
94
|
+
return {
|
|
95
|
+
key: model_instance.model_dump()
|
|
96
|
+
for key, model_instance in self._ssd.items()
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
def delete(self, key: JSONKey) -> None:
|
|
100
|
+
with self._lock:
|
|
101
|
+
del self._ssd[key]
|
|
102
|
+
io.write_json(self.path, self.to_dict)
|
|
103
|
+
|
|
104
|
+
def load(self) -> None:
|
|
105
|
+
try:
|
|
106
|
+
tmp_ssd: dict[JSONKey, BaseModel] = {}
|
|
107
|
+
for value in io.read_json(self.path).values():
|
|
108
|
+
model = self.cls.model_validate(value)
|
|
109
|
+
tmp_ssd[getattr(model, self.key)] = model
|
|
110
|
+
with self._lock:
|
|
111
|
+
# no JSONDecodeError or ValidationError at this point, safe to clear
|
|
112
|
+
self._ssd.clear()
|
|
113
|
+
self._ssd.update(tmp_ssd)
|
|
114
|
+
except (json.JSONDecodeError, ValidationError):
|
|
115
|
+
return
|
|
116
|
+
else:
|
|
117
|
+
io.write_json(self.path, self.to_dict)
|
|
118
|
+
|
|
119
|
+
def _get_path(
|
|
120
|
+
self,
|
|
121
|
+
file_name: str,
|
|
122
|
+
sub_dir: Union[Path, str, None],
|
|
123
|
+
file_path: Union[Path, str, None],
|
|
124
|
+
) -> Path:
|
|
125
|
+
if file_path:
|
|
126
|
+
return Path(file_path).resolve()
|
|
127
|
+
file_name = file_name if file_name.endswith(".json") else f"{file_name}.json"
|
|
128
|
+
if sub_dir:
|
|
129
|
+
return (self.syncwave.data_dir / sub_dir / file_name).resolve()
|
|
130
|
+
return (self.syncwave.data_dir / file_name).resolve()
|
|
131
|
+
|
|
132
|
+
def _patch_cls(self) -> None:
|
|
133
|
+
# preserve original methods
|
|
134
|
+
original_init = self.cls.__init__
|
|
135
|
+
original_setattr = self.cls.__setattr__
|
|
136
|
+
original_delattr = self.cls.__delattr__
|
|
137
|
+
|
|
138
|
+
def __init__(model_instance: BaseModel, *args: Any, **kwargs: Any) -> None:
|
|
139
|
+
original_init(model_instance, *args, **kwargs)
|
|
140
|
+
key_value = getattr(model_instance, self.key)
|
|
141
|
+
with self._lock:
|
|
142
|
+
self._ssd[key_value] = model_instance
|
|
143
|
+
io.write_json(self.path, self.to_dict)
|
|
144
|
+
|
|
145
|
+
def __setattr__(model_instance: BaseModel, attr: str, value: Any) -> None:
|
|
146
|
+
# TODO check for edge cases
|
|
147
|
+
old_value = getattr(model_instance, attr)
|
|
148
|
+
original_setattr(model_instance, attr, value)
|
|
149
|
+
with self._lock:
|
|
150
|
+
if attr == self.key:
|
|
151
|
+
del self._ssd[old_value]
|
|
152
|
+
self._ssd[value] = model_instance
|
|
153
|
+
io.write_json(self.path, self.to_dict)
|
|
154
|
+
|
|
155
|
+
def __delattr__(model_instance: BaseModel, attr: str) -> None:
|
|
156
|
+
old_value = getattr(model_instance, attr)
|
|
157
|
+
original_delattr(model_instance, attr)
|
|
158
|
+
with self._lock:
|
|
159
|
+
if attr == self.key:
|
|
160
|
+
del self._ssd[old_value]
|
|
161
|
+
io.write_json(self.path, self.to_dict)
|
|
162
|
+
|
|
163
|
+
# override original methods
|
|
164
|
+
self.cls.__init__ = __init__
|
|
165
|
+
self.cls.__setattr__ = __setattr__
|
|
166
|
+
self.cls.__delattr__ = __delattr__
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class Syncwave(Mapping[str, SyncStore]):
|
|
170
|
+
def __init__(self, data_dir: Union[str, Path]) -> None:
|
|
171
|
+
self.data_dir = Path(data_dir).resolve()
|
|
172
|
+
self._swd: dict[str, SyncStore] = {} # actual mapping, swd = SyncWaveDict
|
|
173
|
+
|
|
174
|
+
def __getitem__(self, key: str) -> SyncStore:
|
|
175
|
+
return self._swd[key]
|
|
176
|
+
|
|
177
|
+
def __iter__(self) -> Iterator[str]:
|
|
178
|
+
return iter(self._swd)
|
|
179
|
+
|
|
180
|
+
def __len__(self) -> int:
|
|
181
|
+
return len(self._swd)
|
|
182
|
+
|
|
183
|
+
def __repr__(self) -> str:
|
|
184
|
+
return repr(self._swd)
|
|
185
|
+
|
|
186
|
+
def __str__(self) -> str:
|
|
187
|
+
return io.json_dumps(self.to_dict())
|
|
188
|
+
|
|
189
|
+
def to_dict(self) -> dict[str, dict[JSONKey, dict[str, Any]]]:
|
|
190
|
+
return {name: syncstore.to_dict() for name, syncstore in self._swd.items()}
|
|
191
|
+
|
|
192
|
+
def load(self) -> None:
|
|
193
|
+
for syncstore in self._swd.values():
|
|
194
|
+
syncstore.load()
|
|
195
|
+
|
|
196
|
+
def stop(self) -> None:
|
|
197
|
+
watcher.stop()
|
|
198
|
+
|
|
199
|
+
def register(
|
|
200
|
+
self,
|
|
201
|
+
*,
|
|
202
|
+
name: Optional[str] = None,
|
|
203
|
+
key: str,
|
|
204
|
+
skip_key_validation: bool = False,
|
|
205
|
+
file_name: Optional[str] = None,
|
|
206
|
+
sub_dir: Optional[Union[Path, str]] = None,
|
|
207
|
+
file_path: Optional[Union[Path, str]] = None,
|
|
208
|
+
# in_memory: bool = True, # TODO: implement in_memory
|
|
209
|
+
) -> Callable[[Type[BaseModel]], Type[BaseModel]]:
|
|
210
|
+
def decorator(cls: Type[BaseModel]) -> Type[BaseModel]:
|
|
211
|
+
SyncStore(
|
|
212
|
+
self,
|
|
213
|
+
cls,
|
|
214
|
+
name=name,
|
|
215
|
+
key=key,
|
|
216
|
+
skip_key_validation=skip_key_validation,
|
|
217
|
+
file_name=file_name,
|
|
218
|
+
sub_dir=sub_dir,
|
|
219
|
+
file_path=file_path,
|
|
220
|
+
)
|
|
221
|
+
return cls
|
|
222
|
+
|
|
223
|
+
return decorator
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from threading import Lock, Timer
|
|
3
|
+
from time import monotonic
|
|
4
|
+
from typing import Callable, Union
|
|
5
|
+
|
|
6
|
+
from watchdog.events import (
|
|
7
|
+
DirModifiedEvent,
|
|
8
|
+
FileModifiedEvent,
|
|
9
|
+
FileSystemEvent,
|
|
10
|
+
FileSystemEventHandler,
|
|
11
|
+
)
|
|
12
|
+
from watchdog.observers import Observer
|
|
13
|
+
from watchdog.observers.api import ObservedWatch
|
|
14
|
+
|
|
15
|
+
DirPath = Path
|
|
16
|
+
FilePath = Path
|
|
17
|
+
Callback = Callable[[], None]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class _Watcher:
|
|
21
|
+
SELF_WRITE_WINDOW = 0.5
|
|
22
|
+
|
|
23
|
+
def __init__(self) -> None:
|
|
24
|
+
self._lock = Lock()
|
|
25
|
+
# mapping dir_path -> (watch object, set of file_paths)
|
|
26
|
+
self._watched_dirs: dict[DirPath, tuple[ObservedWatch, set[FilePath]]] = {}
|
|
27
|
+
self._self_writes_ts: dict[FilePath, float] = {}
|
|
28
|
+
self._callbacks: dict[FilePath, Callback] = {}
|
|
29
|
+
|
|
30
|
+
self._observer = Observer()
|
|
31
|
+
self._event_handler = _EventHandler(self)
|
|
32
|
+
self._observer.start()
|
|
33
|
+
|
|
34
|
+
def stop(self) -> None:
|
|
35
|
+
self._observer.stop()
|
|
36
|
+
self._observer.join()
|
|
37
|
+
|
|
38
|
+
def watch(self, file_path: FilePath, callback: Callback) -> None:
|
|
39
|
+
dir_path = file_path.parent
|
|
40
|
+
|
|
41
|
+
if not file_path.is_file():
|
|
42
|
+
raise ValueError(f"Path {file_path} is not a file.")
|
|
43
|
+
|
|
44
|
+
with self._lock:
|
|
45
|
+
# if the directory is already being watched...
|
|
46
|
+
if dir_path in self._watched_dirs:
|
|
47
|
+
_, watched_files = self._watched_dirs[dir_path]
|
|
48
|
+
|
|
49
|
+
# ...but the file is not in the set, add it
|
|
50
|
+
if file_path not in watched_files:
|
|
51
|
+
watched_files.add(file_path)
|
|
52
|
+
|
|
53
|
+
# and in any case, update the callback before returning
|
|
54
|
+
self._callbacks[file_path] = callback
|
|
55
|
+
return
|
|
56
|
+
|
|
57
|
+
# otherwise, schedule the directory and register the callback
|
|
58
|
+
self._watched_dirs[dir_path] = (
|
|
59
|
+
self._observer.schedule(self._event_handler, str(dir_path)),
|
|
60
|
+
{file_path},
|
|
61
|
+
)
|
|
62
|
+
self._callbacks[file_path] = callback
|
|
63
|
+
|
|
64
|
+
def unwatch(self, file_path: FilePath) -> None:
|
|
65
|
+
dir_path = file_path.parent
|
|
66
|
+
|
|
67
|
+
with self._lock:
|
|
68
|
+
# remove the callback
|
|
69
|
+
self._callbacks.pop(file_path, None)
|
|
70
|
+
|
|
71
|
+
# if the directory is not being watched, do nothing
|
|
72
|
+
if dir_path not in self._watched_dirs:
|
|
73
|
+
return
|
|
74
|
+
|
|
75
|
+
watch, watched_files = self._watched_dirs[dir_path]
|
|
76
|
+
|
|
77
|
+
# if the file is in the set, remove it
|
|
78
|
+
if file_path in watched_files:
|
|
79
|
+
watched_files.remove(file_path)
|
|
80
|
+
|
|
81
|
+
# if the set is empty, unschedule the directory
|
|
82
|
+
if not watched_files:
|
|
83
|
+
self._observer.unschedule(watch)
|
|
84
|
+
del self._watched_dirs[dir_path]
|
|
85
|
+
|
|
86
|
+
def mark_self_write(self, file_path: FilePath) -> None:
|
|
87
|
+
with self._lock:
|
|
88
|
+
self._self_writes_ts[file_path] = monotonic()
|
|
89
|
+
|
|
90
|
+
def _is_self_write(self, file_path: FilePath) -> bool:
|
|
91
|
+
with self._lock:
|
|
92
|
+
ts = self._self_writes_ts.get(file_path)
|
|
93
|
+
if ts is not None and monotonic() - ts < self.SELF_WRITE_WINDOW:
|
|
94
|
+
return True
|
|
95
|
+
return False
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class _EventHandler(FileSystemEventHandler):
|
|
99
|
+
DEBOUNCE_WINDOW = 0.05
|
|
100
|
+
|
|
101
|
+
def __init__(self, watcher: _Watcher) -> None:
|
|
102
|
+
self.lock = Lock()
|
|
103
|
+
self.watcher = watcher
|
|
104
|
+
self.debounce_timers: dict[FileSystemEvent, Timer] = {}
|
|
105
|
+
|
|
106
|
+
def on_modified(self, event: Union[FileModifiedEvent, DirModifiedEvent]) -> None:
|
|
107
|
+
if event.is_directory or isinstance(event, DirModifiedEvent):
|
|
108
|
+
return
|
|
109
|
+
|
|
110
|
+
file_path = Path(event.src_path).resolve()
|
|
111
|
+
|
|
112
|
+
if self.watcher._is_self_write(file_path):
|
|
113
|
+
return
|
|
114
|
+
|
|
115
|
+
callback = self.watcher._callbacks.get(file_path)
|
|
116
|
+
if callback is None:
|
|
117
|
+
return
|
|
118
|
+
|
|
119
|
+
with self.lock:
|
|
120
|
+
if event in self.debounce_timers:
|
|
121
|
+
self.debounce_timers[event].cancel()
|
|
122
|
+
|
|
123
|
+
timer = Timer(
|
|
124
|
+
self.DEBOUNCE_WINDOW,
|
|
125
|
+
self.schedule_callback,
|
|
126
|
+
args=(event, callback),
|
|
127
|
+
)
|
|
128
|
+
self.debounce_timers[event] = timer
|
|
129
|
+
timer.start()
|
|
130
|
+
|
|
131
|
+
def schedule_callback(self, event: FileSystemEvent, callback: Callback) -> None:
|
|
132
|
+
try:
|
|
133
|
+
callback()
|
|
134
|
+
finally:
|
|
135
|
+
with self.lock:
|
|
136
|
+
self.debounce_timers.pop(event, None)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
watcher = _Watcher()
|