Cello v1.3.0 — Async Rework, Native Data Layer, Plugin Audit & Three-Pillar Upgrades¶
Release Date: July 16, 2026 License: MIT Python: 3.12+
Overview¶
Cello v1.3.0 is a large release built around the framework's three pillars — Speed, Simplicity, Security. It reworks how async def handlers are driven, replaces the old mock database/Redis layer with a real native data layer + ORM, audits and fixes every enable_* plugin, and lands a batch of pillar upgrades (full security headers, Redis TLS, compressed cache, and declarative request validation). The public API is additive — existing apps upgrade in place — with one behavioural note for async handlers (see Migration).
Async runtime rework¶
Persistent asyncio event loop¶
Since v1.2.4, async handlers were driven with a fresh asyncio.run() per request, which created and destroyed an event loop every call. That had two serious problems:
- Loop-bound resources broke. A module-level
aiohttp.ClientSession, anasyncpg/SQLAlchemy async pool, or anasyncio.Lock/Queueis bound to the loop that created it. On the second request you gotRuntimeError: <X> is bound to a different event loop. - No real concurrency. The whole coroutine ran inside a single
Python::with_gil(...), so the GIL was held for the coroutine's entire lifetime — async handlers serialized instead of overlapping their I/O.
v1.3.0 introduces a single persistent asyncio loop (src/async_loop.rs) running on a dedicated daemon thread. Coroutines are submitted with asyncio.run_coroutine_threadsafe(coro, loop) and awaited via the returned future; the wait releases the GIL, so the loop runs other coroutines concurrently.
// handler.rs — Phase 2
tokio::task::spawn_blocking(move || {
let result = Python::with_gil(|py| {
crate::async_loop::run_coroutine_blocking(py, coro.as_ref(py))
});
let _ = tx.send(result);
});
Async startup/shutdown hooks now run on the same loop (previously the pyo3_asyncio::tokio::into_future path failed silently and the hooks never executed).
Security fixes¶
| Area | Fix |
|---|---|
| CSRF Origin/Referer | Validation rewritten to exact-authority matching. origin.starts_with(host) allowed example.com.evil.com; referer.contains(host) allowed evil.com/?x=example.com. Both are closed. |
| Skip-path bypass | Every middleware skip/exclude check now uses path_matches_skip (exact or pattern/ sub-path). Raw starts_with let /health skip /healthz. Fixed in logging, prometheus, telemetry, cache, etag, body-limit. |
| CORS | Reflected (credentials + *) and non-wildcard allow-origin responses now send Vary: Origin to prevent cross-origin cache poisoning. |
| BasicAuth | Username/password comparison no longer short-circuits (& not &&), removing a timing oracle for username validity. |
| SSE injection | id/event fields strip CR/LF so attacker-controlled values cannot inject extra SSE directives. |
DoS protection (limits & timeouts)¶
LimitsConfig/TimeoutConfig existed but were never enforced. They now are:
from cello import App, LimitsConfig, TimeoutConfig
app = App()
app.set_limits(LimitsConfig(max_body_size=5 * 1024 * 1024)) # 5 MB, else 413
app.set_timeouts(TimeoutConfig(read_header=5, read_body=30, handler=30))
- Body size — checked against
Content-Lengthup-front and enforced while streaming viaLimited, so oversized/chunked bodies cannot exhaust memory. Over the limit →413. Default cap: 100 MB (setmax_body_size=0for unlimited). - Timeouts (opt-in, seconds) —
read_header_timeout(Slowloris guard),read_body_timeout→408,handler_timeout→504.
Correctness fixes¶
- Large integers — Python ints above
i64::MAX(up tou64::MAX, e.g. 64-bit IDs) now serialize exactly instead of being downgraded to a lossy float. - DELETE/OPTIONS bodies — request bodies for these methods are now read (RFC 7231).
- Range header — no longer underflows on a 0-byte file.
- Query decoding —
+decodes to a space in query keys as well as values.
Python API fixes¶
app.use(CsrfConfig(cookie_name=..., header_name=..., allowed_origins=...))is now honored — previously the config was silently dropped.enable_csrf()accepts the same optional arguments.@app.options,@app.head, and@app.routeapply the same validation and Redis-injection wrapping as the other verb decorators.
Native data layer + ORM¶
The previous database/Redis layer was mock scaffolding — methods returned []/None and never connected. It is now real (resolves issue #5).
- Native Postgres pool (
src/db/postgres.rs) backed bydeadpool-postgres+tokio-postgres.app.database/request.databaseexposefetch(→list[dict]),fetchrow(→dict|None),fetchval,execute(→rows affected), andtransaction(), with positional$1params andjsonb→nested-Python decoding. - Native async Redis client (
src/db/redis_client.rs) backed by therediscrate'sConnectionManager: get/set/del/expire/incr/hget/hset/lpush/lrange/sadd/publish/eval/ script_load/ping/… — verified against a live server. - Transactions:
async with request.database.transaction() as tx: …plus a rewritten async@transactionaldecorator (auto commit/rollback). - Built-in ORM (
python/cello/orm.py):Model+ typed fields, a chainable asyncQuerySet(filter/exclude/order_by/limit/offsetwith field lookups;get/first/all/count/exists/values/create/update/delete), andcreate_table/drop_table. Intentionally lightweight (no migration diffing, lazy reverse relations,select_related, or signals/admin).
from cello import App, DatabaseConfig
app = App()
app.enable_database(DatabaseConfig(url="postgres://user:pass@localhost/db", pool_size=10))
@app.get("/users/{id}")
async def get_user(request):
return await request.database.fetchrow("SELECT * FROM users WHERE id = $1",
int(request.params["id"]))
Full enable_* plugin audit¶
Every plugin was verified end-to-end over live HTTP. Fixes:
| Plugin | Fix |
|---|---|
| Prometheus | /metrics returned 404 (routing fast-404 ran before the middleware). Served via try_serve in the routing-miss branch. |
| Health checks & GraphQL | Same fast-404 cause — /health* and /graphql weren't registered routes. Added a serves_unrouted() trait hook so path-owning middleware serve their endpoints before the 404. Only those run on a miss, so unknown paths under auth still 404 (no route-existence leak). |
| Basic auth | 401 lacked WWW-Authenticate (the after hook never ran after an Err). Now returns a proper challenge. |
| JWT | Rejected standard tokens missing iat. iat is now optional; exp stays required. |
| Security headers | enable_security_headers accepted only a bool; it now also accepts a SecurityHeadersConfig. |
| Timeouts | set_timeouts panicked (hyper 1.x needs a timer); fixed. |
The seven announce-only plugins (enable_grpc/messaging/rabbitmq/sqs/ event_sourcing/cqrs/saga) now clearly state they record config only — the runtime lives in the cello.grpc / cello.messaging / cello.cqrs / cello.saga / cello.eventsourcing modules.
Three-pillar upgrades¶
Security — full headers. SecurityHeadersConfig now configures CSP (via the CSP builder), Permissions-Policy, and cross-origin isolation (COEP/COOP/CORP):
from cello import CSP, SecurityHeadersConfig
app.enable_security_headers(SecurityHeadersConfig(
csp=CSP().default_src(["'self'"]).img_src(["'self'", "data:"]),
permissions_policy={"geolocation": [], "camera": ["'self'"]},
coep="require-corp", coop="same-origin", corp="same-origin",
))
Security — Redis TLS. rediss:// URLs are now supported (rustls): app.enable_redis(RedisConfig(url="rediss://host:6380")).
Speed — compressed cache. A cache HIT short-circuits the compression middleware, so it used to serve large bodies uncompressed. It now gzips the HIT inline for Accept-Encoding: gzip clients (enable_caching(..., compress=True), default on), sets Vary: Accept-Encoding, and serves identity to non-gzip clients.
DX + Security — request validation. Declarative body validation returns 400 before the handler runs and injects the validated instance:
from pydantic import BaseModel
class UserDTO(BaseModel):
name: str
age: int
@app.post("/users", body=UserDTO)
def create(request, user): # 400 {"detail": [...]} on bad input
return {"id": 1, "name": user.name}
Works with Pydantic models, dataclasses, and plain classes, on App and Blueprint.
Files Changed (highlights)¶
| File | Change |
|---|---|
src/async_loop.rs (new) | Persistent asyncio loop + run_coroutine_blocking |
src/handler.rs, src/lib.rs | Drive async handlers & lifecycle hooks on the persistent loop |
src/lib.rs, src/server/mod.rs | Wire max_body_size + header/body/handler timeouts; set_limits/set_timeouts |
src/middleware/csrf.rs, cors.rs, auth.rs, mod.rs (+ prometheus/telemetry/cache/etag/body_limit) | Security fixes |
src/json.rs, src/response/mod.rs, src/sse.rs, src/response/streaming.rs, src/server/mod.rs | Correctness fixes |
python/cello/__init__.py, middleware.py | set_limits/set_timeouts, CSRF wiring, verb consistency, body= validation |
src/db/postgres.rs, redis_client.rs, value.rs (new) | Native Postgres pool + Redis client (incl. rediss:// TLS) |
python/cello/orm.py (new) | Built-in async ORM |
src/middleware/health.rs, graphql.rs, mod.rs, auth.rs, cache.rs, src/server/mod.rs, src/lib.rs | Plugin audit fixes + serves_unrouted, compressed cache, full security headers |
python/cello/validation.py | wrap_handler_with_body (declarative body= validation) |
tests/test_v130_fixes.py, test_native_db.py, test_middleware.py, test_plugins.py, test_upgrades.py (new) + Rust #[cfg(test)] units | Regression coverage |
Migration¶
Most apps upgrade with no changes. Review these if they apply:
- Async handlers now share one persistent event loop (instead of a fresh loop per request). This is what makes loop-bound resources work — but if you deliberately relied on a brand-new loop each request, adjust accordingly.
- Body size cap defaults to 100 MB. If you accept larger uploads, call
app.set_limits(LimitsConfig(max_body_size=<bytes or 0>)). - DELETE/OPTIONS now deliver request bodies to handlers.
Known follow-ups¶
- The per-process Tokio runtime remains current-thread; multicore scaling continues to come from the multi-process (fork/
SO_REUSEPORT) model. A per-process multi-threaded runtime is deferred pending benchmarking. AsyncClientstill usespyo3_asyncio::future_into_py; it is expected to work atop the new persistent loop but was not independently benchmarked in this release.- Postgres
numeric/timestamptzparams need an explicit$1::typecast. - The announce-only pattern/protocol plugins (
grpc/messaging/…) record config only; the runtime lives in the correspondingcello.*Python modules.