Skip to content

Commit ccf026c

Browse files
committed
docs: correct the find, errors, fields, and storage docstrings
_find.py: - F1 delete() claimed "an empty window is a no-op returning [] with zero storage writes" and that its two paths are "observably identical". The condition-only path runs before the empty-window check and reaches Table.remove(cond=…), which writes the table back whether or not anything matched. Measured with ProfilingMiddleware: 1 write on the condition path, 0 on the resolved-ids path. Now describes both and points at #145, which tracks making it zero. - F2/F3 the module and class docstrings predate filter(): "each clause is stated once" is true of ordering and window only, and filter() appeared in neither the modifier nor the terminal list. - F4 FindQuery.update()'s Raises omitted RevisionUpdateError. - F5 "honouring" -> "honor". F6 a rejection compared to a rule. _errors.py: - E1 SelectorError's docstring described a removed API four ways: it listed get_or_raise() (deleted this cycle), named get() and contains() as raisers (they take a required positional and raise QueryTypeError), described a "conflicting selectors" arm with no code path left, and illustrated it with get(doc_id=1, doc_ids=[1]) — a call that is a plain TypeError. Rewritten against the three real raise sites: update(), remove(), upsert(). - E2 DocumentIDRequiredError's raiser list omitted patch(). - E3 the summary line read as if "tuple" glossed "value". _fields.py: - FI1 both marker docstrings called update()/update_many() the bypass; update_by_ids(), update_all(), and FindQuery.update() bypass too. Same wording as _model.py and crud.md now. - FI2 the only reST `::` literal block in src/. mkdocstrings renders Markdown, so it published as a literal "case::". tinydb/ and scripts/: - T1 the ProfilingMiddleware stacking paragraph was exactly backwards. TinyDB talks to the outermost wrapper. Measured over one insert and five all() calls: outer 7 reads / 1 write, inner 2 reads / 0 writes. The same sentence was published on docs/usage/storage.md (D-STOR-1). - T2 YAMLStorage.__init__ said kwargs go to yaml.dump(); write() uses yaml.safe_dump, and its own docstring explains why. - T3 the access-mode note now says the inherited binary modes are accepted without a warning and do not work. - T4 "adaption" -> "adaptation". storage.md also had two claims that do not hold: - D-STOR-2 "TinyDB stores document ids as strings, so a table with ten documents serializes "1", "10", "2"" was stated as a general property. JSONStorage — the page's own default — writes "1"…"10" in insertion order; lexicographic order comes from sorting serializers such as yaml.safe_dump. Both verified. - D-STOR-3 "kept free of any dependency on tinydantic's core" is contradicted by ProcessLockMiddleware importing DatabaseLockedError and TinydanticUserError. The note now names the one dependency. - D-STOR-4 an open handle on Windows blocks deleting or renaming a file, not opening it. Assisted-by: Claude:claude-opus-5
1 parent 02365b5 commit ccf026c

7 files changed

Lines changed: 86 additions & 55 deletions

File tree

