syncwave 0.1.4__tar.gz → 0.2.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.
@@ -0,0 +1,220 @@
1
+ Metadata-Version: 2.3
2
+ Name: syncwave
3
+ Version: 0.2.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.12.5
30
+ Requires-Dist: typing-extensions>=4.15.0
31
+ Requires-Dist: watchdog>=6.0.0
32
+ Requires-Python: >=3.9
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
+ >
44
+ > Syncwave is under active development. Until version **1.0**, any minor release (`0.x`) may introduce breaking changes. Pin the exact version in production.
45
+ >
46
+ > Version **1.0** will be released when the library is stable, feature-complete, and tested.
47
+
48
+ ## Installation
49
+
50
+ Install from [PyPI](https://pypi.org/project/syncwave/).
51
+
52
+ ```shell
53
+ # pip
54
+ pip install syncwave
55
+
56
+ # uv
57
+ uv add syncwave
58
+ ```
59
+
60
+ Requires Python 3.9+.
61
+
62
+ ## Quick Start
63
+
64
+ Bind in-memory data to a JSON file with `@syncwave.register`. _Syncwave_ automatically synchronizes the file with your in-memory objects.
65
+
66
+ ```python
67
+ from pydantic import BaseModel
68
+
69
+ from syncwave import Syncwave
70
+
71
+ syncwave = Syncwave()
72
+
73
+
74
+ # Register a model — creates `syncstores/customers.json` automatically.
75
+ # If the file already exists, its data is loaded into the store.
76
+ @syncwave.register(name="customers")
77
+ class Customer(BaseModel):
78
+ key: int
79
+ name: str
80
+ age: int
81
+
82
+
83
+ customers = syncwave["customers"]
84
+
85
+ # Add entries — dicts and model instances both work.
86
+ customers[1] = {"key": 1, "name": "John Doe", "age": 30}
87
+ customers[2] = Customer(key=2, name="Jane Doe", age=25)
88
+
89
+ # Grab a reference and mutate — changes are written to disk automatically.
90
+ john = customers[1]
91
+ john.age = 31 # syncstores/customers.json is updated
92
+
93
+ # The store is a dict-like object that can be printed.
94
+ print(customers) # {1: key=1 name='John Doe' age=31, 2: key=2 name='Jane Doe' age=25}
95
+ print(syncwave) # {'customers': ...}
96
+
97
+ # Delete an entry.
98
+ del customers[1] # removed from memory and from the file
99
+
100
+ # External edits propagate back.
101
+ input("Go edit syncstores/customers.json, then press Enter... ")
102
+ print(customers) # reflects the external changes
103
+ ```
104
+
105
+ ## The Reactive System
106
+
107
+ See [`docs/reactivity.md`](docs/reactivity.md).
108
+
109
+ ## API Reference
110
+
111
+ ### `Syncwave`
112
+
113
+ The entry point. A `MutableMapping[str, Any]` where each key is a store name and each value is the store's data (which may or may not be reactive). Each store maps to a single JSON file.
114
+
115
+ ```python
116
+ from syncwave import Syncwave
117
+
118
+ syncwave = Syncwave(root_path="data")
119
+ ```
120
+
121
+ - `root_path` (optional): Directory for JSON files. Defaults to `./syncstores`.
122
+ - `syncwave.root_path`: Read-only property returning the resolved path.
123
+
124
+ **Methods:**
125
+
126
+ - `syncwave.create_store(tp, *, name, default)` — Creates a store for a given type. The type can be anything supported by Pydantic, including reactive types. Creates or loads the associated JSON file. A `default` is required when the file is empty and the type has no natural empty form (e.g., `int`).
127
+ - `syncwave.make_reactive(cls, *, cls_name)` — Turns a Pydantic model into a `SyncModel` class.
128
+ - `@syncwave.register(*, name, collection)` — Class decorator that makes a model reactive and creates a store for it in one step. See [Collection Wrapping](#collection-wrapping) below.
129
+
130
+ Standard `MutableMapping` operations are supported: `syncwave[name]`, `syncwave[name] = value`, `del syncwave[name]`, `len(syncwave)`, iteration, etc.
131
+
132
+ ### `SyncDict`, `SyncList`, `SyncSet`
133
+
134
+ Reactive counterparts of `dict`, `list`, and `set`. They behave like their standard equivalents (`MutableMapping`, `MutableSequence`, `MutableSet`) but every mutation is validated by Pydantic, persisted to disk, and propagated from disk.
135
+
136
+ They are parameterized like their standard counterparts:
137
+
138
+ ```python
139
+ from syncwave import SyncDict, SyncList, SyncSet
140
+
141
+ syncwave.create_store(SyncDict[str, int], name="settings")
142
+ syncwave.create_store(SyncList[str], name="tags")
143
+ syncwave.create_store(SyncSet[int], name="ids")
144
+ ```
145
+
146
+ Reactive collections can be nested: `SyncDict[str, SyncList[int]]`, `SyncList[SyncDict[str, str]]`, etc. `SyncSet` cannot hold reactive items (since reactive objects are mutable and therefore not hashable).
147
+
148
+ `SyncCollection` is the abstract base for all three — useful for `isinstance` checks.
149
+
150
+ ### `SyncModel`
151
+
152
+ A user-defined Pydantic model that Syncwave has made reactive. Attribute access and assignment are intercepted to keep the data in sync.
153
+
154
+ Created via `syncwave.make_reactive` or `@syncwave.register`. The supported model types are:
155
+
156
+ 1. subclasses of `pydantic.BaseModel`,
157
+ 2. subclasses of `pydantic.RootModel`,
158
+ 3. classes decorated with `@pydantic.dataclasses.dataclass`.
159
+
160
+ `@syncwave.register` returns the **original class**, not the `SyncModel`. You can keep using it normally — Syncwave handles the reactive wrapping internally.
161
+
162
+ ### `Reactive`
163
+
164
+ Abstract base class that all reactive types inherit from. Provides the `sync_live` property, which is `True` while the object is connected to a store and `False` after it has been killed (e.g., by deleting the store or replacing the entry). Operations on a dead reference raise `DeadReferenceError`.
165
+
166
+ ```python
167
+ from syncwave import Reactive
168
+
169
+ ref = syncwave["customers"][1]
170
+ isinstance(ref, Reactive) # True
171
+ ref.sync_live # True
172
+
173
+ del syncwave["customers"][1]
174
+ ref.sync_live # False
175
+ ```
176
+
177
+ ### `is_sync_model_supported`
178
+
179
+ Type guard that returns `True` if a class can be made reactive (i.e., it is a `BaseModel`, `RootModel`, or Pydantic dataclass, and is not already a `SyncModel`).
180
+
181
+ ```python
182
+ from syncwave import is_sync_model_supported
183
+
184
+ is_sync_model_supported(Customer) # True
185
+ is_sync_model_supported(int) # False
186
+ ```
187
+
188
+ ## Collection Wrapping
189
+
190
+ When using `@syncwave.register`, the `collection` parameter controls how the model is stored:
191
+
192
+ | Model shape | Wrapping | Access pattern |
193
+ | ----------------------- | ----------------------------- | --------------------------- |
194
+ | Has a `key` field | `SyncDict[<key type>, Model]` | `store[key]` |
195
+ | No `key` field | `SyncList[Model]` | `store[index]` |
196
+ | Subclass of `RootModel` | No wrapping | `syncwave["name"]` directly |
197
+
198
+ This is the default `collection="auto"` behavior. You can override it:
199
+
200
+ ```python
201
+ from syncwave import SyncList
202
+
203
+
204
+ # Force a list even though the model has a `key` field.
205
+ @syncwave.register(name="items", collection=SyncList)
206
+ class Item(BaseModel):
207
+ key: int # ignored
208
+ name: str
209
+
210
+
211
+ # No wrapping — the store holds a single model instance.
212
+ @syncwave.register(name="config", collection=None)
213
+ class Config(BaseModel):
214
+ theme: str
215
+ debug: bool
216
+ ```
217
+
218
+ ## License
219
+
220
+ Syncwave is licensed under the MIT License.
@@ -0,0 +1,183 @@
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
+ >
7
+ > Syncwave is under active development. Until version **1.0**, any minor release (`0.x`) may introduce breaking changes. Pin the exact version in production.
8
+ >
9
+ > Version **1.0** will be released when the library is stable, feature-complete, and tested.
10
+
11
+ ## Installation
12
+
13
+ Install from [PyPI](https://pypi.org/project/syncwave/).
14
+
15
+ ```shell
16
+ # pip
17
+ pip install syncwave
18
+
19
+ # uv
20
+ uv add syncwave
21
+ ```
22
+
23
+ Requires Python 3.9+.
24
+
25
+ ## Quick Start
26
+
27
+ Bind in-memory data to a JSON file with `@syncwave.register`. _Syncwave_ automatically synchronizes the file with your in-memory objects.
28
+
29
+ ```python
30
+ from pydantic import BaseModel
31
+
32
+ from syncwave import Syncwave
33
+
34
+ syncwave = Syncwave()
35
+
36
+
37
+ # Register a model — creates `syncstores/customers.json` automatically.
38
+ # If the file already exists, its data is loaded into the store.
39
+ @syncwave.register(name="customers")
40
+ class Customer(BaseModel):
41
+ key: int
42
+ name: str
43
+ age: int
44
+
45
+
46
+ customers = syncwave["customers"]
47
+
48
+ # Add entries — dicts and model instances both work.
49
+ customers[1] = {"key": 1, "name": "John Doe", "age": 30}
50
+ customers[2] = Customer(key=2, name="Jane Doe", age=25)
51
+
52
+ # Grab a reference and mutate — changes are written to disk automatically.
53
+ john = customers[1]
54
+ john.age = 31 # syncstores/customers.json is updated
55
+
56
+ # The store is a dict-like object that can be printed.
57
+ print(customers) # {1: key=1 name='John Doe' age=31, 2: key=2 name='Jane Doe' age=25}
58
+ print(syncwave) # {'customers': ...}
59
+
60
+ # Delete an entry.
61
+ del customers[1] # removed from memory and from the file
62
+
63
+ # External edits propagate back.
64
+ input("Go edit syncstores/customers.json, then press Enter... ")
65
+ print(customers) # reflects the external changes
66
+ ```
67
+
68
+ ## The Reactive System
69
+
70
+ See [`docs/reactivity.md`](docs/reactivity.md).
71
+
72
+ ## API Reference
73
+
74
+ ### `Syncwave`
75
+
76
+ The entry point. A `MutableMapping[str, Any]` where each key is a store name and each value is the store's data (which may or may not be reactive). Each store maps to a single JSON file.
77
+
78
+ ```python
79
+ from syncwave import Syncwave
80
+
81
+ syncwave = Syncwave(root_path="data")
82
+ ```
83
+
84
+ - `root_path` (optional): Directory for JSON files. Defaults to `./syncstores`.
85
+ - `syncwave.root_path`: Read-only property returning the resolved path.
86
+
87
+ **Methods:**
88
+
89
+ - `syncwave.create_store(tp, *, name, default)` — Creates a store for a given type. The type can be anything supported by Pydantic, including reactive types. Creates or loads the associated JSON file. A `default` is required when the file is empty and the type has no natural empty form (e.g., `int`).
90
+ - `syncwave.make_reactive(cls, *, cls_name)` — Turns a Pydantic model into a `SyncModel` class.
91
+ - `@syncwave.register(*, name, collection)` — Class decorator that makes a model reactive and creates a store for it in one step. See [Collection Wrapping](#collection-wrapping) below.
92
+
93
+ Standard `MutableMapping` operations are supported: `syncwave[name]`, `syncwave[name] = value`, `del syncwave[name]`, `len(syncwave)`, iteration, etc.
94
+
95
+ ### `SyncDict`, `SyncList`, `SyncSet`
96
+
97
+ Reactive counterparts of `dict`, `list`, and `set`. They behave like their standard equivalents (`MutableMapping`, `MutableSequence`, `MutableSet`) but every mutation is validated by Pydantic, persisted to disk, and propagated from disk.
98
+
99
+ They are parameterized like their standard counterparts:
100
+
101
+ ```python
102
+ from syncwave import SyncDict, SyncList, SyncSet
103
+
104
+ syncwave.create_store(SyncDict[str, int], name="settings")
105
+ syncwave.create_store(SyncList[str], name="tags")
106
+ syncwave.create_store(SyncSet[int], name="ids")
107
+ ```
108
+
109
+ Reactive collections can be nested: `SyncDict[str, SyncList[int]]`, `SyncList[SyncDict[str, str]]`, etc. `SyncSet` cannot hold reactive items (since reactive objects are mutable and therefore not hashable).
110
+
111
+ `SyncCollection` is the abstract base for all three — useful for `isinstance` checks.
112
+
113
+ ### `SyncModel`
114
+
115
+ A user-defined Pydantic model that Syncwave has made reactive. Attribute access and assignment are intercepted to keep the data in sync.
116
+
117
+ Created via `syncwave.make_reactive` or `@syncwave.register`. The supported model types are:
118
+
119
+ 1. subclasses of `pydantic.BaseModel`,
120
+ 2. subclasses of `pydantic.RootModel`,
121
+ 3. classes decorated with `@pydantic.dataclasses.dataclass`.
122
+
123
+ `@syncwave.register` returns the **original class**, not the `SyncModel`. You can keep using it normally — Syncwave handles the reactive wrapping internally.
124
+
125
+ ### `Reactive`
126
+
127
+ Abstract base class that all reactive types inherit from. Provides the `sync_live` property, which is `True` while the object is connected to a store and `False` after it has been killed (e.g., by deleting the store or replacing the entry). Operations on a dead reference raise `DeadReferenceError`.
128
+
129
+ ```python
130
+ from syncwave import Reactive
131
+
132
+ ref = syncwave["customers"][1]
133
+ isinstance(ref, Reactive) # True
134
+ ref.sync_live # True
135
+
136
+ del syncwave["customers"][1]
137
+ ref.sync_live # False
138
+ ```
139
+
140
+ ### `is_sync_model_supported`
141
+
142
+ Type guard that returns `True` if a class can be made reactive (i.e., it is a `BaseModel`, `RootModel`, or Pydantic dataclass, and is not already a `SyncModel`).
143
+
144
+ ```python
145
+ from syncwave import is_sync_model_supported
146
+
147
+ is_sync_model_supported(Customer) # True
148
+ is_sync_model_supported(int) # False
149
+ ```
150
+
151
+ ## Collection Wrapping
152
+
153
+ When using `@syncwave.register`, the `collection` parameter controls how the model is stored:
154
+
155
+ | Model shape | Wrapping | Access pattern |
156
+ | ----------------------- | ----------------------------- | --------------------------- |
157
+ | Has a `key` field | `SyncDict[<key type>, Model]` | `store[key]` |
158
+ | No `key` field | `SyncList[Model]` | `store[index]` |
159
+ | Subclass of `RootModel` | No wrapping | `syncwave["name"]` directly |
160
+
161
+ This is the default `collection="auto"` behavior. You can override it:
162
+
163
+ ```python
164
+ from syncwave import SyncList
165
+
166
+
167
+ # Force a list even though the model has a `key` field.
168
+ @syncwave.register(name="items", collection=SyncList)
169
+ class Item(BaseModel):
170
+ key: int # ignored
171
+ name: str
172
+
173
+
174
+ # No wrapping — the store holds a single model instance.
175
+ @syncwave.register(name="config", collection=None)
176
+ class Config(BaseModel):
177
+ theme: str
178
+ debug: bool
179
+ ```
180
+
181
+ ## License
182
+
183
+ Syncwave is licensed under the MIT License.
@@ -1,12 +1,13 @@
1
1
  [project]
2
2
  name = "syncwave"
3
- version = "0.1.4"
3
+ version = "0.2.0"
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" }]
7
7
  requires-python = ">=3.9"
8
8
  dependencies = [
9
- "pydantic>=2.11.7",
9
+ "pydantic>=2.12.5",
10
+ "typing-extensions>=4.15.0",
10
11
  "watchdog>=6.0.0",
11
12
  ]
12
13
  license = { file = "LICENSE" }
@@ -24,22 +25,29 @@ build-backend = "uv_build"
24
25
 
25
26
  [dependency-groups]
26
27
  dev = [
27
- "ruff>=0.12.9",
28
+ "fastapi[standard]>=0.128.8",
29
+ "ruff>=0.15.4",
30
+ "ty>=0.0.20",
28
31
  ]
29
32
 
30
33
  [tool.ruff.lint]
31
34
  select = [
32
- "E", # pycodestyle errors
33
- "F", # pyflakes
34
- "I", # isort
35
- "B", # flake8-bugbear
36
- "C4", # flake8-comprehensions
35
+ "E", # pycodestyle errors
36
+ "F", # pyflakes
37
+ "I", # isort
38
+ "B", # flake8-bugbear
39
+ "C4", # flake8-comprehensions
37
40
  "SIM", # flake8-simplify
38
41
  "ANN", # flake8-annotations
39
42
  "RUF", # ruff
40
- "UP", # pyupgrade
43
+ "UP", # pyupgrade
41
44
  ]
42
45
 
46
+ ignore = [
47
+ "ANN401"
48
+ ]
49
+
50
+
43
51
  unfixable = [
44
52
  "F401" # disable fix for unused imports
45
53
  ]
@@ -49,3 +57,4 @@ unfixable = [
49
57
 
50
58
  [tool.ruff.lint.isort]
51
59
  required-imports = ["from __future__ import annotations"]
60
+ extra-standard-library = ["typing_extensions"]
@@ -0,0 +1,18 @@
1
+ __version__ = "0.2.0"
2
+
3
+
4
+ from .reactive import Reactive
5
+ from .sync_collection import SyncCollection, SyncDict, SyncList, SyncSet
6
+ from .sync_model import SyncModel, is_sync_model_supported
7
+ from .syncwave import Syncwave
8
+
9
+ __all__ = [
10
+ "Reactive",
11
+ "SyncCollection",
12
+ "SyncDict",
13
+ "SyncList",
14
+ "SyncModel",
15
+ "SyncSet",
16
+ "Syncwave",
17
+ "is_sync_model_supported",
18
+ ]
@@ -0,0 +1,164 @@
1
+ from __future__ import annotations
2
+
3
+ import contextlib
4
+ import os
5
+ from pathlib import Path
6
+ from tempfile import TemporaryDirectory, mkstemp
7
+ from threading import Lock, Timer
8
+ from typing import Any, Final
9
+
10
+ from pydantic import TypeAdapter, ValidationError
11
+
12
+ from .watcher import watcher
13
+
14
+ PendingWrite = tuple[Any, TypeAdapter, Timer]
15
+
16
+
17
+ class EmptyFileType: ...
18
+
19
+
20
+ EmptyFile: Final = EmptyFileType()
21
+
22
+
23
+ class _IO:
24
+ ENCODING: Final[str] = "utf-8"
25
+ DUMPS_CONFIG: Final[dict[str, Any]] = {"indent": 2, "warnings": "error"}
26
+ DEBOUNCE_WINDOW: Final[float] = 0.05
27
+
28
+ _any_ta = TypeAdapter(Any)
29
+
30
+ def __init__(self) -> None:
31
+ self._lock = Lock()
32
+ self._pending_writes: dict[Path, PendingWrite] = {}
33
+
34
+ def file_name_guard(self, name: str) -> None:
35
+ # assumes name is already validated to be a non-empty string
36
+ if os.sep in name or (os.altsep is not None and os.altsep in name):
37
+ sep = repr(os.sep) + (f" or {os.altsep!r}" if os.altsep is not None else "")
38
+ raise ValueError(f"File name cannot contain path separators ({sep}).")
39
+ if Path(name).is_absolute():
40
+ raise ValueError("File name cannot be an absolute path.")
41
+ try:
42
+ with TemporaryDirectory() as td:
43
+ (Path(td) / name).mkdir()
44
+ except OSError as e:
45
+ raise ValueError(f"File name {name!r} is not valid: {e.strerror}") from None
46
+
47
+ def sanitize_path(self, path: Path | str) -> Path:
48
+ path_str = os.fspath(path)
49
+ path_str = os.path.expandvars(path_str)
50
+ path_str = os.path.expanduser(path_str)
51
+ return Path(path_str).resolve()
52
+
53
+ def create_dir(self, path: Path) -> None:
54
+ if path.exists() and not path.is_dir():
55
+ raise FileExistsError(f"Path '{path}' exists and is not a directory.")
56
+ try:
57
+ path.mkdir(parents=True, exist_ok=True)
58
+ except PermissionError as e:
59
+ raise PermissionError(f"Permission denied to create dir '{path}'.") from e
60
+ except OSError as e:
61
+ raise OSError(f"Unable to create dir '{path}'.") from e
62
+
63
+ def create_file(self, path: Path) -> None:
64
+ self.create_dir(path.parent)
65
+ if path.exists() and not path.is_file():
66
+ raise FileExistsError(f"Path '{path}' exists and is not a file.")
67
+ try:
68
+ path.touch()
69
+ except PermissionError as e:
70
+ raise PermissionError(f"Permission denied to create file '{path}'.") from e
71
+ except OSError as e:
72
+ raise OSError(f"Unable to create file '{path}'.") from e
73
+
74
+ def remove_file(self, path: Path) -> None:
75
+ if path.exists() and not path.is_file():
76
+ raise OSError(f"Path '{path}' exists but is not a regular file.")
77
+ path.unlink(missing_ok=True)
78
+
79
+ def init_json(self, path: Path, ta: TypeAdapter = _any_ta) -> Any | EmptyFileType:
80
+ self.create_file(path)
81
+ content = path.read_text(encoding=self.ENCODING).strip()
82
+ if content == "":
83
+ default = self._get_default(ta)
84
+ if default is not EmptyFile:
85
+ self._atomic_write(path, self._serialize(default, ta))
86
+ return default
87
+ init_value = self._deserialize(content, ta, path)
88
+ self._atomic_write(path, self._serialize(init_value, ta))
89
+ return init_value
90
+
91
+ def read_json(self, path: Path, ta: TypeAdapter = _any_ta) -> Any:
92
+ # never returns EmptyFile, it throws an error if the file is empty
93
+ with self._lock:
94
+ if path in self._pending_writes:
95
+ value, previous_ta, _ = self._pending_writes[path]
96
+ if previous_ta is ta:
97
+ return value
98
+ text = self._serialize(value, previous_ta)
99
+ return self._deserialize(text, ta, path)
100
+ text = path.read_text(encoding=self.ENCODING).strip()
101
+ return self._deserialize(text, ta, path)
102
+
103
+ def write_json(self, path: Path, value: Any, ta: TypeAdapter = _any_ta) -> None:
104
+ with self._lock:
105
+ if path in self._pending_writes:
106
+ self._pending_writes[path][2].cancel()
107
+ timer = Timer(self.DEBOUNCE_WINDOW, self._scheduled_write, args=(path,))
108
+ self._pending_writes[path] = (value, ta, timer)
109
+ timer.start()
110
+
111
+ def _scheduled_write(self, path: Path) -> None:
112
+ with self._lock:
113
+ if path not in self._pending_writes:
114
+ return # defensive check, should never happen
115
+ value, ta, _ = self._pending_writes.pop(path)
116
+ self._atomic_write(path, self._serialize(value, ta))
117
+
118
+ def _atomic_write(self, path: Path, text: str) -> None:
119
+ fd, tmp_path = mkstemp(prefix=watcher.TMP_FILE_PREFIX, dir=path.parent)
120
+ try:
121
+ with os.fdopen(fd, "w", encoding=self.ENCODING) as tmp_file:
122
+ tmp_file.write(text)
123
+ tmp_file.write("\n")
124
+ tmp_file.flush()
125
+ os.fsync(tmp_file.fileno())
126
+
127
+ # Atomically replace the target file
128
+ watcher.mark_self_write(path)
129
+ os.replace(tmp_path, path)
130
+ # print(f"Wrote JSON to {path}")
131
+
132
+ except Exception as e:
133
+ # Attempt to remove temporary file on error
134
+ with contextlib.suppress(OSError):
135
+ os.remove(tmp_path)
136
+ raise OSError(
137
+ f"Failed to write JSON to '{path}'. "
138
+ f"Temporary file '{tmp_path}' removed. Original error: {e}"
139
+ ) from e
140
+
141
+ def _serialize(self, value: Any, ta: TypeAdapter) -> str:
142
+ return ta.dump_json(value, **self.DUMPS_CONFIG).decode(self.ENCODING)
143
+
144
+ def _deserialize(self, text: str, ta: TypeAdapter, path: Path) -> Any:
145
+ try:
146
+ return ta.validate_json(text)
147
+ except ValidationError as e:
148
+ try:
149
+ self._any_ta.validate_json(text)
150
+ except ValidationError:
151
+ raise ValueError(f"File '{path}' contains malformed JSON.") from None
152
+ raise ValueError(f"File '{path}' contains unexpected data type.") from e
153
+
154
+ def _get_default(self, ta: TypeAdapter) -> Any | EmptyFileType:
155
+ defaults = [{}, [], "", None]
156
+ for default in defaults:
157
+ try:
158
+ return ta.validate_python(default)
159
+ except ValidationError:
160
+ continue
161
+ return EmptyFile
162
+
163
+
164
+ io = _IO()