Shared Python building blocks for DotzInc services: thin layers over SQLAlchemy 2.0, Redis, httpx, Pub/Sub, SQS and friends. The lib stays out of the way — apps import the underlying SDKs directly for the heavy lifting and use serpens for the configuration that should not be duplicated across 24 repos.
- SQS · Lambda API · Schema · CSV
- Database · Migrations · Pony → SQLAlchemy
- DynamoDB · Async HTTP · Rate limiter
- Async Redis cache · Async Pub/Sub
from serpens import sqs
@sqs.handler
def message_processor(record: sqs.Record):
print(record.body)sqs.Record exposes data, body, message_attributes, queue_name, sent_datetime.
from serpens import api
@api.handler
def lambda_handler(request: api.Request):
print(request.body)api.Request exposes authorizer, body, path, query, headers, identity.
All but body are AttrDict — request.path.user_id shortcut for request.path["user_id"].
Dataclass with static type checks and dict/JSON helpers.
from dataclasses import dataclass
from serpens.schema import Schema
@dataclass
class Person(Schema):
name: str
age: int
Person.load({"name": "Mike", "age": 18})
Person.loads('{"name": "Mike", "age": 18}')
Person("Mike", 18).dump() # dict
Person("Mike", 18).dumps() # JSON stringfrom serpens import csvutils as csv
for row in csv.open_csv_reader("fruits.csv"):
print(row)
w = csv.open_csv_writer("out.csv")
w.writerow(["id", "name"]); w.writerow(["1", "Açaí"])Thin layer over SQLAlchemy 2.0. Owns:
- Engine setup with production defaults (Postgres
statement_timeout/lock_timeout/idle_in_transaction_session_timeout, Cloud SQL keepalives, scheme normalization,pool_pre_ping/pool_use_lifo, Lambda-aware tuning). - Session factories (
SessionLocal,AsyncSessionLocal). - Declarative
BaseandTimestampMixin. - Alembic helper (see Migrations).
Query construction stays in sqlalchemy proper — the lib does not re-export
select, Integer, etc.
from sqlalchemy import Integer, String, select
from sqlalchemy.orm import Mapped, mapped_column
from serpens.database import Base, SessionLocal, TimestampMixin, bind
class User(TimestampMixin, Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String, nullable=False)
bind() # reads DATABASE_URL
with SessionLocal() as sess:
sess.add(User(name="Ana"))
sess.commit()from sqlalchemy import select
from serpens.database import async_bind, async_db_session
async_bind() # reads DATABASE_URL, normalizes scheme to asyncpg
async def fetch(user_id: int):
async with async_db_session() as sess:
return (await sess.scalars(select(User).filter_by(id=user_id))).first()Async requires asyncpg (Postgres) or aiosqlite (SQLite).
Explicit (preferred). SessionLocal() / AsyncSessionLocal() — caller owns
commit/rollback. FastAPI handlers should use the per-request dependency:
from fastapi import Depends
from sqlalchemy.orm import Session
from serpens.database import fastapi_session # or fastapi_async_session
@app.get("/users/{id}")
def get_user(id: int, db: Session = Depends(fastapi_session)):
return db.scalars(select(User).filter_by(id=id)).first()Auto-managed. db_session() / async_db_session() — context managers that
commit on success, roll back on exception and close always. Convenient for
Lambda handlers and short scripts. Pass the session explicitly to helpers:
from serpens.database import async_db_session
async def fetch(sess, user_id: int):
return (await sess.scalars(select(User).filter_by(id=user_id))).first()
async with async_db_session() as sess:
user = await fetch(sess, 1)There is no current_session() global — sessions are passed.
from serpens.database import declarative_base
Base = declarative_base(schema="public")| Variable | Default | Purpose |
|---|---|---|
DATABASE_URL |
— | Connection string. postgres:// and postgresql:// are normalized to postgresql+psycopg2:// (sync) / postgresql+asyncpg:// (async). |
APP_NAME |
serpens |
Postgres application_name. Overridden by K_SERVICE (Cloud Run) or AWS_LAMBDA_FUNCTION_NAME (Lambda). |
DB_POOL_SIZE |
10 |
Pool size. Set to 1 on Lambda. |
DB_MAX_OVERFLOW |
20 |
Extra connections. Set to 0 on Lambda. |
DB_POOL_TIMEOUT |
10 |
Seconds to wait for a free connection. |
DB_POOL_RECYCLE |
1800 |
Recycle older connections. |
DB_STATEMENT_TIMEOUT_MS |
5000 |
Postgres statement_timeout. |
DB_LOCK_TIMEOUT_MS |
2000 |
Postgres lock_timeout. |
DB_IDLE_IN_TX_TIMEOUT_MS |
10000 |
Postgres idle_in_transaction_session_timeout. |
DB_POOL_USE_LIFO |
true |
LIFO checkout (warm connections preferred). Set false for FIFO. |
DB_ECHO |
false |
Log every SQL statement. |
bind() and async_bind() also accept pool_use_lifo=True/False as a direct
override of the env var.
Cloud SQL keepalives (keepalives=1, keepalives_idle=30,
keepalives_interval=10, keepalives_count=3) are applied automatically on
Postgres connections.
from contextlib import asynccontextmanager
from fastapi import FastAPI
from serpens.database import async_bind, async_dispose
@asynccontextmanager
async def lifespan(_app: FastAPI):
async_bind()
yield
await async_dispose()
app = FastAPI(lifespan=lifespan)Calling bind() at the top of models.py makes import order matter and
breaks under cold-start.
Repository[T] (sync) and AsyncRepository[T] (async) cover the CRUD that
every service rewrites: PK lookups, filtered queries, paginate, add, upsert.
Subclass with model = X and add your own methods for anything custom — the
query property exposes a Select(model) you compose on. The base
intentionally does not ship hard-delete or partial update: services do
soft-delete differently and updates often need optimistic locking.
from serpens.database import AsyncRepository
class ProductRepo(AsyncRepository[Product]):
model = Product
async def by_slug(self, slug): # custom lookup
return await self.get_by(slug=slug)
async with async_db_session() as sess:
p = await ProductRepo(sess).by_slug("noverde_empirica")Built-in methods: get, get_or_raise (raises serpens.database.NotFound),
get_by, exists, count, list(order_by=, limit=, offset=, **filters),
paginate(stmt=, page=, size=), add(obj, flush=True), bulk_add(objs),
upsert(values, conflict_on=, update_fields=).
Don't expose hard delete. Add a method on your repo:
class PaymentRepo(AsyncRepository[Payment]):
model = Payment
async def cancel(self, payment, *, reason: str):
payment.status = "cancelled"
payment.cancel_reason = reason
await self.sess.flush()Declare version_id_col on the model — Repository doesn't fight it:
class Payment(TimestampMixin, Base):
__tablename__ = "payments"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
amount: Mapped[Decimal] = mapped_column(Numeric)
version: Mapped[int] = mapped_column(Integer, nullable=False)
__mapper_args__ = {"version_id_col": version}SA will raise StaleDataError on concurrent update. Catch it in the handler.
get_or_create has a race between SELECT and INSERT. Use upsert instead —
Postgres INSERT ... ON CONFLICT ... RETURNING:
class IdempotentPaymentRepo(AsyncRepository[Payment]):
model = Payment
await IdempotentPaymentRepo(sess).upsert(
{"external_id": req.idempotency_key, "amount": req.amount, "status": "received"},
conflict_on=["external_id"],
update_fields=["amount"], # omit to do nothing on conflict
)New repos use Alembic. serpens.migrations (yoyo) is kept only for legacy
services that haven't migrated yet.
serpens.database.alembic.run_migrations wires Alembic against your app's
Base.metadata with sensible defaults (offline/online, scheme normalization to
psycopg2, NullPool to keep migration jobs from leaking pool slots).
alembic/env.py:
from myapp.models import Base
from serpens.database.alembic import run_migrations
run_migrations(target_metadata=Base.metadata)alembic.ini (minimal):
[alembic]
script_location = alembic
prepend_sys_path = .
file_template = %%(year)d%%(month).2d%%(day).2d_%%(rev)s_%%(slug)s
sqlalchemy.url =Run with DATABASE_URL set:
alembic revision -m "add user table" --autogenerate
alembic upgrade headFor a Lambda migration job (no alembic.ini in the package):
import os
from alembic import command
from alembic.config import Config
def migrate_handler(event, context):
cfg = Config()
cfg.set_main_option("script_location", os.path.dirname(os.path.abspath(__file__)))
command.upgrade(cfg, "head")Reference setup: platform-agreements/alembic.
Pony lacks async, lacks typed Mapped[T], and is in limited maintenance. The
platform standardised on SQLAlchemy 2.0 via serpens.database. Use the async
API by default — platform-agreements is the reference and runs SA 2.0 async
end-to-end.
| Pony | SQLAlchemy 2.0 + serpens |
|---|---|
class X(db.Entity): |
class X(TimestampMixin, Base): |
name = Required(str) |
name: Mapped[str] = mapped_column(String, nullable=False) |
email = Optional(str) |
email: Mapped[str | None] = mapped_column(String, nullable=True) |
created_at = Required(datetime) |
inherit TimestampMixin |
loans = Set(lambda: Loan) |
loans: Mapped[list["Loan"]] = relationship(back_populates="user") |
composite_key(a, b) |
__table_args__ = (UniqueConstraint("a", "b"),) |
_table_ = ("public", "users") |
__tablename__ = "users"; __table_args__ = {"schema": "public"} |
@db_session decorator |
with db_session() block or pass Session |
X(field=value) (auto-flush) |
obj = X(...); sess.add(obj); sess.flush() |
X.get(field=value) |
sess.scalars(select(X).filter_by(field=value)).first() |
X.select(...).order_by(X.id)[:10] |
sess.scalars(select(X).order_by(X.id).limit(10)).all() |
X.select_by_sql("SELECT ...", params) |
sess.scalars(select(X).from_statement(text("SELECT ..."))).all() |
db.generate_mapping(create_tables=True) |
Base.metadata.create_all(engine) |
Branch from staging → feat/migrate-pony-to-sqlalchemy.
requirements.txt— bumpnoverde-serpens, ensureSQLAlchemy>=2.0, addasyncpg(for async) and/orpsycopg2-binary(for sync + Alembic), dropponyandyoyo-migrations.- Models — replace
db = Database()andclass X(db.Entity). Methods stuck to the entity (X.get_by_slug,X.create) become module-level functions takingSession/AsyncSessionas first argument:async def get_product_by_slug(sess: AsyncSession, slug: str) -> Product | None: return (await sess.scalars(select(Product).filter_by(slug=slug))).first()
- Handlers — open
async with async_db_session() as sess:and passsessdown. In FastAPI routes useDepends(fastapi_async_session). main.py—async_bind()runs in a FastAPIlifespan, never at import time.- Tests —
setUpusesasync with async_db_session() as sess: sess.add(...).tearDownusesawait sess.execute(delete(...)).testgres.setup(Base)still works. - Migrations — yoyo → Alembic. Add
alembic.ini,alembic/env.pydelegating toserpens.database.alembic.run_migrations, and a baseline revision wrapping the existing schema withIF NOT EXISTS. LambdaMigrateswitches toHandler: migrate_handler.migrate_handler. Runalembic stamp 0001_baselineonce per environment.
- Optimistic lock changes. Pony locks read rows by default; SA 2.0 does
not. If a job relied on it (e.g.
platform-servicing), opt back in withversion_id_colon the model. X(...)does not INSERT in SA 2.0. Usesess.add(obj)and, if you needobj.idpopulated,sess.flush().autoflush=False. Serpens disables autoflush so a strayselectdoesn't flush pending changes. Callsess.flush()explicitly when needed.- Lambda pool:
DB_POOL_SIZE=1,DB_MAX_OVERFLOW=0. A larger pool causes Cloud SQL churn under burst. postgres://vspostgresql://: SA 2.0 dropped the short prefix. serpens normalises both.- Schema declaration: pick one place —
declarative_base(schema=...)centralized, or__table_args__={"schema":...}per model. Don't mix.
- The repo already has an idiomatic SA 2.0
SessionLocal(e.g.platform-conciliation). Don't migrate just for standardization. - You need an SA feature serpens does not expose — import from
sqlalchemydirectly. Serpens is a thin layer by design.
from dataclasses import dataclass
from serpens.document import BaseDocument
@dataclass
class PersonDocument(BaseDocument):
_table_name_ = "person"
id: str
name: str
PersonDocument(id="1", name="Ana").save()
PersonDocument.get_by_key({"id": "1"})
PersonDocument.get_table()Singleton httpx.AsyncClient — connection pools survive across requests.
from contextlib import asynccontextmanager
from fastapi import Depends, FastAPI
from httpx import AsyncClient
from serpens.http_client import close_client, get_client, init_client
@asynccontextmanager
async def lifespan(_app: FastAPI):
await init_client()
yield
await close_client()
app = FastAPI(lifespan=lifespan)
@app.get("/proxy")
async def proxy(client: AsyncClient = Depends(get_client)):
return (await client.get("https://example.com")).json()Timeout defaults to HTTP_CLIENT_TIMEOUT (env, seconds) or 30s. Extra kwargs
pass through to httpx.AsyncClient.
Token-bucket limiter for outbound calls plus an auth_lock that serializes
token refresh (avoids thundering-herd re-auths on expiry).
from serpens.rate_limit import RateLimiter
limiter = RateLimiter(rate=20, per_seconds=1.0)
@asynccontextmanager
async def lifespan(_app):
limiter.start()
yield
await limiter.stop()
async def call_external():
await limiter.acquire()
return await client.get(...)
async def fetch_token():
async with limiter.auth_lock:
return await cached_get_or_set("token", 1800, _refresh_token)Async, Redis-backed counterpart of serpens.cache (which is in-memory). Use
in FastAPI / long-running services that need a shared cache across workers.
from serpens.cache_async import cached, cached_get_or_set, close, delete, get, init, set_
@asynccontextmanager
async def lifespan(_app):
await init()
yield
await close()
await set_("user:42", {"name": "Ana"}, ttl=60)
user = await get("user:42")
@cached("products", ttl=600)
async def get_product(slug: str):
return await fetch_product(slug)| Variable | Default | Purpose |
|---|---|---|
REDIS_URL |
— | Redis connection string. |
CACHE_PREFIX |
serpens |
Prefix prepended to every key. Set per-service. |
CACHE_TTL |
300 |
Default TTL for set_ / cached. |
testgres.setup(Base, redis_mode=True) spins a Redis container alongside
Postgres and exports REDIS_URL to the test environment — cache_async.init()
picks it up without further config. If REDIS_URL is already set, the existing
instance is reused.
serpens.pubsub.AsyncPublisher wraps the sync Google SDK with
asyncio.wrap_future so await publish(...) does not block the event loop.
Instantiate once per process in lifespan, close on shutdown.
topic is the full topic id (projects/PROJECT/topics/NAME) — the same value
Terraform exposes as an env var, no need to rebuild it via
client.topic_path(...). When elasticapm is installed, every publish emits a
messaging span labeled with the topic.
from serpens.pubsub import AsyncPublisher
publisher = AsyncPublisher()
@asynccontextmanager
async def lifespan(_app):
yield
publisher.close()
async def emit(payload: dict):
await publisher.publish(settings.MY_TOPIC, payload)