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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: syncwave
3
- Version: 0.1.2
3
+ Version: 0.1.4
4
4
  Summary: Make your code reactive; Turn plain JSONs into a live data store, two-way synced with Python objects.
5
5
  Keywords:
6
6
  Author: Lucas Iannitello
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "syncwave"
3
- version = "0.1.2"
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 = ["E", "F"]
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,4 +1,4 @@
1
- __version__ = "0.1.2"
1
+ __version__ = "0.1.4"
2
2
 
3
3
 
4
4
  from .syncwave import SyncStore, Syncwave
@@ -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 IOError(f"File '{path}' exists but is not a valid JSON file.") from e
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
- try:
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 IOError(
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 collections.abc import Mapping
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, Iterator, Optional, Type, Union, get_args
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: Type[BaseModel], key: str) -> None:
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: "Syncwave",
35
- cls: Type[BaseModel],
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: Optional[str] = None,
41
- sub_dir: Optional[Union[Path, str]] = None,
42
- file_path: Optional[Union[Path, str]] = None,
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 ValueError("The syncwave argument must be an instance of Syncwave.")
50
+ raise TypeError("The first argument must be an instance of Syncwave.")
47
51
  if not (isclass(cls) and issubclass(cls, BaseModel)):
48
- raise ValueError("Only subclasses of BaseModel can be used.")
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_}' 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}'.")
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, key)
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 = 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, convert to a list so the iterator is over a frozen object
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: Union[Path, str, None],
123
- file_path: Union[Path, str, None],
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) -> 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)
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] = model_instance
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__(model_instance: BaseModel, attr: str, value: Any) -> None:
162
+ def __setattr__(self_model: BaseModel, attr: str, value: Any) -> None: # noqa: ANN401
146
163
  # TODO check for edge cases
147
- old_value = getattr(model_instance, attr)
148
- original_setattr(model_instance, attr, value)
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] = model_instance
169
+ self._ssd[value] = self_model
153
170
  io.write_json(self.path, self.to_dict)
154
171
 
155
- def __delattr__(model_instance: BaseModel, attr: str) -> None:
156
- old_value = getattr(model_instance, attr)
157
- original_delattr(model_instance, attr)
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
- # override original methods
164
- self.cls.__init__ = __init__
165
- self.cls.__setattr__ = __setattr__
166
- self.cls.__delattr__ = __delattr__
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: Union[str, Path]) -> None:
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: Optional[str] = None,
203
- key: str,
234
+ name: str | None = None,
235
+ key: str | None = None,
204
236
  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,
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[[Type[BaseModel]], Type[BaseModel]]:
210
- def decorator(cls: Type[BaseModel]) -> Type[BaseModel]:
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
- return decorator
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
- if ts is not None and monotonic() - ts < self.SELF_WRITE_WINDOW:
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