Repository navigation
Description
What happens
ShadowedFieldError offers the same remedy for every reserved name: rename the field, or list it in shadowed_fields= and query it with field(). For most of the ~60 names that contract holds — the only cost of opting out is the Model.field query sugar. But three names are methods tinydantic itself calls on instances during reads and writes: after_read, before_write, and to_tinydb_document. A pydantic field shadows only at the instance level, so opting out on one of these hands the machinery a field value where it expects a callable:
- A field named
after_readbreaks every read:from_tinydb_document()ends withinstance.after_read(), which is now a string. - A field named
to_tinydb_documentbreaks every write:insert(),save(),replace(),upsert(), andinsert_many()all serialize viaself.to_tinydb_document(). - A field named
before_writeis the treacherous one:insert()andsave()work, butpatch()raises.
The before_write split is an accident of a performance guard, not a decision. _before_write_whole_model() skips the hook when the model never overrode it, and its check — type(self).before_write is TinydanticModel.before_write — is a class-level comparison that instance shadowing cannot affect, so whole-model writes never reach the field. patch() calls _run_before_write() unguarded (guarding would save nothing there: the mapping the hook receives already exists), and that method begins with self.before_write(fields) — instance lookup, field value, TypeError.
Why it matters
The opt-out's stated cost is "query sugar is off for this name". For these three names the actual cost is "the model stops reading, or stops writing — possibly only on the code path you didn't test". A model with a before_write field can ship, insert and save correctly in production, and fail on its first patch() with a bare TypeError that names neither the field nor tinydantic.
What to do
Refuse the internally-dispatched names in shadowed_fields=: raise at class definition even when the name is listed, saying that renaming is the only option for these. The alternative — adding override-guards so a shadowed hook is skipped everywhere — makes the field appear to work while silently disabling any genuinely inherited hook, and every future instance-dispatched method would need to remember its own guard. Refusal is one list in one place.
(insert, save, delete, replace, and patch are also instance methods, but only user code calls them and the class-call form Model.save(doc) keeps working, so whether they stay opt-outable is a separate, softer question.)
Reproduction
import warnings
from tinydb import TinyDB
from tinydb.storages import MemoryStorage
from tinydantic import TinydanticModel
warnings.filterwarnings("ignore")
db = TinyDB(storage=MemoryStorage)
class Hooked(TinydanticModel, database=db, shadowed_fields=("after_read",)):
after_read: str
Hooked.get_table().insert({"after_read": "x"})
try:
Hooked.all()
except TypeError as e:
print("read with an after_read field:", e)
class Patched(TinydanticModel, database=db, shadowed_fields=("before_write",)):
before_write: str
n: int = 0
doc = Patched(before_write="x").insert()
print("insert() with a before_write field: worked, id =", doc.id)
try:
doc.patch(n=1)
except TypeError as e:
print("patch() with a before_write field:", e)Run with python repro.py (project venv).
Actual output:
read with an after_read field: 'str' object is not callable
insert() with a before_write field: worked, id = 1
patch() with a before_write field: 'str' object is not callable
Expected: both class definitions fail with an error stating that after_read / before_write cannot be opted out and must be renamed — rather than models that build fine and fail path-dependently at run time.
Also verified, same shape, kept out of the script for brevity: with shadowed_fields=("to_tinydb_document",) and a field of that name, insert() raises TypeError: 'str' object is not callable.
Environment: tinydantic 0.4.0 at 389c6d9, Python 3.10.20, TinyDB 4.9.0, pydantic 2.13.4, macOS. Run 2026-08-14.
The reproductions were run as shown. No fix was built or tested. Surfaced while working through the conflict-handling story for #121.
Assisted-by: Claude:claude-fable-5
Activity