syncwave 0.1.2__tar.gz → 0.1.3__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.3
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.3"
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" }]
@@ -1,4 +1,4 @@
1
- __version__ = "0.1.2"
1
+ __version__ = "0.1.3"
2
2
 
3
3
 
4
4
  from .syncwave import SyncStore, Syncwave
@@ -79,6 +79,7 @@ class _IO:
79
79
  # Atomically replace the target file
80
80
  watcher.mark_self_write(path)
81
81
  os.replace(tmp_path, path)
82
+ # print(f"Wrote JSON to {path}")
82
83
 
83
84
  except Exception as e:
84
85
  # Attempt to remove temporary file on error
@@ -3,9 +3,9 @@ from inspect import isclass
3
3
  from json import JSONDecodeError
4
4
  from pathlib import Path
5
5
  from threading import RLock
6
- from typing import Any, Callable, Iterator, Optional, Type, Union, get_args
6
+ from typing import Any, Callable, Iterator, Optional, Type, Union, get_args, overload
7
7
 
8
- from pydantic import BaseModel, ValidationError
8
+ from pydantic import BaseModel, ValidationError, create_model, model_validator
9
9
 
10
10
  from .io import io
11
11
  from .watcher import watcher
@@ -33,37 +33,44 @@ class SyncStore(Mapping[JSONKey, BaseModel]):
33
33
  self,
34
34
  syncwave: "Syncwave",
35
35
  cls: Type[BaseModel],
36
- *,
36
+ /,
37
37
  name: Optional[str] = None,
38
- key: str,
38
+ key: Optional[str] = None,
39
+ *,
39
40
  skip_key_validation: bool = False,
40
41
  file_name: Optional[str] = None,
41
42
  sub_dir: Optional[Union[Path, str]] = None,
42
43
  file_path: Optional[Union[Path, str]] = None,
44
+ new_cls_name: Optional[str] = None,
43
45
  # in_memory: bool = True, # TODO: implement in_memory
44
46
  ) -> None:
45
47
  if not isinstance(syncwave, Syncwave):
46
- raise ValueError("The syncwave argument must be an instance of Syncwave.")
48
+ raise TypeError("The first argument must be an instance of Syncwave.")
47
49
  if not (isclass(cls) and issubclass(cls, BaseModel)):
48
- raise ValueError("Only subclasses of BaseModel can be used.")
50
+ raise TypeError("Only subclasses of Pydantic's BaseModel can be used.")
51
+ # `store.cls` is created with `create_model`, with `cls` as its only base class
52
+ # in other words, this checks that `cls` has not been registered already
53
+ if any(store.cls.__bases__[0] is cls for store in syncwave.values()):
54
+ raise ValueError(f"The class '{cls.__name__}' has already been registered.")
55
+ # `name` defaults to the lowercased class name if not provided
49
56
  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}'.")
57
+ raise ValueError(f"The name '{name_}' has already been registered.")
58
+ if not cls.model_fields:
59
+ raise ValueError(f"Class '{cls.__name__}' has no fields defined.")
60
+ # `key` defaults to the first field in the model if not provided
61
+ if (key_ := key or next(iter(cls.model_fields))) not in cls.model_fields:
62
+ raise ValueError(f"'{cls.__name__}' does not have a field named '{key_}'.")
55
63
  if not skip_key_validation:
56
- _validate_key_type(cls, key)
64
+ _validate_key_type(cls, key_)
57
65
 
58
66
  self.syncwave = syncwave
59
- self.cls = cls
67
+ self.cls = self._patch_cls(cls, new_cls_name or f"Syncwave{cls.__name__}")
60
68
  self.name = name_
61
- self.key = key
69
+ self.key = key_
62
70
  self.path = self._get_path(file_name or name_, sub_dir, file_path)
63
71
 
64
72
  self._ssd: dict[JSONKey, BaseModel] = {} # actual mapping, ssd = SyncStoreDict
65
73
  self._lock = RLock()
66
- self._patch_cls()
67
74
  io.init_json_file(self.path)
68
75
  self.load()
69
76
  watcher.watch(self.path, self.load)
@@ -75,7 +82,7 @@ class SyncStore(Mapping[JSONKey, BaseModel]):
75
82
 
76
83
  def __iter__(self) -> Iterator[JSONKey]:
77
84
  with self._lock:
78
- # first, convert to a list so the iterator is over a frozen object
85
+ # first convert to a list so the iterator is over a frozen object
79
86
  return iter(list(self._ssd))
80
87
 
81
88
  def __len__(self) -> int:
@@ -91,10 +98,7 @@ class SyncStore(Mapping[JSONKey, BaseModel]):
91
98
 
92
99
  def to_dict(self) -> dict[JSONKey, dict[str, Any]]:
93
100
  with self._lock:
94
- return {
95
- key: model_instance.model_dump()
96
- for key, model_instance in self._ssd.items()
97
- }
101
+ return {key: instance.model_dump() for key, instance in self._ssd.items()}
98
102
 
99
103
  def delete(self, key: JSONKey) -> None:
100
104
  with self._lock:
@@ -129,41 +133,53 @@ class SyncStore(Mapping[JSONKey, BaseModel]):
129
133
  return (self.syncwave.data_dir / sub_dir / file_name).resolve()
