syncwave 0.2.1__tar.gz → 0.3.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.
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2025 Lucas Iannitello
3
+ Copyright (c) 2026 Lucas Iannitello
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
@@ -0,0 +1,84 @@
1
+ Metadata-Version: 2.3
2
+ Name: syncwave
3
+ Version: 0.3.0
4
+ Summary: Turn plain JSON files into a live data store, two-way synced with your 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) 2026 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.13.5
30
+ Requires-Dist: typing-extensions>=4.16.0
31
+ Requires-Dist: watchdog>=6.0.0
32
+ Requires-Python: >=3.10
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
+ Turn plain JSON files into a live data store, two-way synced with your Python objects.
41
+
42
+ ## Documentation
43
+
44
+ Read the docs at https://syncwave.dev/.
45
+
46
+ A few useful links:
47
+
48
+ - [Cheat Sheet](https://syncwave.dev/cheat_sheet/)
49
+ - [Usage Guide](https://syncwave.dev/usage/syncwave/)
50
+ - [API Reference](https://syncwave.dev/api/syncwave/)
51
+
52
+ ## Installation
53
+
54
+ Install from [PyPI](https://pypi.org/project/syncwave/).
55
+
56
+ ```shell
57
+ # uv
58
+ uv add syncwave
59
+
60
+ # pip
61
+ pip install syncwave
62
+ ```
63
+
64
+ ## What Is Syncwave
65
+
66
+ Syncwave keeps in-memory Python objects and JSON files synchronized, in both directions:
67
+
68
+ - Change your data from Python (an `append`, a dict assignment, a model field update) and the JSON file is updated.
69
+ - Edit the JSON file, by hand or from another process, and your Python objects are updated in place.
70
+ - Every change, from either side, is validated with [Pydantic](https://pydantic.dev/docs/validation/latest/get-started/) against the store's declared type.
71
+
72
+ You get persistence with _zero_ setup: no database to install, no ORM, no serialization code. Your data lives in JSON files you can open and edit at any time.
73
+
74
+ If you know Python type hints and Pydantic models, you already know everything you need.
75
+
76
+ Requires Python 3.10+.
77
+
78
+ ## Versioning
79
+
80
+ Syncwave is under active development, and until version 1.0, new releases may introduce backward-incompatible changes.
81
+
82
+ ## License
83
+
84
+ Syncwave is licensed under the MIT License.
@@ -0,0 +1,47 @@
1
+ # Syncwave
2
+
3
+ Turn plain JSON files into a live data store, two-way synced with your Python objects.
4
+
5
+ ## Documentation
6
+
7
+ Read the docs at https://syncwave.dev/.
8
+
9
+ A few useful links:
10
+
11
+ - [Cheat Sheet](https://syncwave.dev/cheat_sheet/)
12
+ - [Usage Guide](https://syncwave.dev/usage/syncwave/)
13
+ - [API Reference](https://syncwave.dev/api/syncwave/)
14
+
15
+ ## Installation
16
+
17
+ Install from [PyPI](https://pypi.org/project/syncwave/).
18
+
19
+ ```shell
20
+ # uv
21
+ uv add syncwave
22
+
23
+ # pip
24
+ pip install syncwave
25
+ ```
26
+
27
+ ## What Is Syncwave
28
+
29
+ Syncwave keeps in-memory Python objects and JSON files synchronized, in both directions:
30
+
31
+ - Change your data from Python (an `append`, a dict assignment, a model field update) and the JSON file is updated.
32
+ - Edit the JSON file, by hand or from another process, and your Python objects are updated in place.
33
+ - Every change, from either side, is validated with [Pydantic](https://pydantic.dev/docs/validation/latest/get-started/) against the store's declared type.
34
+
35
+ You get persistence with _zero_ setup: no database to install, no ORM, no serialization code. Your data lives in JSON files you can open and edit at any time.
36
+
37
+ If you know Python type hints and Pydantic models, you already know everything you need.
38
+
39
+ Requires Python 3.10+.
40
+
41
+ ## Versioning
42
+
43
+ Syncwave is under active development, and until version 1.0, new releases may introduce backward-incompatible changes.
44
+
45
+ ## License
46
+
47
+ Syncwave is licensed under the MIT License.
@@ -0,0 +1,97 @@
1
+ [project]
2
+ name = "syncwave"
3
+ version = "0.3.0"
4
+ description = "Turn plain JSON files into a live data store, two-way synced with your Python objects."
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "Lucas Iannitello", email = "lucas.iannitello@protonmail.com" },
8
+ ]
9
+ requires-python = ">=3.10"
10
+ dependencies = [
11
+ "pydantic>=2.13.5",
12
+ "typing-extensions>=4.16.0",
13
+ "watchdog>=6.0.0",
14
+ ]
15
+ license = { file = "LICENSE" }
16
+ keywords = []
17
+ classifiers = []
18
+
19
+ [project.urls]
20
+ Homepage = "https://github.com/iannitello/syncwave"
21
+ Issues = "https://github.com/iannitello/syncwave/issues"
22
+ Repository = "https://github.com/iannitello/syncwave"
23
+
24
+ [build-system]
25
+ requires = ["uv_build>=0.8.4,<0.9.0"]
26
+ build-backend = "uv_build"
27
+
28
+ [dependency-groups]
29
+ dev = [
30
+ "fastapi[standard]>=0.141.1",
31
+ "mkdocstrings-python>=2.0.8",
32
+ "ruff>=0.16.8",
33
+ "ty>=0.0.82",
34
+ "zensical>=0.0.62",
35
+ ]
36
+
37
+ [tool.uv]
38
+ exclude-newer = "28 days"
39
+
40
+ [tool.ruff.format]
41
+ preview = true
42
+ docstring-code-format = true
43
+
44
+ [tool.ruff.lint]
45
+ preview = true
46
+ select = [
47
+ "E", # pycodestyle errors
48
+ "F", # pyflakes
49
+ "I", # isort
50
+ "D", # pydocstyle
51
+ "A", # flake8-builtins
52
+ "B", # flake8-bugbear
53
+ "C4", # flake8-comprehensions
54
+ "ANN", # flake8-annotations
55
+ "FBT", # flake8-boolean-trap
56
+ "ICN", # flake8-import-conventions
57
+ "PYI", # flake8-pyi
58
+ "SIM", # flake8-simplify
59
+ "RUF", # ruff
60
+ "UP", # pyupgrade
61
+ ]
62
+ extend-select = [
63
+ "non-imperative-mood", # D401
64
+ "docstring-starts-with-this", # D404
65
+ "missing-blank-line-after-last-section", # D413
66
+ ]
67
+ ignore = [
68
+ "undocumented-magic-method", # D105
69
+ "any-type", # ANN401
70
+ ]
71
+ unfixable = [
72
+ "unused-import", # F401
73
+ ]
74
+ pydocstyle = { convention = "google" }
75
+
76
+ [tool.ruff.lint.per-file-ignores]
77
+ "__init__.py" = [
78
+ "missing-required-import", # I002
79
+ "missing-terminal-punctuation", # D415
80
+ ]
81
+ "!src/**.py" = ["D"]
82
+ "!src/__init__.py" = [
83
+ "undocumented-public-module", # D100
84
+ ]
85
+
86
+ [tool.ruff.lint.isort]
87
+ required-imports = ["from __future__ import annotations"]
88
+ extra-standard-library = ["typing_extensions"]
89
+ known-third-party = ["fastapi", "pydantic", "syncwave"]
90
+ combine-as-imports = true
91
+
92
+ [tool.ruff.lint.flake8-import-conventions]
93
+ banned-from = ["os"]
94
+
95
+ [tool.ruff.lint.flake8-import-conventions.extend-aliases]
96
+ "collections.abc.Callable" = "F"
97
+ "pydantic.dataclasses.dataclass" = "py_dataclass"
@@ -0,0 +1,43 @@
1
+ """Syncwave 🌊
2
+
3
+ Turn plain JSON files into a live data store, two-way synced with your Python objects.
4
+
5
+ Imports available from this module:
6
+
7
+ - [Syncwave](https://syncwave.dev/api/syncwave/#syncwave.Syncwave)
8
+ - [SyncCollection](https://syncwave.dev/api/sync_collections/#syncwave.SyncCollection)
9
+ - [SyncDict](https://syncwave.dev/api/sync_collections/#syncwave.SyncDict)
10
+ - [SyncList](https://syncwave.dev/api/sync_collections/#syncwave.SyncList)
11
+ - [SyncSet](https://syncwave.dev/api/sync_collections/#syncwave.SyncSet)
12
+ - [SyncModel](https://syncwave.dev/api/sync_model/#syncwave.SyncModel)
13
+ - [SyncRoot](https://syncwave.dev/api/sync_model/#syncwave.SyncRoot)
14
+ - [sync_dataclass](https://syncwave.dev/api/sync_model/#syncwave.sync_dataclass)
15
+ - [is_sync_dataclass](https://syncwave.dev/api/sync_model/#syncwave.is_sync_dataclass)
16
+ - [SyncState](https://syncwave.dev/api/reactive/#syncwave.SyncState)
17
+ - [Reactive](https://syncwave.dev/api/reactive/#syncwave.Reactive)
18
+ - [DeadReferenceError](https://syncwave.dev/api/errors/#syncwave.DeadReferenceError)
19
+ """
20
+
21
+ __version__ = "0.3.0"
22
+
23
+
24
+ from .errors import DeadReferenceError
25
+ from .reactive import Reactive, SyncState
26
+ from .sync_collection import SyncCollection, SyncDict, SyncList, SyncSet
27
+ from .sync_model import SyncModel, SyncRoot, is_sync_dataclass, sync_dataclass
28
+ from .syncwave import Syncwave
29
+
30
+ __all__ = [
31
+ "DeadReferenceError",
32
+ "Reactive",
33
+ "SyncCollection",
34
+ "SyncDict",
35
+ "SyncList",
36
+ "SyncModel",
37
+ "SyncRoot",
38
+ "SyncSet",
39
+ "SyncState",
40
+ "Syncwave",
41
+ "is_sync_dataclass",
42
+ "sync_dataclass",
43
+ ]
@@ -0,0 +1,62 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, NoReturn
4
+
5
+ __all__ = ["DeadReferenceError"]
6
+
7
+
8
+ if TYPE_CHECKING:
9
+ from .reactive import ReactiveLike
10
+
11
+
12
+ class DeadReferenceError(RuntimeError):
13
+ """Raised when an operation is attempted on a dead reactive object.
14
+
15
+ A reactive object dies when it's removed from its store, e.g. when its key or the
16
+ whole store is deleted. Check `sync_live` to know if an object is still usable.
17
+
18
+ Example:
19
+ ```python
20
+ from syncwave import DeadReferenceError, SyncModel, Syncwave
21
+
22
+ syncwave = Syncwave()
23
+
24
+
25
+ @syncwave.store(name="customers")
26
+ class Customer(SyncModel):
27
+ name: str
28
+ age: int
29
+
30
+
31
+ customers = syncwave["customers"]
32
+ customers.append({"name": "Alice", "age": 30})
33
+ alice = customers[0]
34
+ del customers[0]
35
+
36
+ try:
37
+ alice.age = 31
38
+ except DeadReferenceError as e:
39
+ print(e)
40
+ ```
41
+
42
+ ---
43
+
44
+ Abstract: Usage Documentation
45
+ [Lifecycle](https://syncwave.dev/usage/reactivity/#lifecycle-and-syncstate)
46
+
47
+ """
48
+
49
+ def __init__(self, *, reference: ReactiveLike) -> None: # ruff: ignore[undocumented-public-init]
50
+ message = f"Operation attempted on a dead reference: {reference!r}"
51
+ super().__init__(message)
52
+
53
+
54
+ class _InternalError(RuntimeError): ...
55
+
56
+
57
+ def unreachable(detail: str = "", /, *, from_: Exception | None = None) -> NoReturn:
58
+ message = (detail or "Unreachable code was reached.") + "\n\n"
59
+ message += "This is a bug in Syncwave, please report it at https://github.com/iannitello/syncwave/issues."
60
+ if from_ is not None:
61
+ raise _InternalError(message) from from_
62
+ raise _InternalError(message)
@@ -2,25 +2,32 @@ from __future__ import annotations
2
2
 
3
3
  import contextlib
4
4
  import os
5
+ from dataclasses import dataclass
5
6
  from pathlib import Path
6
7
  from tempfile import TemporaryDirectory, mkstemp
7
8
  from threading import Lock, Timer
8
9
  from typing import Any, Final
9
10
 
10
11
  from pydantic import TypeAdapter, ValidationError
12
+ from pydantic_core import PydanticSerializationError
11
13
 
14
+ from .errors import unreachable
12
15
  from .watcher import watcher
13
16
 
14
17
  __all__ = []
15
18
 
16
19
 
17
- PendingWrite = tuple[Any, TypeAdapter, Timer]
20
+ @dataclass(frozen=True)
21
+ class PendingWrite:
22
+ value: Any
23
+ ta: TypeAdapter
24
+ timer: Timer
18
25
 
19
26
 
20
- class EmptyFileType: ...
27
+ class Void: ...
21
28
 
22
29
 
23
- EmptyFile: Final = EmptyFileType()
30
+ void: Final = Void()
24
31
 
25
32
 
26
33
  class _IO:
@@ -77,28 +84,40 @@ class _IO:
77
84
  def remove_file(self, path: Path) -> None:
78
85
  if path.exists() and not path.is_file():
79
86
  raise OSError(f"Path '{path}' exists but is not a regular file.")
80
- path.unlink(missing_ok=True)
87
+ with self._lock:
88
+ if path in self._pending_writes:
89
+ self._pending_writes.pop(path).timer.cancel()
90
+ path.unlink(missing_ok=True)
91
+
92
+ def init_json(
93
+ self,
94
+ path: Path,
95
+ ta: TypeAdapter = _any_ta,
96
+ default: Any | Void = void,
97
+ ) -> Any | Void:
81
98
 
82
- def init_json(self, path: Path, ta: TypeAdapter = _any_ta) -> Any | EmptyFileType:
99
+ created = not path.exists()
83
100
  self.create_file(path)
84
101
  content = path.read_text(encoding=self.ENCODING).strip()
85
- if content == "":
86
- default = self._get_default(ta)
87
- if default is not EmptyFile:
88
- self._atomic_write(path, self._serialize(default, ta))
89
- return default
90
- init_value = self._deserialize(content, ta, path)
91
- self._atomic_write(path, self._serialize(init_value, ta))
92
- return init_value
102
+ if content:
103
+ value = self._deserialize(content, ta, path)
104
+ else:
105
+ value = default if default is not void else self._get_default(ta)
106
+ if value is void:
107
+ if created:
108
+ path.unlink()
109
+ return void
110
+ self._atomic_write(path, self._serialize(value, ta))
111
+ return value
93
112
 
94
113
  def load(self, path: Path, ta: TypeAdapter = _any_ta) -> Any:
95
114
  # never returns EmptyFile, it throws an error if the file is empty
96
115
  with self._lock:
97
116
  if path in self._pending_writes:
98
- value, previous_ta, _ = self._pending_writes[path]
99
- if previous_ta is ta:
100
- return value
101
- text = self._serialize(value, previous_ta)
117
+ pending = self._pending_writes[path]
118
+ if pending.ta is ta:
119
+ return pending.value
120
+ text = self._serialize(pending.value, pending.ta)
102
121
  return self._deserialize(text, ta, path)
103
122
  text = path.read_text(encoding=self.ENCODING)
104
123
  return self._deserialize(text, ta, path)
@@ -106,31 +125,41 @@ class _IO:
106
125
  def dump(self, path: Path, value: Any, ta: TypeAdapter = _any_ta) -> None:
107
126
  with self._lock:
108
127
  if path in self._pending_writes:
109
- self._pending_writes[path][2].cancel()
128
+ self._pending_writes[path].timer.cancel()
110
129
  timer = Timer(self.DEBOUNCE_WINDOW, self._scheduled_write, args=(path,))
111
- self._pending_writes[path] = (value, ta, timer)
130
+ self._pending_writes[path] = PendingWrite(value, ta, timer)
112
131
  timer.start()
113
132
 
114
133
  def read_json(self, path: Path) -> str:
115
134
  with self._lock:
116
135
  if path in self._pending_writes:
117
- value, ta, _ = self._pending_writes[path]
118
- return self._serialize(value, ta)
136
+ pending = self._pending_writes[path]
137
+ return self._serialize(pending.value, pending.ta)
119
138
  return path.read_text(encoding=self.ENCODING)
120
139
 
121
140
  def write_json(self, path: Path, text: str) -> None:
122
141
  with self._lock:
123
142
  if path in self._pending_writes:
124
- _, _, timer = self._pending_writes.pop(path)
125
- timer.cancel()
143
+ self._pending_writes.pop(path).timer.cancel()
126
144
  self._atomic_write(path, text)
127
145
 
128
146
  def _scheduled_write(self, path: Path) -> None:
129
147
  with self._lock:
130
- if path not in self._pending_writes:
131
- return # defensive check, should never happen
132
- value, ta, _ = self._pending_writes.pop(path)
133
- self._atomic_write(path, self._serialize(value, ta))
148
+ # `cancel()` is best effort: this could run with a missing pending write.
149
+ try:
150
+ pending = self._pending_writes.pop(path)
151
+ except KeyError:
152
+ return # A newer `dump` or `write_json` has taken over, nothing to do.
153
+
154
+ # Only guarded `_serialize`: runs on a timer thread (the traceback is useless).
155
+ try:
156
+ text = self._serialize(pending.value, pending.ta)
157
+ except PydanticSerializationError as e:
158
+ # Failing here is a bug. The value comes from a store, it should serialize.
159
+ err = f"An invalid value reached the store associated with file '{path}'."
160
+ unreachable(err, from_=e)
161
+
162
+ self._atomic_write(path, text)
134
163
 
135
164
  def _atomic_write(self, path: Path, text: str) -> None:
136
165
  fd, tmp_path = mkstemp(prefix=watcher.TMP_FILE_PREFIX, dir=path.parent)
@@ -159,22 +188,30 @@ class _IO:
159
188
 
160
189
  def _deserialize(self, text: str, ta: TypeAdapter, path: Path) -> Any:
161
190
  try:
162
- return ta.validate_json(text)
191
+ value = ta.validate_json(text)
163
192
  except ValidationError as e:
164
193
  try:
165
194
  self._any_ta.validate_json(text)
166
195
  except ValidationError:
167
196
  raise ValueError(f"File '{path}' contains malformed JSON.") from None
168
197
  raise ValueError(f"File '{path}' contains unexpected data type.") from e
198
+ # Validate_json can succeed while dump_json fails, e.g. in case of an invalid
199
+ # default_factory or a validator/serializer mismatch.
200
+ try:
201
+ ta.dump_json(value, warnings="error")
202
+ except PydanticSerializationError as e:
203
+ err = f"The store associated with file '{path}' cannot be serialized."
204
+ raise ValueError(err) from e
205
+ return value
169
206
 
170
- def _get_default(self, ta: TypeAdapter) -> Any | EmptyFileType:
207
+ def _get_default(self, ta: TypeAdapter) -> Any | Void:
171
208
  defaults = [{}, [], "", None]
172
209
  for default in defaults:
173
210
  try:
174
211
  return ta.validate_python(default)
175
212
  except ValidationError:
176
213
  continue
177
- return EmptyFile
214
+ return void
178
215
 
179
216
 
180
217
  io = _IO()
@@ -0,0 +1,31 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Final
4
+
5
+ from pydantic import TypeAdapter
6
+
7
+ from .reactive import is_reactive
8
+
9
+ __all__ = []
10
+
11
+
12
+ _IMMUTABLE_TYPES: Final = (int, float, bool, str, bytes, type(None))
13
+
14
+
15
+ def detach(value: Any, ta: TypeAdapter) -> Any:
16
+ if type(value) in _IMMUTABLE_TYPES or is_reactive(value):
17
+ return value
18
+ return ta.validate_json(ta.dump_json(value))
19
+
20
+
21
+ def ingest(value: Any, ta: TypeAdapter) -> Any:
22
+ # Implicitly guards against dead references: all reactive values run `dead_guard`.
23
+ validated = ta.validate_python(value)
24
+
25
+ # Must run before the immutable fast path: a type inconsistent with its validator,
26
+ # e.g. `Annotated[int, AfterValidator(str)]`, yields an immutable `str`.
27
+ json = ta.dump_json(validated, warnings="error")
28
+
29
+ if type(validated) in _IMMUTABLE_TYPES:
30
+ return validated
31
+ return ta.validate_json(json)