Cendry
A Firestore ODM for Python. Typed models, composable filters, sync and async support.
Built on top of google-cloud-firestore and anyio.
Python >= 3.13
Installation
pip install cendry
Quick Start
Define Models
from cendry import Model, Map, Field, field
class Mayor(Map):
name: Field[str]
since: Field[int]
class City(Model, collection="cities"):
name: Field[str]
state: Field[str]
country: Field[str]
capital: Field[bool]
population: Field[int]
regions: Field[list[str]]
nickname: Field[str | None] = field(default=None)
mayor: Field[Mayor | None] = field(default=None)
Model— top-level Firestore document, requirescollection=Map— embedded data (Firestore map), no collection, noidField[T]— typed field descriptor- Every
Modelhas anid: str | Nonefield (Firestore document ID) - All fields are keyword-only
Query Documents
Sync
from cendry import Cendry
with Cendry() as ctx:
# Get by ID (raises DocumentNotFoundError if missing)
city = ctx.get(City, "SF")
# Find by ID (returns None if missing)
city = ctx.find(City, "SF")
# Select with filters
for city in ctx.select(City, City.state == "CA", limit=10):
print(city.name)
Async
from cendry import AsyncCendry
async with AsyncCendry() as ctx:
city = await ctx.get(City, "SF")
city = await ctx.find(City, "SF")
async for city in ctx.select(City, City.state == "CA"):
print(city.name)
Custom Client
from google.cloud.firestore import Client, AsyncClient
ctx = Cendry(client=Client(project="my-project"))
ctx = AsyncCendry(client=AsyncClient(project="my-project"))
Filters
FieldFilter (Firestore-native)
from cendry import FieldFilter
ctx.select(City, FieldFilter("state", "==", "CA"))
ctx.select(City, FieldFilter("regions", "array-contains", "west_coast"))
ctx.select(City, FieldFilter("country", "in", ["USA", "Japan"]))
Operators: <, <=, ==, >, >=, !=, array-contains, array-contains-any, in, not-in
Field Descriptors
Python operators work directly:
City.state == "CA"
City.state != "CA"
City.population > 1_000_000
City.population >= 1_000_000
City.population < 500_000
City.population <= 500_000
Named methods for Firestore-specific operators:
City.regions.array_contains("west_coast")
City.regions.array_contains_any(["west_coast", "east_coast"])
City.country.is_in(["USA", "Japan"])
City.country.not_in(["China"])
Composition
# & (AND) and | (OR)
City.state != "CA" & City.population > 1_000_000
City.state == "CA" | (City.country == "Japan" & City.population > 1_000_000)
# Explicit
from cendry import And, Or
Or(
City.state == "CA",
And(City.country == "Japan", City.population > 1_000_000),
)
# Multiple varargs — implicit AND
ctx.select(City, City.state == "CA", City.population > 1_000_000)
Query Object
select() and select_group() return a Query (sync) or AsyncQuery (async) with convenience methods:
query = ctx.select(City, City.state == "CA")
# Iterate (streaming)
for city in query:
print(city.name)
# Chainable filtering
query = ctx.select(City).filter(City.state == "CA").filter(City.population > 500_000)
# Also accepts a list
query = ctx.select(City).filter([City.state == "CA", City.population > 500_000])
# Chainable ordering and limiting
query = (
ctx.select(City)
.filter(City.state == "CA")
.order_by(City.population) # ascending by default
.order_by(City.name.desc()) # descending
.limit(10)
)
# Convenience methods
cities = query.to_list() # list[City]
city = query.first() # City | None
city = query.one() # City (raises if not exactly 1)
exists = query.exists() # bool
n = query.count() # int (Firestore aggregation)
# Pagination
for page in query.paginate(page_size=10):
print(f"Got {len(page)} cities")
Async:
cities = await query.to_list()
city = await query.first()
n = await query.count()
async for page in query.paginate(page_size=10):
process(page)
Write Operations
Save (upsert)
city = City(name="SF", state="CA", country="USA", capital=False, population=870_000, regions=[])
doc_id = ctx.save(city) # auto-generates ID, mutates city.id
print(city.id) # "auto-generated-id"
city.population = 900_000
ctx.save(city) # overwrites with explicit ID
Create (insert only)
from cendry import DocumentAlreadyExistsError
try:
ctx.create(city)
except DocumentAlreadyExistsError:
print("Already exists!")
Update (partial)
from cendry import DELETE_FIELD, SERVER_TIMESTAMP, Increment
# By instance
ctx.update(city, {"population": 900_000, "name": "San Francisco"})
# By class + ID
ctx.update(City, "SF", {"population": Increment(1000)})
# Dot-notation for nested fields
ctx.update(city, {"mayor.name": "Jane", "updated_at": SERVER_TIMESTAMP})
Delete
ctx.delete(city) # by instance
ctx.delete(City, "SF") # by class + ID
ctx.delete(City, "SF", must_exist=True) # raises if missing
Refresh
ctx.update(city, {"population": Increment(1000)})
ctx.refresh(city) # re-fetches from Firestore, mutates in-place
print(city.population) # updated value
Batch Writes
# Save many (atomic, max 500)
ctx.save_many([city1, city2, city3])
# Delete many
ctx.delete_many([city1, city2])
ctx.delete_many(City, ["SF", "LA"])
# Mix operations with batch context manager
with ctx.batch() as batch:
batch.save(city1)
batch.create(city2)
batch.update(city3, {"population": 1_000_000})
batch.delete(city4)
# auto-commits on exit
Transactions
# Callback pattern (auto-retry on contention)
def transfer(txn):
from_city = txn.get(City, "SF")
to_city = txn.get(City, "LA")
txn.update(from_city, {"population": from_city.population - 1000})
txn.update(to_city, {"population": to_city.population + 1000})
ctx.transaction(transfer)
# Context manager (single attempt)
with ctx.transaction() as txn:
city = txn.get(City, "SF")
txn.update(city, {"population": city.population + 1000})
Batch Fetch
cities = ctx.get_many(City, ["SF", "LA", "NYC"])
Raises DocumentNotFoundError if any IDs are missing.
Ordering and Pagination
ctx.select(City,
City.state == "CA",
order_by=[City.population.asc(), City.name.desc()],
limit=10,
start_after={"population": 1_000_000},
)
Pagination cursors: start_at, start_after, end_at, end_before — accept dict or Model instance.
Subcollections
class Neighborhood(Model, collection="neighborhoods"):
name: Field[str]
population: Field[int]
city = ctx.get(City, "SF")
for n in ctx.select(Neighborhood, parent=city):
print(n.name)
Collection Groups
for n in ctx.select_group(Neighborhood, Neighborhood.population > 50_000):
print(n.name)
from_dict
Construct models from raw dicts (useful for testing):
from cendry import from_dict
city = from_dict(City, {
"name": "SF", "state": "CA", "country": "USA",
"capital": False, "population": 870_000, "regions": ["west"],
})
# With document ID
city = from_dict(City, {...}, doc_id="123")
Nested Map dicts are automatically converted. Raises TypeError if required fields are missing.
to_dict
Convert models to dicts:
from cendry import to_dict
data = to_dict(city) # Python field names
data = to_dict(city, by_alias=True) # Firestore field names
data = to_dict(city, include_id=True) # Include document ID
Field Aliases
When the Firestore field name differs from Python:
class City(Model, collection="cities"):
name: Field[str] = field(alias="displayName")
Filters, ordering, and Firestore reads/writes use the alias automatically.
Enum Support
import enum
class Status(enum.Enum):
ACTIVE = "active"
INACTIVE = "inactive"
class User(Model, collection="users"):
status: Field[Status]
role: Field[Role] = field(enum_by="name") # store by name instead of value
Type Validation
Field[T] annotations are validated at class definition time. Only Firestore-compatible types are accepted:
from cendry import register_type
# Register custom types
register_type(MyCustomClass)
register_type(lambda cls: hasattr(cls, "__my_protocol__"))
Supported: str, int, float, bool, bytes, Decimal, datetime, GeoPoint, DocumentReference, list, tuple, set, dict, Map, dataclasses, TypedDict, enum.Enum, pydantic/attrs/msgspec (if installed).
Optimistic Locking
Prevent conflicting writes — Cendry tracks Firestore's update_time metadata automatically:
from cendry import get_metadata
city = ctx.get(City, "SF")
meta = get_metadata(city)
print(meta.update_time) # datetime from Firestore
# Only update if nobody changed it since we read
ctx.update(city, {"population": 900_000}, if_unchanged=True)
ctx.delete(city, if_unchanged=True)
# Class+ID form — pass datetime directly
ctx.update(City, "SF", {"population": 900_000}, if_unchanged=some_datetime)
After batch writes, refresh to get metadata:
with ctx.batch() as batch:
batch.save(city1)
batch.save(city2)
ctx.refresh(city1) # now get_metadata(city1).update_time is set
ctx.update(city1, {"population": 900_000}, if_unchanged=True)
Exceptions
from cendry import CendryError, DocumentNotFoundError, DocumentAlreadyExistsError
try:
city = ctx.get(City, "NOPE")
except DocumentNotFoundError as e:
print(e.collection, e.document_id)
try:
ctx.create(city)
except DocumentAlreadyExistsError as e:
print(e.collection, e.document_id)
Testing
# Unit tests (no external dependencies)
uv run pytest
# Integration tests (requires Docker — auto-starts Firestore emulator)
uv run pytest tests/integration/ -v
Integration tests use testcontainers to automatically start a Firestore emulator via Docker. No manual setup needed.
License
MIT
Metadata
Release files for cendry 0.5.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| cendry-0.5.0.tar.gz | 1.4 MB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| cendry-0.5.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 1.4 MB
Release files / cendry-0.5.0.tar.gz
| Download URL | cendry-0.5.0.tar.gz |
|---|---|
| Size | 1.4 MB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
50daffed5f63e8fe197ef0d6c263ee470cbca33e75a7752aecafc1b5d20cc4a0
|
|
BLAKE2b-256 checksum How to use checksums |
67d93d351f6e44f41caa5b0f176ed3611f6e312185da2d59cd3af5576f6b9c6d
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.11.1 {"installer":{"name":"uv","version":"0.11.1","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|
Release files / cendry-0.5.0-py3-none-any.whl
| Download URL | cendry-0.5.0-py3-none-any.whl |
|---|---|
| Size | 38.4 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
bacd4627ce8d1420022fac5d01207922f559d1da243fd3f134016306a7185453
|
|
BLAKE2b-256 checksum How to use checksums |
a22c5cf40af0f85620ac5da43da45c68b9ff994a0cc26d5320dc48a2fdf0ce35
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.11.1 {"installer":{"name":"uv","version":"0.11.1","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|