tinydantic 0.3.0__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tinydantic
3
- Version: 0.3.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.0"
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.commitizen]
150
- annotated_tag = true
151
- major_version_zero = true
152
- name = "cz_conventional_commits"
153
- tag_format = "v$version"
154
- version_provider = "pep621"
155
- version_scheme = "semver2"
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,20 +233,28 @@ 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.interrogate]
210
- fail-under = 100
211
- ignore-overloaded-functions = true
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.mypy]
219
- python_version = "3.10"
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
- addopts = "--doctest-modules --doctest-glob '*.md' --ignore=docs/superpowers"
257
+ addopts = "--doctest-modules --doctest-glob '*.md'"
223
258
  doctest_optionflags = "NORMALIZE_WHITESPACE IGNORE_EXCEPTION_DETAIL"
224
259
  filterwarnings = [
225
260
  # The queries.md "shadowing" section deliberately defines a field named
@@ -2,12 +2,15 @@
2
2
  #
3
3
  # SPDX-License-Identifier: Apache-2.0 OR MIT
4
4
 
5
- """`tinydantic` is a simple Python object-document mapper (ODM) for the
6
- [TinyDB](https://tinydb.readthedocs.io/en/latest/) document database.
5
+ """A simple Python object-document mapper (ODM) for TinyDB.
6
+
7
+ `tinydantic` maps Python objects to and from documents stored in
8
+ the [TinyDB](https://tinydb.readthedocs.io/en/latest/) document
9
+ database.
7
10
 
8
11
  Attributes:
9
12
  __version__: The `tinydantic` package version.
10
- """ # noqa: D205
13
+ """
11
14
 
12
15
  from importlib import metadata
13
16
 
@@ -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
- def __init__(self) -> None:
67
- """Initializes an instance of this class."""
68
- super().__init__("Document not found")
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
- """Initializes an instance of this class."""
76
- super().__init__("Document ID is required")
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
- """Tell the type checker a field expression is a TinyDB Query.
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 same object, typed as a Query.
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 not actually a TinyDB Query for
70
- example when called with an instance attribute or a plain
71
- string instead of class-level field access.
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 {type(field).__name__!r}"
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[int]:
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][]. The
279
- models' own ``id`` attributes are NOT updated in place; the
280
- assigned document ids are returned instead.
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 ids of the inserted documents, in insertion order.
307
+ The inserted models, with ``id`` set, in insertion order.
287
308
  """
288
- return cls.get_table().insert_multiple(
289
- [doc.to_tinydb_document() for doc in documents],
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
- Unlike ``insert``/``save``/``upsert``, the ``fields`` mapping
418
- is passed to storage as-is values are NOT serialized through
419
- pydantic. Pass JSON-safe primitives (e.g.
420
- ``datetime.isoformat()`` strings), or use a validated
421
- instance's ``save()``/``replace()`` for full-model updates.
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
- Unlike ``insert``/``save``/``upsert``, each update's fields
448
- mapping is passed to storage as-is values are NOT serialized
449
- through pydantic. Pass JSON-safe primitives (e.g.
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
- updates,
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
- Delegates to [tinydb.table.Table.count][].
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 from None
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 from None
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.
@@ -31,7 +31,7 @@ class YAMLStorage(Storage):
31
31
  create_dirs: bool = False,
32
32
  encoding: str | None = None,
33
33
  access_mode: str = "r+",
34
- **kwargs,
34
+ **kwargs: Any,
35
35
  ) -> None:
36
36
  """Create a new instance.
37
37
 
@@ -41,9 +41,14 @@ class YAMLStorage(Storage):
41
41
  Note: Using an access mode other than `r` or `r+` will probably
42
42
  lead to data loss or data corruption!
43
43
 
44
- :param path: Where to store the YAML data.
45
- :param access_mode: mode in which the file is opened (r, r+)
46
- :type access_mode: str
44
+ Args:
45
+ path: Where to store the YAML data.
46
+ create_dirs: Whether to create all missing parent
47
+ directories of the storage file.
48
+ encoding: The encoding used to open the storage file.
49
+ access_mode: Mode in which the file is opened (`r`, `r+`).
50
+ **kwargs: Additional keyword arguments passed to
51
+ `yaml.dump()` when writing data.
47
52
  """
48
53
  super().__init__()
49
54
 
@@ -74,9 +79,9 @@ class YAMLStorage(Storage):
74
79
  def read(self) -> dict[str, dict[str, Any]] | None:
75
80
  """Read data from storage.
76
81
 
77
- :return: A python dict containing the database data from
78
- storage.
79
- :rtype: dict[str, dict[str, Any]] | None
82
+ Returns:
83
+ A python dict containing the database data from storage,
84
+ or `None` if the storage file is empty.
80
85
  """
81
86
  # Get the file size by moving the cursor to the file end and
82
87
  # reading its location
File without changes
File without changes