Contacts CLI example¶
A persistent contacts manager you run from the terminal. Each invocation reopens the same TinyStore directory, so your data survives between runs — the classic "embedded database for a CLI tool" use case that TinyStore is built for.
The full source lives at
examples/cli_contacts.py.
Run it¶
The database lives in examples/contacts_data/ and accumulates across
invocations. Try a full session:
python examples/cli_contacts.py add --name "Alice Lee" --email alice@x.dev --tag team --tag eng
python examples/cli_contacts.py add --name "Bob Carr" --email bob@x.dev --phone "555-1234" --tag team
python examples/cli_contacts.py add --name "Carol Ng" --email carol@x.dev
python examples/cli_contacts.py list
python examples/cli_contacts.py search ali
python examples/cli_contacts.py show 1
python examples/cli_contacts.py tag 1 --add vip
python examples/cli_contacts.py list --tag vip
python examples/cli_contacts.py delete 2
python examples/cli_contacts.py stats
It's a real app, not a one-shot demo
Unlike the task queue and library
examples, this one keeps its data between runs. Delete
examples/contacts_data/ to start fresh.
The model¶
A single Contact model with the field features that make a CLI pleasant:
a unique, indexed email for fast lookups, optional fields, and a list[str]
for tags:
from tinystore import Database, Field, Model
class Contact(Model):
id: int | None = Field(default=None, primary_key=True)
name: str = Field(min_length=1, max_length=100) # (1)
email: str = Field(unique=True, index=True) # (2)
phone: str | None = None
notes: str = ""
tags: list[str] = Field(default_factory=list) # (3)
- Extra kwargs like
min_length=/max_length=are forwarded straight to Pydantic, so validation is free. unique=True, index=Truemakesget_by(Contact, "email", ...)an index lookup and prevents duplicates.list[str]round-trips through JSON natively — no join table needed for simple tags.
Open the database on every run¶
Each command opens the database, registers the model, does its work, and exits. This is cheap: TinyStore reopens the directory and re-checks the schema fingerprint against what's stored on disk.
from pathlib import Path
DB_PATH = Path(__file__).parent / "contacts_data"
def get_db() -> Database:
db = Database(DB_PATH)
db.register(Contact)
return db
Schema evolution is safe by default
When you reopen a database, TinyStore compares the registered model's
schema against the stored fingerprint. Additive changes (a new
nullable/defaulted field) are accepted automatically; breaking changes
raise SchemaError. So adding a field to Contact and rerunning the CLI
just works. See Models & fields.
Add a contact (and handle duplicates)¶
unique=True on email turns a duplicate add into a clean
UniqueConstraintError you can map to a friendly message:
from tinystore import UniqueConstraintError
def cmd_add(args):
db = get_db()
try:
contact = db.insert(
Contact(
name=args.name,
email=args.email,
phone=args.phone,
notes=args.notes,
tags=list(args.tag or []),
)
)
except UniqueConstraintError:
print(f"error: a contact with email {args.email!r} already exists", file=sys.stderr)
return 1
print(f"added [{contact.id}] {contact.name} <{contact.email}>")
return 0
Search with contains()¶
Build a typed query across multiple fields, combined with | (or):
matches = (
db.select(Contact)
.where(Contact.name.contains(q) | Contact.email.contains(q))
.order_by(Contact.name)
.all()
)
No magic strings
Comparisons go through the model class (Contact.name.contains(...)), so
they're refactor-safe and IDE-friendly. See Querying.
Mutate, then save¶
Tagging a contact is a load-mutate-save cycle. Because validate_assignment
is on, mutating c.tags re-validates automatically:
c = db.get(Contact, args.id)
c.tags = sorted((set(c.tags) | set(args.add)) - set(args.remove))
db.save(c)
Missing rows are a normal control flow¶
db.get() raises DoesNotExist for a missing primary key — catch it and
return a clear error code:
from tinystore import DoesNotExist
try:
c = db.get(Contact, args.id)
except DoesNotExist:
print(f"error: no contact with id {args.id}", file=sys.stderr)
return 1
What's on disk¶
After a session, examples/contacts_data/ is plain JSON you can read, diff,
or back up with any tool:
contacts_data/
├── metadata.json # schema fingerprint + counters
├── tinystore.lock # OS-lock coordination file
└── tables/
└── contacts.json # {"version": 1, "next_id": 4, "rows": [...]}
Back it up by copying the directory
db.backup("./my_snapshot") makes a point-in-time copy under the lock.
Or just cp -r contacts_data/ snaps/ — there are no binary formats to
worry about. See Storage format.
Recap¶
This example demonstrated:
- Persistence across invocations — the CLI use case.
- Indexed lookups (
get_byon email). - Unique constraints mapped to user-friendly errors.
- Text search with
contains()and|. -
list[str]fields (tags) stored as JSON. - Missing rows as
DoesNotExistcontrol flow.
Browse the other examples — the task queue for transactions and concurrency, or the library catalog for relationships and joins.