‎docs/usage/storage.md‎

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ A file-backed TinyDB holds an open file handle. Call [close()][tinydb.database.T
6363

6464
> [!TIP]
6565
>
66-
> `TinyDB` supports the context-manager protocol: `with TinyDB('db.json') as db:` closes the file on exit. TinyDB flushes on every write, so an unclosed handle will not lose data — but closing it releases the OS file handle promptly, which matters for long-running processes and on Windows, where an open handle blocks other openers.
66+
> `TinyDB` supports the context-manager protocol: `with TinyDB('db.json') as db:` closes the file on exit. TinyDB flushes on every write, so an unclosed handle will not lose data — but closing it releases the OS file handle promptly, which matters for long-running processes and on Windows, where an open handle blocks deleting or renaming the file.
6767
6868
## YAML files
6969

@@ -125,7 +125,7 @@ Log(id=1, msg='cached')
125125

126126
### Keeping document ids in numeric order
127127

128-
TinyDB stores document ids as strings, so a table with ten documents serializes `"1"`, `"10"`, `"2"` — lexicographic order, which undoes much of the reason to choose a human-readable format in the first place. [SortIntDocIDsMiddleware][tinydantic.tinydb.middlewares.SortIntDocIDsMiddleware] converts the ids back to integers on write and inserts them in ascending numeric order:
128+
TinyDB stringifies document ids before handing a table to the storage layer, so whether ten documents serialize in numeric or lexicographic order is up to the serializer. `JSONStorage` preserves insertion order and writes `"1"` … `"10"`; a serializer that sorts its keys — notably `yaml.safe_dump`, which sorts by default — writes `"1"`, `"10"`, `"2"`, which undoes much of the reason to choose a human-readable format in the first place. [SortIntDocIDsMiddleware][tinydantic.tinydb.middlewares.SortIntDocIDsMiddleware] converts the ids back to integers on write and inserts them in ascending numeric order, so both kinds of serializer agree:
129129

130130
```pycon
131131
>>> from tinydantic.tinydb.middlewares import SortIntDocIDsMiddleware
@@ -147,7 +147,7 @@ tickets:
147147

148148
```
149149

150-
Without it, that file would open with `1`, `10`, `2`. The trade-off is in the name: the middleware hands integer keys to the storage below it, which is fine for JSON and YAML but may break a storage that can only serialize string keys.
150+
Without it, that YAML file would open with `'1'`, `'10'`, `'2'`. The trade-off is in the name: the middleware hands integer keys to the storage below it, which is fine for JSON and YAML but may break a storage that can only serialize string keys.
151151

152152
### Counting storage operations
153153

@@ -173,15 +173,17 @@ Event(id=1, name='boot')
173173

174174
```
175175

176-
The counters are exact where timings are noisy, which makes them the right evidence for a performance bug report — an issue's measurement harness can state "3 storage reads" and be reproducible on any machine. Middlewares stack, and the position in the stack is what gets measured: `ProfilingMiddleware(CachingMiddleware(JSONStorage))` counts only what the cache lets through to disk, while `CachingMiddleware(ProfilingMiddleware(JSONStorage))` counts what the database asks for before caching.
176+
The counters are exact where timings are noisy, which makes them the right evidence for a performance bug report — an issue's measurement harness can state "3 storage reads" and be reproducible on any machine.
177+
178+
Middlewares stack, and the position in the stack is what gets measured. TinyDB talks to the **outermost** wrapper, so `ProfilingMiddleware(CachingMiddleware(JSONStorage))` counts what the database asks for _before_ caching, while `CachingMiddleware(ProfilingMiddleware(JSONStorage))` counts only what the cache lets through to disk. Wrap outermost to answer "how many operations did this call make?", innermost to answer "how much disk traffic did it cause?" — for one insert followed by five `all()` calls, the outer profiler reports 7 reads and 1 write, the inner one 2 reads and 0 writes.
177179

178180
## Beyond the built-ins
179181

180182
TinyDB's [storage documentation](https://tinydb.readthedocs.io/en/latest/usage.html#storage-types) lists more options, and third-party packages add backends such as SQLite. Any storage that presents the TinyDB `Storage` interface works with `tinydantic` unchanged, because `tinydantic` only ever hands the storage layer JSON-safe primitives.
181183

182184
> [!NOTE]
183185
>
184-
> `YAMLStorage` and the other helpers under `tinydantic.tinydb` are kept free of any dependency on `tinydantic`'s core, so they can later be extracted into a standalone TinyDB-extensions package. Import them from `tinydantic.tinydb.storages` today; if that extraction happens, the import path is the only thing that would change.
186+
> `YAMLStorage` and the other helpers under `tinydantic.tinydb` are meant to be extractable into a standalone TinyDB-extensions package, so they depend on `tinydantic`'s core as little as possible. The one dependency today is `ProcessLockMiddleware`, which raises [DatabaseLockedError][tinydantic.DatabaseLockedError] and [TinydanticUserError][tinydantic.TinydanticUserError] from `tinydantic._errors`; an extraction would have to give those errors a home. Everything else — `YAMLStorage`, `SortIntDocIDsMiddleware`, `ProfilingMiddleware`, and the operations — imports nothing from `tinydantic` at all. Import them from `tinydantic.tinydb.storages` and `tinydantic.tinydb.middlewares` today.
185187
186188
## Where next
187189

‎scripts/gen_api_docs.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
"""Generate API documentation pages and navigation.
66
7-
This script is an adaption of the recipe described in
7+
This script is an adaptation of the recipe described in
88
<https://mkdocstrings.github.io/recipes/#automatic-code-reference-pages>
99
for use with the [mkdocs-awesome-pages-plugin][1].
1010

‎src/tinydantic/_errors.py‎

Lines changed: 24 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ def __init__(
115115

116116

117117
class UniqueConstraintError(TinydanticError):
118-
"""A write would duplicate a unique value (tuple).
118+
"""A write would duplicate a unique value, or value tuple.
119119
120120
Raised by create-style and instance-level writes when a field
121121
marked [Unique][tinydantic.Unique] — or the field tuple of a
@@ -223,24 +223,31 @@ def __init__(self, *, model_name: str, keys: list[str]) -> None:
223223

224224

225225
class SelectorError(TinydanticUserError, ValueError):
226-
"""A method got no selector, or conflicting selectors.
227-
228-
Selector-taking methods (``get()``, ``contains()``,
229-
``update()``, ``remove()``, ``get_or_raise()``, ``upsert()``)
230-
need exactly one way to pick documents. This covers *how many*
231-
were given — none, or several — not what they are: a selector
232-
of the wrong kind is a
233-
[QueryTypeError][tinydantic.QueryTypeError].
226+
"""A method needed a way to pick documents and got none.
227+
228+
Raised by ``update()`` and ``remove()`` when their optional
229+
``cond`` is omitted, and by ``upsert()`` when ``cond`` is
230+
omitted and the document's ``id`` is ``None`` too. Each of the
231+
three has a whole-table or by-id spelling that the message
232+
names, so a dropped condition can never quietly become "every
233+
document".
234+
235+
This covers *whether* a selector was given, not what it is: a
236+
selector of the wrong kind is a
237+
[QueryTypeError][tinydantic.QueryTypeError]. Methods whose
238+
``cond`` is a required positional — ``search()``, ``get()``,
239+
``get_or_none()``, ``contains()`` — never raise this; omitting
240+
the argument is an ordinary ``TypeError``.
234241
235242
It stays separate from
236243
[QueryValueError][tinydantic.QueryValueError], despite sharing
237-
a base, because a selector is not always a query:
238-
``get(doc_id=1, doc_ids=[1])`` names no query at all.
244+
a base, because a selector is not always a query: ``upsert()``
245+
can be selected by the document's own ``id``.
239246
240-
``ValueError`` is the base for the same reason
241-
``subprocess.run(capture_output=True, stdout=...)`` raises one
242-
— the arguments are individually well-typed and wrong in
243-
combination. tinydantic raises it instead of letting TinyDB's
247+
``ValueError`` is the base because the arguments are
248+
individually well-typed and wrong in combination, the same
249+
reason ``subprocess.run(capture_output=True, stdout=...)``
250+
raises one. tinydantic raises it instead of letting TinyDB's
244251
``RuntimeError`` (which nothing catches deliberately) or its
245252
hints about TinyDB internals leak through.
246253
"""
@@ -425,8 +432,8 @@ class DocumentIDRequiredError(TinydanticError):
425432
"""Required document ID is missing.
426433
427434
Raised by instance operations that address a stored document by
428-
its id (``replace()``, ``delete()``) when the instance was never
429-
inserted, so its ``id`` is still ``None``.
435+
its id (``replace()``, ``delete()``, ``patch()``) when the
436+
instance was never inserted, so its ``id`` is still ``None``.
430437
"""
431438

432439
def __init__(self, *, model_name: str, operation: str) -> None:

‎src/tinydantic/_fields.py‎

Lines changed: 22 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -46,15 +46,16 @@ class Follow(
4646
class Unique:
4747
"""Mark a field's values as unique within the model's table.
4848
49-
Enforced with a check-then-write scan on create-style and
50-
instance-level writes (``insert``, ``insert_many``,
51-
``save``, ``replace``, ``upsert``, ``patch``); violations
52-
raise
49+
Enforced with a check-then-write scan by the six writes that
50+
carry a model instance (``insert``, ``insert_many``, ``save``,
51+
``replace``, ``patch``, ``upsert``); violations raise
5352
[UniqueConstraintError][tinydantic.UniqueConstraintError].
54-
``None`` values are exempt (SQL ``NULL`` semantics), the
55-
table-level bulk ``update()``/``update_many()`` path is a
56-
documented bypass, and the check is in-process only — see the
57-
models guide for the full contract.
53+
``None`` values are exempt (SQL ``NULL`` semantics), the five
54+
update verbs (``update()``, ``update_by_ids()``,
55+
``update_all()``, ``update_many()``, and
56+
``FindQuery.update()``) are a documented bypass, and the check
57+
is in-process only — see the models guide for the full
58+
contract.
5859
5960
Attributes:
6061
key: Optional comparison-key callable. When set,
@@ -87,9 +88,10 @@ class UniqueConstraint:
8788
[UniqueConstraintError][tinydantic.UniqueConstraintError].
8889
A row participates in the check only when **all** of the
8990
constraint's fields are non-``None`` (SQL composite
90-
``UNIQUE``/``NULL`` semantics); the table-level bulk
91-
``update()``/``update_many()`` path is a documented
92-
bypass, and the check is in-process only.
91+
``UNIQUE``/``NULL`` semantics); the five update verbs
92+
(``update()``, ``update_by_ids()``, ``update_all()``,
93+
``update_many()``, and ``FindQuery.update()``) are a
94+
documented bypass, and the check is in-process only.
9395
9496
Attributes:
9597
fields: The constrained field names, in declared order.
@@ -103,13 +105,15 @@ class UniqueConstraint:
103105
must be pure and deterministic and return a hashable
104106
result. It is never called when any field value is
105107
``None``. Example — slug unique per author, ignoring
106-
case::
107-
108-
UniqueConstraint(
109-
"author_id",
110-
"slug",
111-
key=lambda a, s: (a, s.casefold()),
112-
)
108+
case:
109+
110+
```python
111+
UniqueConstraint(
112+
"author_id",
113+
"slug",
114+
key=lambda a, s: (a, s.casefold()),
115+
)
116+
```
113117
"""
114118

115119
fields: tuple[str, ...]

‎src/tinydantic/_find.py‎

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
- The pipeline is fixed — match, sort, skip, limit — regardless
1616
of modifier call order, following the unanimous SQL/MongoDB/
1717
ODM convention.
18-
- Each clause is stated once; repeating a modifier raises
18+
- Ordering and window are stated once; repeating either raises
1919
[QueryUsageError][tinydantic.QueryUsageError] because the wider
2020
ecosystem disagrees on what repetition means (Beanie appends a
2121
tiebreaker, Python's stable-sort idiom and pandas make the
@@ -71,7 +71,7 @@
7171
def _validated_window(name: str, n: object) -> int:
7272
"""Check that a skip/limit operand is a non-negative int.
7373
74-
Bools are rejected like the id-condition operand rule.
74+
Bools are rejected, matching the id-condition operand rule.
7575
7676
Raises:
7777
QueryTypeError: If ``n`` is not an int (bools included).
@@ -114,11 +114,14 @@ class FindQuery(Generic[ModelT]):
114114
"""An immutable, lazy fluent query over a model's table.
115115
116116
Built by [find][tinydantic.TinydanticModel.find]; not meant to
117-
be constructed directly. Modifiers (``sort``/``skip``/
118-
``limit``) return new chains and validate eagerly; terminals
117+
be constructed directly. Chain-returning methods (``filter``,
118+
and the ``sort``/``skip``/``limit`` modifiers) return new
119+
chains and validate eagerly; terminals
119120
(``all``/``first``/``first_or_raise``/``count``/``exists``/
120121
iteration/``delete``/``update``) execute a fresh read against
121122
the current binding — results are never cached on the chain.
123+
``filter()`` is repeatable and ANDs into the condition; the
124+
three modifiers each state their clause once.
122125
123126
The pipeline is fixed: match, then sort, then skip, then
124127
limit, regardless of the order modifiers are called in. Every
@@ -433,7 +436,7 @@ def _in_window_order(written: list[int], window: list[int]) -> list[int]:
433436
434437
Condition writes report ids in table order, but a chain
435438
promises the order its own read pipeline produced — the
436-
whole point of honouring ``sort``/``skip``/``limit``.
439+
whole point of honoring ``sort``/``skip``/``limit``.
437440
"""
438441
touched = set(written)
439442
return [doc_id for doc_id in window if doc_id in touched]
@@ -456,11 +459,17 @@ def delete(self) -> list[int]:
456459
concrete ids first and removes those — unlike Beanie,
457460
which silently ignores sort/skip/limit on delete. Under
458461
the documented single-process, single-threaded contract
459-
the two paths are observably identical.
462+
the two paths remove the same documents and return the
463+
same ids.
460464
461-
An empty window is a no-op returning ``[]`` with zero
462-
storage writes. Deleting the whole table via ``find()``
463-
with no condition is legal (the spelling is explicit);
465+
They differ in storage cost on an empty window: the
466+
resolved-ids path returns ``[]`` without writing, while
467+
the condition-only path reaches TinyDB's
468+
``Table.remove(cond=…)``, which writes the table back
469+
whether or not anything matched. Tracked in issue #145.
470+
471+
Deleting the whole table via ``find()`` with no condition
472+
is legal (the spelling is explicit);
464473
[truncate][tinydantic.TinydanticModel.truncate] remains
465474
the idiomatic one-pass spelling.
466475
@@ -511,6 +520,8 @@ def update(
511520
Raises:
512521
DocumentIDUpdateError: If a mapping contains
513522
``id``.
523+
RevisionUpdateError: If a mapping contains
524+
``revision_id`` on a ``use_revision=True`` model.
514525
UnknownUpdateFieldError: If a mapping has non-field
515526
keys and ``extra_keys`` is ``"reject"``.
516527
pydantic.ValidationError: If a value or a merged

‎src/tinydantic/tinydb/middlewares.py‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -126,10 +126,13 @@ class ProfilingMiddleware(Middleware):
126126
to zero them between measurements.
127127
128128
Middlewares stack, and the position in the stack is what gets
129-
measured: ``ProfilingMiddleware(CachingMiddleware(JSONStorage))``
130-
counts only the operations the cache lets through, while
129+
measured. TinyDB talks to the **outermost** wrapper, so
130+
``ProfilingMiddleware(CachingMiddleware(JSONStorage))`` counts
131+
what the database asks for *before* caching, while
131132
``CachingMiddleware(ProfilingMiddleware(JSONStorage))`` counts
132-
what the database asks for before caching.
133+
only the operations the cache lets through to the file. Wrap
134+
outermost to measure the database's demand; wrap innermost to
135+
measure real disk traffic.
133136
134137
Attributes:
135138
read_count: Number of ``read()`` calls seen so far.

‎src/tinydantic/tinydb/storages.py‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,11 @@ def __init__(
5151
access mode is appropriate for writing.
5252
5353
Note: Using an access mode other than `r` or `r+` will probably
54-
lead to data loss or data corruption!
54+
lead to data loss or data corruption! The binary modes `rb`
55+
and `rb+` are accepted without a warning — inherited from
56+
TinyDB's `JSONStorage`, whose check this mirrors — but they
57+
do not work here: `write` writes a `str`, which raises
58+
`TypeError` on a binary handle.
5559
5660
Args:
5761
path: Where to store the YAML data.
@@ -60,7 +64,7 @@ def __init__(
6064
encoding: The encoding used to open the storage file.
6165
access_mode: Mode in which the file is opened (`r`, `r+`).
6266
**kwargs: Additional keyword arguments passed to
63-
`yaml.dump()` when writing data.
67+
`yaml.safe_dump()` when writing data.
6468
6569
Raises:
6670
ImportError: If PyYAML is not installed — it ships in

0 commit comments

Comments
 (0)