tinydantic 0.3.1__tar.gz → 0.4.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.
- {tinydantic-0.3.1 → tinydantic-0.4.0}/PKG-INFO +2 -2
- {tinydantic-0.3.1 → tinydantic-0.4.0}/pyproject.toml +74 -39
- {tinydantic-0.3.1 → tinydantic-0.4.0}/src/tinydantic/_errors.py +34 -8
- {tinydantic-0.3.1 → tinydantic-0.4.0}/src/tinydantic/_model.py +212 -37
- {tinydantic-0.3.1 → tinydantic-0.4.0}/LICENSES/Apache-2.0.txt +0 -0
- {tinydantic-0.3.1 → tinydantic-0.4.0}/LICENSES/CC-BY-4.0.txt +0 -0
- {tinydantic-0.3.1 → tinydantic-0.4.0}/LICENSES/MIT.txt +0 -0
- {tinydantic-0.3.1 → tinydantic-0.4.0}/README.md +0 -0
- {tinydantic-0.3.1 → tinydantic-0.4.0}/src/tinydantic/__init__.py +0 -0
- {tinydantic-0.3.1 → tinydantic-0.4.0}/src/tinydantic/_config.py +0 -0
- {tinydantic-0.3.1 → tinydantic-0.4.0}/src/tinydantic/py.typed +0 -0
- {tinydantic-0.3.1 → tinydantic-0.4.0}/src/tinydantic/tinydb/__init__.py +0 -0
- {tinydantic-0.3.1 → tinydantic-0.4.0}/src/tinydantic/tinydb/middlewares.py +0 -0
- {tinydantic-0.3.1 → tinydantic-0.4.0}/src/tinydantic/tinydb/operations.py +0 -0
- {tinydantic-0.3.1 → tinydantic-0.4.0}/src/tinydantic/tinydb/storages.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: tinydantic
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: A Pydantic-powered ODM (object-document mapper) for TinyDB
|
|
5
5
|
Keywords: odm,pydantic,tinydb
|
|
6
6
|
Author: Chris Wilson
|
|
@@ -13,6 +13,7 @@ Classifier: Development Status :: 3 - Alpha
|
|
|
13
13
|
Classifier: Framework :: Pydantic :: 2
|
|
14
14
|
Classifier: Intended Audience :: Developers
|
|
15
15
|
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python
|
|
16
17
|
Classifier: Programming Language :: Python :: 3
|
|
17
18
|
Classifier: Programming Language :: Python :: 3.10
|
|
18
19
|
Classifier: Programming Language :: Python :: 3.11
|
|
@@ -20,7 +21,6 @@ Classifier: Programming Language :: Python :: 3.12
|
|
|
20
21
|
Classifier: Programming Language :: Python :: 3.13
|
|
21
22
|
Classifier: Programming Language :: Python :: 3.14
|
|
22
23
|
Classifier: Programming Language :: Python :: Implementation :: CPython
|
|
23
|
-
Classifier: Programming Language :: Python
|
|
24
24
|
Classifier: Topic :: Database
|
|
25
25
|
Classifier: Topic :: Software Development :: Libraries
|
|
26
26
|
Classifier: Typing :: Typed
|
|
@@ -4,22 +4,31 @@
|
|
|
4
4
|
|
|
5
5
|
#:schema https://json.schemastore.org/pyproject.json
|
|
6
6
|
|
|
7
|
-
[build-system]
|
|
8
|
-
build-backend = "uv_build"
|
|
9
|
-
requires = [
|
|
10
|
-
"uv_build>=0.11,<0.12",
|
|
11
|
-
]
|
|
12
|
-
|
|
13
7
|
[project]
|
|
8
|
+
name = "tinydantic"
|
|
9
|
+
version = "0.4.0"
|
|
10
|
+
description = "A Pydantic-powered ODM (object-document mapper) for TinyDB"
|
|
11
|
+
readme = "README.md"
|
|
12
|
+
requires-python = ">=3.10"
|
|
13
|
+
license = "Apache-2.0 OR MIT"
|
|
14
|
+
license-files = [
|
|
15
|
+
"LICENSES/*",
|
|
16
|
+
]
|
|
14
17
|
authors = [
|
|
15
18
|
{ name = "Chris Wilson", email = "christopher.david.wilson@gmail.com" },
|
|
16
19
|
]
|
|
20
|
+
keywords = [
|
|
21
|
+
"odm",
|
|
22
|
+
"pydantic",
|
|
23
|
+
"tinydb",
|
|
24
|
+
]
|
|
17
25
|
# https://pypi.org/classifiers/
|
|
18
26
|
classifiers = [
|
|
19
27
|
"Development Status :: 3 - Alpha",
|
|
20
28
|
"Framework :: Pydantic :: 2",
|
|
21
29
|
"Intended Audience :: Developers",
|
|
22
30
|
"Operating System :: OS Independent",
|
|
31
|
+
"Programming Language :: Python",
|
|
23
32
|
"Programming Language :: Python :: 3",
|
|
24
33
|
"Programming Language :: Python :: 3.10",
|
|
25
34
|
"Programming Language :: Python :: 3.11",
|
|
@@ -27,7 +36,6 @@ classifiers = [
|
|
|
27
36
|
"Programming Language :: Python :: 3.13",
|
|
28
37
|
"Programming Language :: Python :: 3.14",
|
|
29
38
|
"Programming Language :: Python :: Implementation :: CPython",
|
|
30
|
-
"Programming Language :: Python",
|
|
31
39
|
"Topic :: Database",
|
|
32
40
|
"Topic :: Software Development :: Libraries",
|
|
33
41
|
"Typing :: Typed",
|
|
@@ -37,20 +45,6 @@ dependencies = [
|
|
|
37
45
|
"pydantic[email]>=2.11",
|
|
38
46
|
"tinydb>=4.8",
|
|
39
47
|
]
|
|
40
|
-
description = "A Pydantic-powered ODM (object-document mapper) for TinyDB"
|
|
41
|
-
keywords = [
|
|
42
|
-
"odm",
|
|
43
|
-
"pydantic",
|
|
44
|
-
"tinydb",
|
|
45
|
-
]
|
|
46
|
-
license = "Apache-2.0 OR MIT"
|
|
47
|
-
license-files = [
|
|
48
|
-
"LICENSES/*",
|
|
49
|
-
]
|
|
50
|
-
name = "tinydantic"
|
|
51
|
-
readme = "README.md"
|
|
52
|
-
requires-python = ">=3.10"
|
|
53
|
-
version = "0.3.1"
|
|
54
48
|
|
|
55
49
|
[project.urls]
|
|
56
50
|
Changelog = "https://github.com/tinydantic/tinydantic/blob/main/CHANGELOG.md"
|
|
@@ -109,11 +103,11 @@ test = [
|
|
|
109
103
|
"httpx2",
|
|
110
104
|
"packaging",
|
|
111
105
|
"poethepoet",
|
|
106
|
+
"pytest>=8",
|
|
112
107
|
"pytest-cov",
|
|
113
108
|
"pytest-randomly",
|
|
114
109
|
"pytest-rerunfailures",
|
|
115
110
|
"pytest-xdist",
|
|
116
|
-
"pytest>=8",
|
|
117
111
|
]
|
|
118
112
|
types = [
|
|
119
113
|
"mypy>=1.14",
|
|
@@ -121,6 +115,20 @@ types = [
|
|
|
121
115
|
"types-PyYAML",
|
|
122
116
|
]
|
|
123
117
|
|
|
118
|
+
[build-system]
|
|
119
|
+
requires = [
|
|
120
|
+
"uv_build>=0.11,<0.12",
|
|
121
|
+
]
|
|
122
|
+
build-backend = "uv_build"
|
|
123
|
+
|
|
124
|
+
[tool.commitizen]
|
|
125
|
+
annotated_tag = true
|
|
126
|
+
major_version_zero = true
|
|
127
|
+
name = "cz_conventional_commits"
|
|
128
|
+
tag_format = "v$version"
|
|
129
|
+
version_provider = "pep621"
|
|
130
|
+
version_scheme = "semver2"
|
|
131
|
+
|
|
124
132
|
[tool.coverage.run]
|
|
125
133
|
branch = true
|
|
126
134
|
parallel = true
|
|
@@ -146,13 +154,17 @@ exclude_lines = [
|
|
|
146
154
|
"no cov",
|
|
147
155
|
]
|
|
148
156
|
|
|
149
|
-
[tool.
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
157
|
+
[tool.interrogate]
|
|
158
|
+
fail-under = 100
|
|
159
|
+
ignore-overloaded-functions = true
|
|
160
|
+
ignore-private = true
|
|
161
|
+
ignore-semiprivate = true
|
|
162
|
+
omit-covered-files = true
|
|
163
|
+
style = "google"
|
|
164
|
+
verbose = 2
|
|
165
|
+
|
|
166
|
+
[tool.mypy]
|
|
167
|
+
python_version = "3.10"
|
|
156
168
|
|
|
157
169
|
[tool.poe.tasks]
|
|
158
170
|
clean = "pyclean . --debris --verbose"
|
|
@@ -170,9 +182,24 @@ sbom-check = "reuse lint"
|
|
|
170
182
|
spell-check = "npx cspell --gitignore --dot ."
|
|
171
183
|
test = "pytest"
|
|
172
184
|
test-cov = "pytest --cov --cov-branch"
|
|
185
|
+
# Each step syncs an ephemeral environment from uv.lock for one
|
|
186
|
+
# supported Python version (--isolated leaves .venv untouched) and
|
|
187
|
+
# runs test-cov in it, writing a per-version COVERAGE_FILE that the
|
|
188
|
+
# final coverage steps merge into one cross-version report.
|
|
189
|
+
test-matrix = [
|
|
190
|
+
"_test-py310",
|
|
191
|
+
"_test-py311",
|
|
192
|
+
"_test-py312",
|
|
193
|
+
"_test-py313",
|
|
194
|
+
"_test-py314",
|
|
195
|
+
"_coverage-combine",
|
|
196
|
+
"_coverage-report",
|
|
197
|
+
]
|
|
173
198
|
types = "mypy --install-types --non-interactive src/tinydantic tests"
|
|
174
199
|
update-project-words = { shell = "npx cspell --gitignore --dot --words-only --unique . | sort --ignore-case >> project-words.txt" }
|
|
175
200
|
|
|
201
|
+
_coverage-combine = "coverage combine .coverage.py310 .coverage.py311 .coverage.py312 .coverage.py313 .coverage.py314"
|
|
202
|
+
_coverage-report = "coverage report"
|
|
176
203
|
_ruff-check = "ruff check ."
|
|
177
204
|
_ruff-fix = "ruff check --fix ."
|
|
178
205
|
_ruff-format = "ruff format ."
|
|
@@ -206,17 +233,25 @@ env = { PYTHONUNBUFFERED = "1", SOURCE_DATE_EPOCH = "1580601600" }
|
|
|
206
233
|
cmd = "properdocs serve --config-file mkdocs.yaml --dev-addr localhost:8000"
|
|
207
234
|
env = { PYTHONUNBUFFERED = "1" }
|
|
208
235
|
|
|
209
|
-
[tool.
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
ignore-private = true
|
|
213
|
-
ignore-semiprivate = true
|
|
214
|
-
omit-covered-files = true
|
|
215
|
-
style = "google"
|
|
216
|
-
verbose = 2
|
|
236
|
+
[tool.poe.tasks._test-py310]
|
|
237
|
+
cmd = "uv run --isolated --python 3.10 --group test poe test-cov"
|
|
238
|
+
env = { COVERAGE_FILE = ".coverage.py310" }
|
|
217
239
|
|
|
218
|
-
[tool.
|
|
219
|
-
|
|
240
|
+
[tool.poe.tasks._test-py311]
|
|
241
|
+
cmd = "uv run --isolated --python 3.11 --group test poe test-cov"
|
|
242
|
+
env = { COVERAGE_FILE = ".coverage.py311" }
|
|
243
|
+
|
|
244
|
+
[tool.poe.tasks._test-py312]
|
|
245
|
+
cmd = "uv run --isolated --python 3.12 --group test poe test-cov"
|
|
246
|
+
env = { COVERAGE_FILE = ".coverage.py312" }
|
|
247
|
+
|
|
248
|
+
[tool.poe.tasks._test-py313]
|
|
249
|
+
cmd = "uv run --isolated --python 3.13 --group test poe test-cov"
|
|
250
|
+
env = { COVERAGE_FILE = ".coverage.py313" }
|
|
251
|
+
|
|
252
|
+
[tool.poe.tasks._test-py314]
|
|
253
|
+
cmd = "uv run --isolated --python 3.14 --group test poe test-cov"
|
|
254
|
+
env = { COVERAGE_FILE = ".coverage.py314" }
|
|
220
255
|
|
|
221
256
|
[tool.pytest.ini_options]
|
|
222
257
|
addopts = "--doctest-modules --doctest-glob '*.md'"
|
|
@@ -61,16 +61,42 @@ class AmbiguousConfigError(TinydanticUserError):
|
|
|
61
61
|
|
|
62
62
|
|
|
63
63
|
class DocumentNotFoundError(TinydanticError):
|
|
64
|
-
"""Requested document is not found.
|
|
64
|
+
"""Requested document is not found.
|
|
65
65
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
66
|
+
The message names the model, the table, and — when the lookup was
|
|
67
|
+
by id — the missing document id, so the error is actionable
|
|
68
|
+
without a debugger.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
def __init__(
|
|
72
|
+
self,
|
|
73
|
+
*,
|
|
74
|
+
model_name: str,
|
|
75
|
+
table_name: str,
|
|
76
|
+
doc_id: int | None = None,
|
|
77
|
+
) -> None:
|
|
78
|
+
"""Initialize with the model, table, and optional id context."""
|
|
79
|
+
if doc_id is not None:
|
|
80
|
+
selector = f"with id {doc_id}"
|
|
81
|
+
else:
|
|
82
|
+
selector = "matching the given query"
|
|
83
|
+
super().__init__(
|
|
84
|
+
f"No document {selector} in table {table_name!r} "
|
|
85
|
+
f"(model {model_name!r})",
|
|
86
|
+
)
|
|
69
87
|
|
|
70
88
|
|
|
71
89
|
class DocumentIDRequiredError(TinydanticError):
|
|
72
|
-
"""Required document ID is missing.
|
|
90
|
+
"""Required document ID is missing.
|
|
91
|
+
|
|
92
|
+
Raised by instance operations that address a stored document by
|
|
93
|
+
its id (``replace()``, ``delete()``) when the instance was never
|
|
94
|
+
inserted, so its ``id`` is still ``None``.
|
|
95
|
+
"""
|
|
73
96
|
|
|
74
|
-
def __init__(self) -> None:
|
|
75
|
-
"""Initialize with the
|
|
76
|
-
super().__init__(
|
|
97
|
+
def __init__(self, *, model_name: str, operation: str) -> None:
|
|
98
|
+
"""Initialize with the model name and attempted operation."""
|
|
99
|
+
super().__init__(
|
|
100
|
+
f"Cannot {operation}() a {model_name!r} instance whose id "
|
|
101
|
+
"is None — insert() or save() it first",
|
|
102
|
+
)
|
|
@@ -8,7 +8,7 @@ from __future__ import annotations
|
|
|
8
8
|
|
|
9
9
|
from typing import TYPE_CHECKING, Any, ClassVar, cast, overload
|
|
10
10
|
|
|
11
|
-
from pydantic import BaseModel, ConfigDict, Field
|
|
11
|
+
from pydantic import BaseModel, ConfigDict, Field, TypeAdapter
|
|
12
12
|
from pydantic.alias_generators import to_snake
|
|
13
13
|
from tinydb.queries import Query, where
|
|
14
14
|
from tinydb.table import Document, Table
|
|
@@ -44,9 +44,13 @@ if TYPE_CHECKING:
|
|
|
44
44
|
else:
|
|
45
45
|
ModelMetaclass = type(BaseModel)
|
|
46
46
|
|
|
47
|
+
# Name of the per-class attribute caching per-field TypeAdapters
|
|
48
|
+
# (built lazily by _field_adapter for update() serialization).
|
|
49
|
+
_FIELD_ADAPTERS_ATTR = "__tinydantic_field_adapters__"
|
|
50
|
+
|
|
47
51
|
|
|
48
52
|
def q(field: Any) -> Query:
|
|
49
|
-
"""
|
|
53
|
+
"""Build a typed TinyDB Query from a field or a field name.
|
|
50
54
|
|
|
51
55
|
At runtime, class-level field access like ``User.name`` already
|
|
52
56
|
returns a [Query][tinydb.queries.Query] (courtesy of the model
|
|
@@ -59,21 +63,36 @@ def q(field: Any) -> Query:
|
|
|
59
63
|
User.search(q(User.name) == "Alice")
|
|
60
64
|
```
|
|
61
65
|
|
|
66
|
+
A string builds a query on that document key
|
|
67
|
+
(``tinydb.queries.where``). This is the escape hatch for fields
|
|
68
|
+
whose names collide with model methods (``search``, ``get``,
|
|
69
|
+
``count``, ...) and are therefore unreachable through the
|
|
70
|
+
``Model.field`` shorthand:
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
Command.search(q("search") == "fuzzy")
|
|
74
|
+
```
|
|
75
|
+
|
|
62
76
|
Args:
|
|
63
|
-
field: A class-level field expression (e.g. ``User.name``)
|
|
77
|
+
field: A class-level field expression (e.g. ``User.name``)
|
|
78
|
+
or a field name string (e.g. ``"name"``).
|
|
64
79
|
|
|
65
80
|
Returns:
|
|
66
|
-
The
|
|
81
|
+
The field expression unchanged, or a Query on the named
|
|
82
|
+
field — either way, typed as a Query.
|
|
67
83
|
|
|
68
84
|
Raises:
|
|
69
|
-
TypeError: If ``field`` is
|
|
70
|
-
example when called with an instance
|
|
71
|
-
|
|
85
|
+
TypeError: If ``field`` is neither a TinyDB Query nor a
|
|
86
|
+
string — for example when called with an instance
|
|
87
|
+
attribute instead of class-level field access.
|
|
72
88
|
"""
|
|
89
|
+
if isinstance(field, str):
|
|
90
|
+
return where(field)
|
|
73
91
|
if not isinstance(field, Query):
|
|
74
92
|
msg = (
|
|
75
93
|
f"q() expected a TinyDB Query (class-level field access "
|
|
76
|
-
f"like Model.field), got
|
|
94
|
+
f"like Model.field) or a field name string, got "
|
|
95
|
+
f"{type(field).__name__!r}"
|
|
77
96
|
)
|
|
78
97
|
raise TypeError(msg)
|
|
79
98
|
return field
|
|
@@ -270,24 +289,30 @@ class TinydanticModel(BaseModel, metaclass=TinydanticModelMetaclass):
|
|
|
270
289
|
return instance
|
|
271
290
|
|
|
272
291
|
@classmethod
|
|
273
|
-
def insert_multiple(cls, documents: Iterable[Self]) -> list[
|
|
292
|
+
def insert_multiple(cls, documents: Iterable[Self]) -> list[Self]:
|
|
274
293
|
"""Insert several models at once.
|
|
275
294
|
|
|
276
295
|
Serializes each model with
|
|
277
296
|
[to_tinydb_document][tinydantic.TinydanticModel.to_tinydb_document]
|
|
278
|
-
and hands them to [tinydb.table.Table.insert_multiple][].
|
|
279
|
-
|
|
280
|
-
|
|
297
|
+
and hands them to [tinydb.table.Table.insert_multiple][].
|
|
298
|
+
Exactly like [insert][tinydantic.TinydanticModel.insert], each
|
|
299
|
+
model's ``id`` is set in place to the document id TinyDB
|
|
300
|
+
assigned, and the same instances are returned in insertion
|
|
301
|
+
order.
|
|
281
302
|
|
|
282
303
|
Args:
|
|
283
304
|
documents: The models to insert.
|
|
284
305
|
|
|
285
306
|
Returns:
|
|
286
|
-
The
|
|
307
|
+
The inserted models, with ``id`` set, in insertion order.
|
|
287
308
|
"""
|
|
288
|
-
|
|
289
|
-
|
|
309
|
+
docs = list(documents)
|
|
310
|
+
doc_ids = cls.get_table().insert_multiple(
|
|
311
|
+
[doc.to_tinydb_document() for doc in docs],
|
|
290
312
|
)
|
|
313
|
+
for doc, doc_id in zip(docs, doc_ids, strict=True):
|
|
314
|
+
doc.id = doc_id
|
|
315
|
+
return docs
|
|
291
316
|
|
|
292
317
|
@classmethod
|
|
293
318
|
def all(cls) -> list[Self]:
|
|
@@ -387,6 +412,58 @@ class TinydanticModel(BaseModel, metaclass=TinydanticModelMetaclass):
|
|
|
387
412
|
"""Get documents for the given ids (see get() for semantics)."""
|
|
388
413
|
return cls.get(doc_ids=doc_ids)
|
|
389
414
|
|
|
415
|
+
@overload
|
|
416
|
+
@classmethod
|
|
417
|
+
def get_or_raise(cls, cond: QueryLike) -> Self: ...
|
|
418
|
+
|
|
419
|
+
@overload
|
|
420
|
+
@classmethod
|
|
421
|
+
def get_or_raise(cls, *, doc_id: int) -> Self: ...
|
|
422
|
+
|
|
423
|
+
@classmethod
|
|
424
|
+
def get_or_raise(
|
|
425
|
+
cls,
|
|
426
|
+
cond: QueryLike | None = None,
|
|
427
|
+
*,
|
|
428
|
+
doc_id: int | None = None,
|
|
429
|
+
) -> Self:
|
|
430
|
+
"""Get one document, raising instead of returning ``None``.
|
|
431
|
+
|
|
432
|
+
The strict counterpart to
|
|
433
|
+
[get][tinydantic.TinydanticModel.get] for call sites where a
|
|
434
|
+
missing document is an error rather than an expected outcome
|
|
435
|
+
(request handlers, lookups by known id, ...). Accepts exactly
|
|
436
|
+
one selector: a query condition or a ``doc_id``. There is no
|
|
437
|
+
``doc_ids`` form — TinyDB silently skips missing ids in bulk
|
|
438
|
+
gets, so "raise if missing" has no single obvious meaning
|
|
439
|
+
there.
|
|
440
|
+
|
|
441
|
+
Args:
|
|
442
|
+
cond: The query condition to match.
|
|
443
|
+
doc_id: The document id to fetch.
|
|
444
|
+
|
|
445
|
+
Returns:
|
|
446
|
+
The validated model instance.
|
|
447
|
+
|
|
448
|
+
Raises:
|
|
449
|
+
DocumentNotFoundError: If no matching document exists.
|
|
450
|
+
ValueError: If no selector or both selectors are provided.
|
|
451
|
+
"""
|
|
452
|
+
if cond is not None and doc_id is None:
|
|
453
|
+
result = cls.get(cond)
|
|
454
|
+
elif doc_id is not None and cond is None:
|
|
455
|
+
result = cls.get(doc_id=doc_id)
|
|
456
|
+
else:
|
|
457
|
+
msg = "Provide exactly one of cond or doc_id"
|
|
458
|
+
raise ValueError(msg)
|
|
459
|
+
if result is None:
|
|
460
|
+
raise DocumentNotFoundError(
|
|
461
|
+
model_name=cls.__name__,
|
|
462
|
+
table_name=cls.get_table().name,
|
|
463
|
+
doc_id=doc_id,
|
|
464
|
+
)
|
|
465
|
+
return result
|
|
466
|
+
|
|
390
467
|
@classmethod
|
|
391
468
|
def contains(
|
|
392
469
|
cls,
|
|
@@ -404,6 +481,56 @@ class TinydanticModel(BaseModel, metaclass=TinydanticModelMetaclass):
|
|
|
404
481
|
raise ValueError(msg)
|
|
405
482
|
return cls.get_table().contains(cond=cond, doc_id=doc_id)
|
|
406
483
|
|
|
484
|
+
@classmethod
|
|
485
|
+
def _field_adapter(cls, field_name: str) -> TypeAdapter[Any]:
|
|
486
|
+
"""Get (or build and cache) a TypeAdapter for a model field.
|
|
487
|
+
|
|
488
|
+
The adapter is built from the field's full annotation
|
|
489
|
+
(including ``Field(...)`` metadata, via
|
|
490
|
+
[rebuild_annotation][pydantic.fields.FieldInfo.rebuild_annotation])
|
|
491
|
+
and cached on this class, so repeated ``update()`` calls pay
|
|
492
|
+
the construction cost once per field.
|
|
493
|
+
"""
|
|
494
|
+
adapters: dict[str, TypeAdapter[Any]] | None = cls.__dict__.get(
|
|
495
|
+
_FIELD_ADAPTERS_ATTR,
|
|
496
|
+
)
|
|
497
|
+
if adapters is None:
|
|
498
|
+
adapters = {}
|
|
499
|
+
setattr(cls, _FIELD_ADAPTERS_ATTR, adapters)
|
|
500
|
+
adapter = adapters.get(field_name)
|
|
501
|
+
if adapter is None:
|
|
502
|
+
field_info = cls.model_fields[field_name]
|
|
503
|
+
adapter = TypeAdapter(field_info.rebuild_annotation())
|
|
504
|
+
adapters[field_name] = adapter
|
|
505
|
+
return adapter
|
|
506
|
+
|
|
507
|
+
@classmethod
|
|
508
|
+
def _serialize_update_fields(cls, fields: Mapping) -> dict[str, Any]:
|
|
509
|
+
"""Validate and JSON-serialize known field values in a mapping.
|
|
510
|
+
|
|
511
|
+
Each key that names a model field has its value validated
|
|
512
|
+
against that field's type and serialized in JSON mode — the
|
|
513
|
+
same treatment ``insert()``/``save()`` give whole models — so
|
|
514
|
+
rich values (datetime, UUID, nested models, ...) land in
|
|
515
|
+
storage as JSON-safe primitives. Keys that are not model
|
|
516
|
+
fields pass through unchanged.
|
|
517
|
+
|
|
518
|
+
Raises:
|
|
519
|
+
pydantic.ValidationError: If a value fails validation
|
|
520
|
+
against its field's type.
|
|
521
|
+
"""
|
|
522
|
+
serialized: dict[str, Any] = {}
|
|
523
|
+
for key, value in fields.items():
|
|
524
|
+
if key in cls.model_fields:
|
|
525
|
+
adapter = cls._field_adapter(key)
|
|
526
|
+
serialized[key] = adapter.dump_python(
|
|
527
|
+
adapter.validate_python(value),
|
|
528
|
+
mode="json",
|
|
529
|
+
)
|
|
530
|
+
else:
|
|
531
|
+
serialized[key] = value
|
|
532
|
+
return serialized
|
|
533
|
+
|
|
407
534
|
@classmethod
|
|
408
535
|
def update(
|
|
409
536
|
cls,
|
|
@@ -414,15 +541,23 @@ class TinydanticModel(BaseModel, metaclass=TinydanticModelMetaclass):
|
|
|
414
541
|
) -> list[int]:
|
|
415
542
|
"""Update matching documents with new fields or a transform.
|
|
416
543
|
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
544
|
+
A ``fields`` mapping gets the same treatment ``insert()`` and
|
|
545
|
+
``save()`` give whole models: each value that belongs to a
|
|
546
|
+
model field is validated against that field's type and
|
|
547
|
+
serialized to a JSON-safe primitive before it reaches storage
|
|
548
|
+
(keys that are not model fields pass through unchanged). A
|
|
549
|
+
transform callable is handed to TinyDB as-is — what it writes
|
|
550
|
+
is up to you.
|
|
422
551
|
|
|
423
552
|
Returns:
|
|
424
553
|
The ids of all updated documents.
|
|
554
|
+
|
|
555
|
+
Raises:
|
|
556
|
+
pydantic.ValidationError: If a mapping value fails
|
|
557
|
+
validation against its field's type.
|
|
425
558
|
"""
|
|
559
|
+
if not callable(fields):
|
|
560
|
+
fields = cls._serialize_update_fields(fields)
|
|
426
561
|
return cls.get_table().update(
|
|
427
562
|
# See replace() for why this cast is needed.
|
|
428
563
|
# TODO @cdwilson: remove this cast once the annotation is
|
|
@@ -444,20 +579,31 @@ class TinydanticModel(BaseModel, metaclass=TinydanticModelMetaclass):
|
|
|
444
579
|
) -> list[int]:
|
|
445
580
|
"""Apply several (fields_or_transform, cond) updates at once.
|
|
446
581
|
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
through
|
|
450
|
-
``datetime.isoformat()`` strings), or use a validated
|
|
451
|
-
instance's ``save()``/``replace()`` for full-model updates.
|
|
582
|
+
Each update's fields mapping is validated and serialized
|
|
583
|
+
exactly as in [update][tinydantic.TinydanticModel.update];
|
|
584
|
+
transform callables pass through to TinyDB as-is.
|
|
452
585
|
|
|
453
586
|
Returns:
|
|
454
587
|
The ids of all updated documents.
|
|
588
|
+
|
|
589
|
+
Raises:
|
|
590
|
+
pydantic.ValidationError: If a mapping value fails
|
|
591
|
+
validation against its field's type.
|
|
455
592
|
"""
|
|
593
|
+
prepared = [
|
|
594
|
+
(
|
|
595
|
+
fields
|
|
596
|
+
if callable(fields)
|
|
597
|
+
else cls._serialize_update_fields(fields),
|
|
598
|
+
cond,
|
|
599
|
+
)
|
|
600
|
+
for fields, cond in updates
|
|
601
|
+
]
|
|
456
602
|
return cls.get_table().update_multiple(
|
|
457
603
|
# See replace() for why this cast is needed.
|
|
458
604
|
cast(
|
|
459
605
|
"Iterable[tuple[Callable[[Mapping], None], QueryLike]]",
|
|
460
|
-
|
|
606
|
+
prepared,
|
|
461
607
|
),
|
|
462
608
|
)
|
|
463
609
|
|
|
@@ -501,17 +647,24 @@ class TinydanticModel(BaseModel, metaclass=TinydanticModelMetaclass):
|
|
|
501
647
|
cls.get_table().truncate()
|
|
502
648
|
|
|
503
649
|
@classmethod
|
|
504
|
-
def count(cls, cond: QueryLike) -> int:
|
|
505
|
-
"""Count the documents matching ``cond
|
|
650
|
+
def count(cls, cond: QueryLike | None = None) -> int:
|
|
651
|
+
"""Count the documents matching ``cond``, or all documents.
|
|
506
652
|
|
|
507
|
-
|
|
653
|
+
With a condition, delegates to [tinydb.table.Table.count][].
|
|
654
|
+
Without one, returns the total number of documents in the
|
|
655
|
+
table (``len(table)``) — TinyDB itself spells this
|
|
656
|
+
``len(db.table(...))``; tinydantic folds it into ``count()``
|
|
657
|
+
so "how many documents are there?" needs no query object.
|
|
508
658
|
|
|
509
659
|
Args:
|
|
510
|
-
cond: The query condition to match.
|
|
660
|
+
cond: The query condition to match. When omitted, every
|
|
661
|
+
document in the table is counted.
|
|
511
662
|
|
|
512
663
|
Returns:
|
|
513
|
-
The number of matching documents.
|
|
664
|
+
The number of matching (or total) documents.
|
|
514
665
|
"""
|
|
666
|
+
if cond is None:
|
|
667
|
+
return len(cls.get_table())
|
|
515
668
|
return cls.get_table().count(cond)
|
|
516
669
|
|
|
517
670
|
@classmethod
|
|
@@ -590,7 +743,10 @@ class TinydanticModel(BaseModel, metaclass=TinydanticModelMetaclass):
|
|
|
590
743
|
exists in the table.
|
|
591
744
|
"""
|
|
592
745
|
if self.id is None:
|
|
593
|
-
raise DocumentIDRequiredError
|
|
746
|
+
raise DocumentIDRequiredError(
|
|
747
|
+
model_name=type(self).__name__,
|
|
748
|
+
operation="replace",
|
|
749
|
+
)
|
|
594
750
|
|
|
595
751
|
try:
|
|
596
752
|
updated_doc_ids = self.get_table().update(
|
|
@@ -614,10 +770,18 @@ class TinydanticModel(BaseModel, metaclass=TinydanticModelMetaclass):
|
|
|
614
770
|
doc_ids=[self.id],
|
|
615
771
|
)
|
|
616
772
|
except KeyError:
|
|
617
|
-
raise DocumentNotFoundError
|
|
773
|
+
raise DocumentNotFoundError(
|
|
774
|
+
model_name=type(self).__name__,
|
|
775
|
+
table_name=self.get_table().name,
|
|
776
|
+
doc_id=self.id,
|
|
777
|
+
) from None
|
|
618
778
|
|
|
619
779
|
if not updated_doc_ids:
|
|
620
|
-
raise DocumentNotFoundError
|
|
780
|
+
raise DocumentNotFoundError(
|
|
781
|
+
model_name=type(self).__name__,
|
|
782
|
+
table_name=self.get_table().name,
|
|
783
|
+
doc_id=self.id,
|
|
784
|
+
)
|
|
621
785
|
|
|
622
786
|
def delete(self) -> None:
|
|
623
787
|
"""Remove this model's document from its table.
|
|
@@ -629,13 +793,24 @@ class TinydanticModel(BaseModel, metaclass=TinydanticModelMetaclass):
|
|
|
629
793
|
exists in the table.
|
|
630
794
|
"""
|
|
631
795
|
if self.id is None:
|
|
632
|
-
raise DocumentIDRequiredError
|
|
796
|
+
raise DocumentIDRequiredError(
|
|
797
|
+
model_name=type(self).__name__,
|
|
798
|
+
operation="delete",
|
|
799
|
+
)
|
|
633
800
|
try:
|
|
634
801
|
removed = self.get_table().remove(doc_ids=[self.id])
|
|
635
802
|
except KeyError:
|
|
636
|
-
raise DocumentNotFoundError
|
|
803
|
+
raise DocumentNotFoundError(
|
|
804
|
+
model_name=type(self).__name__,
|
|
805
|
+
table_name=self.get_table().name,
|
|
806
|
+
doc_id=self.id,
|
|
807
|
+
) from None
|
|
637
808
|
if not removed:
|
|
638
|
-
raise DocumentNotFoundError
|
|
809
|
+
raise DocumentNotFoundError(
|
|
810
|
+
model_name=type(self).__name__,
|
|
811
|
+
table_name=self.get_table().name,
|
|
812
|
+
doc_id=self.id,
|
|
813
|
+
)
|
|
639
814
|
|
|
640
815
|
def save(self) -> Self:
|
|
641
816
|
"""Insert this model if it is new, otherwise update it by id.
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|