syncwave 0.2.0__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.0 → 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.0 → syncwave-0.3.0}/src/syncwave/io.py +84 -31
- 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.0 → syncwave-0.3.0}/src/syncwave/watcher.py +11 -15
- syncwave-0.2.0/PKG-INFO +0 -220
- syncwave-0.2.0/README.md +0 -183
- syncwave-0.2.0/pyproject.toml +0 -60
- syncwave-0.2.0/src/syncwave/__init__.py +0 -18
- syncwave-0.2.0/src/syncwave/reactive.py +0 -91
- syncwave-0.2.0/src/syncwave/sync_collection.py +0 -523
- syncwave-0.2.0/src/syncwave/sync_model.py +0 -229
- syncwave-0.2.0/src/syncwave/syncwave.py +0 -239
- syncwave-0.2.0/src/syncwave/tp_validation.py +0 -375
- {syncwave-0.2.0 → 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,22 +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
|
-
|
|
27
|
+
class Void: ...
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
void: Final = Void()
|
|
21
31
|
|
|
22
32
|
|
|
23
33
|
class _IO:
|
|
@@ -74,46 +84,82 @@ class _IO:
|
|
|
74
84
|
def remove_file(self, path: Path) -> None:
|
|
75
85
|
if path.exists() and not path.is_file():
|
|
76
86
|
raise OSError(f"Path '{path}' exists but is not a regular file.")
|
|
77
|
-
|
|
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:
|
|
78
98
|
|
|
79
|
-
|
|
99
|
+
created = not path.exists()
|
|
80
100
|
self.create_file(path)
|
|
81
101
|
content = path.read_text(encoding=self.ENCODING).strip()
|
|
82
|
-
if content
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
|
112
|
+
|
|
113
|
+
def load(self, path: Path, ta: TypeAdapter = _any_ta) -> Any:
|
|
92
114
|
# never returns EmptyFile, it throws an error if the file is empty
|
|
93
115
|
with self._lock:
|
|
94
116
|
if path in self._pending_writes:
|
|
95
|
-
|
|
96
|
-
if
|
|
97
|
-
return value
|
|
98
|
-
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)
|
|
99
121
|
return self._deserialize(text, ta, path)
|
|
100
|
-
text = path.read_text(encoding=self.ENCODING)
|
|
122
|
+
text = path.read_text(encoding=self.ENCODING)
|
|
101
123
|
return self._deserialize(text, ta, path)
|
|
102
124
|
|
|
103
|
-
def
|
|
125
|
+
def dump(self, path: Path, value: Any, ta: TypeAdapter = _any_ta) -> None:
|
|
104
126
|
with self._lock:
|
|
105
127
|
if path in self._pending_writes:
|
|
106
|
-
self._pending_writes[path]
|
|
128
|
+
self._pending_writes[path].timer.cancel()
|
|
107
129
|
timer = Timer(self.DEBOUNCE_WINDOW, self._scheduled_write, args=(path,))
|
|
108
|
-
self._pending_writes[path] = (value, ta, timer)
|
|
130
|
+
self._pending_writes[path] = PendingWrite(value, ta, timer)
|
|
109
131
|
timer.start()
|
|
110
132
|
|
|
133
|
+
def read_json(self, path: Path) -> str:
|
|
134
|
+
with self._lock:
|
|
135
|
+
if path in self._pending_writes:
|
|
136
|
+
pending = self._pending_writes[path]
|
|
137
|
+
return self._serialize(pending.value, pending.ta)
|
|
138
|
+
return path.read_text(encoding=self.ENCODING)
|
|
139
|
+
|
|
140
|
+
def write_json(self, path: Path, text: str) -> None:
|
|
141
|
+
with self._lock:
|
|
142
|
+
if path in self._pending_writes:
|
|
143
|
+
self._pending_writes.pop(path).timer.cancel()
|
|
144
|
+
self._atomic_write(path, text)
|
|
145
|
+
|
|
111
146
|
def _scheduled_write(self, path: Path) -> None:
|
|
112
147
|
with self._lock:
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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)
|
|
117
163
|
|
|
118
164
|
def _atomic_write(self, path: Path, text: str) -> None:
|
|
119
165
|
fd, tmp_path = mkstemp(prefix=watcher.TMP_FILE_PREFIX, dir=path.parent)
|
|
@@ -127,7 +173,6 @@ class _IO:
|
|
|
127
173
|
# Atomically replace the target file
|
|
128
174
|
watcher.mark_self_write(path)
|
|
129
175
|
os.replace(tmp_path, path)
|
|
130
|
-
# print(f"Wrote JSON to {path}")
|
|
131
176
|
|
|
132
177
|
except Exception as e:
|
|
133
178
|
# Attempt to remove temporary file on error
|
|
@@ -143,22 +188,30 @@ class _IO:
|
|
|
143
188
|
|
|
144
189
|
def _deserialize(self, text: str, ta: TypeAdapter, path: Path) -> Any:
|
|
145
190
|
try:
|
|
146
|
-
|
|
191
|
+
value = ta.validate_json(text)
|
|
147
192
|
except ValidationError as e:
|
|
148
193
|
try:
|
|
149
194
|
self._any_ta.validate_json(text)
|
|
150
195
|
except ValidationError:
|
|
151
196
|
raise ValueError(f"File '{path}' contains malformed JSON.") from None
|
|
152
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
|
|
153
206
|
|
|
154
|
-
def _get_default(self, ta: TypeAdapter) -> Any |
|
|
207
|
+
def _get_default(self, ta: TypeAdapter) -> Any | Void:
|
|
155
208
|
defaults = [{}, [], "", None]
|
|
156
209
|
for default in defaults:
|
|
157
210
|
try:
|
|
158
211
|
return ta.validate_python(default)
|
|
159
212
|
except ValidationError:
|
|
160
213
|
continue
|
|
161
|
-
return
|
|
214
|
+
return void
|
|
162
215
|
|
|
163
216
|
|
|
164
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)
|