Skip to content

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, an asyncpg/SQLAlchemy async pool, or an asyncio.Lock/Queue is bound to the loop that created it. On the second request you got RuntimeError: <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-Length up-front and enforced while streaming via Limited, so oversized/chunked bodies cannot exhaust memory. Over the limit → 413. Default cap: 100 MB (set max_body_size=0 for unlimited).
  • Timeouts (opt-in, seconds) — read_header_timeout (Slowloris guard), read_body_timeout408, handler_timeout504.

Correctness fixes

  • Large integers — Python ints above i64::MAX (up to u64::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.route apply 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 by deadpool-postgres + tokio-postgres. app.database / request.database expose fetch (→list[dict]), fetchrow (→dict|None), fetchval, execute (→rows affected), and transaction(), with positional $1 params and jsonb→nested-Python decoding.
  • Native async Redis client (src/db/redis_client.rs) backed by the redis crate's ConnectionManager: 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 @transactional decorator (auto commit/rollback).
  • Built-in ORM (python/cello/orm.py): Model + typed fields, a chainable async QuerySet (filter/exclude/order_by/limit/offset with field lookups; get/first/all/count/exists/values/create/update/delete), and create_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

pip install --upgrade cello-framework==1.3.0

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.
  • AsyncClient still uses pyo3_asyncio::future_into_py; it is expected to work atop the new persistent loop but was not independently benchmarked in this release.
  • Postgres numeric/timestamptz params need an explicit $1::type cast.
  • The announce-only pattern/protocol plugins (grpc/messaging/…) record config only; the runtime lives in the corresponding cello.* Python modules.