130
134
  return (self.syncwave.data_dir / file_name).resolve()
131
135
 
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)
136
+ def _patch_cls(self, cls: Type[BaseModel], new_cls_name: str) -> Type[BaseModel]:
137
+ # This is a validator that acts as a post-initialization hook.
138
+ # Using a validator instead of `__init__` ensures every instance is tracked.
139
+ # Libraries such as FastAPI may create instances indirectly with methods like
140
+ # `validate_python` or `model_validate` and `__init__` may never be called.
141
+ def init(self_model: BaseModel) -> BaseModel:
142
+ key_value = getattr(self_model, self.key)
141
143
  with self._lock:
142
- self._ssd[key_value] = model_instance
144
+ if key_value in self._ssd and self._ssd[key_value] == self_model:
145
+ return self_model # no changes, no need to write
146
+ self._ssd[key_value] = self_model
143
147
  io.write_json(self.path, self.to_dict)
148
+ return self_model
149
+
150
+ new_cls = create_model(
151
+ new_cls_name,
152
+ __base__=cls,
153
+ __validators__={"__syncwave_init__": model_validator(mode="after")(init)},
154
+ )
144
155
 
145
- def __setattr__(model_instance: BaseModel, attr: str, value: Any) -> None:
156
+ # preserve original methods
157
+ original_setattr = cls.__setattr__
158
+ original_delattr = cls.__delattr__
159
+
160
+ def __setattr__(self_model: BaseModel, attr: str, value: Any) -> None:
146
161
  # TODO check for edge cases
147
- old_value = getattr(model_instance, attr)
148
- original_setattr(model_instance, attr, value)
162
+ old_value = getattr(self_model, attr)
163
+ original_setattr(self_model, attr, value)
149
164
  with self._lock:
150
165
  if attr == self.key:
151
166
  del self._ssd[old_value]
152
- self._ssd[value] = model_instance
167
+ self._ssd[value] = self_model
153
168
  io.write_json(self.path, self.to_dict)
154
169
 
155
- def __delattr__(model_instance: BaseModel, attr: str) -> None:
156
- old_value = getattr(model_instance, attr)
157
- original_delattr(model_instance, attr)
170
+ def __delattr__(self_model: BaseModel, attr: str) -> None:
171
+ old_value = getattr(self_model, attr)
172
+ original_delattr(self_model, attr)
158
173
  with self._lock:
159
174
  if attr == self.key:
160
175
  del self._ssd[old_value]
161
176
  io.write_json(self.path, self.to_dict)
162
177
 
163
- # override original methods
164
- self.cls.__init__ = __init__
165
- self.cls.__setattr__ = __setattr__
166
- self.cls.__delattr__ = __delattr__
178
+ # assign the new methods
179
+ new_cls.__setattr__ = __setattr__
180
+ new_cls.__delattr__ = __delattr__
181
+
182
+ return new_cls
167
183
 
168
184
 
169
185
  class Syncwave(Mapping[str, SyncStore]):
@@ -196,19 +212,33 @@ class Syncwave(Mapping[str, SyncStore]):
196
212
  def stop(self) -> None:
197
213
  watcher.stop()
198
214
 
215
+ @overload
216
+ def register(
217
+ self,
218
+ *,
219
+ name: Optional[str] = None,
220
+ key: Optional[str] = None,
221
+ skip_key_validation: bool = False,
222
+ file_name: Optional[str] = None,
223
+ sub_dir: Optional[Union[Path, str]] = None,
224
+ file_path: Optional[Union[Path, str]] = None,
225
+ ) -> Callable[[Type[BaseModel]], Type[BaseModel]]: ...
226
+
199
227
  def register(
200
228
  self,
229
+ _cls: Optional[Type[BaseModel]] = None,
230
+ /,
201
231
  *,
202
232
  name: Optional[str] = None,
203
- key: str,
233
+ key: Optional[str] = None,
204
234
  skip_key_validation: bool = False,
205
235
  file_name: Optional[str] = None,
206
236
  sub_dir: Optional[Union[Path, str]] = None,
207
237
  file_path: Optional[Union[Path, str]] = None,
208
238
  # in_memory: bool = True, # TODO: implement in_memory
209
- ) -> Callable[[Type[BaseModel]], Type[BaseModel]]:
239
+ ) -> Union[Type[BaseModel], Callable[[Type[BaseModel]], Type[BaseModel]]]:
210
240
  def decorator(cls: Type[BaseModel]) -> Type[BaseModel]:
211
- SyncStore(
241
+ store = SyncStore(
212
242
  self,
213
243
  cls,
214
244
  name=name,
@@ -217,7 +247,12 @@ class Syncwave(Mapping[str, SyncStore]):
217
247
  file_name=file_name,
218
248
  sub_dir=sub_dir,
219
249
  file_path=file_path,
250
+ new_cls_name=cls.__name__,
220
251
  )
221
- return cls
252
+ return store.cls
222
253
 
223
- return decorator
254
+ if _cls is None:
255
+ # called with parenthesis: @syncwave.register(...)
256
+ return decorator
257
+ # called without parenthesis: @syncwave.register
258
+ return decorator(_cls)
File without changes
File without changes
File without changes