Data Adapters¶
The abstraction layer between NiceView components and storage backends.
Data Adapters¶
Adapters decouple UI components from storage. Pass them explicitly for full control,
or let the from_* factory methods create them transparently.
| Adapter | Backs | Description |
|---|---|---|
ListAdapter(Type, list) |
Grid | In-memory list |
JsonAdapter(Type, path) |
Form | Single object in a JSON file; supports lock_field=, created_field=, strict= |
JsonListAdapter(Type, path) |
Grid | List of objects in a JSON file; supports created_field=, strict= |
JsonDirectoryAdapter(Type, dir_path, key_field=) |
Grid, Form, DrillDownWrapper |
One parsed object per file, named by a model-owned key; supports sort_key=, created_field=, lock_field=, strict= |
SqlModelAdapter(Type, engine) |
Grid, Form | SQLModel / SQLAlchemy table (requires the sqlmodel extra, see Installation) |
DirectoryAdapter(dir_path) |
DrillDownWrapper |
One file per item in a directory; items are filename metadata (FileEntry), not parsed content — supports rename(); suffix=None for a mixed-extension file browser |
FilteredAdapter(inner, predicate, defaults=) |
Grid, DrillDownWrapper |
Filtered view of another CollectionAdapter (see below) |
All JSON adapters write atomically (.tmp → rename).
JsonListAdapter, JsonDirectoryAdapter and SqlModelAdapter all implement ReloadableAdapter:
reload() re-reads from disk (JsonListAdapter) or fires a grid-refresh notification
(SqlModelAdapter and JsonDirectoryAdapter, where every read() is already live from disk).
DirectoryAdapter models "files in a directory" for DrillDownWrapper's file-per-item use
case. items()/read() return FileEntry(name, mtime, size) — metadata only, never the parsed
file content; open the file's own JsonAdapter/JsonListAdapter for that (typically inside
render_detail). Keys never carry the file suffix — name is always the bare filename stem, so
a "Name" widget in render_detail never has to show or strip .json itself; if a user types the
suffix anyway, create()/rename() strip a trailing match rather than doubling it up
("note.json" → note, file note.json). create(item=None) picks a free 'untitled-01',
'untitled-02', ... name and writes default_content; rename(key, new_key) renames the file
on disk. Both are meant to be called directly by application code (an "Add" handler, a "Name"
widget's blur handler), not through DrillDownWrapper's generic item_type()-based Add flow
— see examples/13_directory_drilldown.py.
JsonDirectoryAdapter is the parsed sibling of DirectoryAdapter: each file holds one
Pydantic model (like JsonAdapter), but the collection is a directory of them (like
JsonListAdapter), one item per file. The key is the value of a model field (key_field=,
default 'id') and is also the file name — item x lives at <dir>/<x.id>.json. So a model
must own a stable key (typically id: str = Field(default_factory=lambda: uuid4().hex)) that
never changes, which is what lets other records reference an item by key across renames of any
other field — the natural backing store for a key-select foreign key (see
Concepts → Relationships). CRUD is strict:
create() refuses an existing key, update()/delete() an absent one, matching the
DrillDownWrapper Add-then-autosave flow. Reads always hit disk, so the listing is always
fresh (reload() just fires a refresh notification — there is no cache); a file that cannot yield
a keyed record — malformed JSON, a non-object root, or a missing/invalid key — is skipped from the
listing (logged), never listed as an empty ghost. strict= (default False, like the other JSON
adapters) decides what happens to a recoverable file — a valid object with one bad field: lenient
drops the field, strict rejects (and skips) it. Per-file IO goes through JsonAdapter, so
created_field=/lock_field= optimistic locking work per item. sort_key= orders the listing
(default: by key).
Passing suffix=None (or '') switches DirectoryAdapter into all-files mode — a general
file browser over a directory with mixed extensions. Keys become the full filename (extension
included, no stripping), and create() takes a full name verbatim (create() without an item
generates a bare untitled-NN). The default suffix mode (suffix='.json') keeps keying by stem.
In both modes the listing covers regular files only, hidden dotfiles are excluded, and an
optional name_filter=Callable[[str], bool] narrows it further. name_filter always receives the
full filename including the extension, never the key — so it reads the same in either mode. Note
that dotfile exclusion is not redundant in suffix mode: pathlib's glob('*.json') does match
.hidden.json. Files whose key would be unusable (a bare .json in suffix mode, or a name
containing a path separator) are skipped rather than raising, so a single odd file cannot break the
whole listing; addressing such a file explicitly by key still raises ValueError.
FilteredAdapter wraps any CollectionAdapter and filters iteration by a predicate —
the standard way to show a parent-filtered view of a child collection (e.g. only the books of
one author) while mutations still go through the inner adapter for persistence. defaults=
injects field values on create(), so new items automatically belong to the current parent.
Change notifications and reload() are forwarded from/to the inner adapter. See
examples/11_tree_navigation.py.
from niceview import FilteredAdapter
books_of_author = FilteredAdapter(books_adapter,
predicate=lambda b: b.author_id == author.id,
defaults={'author_id': author.id}, # applied to every create()
)
ModelGrid(Book, books_of_author).render()
Lenient loading (default) vs strict loading: JsonAdapter and JsonListAdapter default to
strict=False, which means a hand-edited or partially-migrated file does not crash the
application:
- Malformed JSON or a non-object/non-array root →
log.error, return a default instance / empty list. - Unknown field →
log.error, field ignored. - Field with invalid value →
log.error, field dropped and replaced by its model default. - Required field absent (no default) →
log.error, item/load fails and raises (last resort). OSErrorreading the file (JsonAdapteronly) →log.error, returnType()with all defaults.
Set strict=True to restore the original behaviour where any read error raises immediately.
The StorageError raised by JsonAdapter.save() during an optimistic-locking check is only
possible in strict mode (in lenient mode a corrupted file returns None lock values, so the
check is skipped).
The helper functions lenient_model_load and lenient_list_load are also importable from
niceview for use outside the built-in adapters.
Breaking change (since lenient default): code that relied on
JsonAdapter.read()orJsonListAdapterraising on a malformed file must passstrict=Trueexplicitly.
from niceview import lenient_model_load, lenient_list_load
# Load a single model from a JSON string, tolerating bad fields:
item = lenient_model_load(MyModel, json_text, context='myfile.json')
# Load a list, skipping unrecoverable items:
items = lenient_list_load(MyModel, json_text, context='myfile.json')
Optimistic locking: When lock_field= is set, save() compares the stored timestamp against
the one in memory before writing. The check only fires when both sides have a non-None value —
a None on either side is treated as "no lock data" and the save proceeds. Two exceptions can be
raised; both are caught by ModelForm.save() and EditGridWrapper with a user-facing ui.notify:
| Exception | When raised |
|---|---|
niceview.ConflictError |
Both timestamps are set but differ (concurrent modification) |
niceview.StorageError |
The stored file cannot be read during the lock check (corrupted, wrong schema, I/O error) |
Custom code calling adapter.save() directly should handle both:
from niceview import ConflictError, StorageError
try:
adapter.save(item)
except ConflictError:
ui.notify('Someone else changed this item. Please reload.', color='warning')
except StorageError:
ui.notify('The data file could not be read. Please contact your administrator.', color='negative')
Adapter protocols — implement these for custom backends:
| Protocol | Methods | Used by |
|---|---|---|
ItemAdapter[T] |
read() -> T, save(item) -> T |
ModelForm |
CollectionAdapter[T] |
__iter__, key_from_item(item) -> str, items() -> Iterator[(str, T)], read(key) -> T, create(item) -> T, update(item) -> T, delete(key) |
ModelGrid, EditGridWrapper |
ReloadableAdapter |
reload() |
EditGridWrapper (Refresh button), FilteredAdapter (forwarded) |
ReactiveAdapter |
on_change(handler) |
ModelGrid (auto-update) |
update() returns the stored item, which may differ from the input (e.g. SqlModelAdapter refreshes updated_at). key_from_item() raises KeyError if the item is not in the adapter; read() raises KeyError/ValueError if the key is not found.
items() yields (key, item) pairs — like dict.items(), useful whenever key and item are needed together (e.g. building navigation URLs):
for key, project in projects_adapter.items():
with ui.card().on('click', lambda k=key: ui.navigate.to(f'/projects/{k}')):
ui.label(project.name)
BoundItem(adapter, key) wraps a CollectionAdapter + a string key into an ItemAdapter —
the standard bridge for master-detail navigation (e.g. ModelForm.from_adapter()).
BoundItem can be imported directly from niceview (from niceview import BoundItem).
BoundFieldAdapter(parent_adapter, field_name) wraps an ItemAdapter + a field name into an
ItemAdapter for one named sub-field of the parent item. Its purpose is the one thing ModelForm
does not render on its own: a single embedded model (address: Address). Bind a second form
to that field by focusing the parent adapter onto it:
parent = JsonAdapter(Device, path)
ModelForm.from_adapter(Device, parent).render() # the parent's own fields
ModelForm.from_adapter(Address, BoundFieldAdapter(parent, 'address')).render() # the nested model
save() is read-modify-write: it re-reads the parent, sets only this one field, and saves the
parent back. So sibling fields are never touched, and several BoundFieldAdapters over the same
parent stay independent — one ModelForm per card, edited in any order, each save picking up the
others' saved values. The flip side: it does not take part in the parent adapter's optimistic
locking (re-reading right before the write makes the lock token always match). Do not set a
lock_field on a parent reached only through BoundFieldAdapters — it adds no protection and its
token bump would make a concurrently open full-parent form see a false conflict. For real
single-field locking, bind a ModelForm to the parent adapter directly instead.
Reactive updates
All built-in adapters implement ReactiveAdapter via the _ChangeNotifier mixin.
ModelGrid.render() detects this and registers update_rows() automatically, so
structural mutations through the adapter (create / update / delete) refresh the grid
without any manual call — for in-memory lists, JSON files, and SQL databases alike.
What is not caught automatically: in-place attribute changes on existing items
(item.name = 'new'). These bypass the adapter entirely. Use grid.update_rows()
or the EditGridWrapper Refresh button for that case.
# Adapter mutations → grid auto-updates (all adapter types)
adapter = ListAdapter(User, items)
adapter = ListAdapter(User, items, created_field='created_at') # set created_at on create()
grid = ModelGridInlineEdit.from_adapter(User, adapter)
grid.render()
adapter.create(User(name='Carol')) # grid refreshes automatically
adapter.delete(key) # grid refreshes automatically
# In-place attribute change → manual refresh needed
items[0].name = 'new name'
grid.update_rows() # must call explicitly
ObservableList additionally catches direct mutations on the list object that bypass the adapter — useful when non-NiceView code appends to the same list:
from nicegui.observables import ObservableList
obs = ObservableList([User(name='Alice')])
grid = ModelGrid.from_list(User, obs)
grid.render()
obs.append(User(name='Bob')) # also triggers update_rows(), no adapter call needed
from niceview import SqlModelAdapter
adapter = SqlModelAdapter(Book, engine) # optimistic locking on updated_at
adapter = SqlModelAdapter(Book, engine, lock_field=None) # without locking
adapter = SqlModelAdapter(Book, engine, created_field='created_at') # set created_at on create()
adapter = SqlModelAdapter(Book, engine, created_field='created_at', lock_field='updated_at') # both
# Form for a specific record (fields only) — key must be str
form = ModelForm.from_adapter(Book, adapter, str(book_id))
form.render()
# Form with chrome (title + save/refresh buttons)
EditFormWrapper.from_adapter(Book, adapter, str(book_id), title='Edit Book').render()
# Grid over the full table
ModelGrid(Book, adapter).render()
EditGridWrapper.from_adapter(Book, adapter, title='Books').render()