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.
- {syncwave-0.2.1 → syncwave-0.3.0}/LICENSE +1 -1
- syncwave-0.3.0/PKG-INFO +84 -0
- syncwave-0.3.0/README.md +47 -0
- syncwave-0.3.0/pyproject.toml +97 -0
- syncwave-0.3.0/src/syncwave/__init__.py +43 -0
- syncwave-0.3.0/src/syncwave/errors.py +62 -0
- {syncwave-0.2.1 → syncwave-0.3.0}/src/syncwave/io.py +67 -30
- syncwave-0.3.0/src/syncwave/ownership.py +31 -0
- syncwave-0.3.0/src/syncwave/reactive.py +255 -0
- syncwave-0.3.0/src/syncwave/sync_collection.py +790 -0
- syncwave-0.3.0/src/syncwave/sync_model.py +573 -0
- syncwave-0.3.0/src/syncwave/syncwave.py +366 -0
- syncwave-0.3.0/src/syncwave/tp_validation.py +533 -0
- {syncwave-0.2.1 → syncwave-0.3.0}/src/syncwave/watcher.py +8 -15
- syncwave-0.2.1/PKG-INFO +0 -220
- syncwave-0.2.1/README.md +0 -183
- syncwave-0.2.1/pyproject.toml +0 -72
- syncwave-0.2.1/src/syncwave/__init__.py +0 -21
- syncwave-0.2.1/src/syncwave/reactive.py +0 -188
- syncwave-0.2.1/src/syncwave/sync_collection.py +0 -637
- syncwave-0.2.1/src/syncwave/sync_model.py +0 -309
- syncwave-0.2.1/src/syncwave/syncwave.py +0 -504
- syncwave-0.2.1/src/syncwave/tp_validation.py +0 -377
- {syncwave-0.2.1 → syncwave-0.3.0}/src/syncwave/py.typed +0 -0
syncwave-0.3.0/PKG-INFO
ADDED
|
@@ -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.
|
syncwave-0.3.0/README.md
ADDED
|
@@ -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
|
-
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class PendingWrite:
|
|
22
|
+
value: Any
|
|
23
|
+
ta: TypeAdapter
|
|
24
|
+
timer: Timer
|
|
18
25
|
|
|
19
26
|
|
|
20
|
-
class
|
|
27
|
+
class Void: ...
|
|
21
28
|
|
|
22
29
|
|
|
23
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
99
|
-
if
|
|
100
|
-
return value
|
|
101
|
-
text = self._serialize(value,
|
|
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]
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
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 |
|
|
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
|
|
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)
|