Skip to content

Cello v1.2.0 — Bug Fixes & Rust-Native AsyncClient

Release Date: June 2, 2026 License: MIT Python: 3.12+


Overview

Cello v1.2.0 ships three bug fixes reported against v1.1.0 and two new features — including a Rust-native async HTTP client that eliminates Python entirely from the outbound HTTP hot path.

All changes are fully backwards-compatible. No API surface was removed or renamed.


Bug Fixes

Fix 1 — on_event("shutdown") coroutine never awaited

Symptom: async def shutdown handlers registered with @app.on_event("shutdown") produced a RuntimeWarning: coroutine '...' was never awaited and the handler body was never executed.

Root cause: Shutdown hooks were called but the returned coroutine was discarded instead of being driven to completion.

Fix: Shutdown (and startup) hooks now go through run_lifecycle_handler_async, which uses pyo3_asyncio::tokio::into_future to drive the coroutine to completion via Tokio — the same mechanism used for request handlers. The GIL is released during I/O waits inside the hook.

@app.on_event("shutdown")
async def shutdown():
    await some_client.aclose()  # now correctly awaited

Fix 2 — KeyboardInterrupt propagating into shutdown handlers

Symptom: Pressing CTRL+C printed Error in shutdown handler: KeyboardInterrupt and could interrupt the shutdown sequence mid-way.

Fix: The shutdown handler loop now explicitly suppresses KeyboardInterrupt errors:

Err(e) if !e.to_string().contains("KeyboardInterrupt") => {
    eprintln!("Error in shutdown handler: {e}");
}
_ => {}

KeyboardInterrupt is expected during a CTRL+C shutdown and is silently ignored; all other errors are still printed.


Fix 3 — request.redis raises AttributeError

Symptom: Accessing request.redis inside a handler raised AttributeError: 'Request' object has no attribute 'redis' even when app.enable_redis() was configured.

Fix:

  • A redis property and _inject_redis method were added to the Rust Request struct (src/request/mod.rs).
  • The Python dispatch wrapper (__init__.py) now calls request._inject_redis(app._redis) before each handler invocation when Redis is configured.
app.enable_redis(RedisConfig(url="redis://localhost:6379", pool_size=10))

@app.get("/")
async def index(request):
    r = request.redis          # works — no longer raises AttributeError
    await r.set("key", "val")
    return {"ok": True}

New Features

Feature 1 — Redis Lua scripting: eval, evalsha, script_load

Three new methods on the Redis client enable atomic multi-command operations via Lua scripts, reducing round-trips for compound read-modify-write patterns.

Method Description
await r.eval(script, numkeys, *keys_and_args) Execute a Lua script inline
await r.evalsha(sha, numkeys, *keys_and_args) Execute a cached script by SHA1
await r.script_load(script) Upload a script; returns its SHA1

Example — atomic check-and-enqueue:

sha = await r.script_load("""
    local m = redis.call('SISMEMBER', KEYS[1], ARGV[1])
    if m == 0 then return 0 end
    redis.call('LPUSH', KEYS[2], ARGV[2])
    return 1
""")

# 1 network round-trip instead of 2
result = await r.evalsha(sha, 2, "tokens", "queue", token, data)

Feature 2 — Rust-native AsyncClient (reqwest + Tokio)

The built-in AsyncClient has been rewritten from Python (urllib.request + asyncio.to_thread) to a pure Rust implementation backed by reqwest 0.11 and Tokio.

Before (v1.1.0): Python stdlib + thread-pool executor After (v1.2.0): Rust reqwest + Tokio — GIL never held during network I/O

The API is identical to v1.1.0. No user code changes are required.

from cello import AsyncClient

client = AsyncClient(timeout=10.0)

# GET
resp = await client.get("https://api.example.com/data")
print(resp.status)    # int
print(resp.text)      # str  (UTF-8 decoded)
print(resp.json())    # dict (Python object)
print(resp.content)   # bytes

# POST with JSON body
resp = await client.post("https://api.example.com/items", json={"name": "widget"})

# POST with raw bytes
resp = await client.post("https://api.example.com/upload", content=b"...", headers={"Content-Type": "application/octet-stream"})

# PUT / PATCH / DELETE
resp = await client.put("https://api.example.com/items/1", json={"name": "updated"})
resp = await client.patch("https://api.example.com/items/1", json={"name": "patched"})
resp = await client.delete("https://api.example.com/items/1")

# Async context manager
async with AsyncClient() as client:
    resp = await client.get("https://example.com")

What changed internally:

Aspect v1.1.0 v1.2.0
Backend urllib.request reqwest (Rust)
Concurrency asyncio.to_thread (thread pool) Tokio native I/O
GIL during I/O Held in thread Never held
HTTP/2 No Yes (via reqwest)
TLS Python stdlib rustls (same as server)
Gzip decompression No Automatic
Connection pooling OS per-thread reqwest shared pool

Implementation details:

  • src/http_client.rs — new module; PyHttpResponse and PyAsyncClient are #[pyclass] types
  • JSON bodies are serialized via Python's json.dumps while the GIL is held (before the async boundary), then the raw bytes are passed into the Rust future — GIL is dropped for all I/O
  • Coroutines returned to Python via pyo3_asyncio::tokio::future_into_py
  • reqwest = { version = "0.11", features = ["rustls-tls", "gzip"], default-features = false } added to [dependencies] in Cargo.toml

Architecture — Request Handler Hot Path (unchanged since v1.0.0)

For completeness, this section documents the end-to-end async execution path that was already in place and is unaffected by this release:

TCP accept        → Rust (Tokio)
HTTP parse        → Rust (hyper)
Route lookup      → Rust (matchit radix tree)
Handler call      → Python (GIL acquired, handler invoked)
Coroutine await   → Rust (Tokio via pyo3-asyncio::tokio::into_future, GIL released)
Response build    → Rust (SIMD JSON serialization)

Python async def handlers are driven by Tokio, not asyncio. The event loop seen inside a handler (asyncio.get_event_loop()) is the pyo3-asyncio Tokio-backed loop — uvloop is not required and has no effect.


Upgrade Guide

No code changes are required. Install the new wheel:

pip install --upgrade cello-framework

Or rebuild from source:

maturin develop --release

Version History

Version Date Highlights
1.2.0 2026-06-02 Bug fixes (shutdown, redis), Lua scripting, Rust AsyncClient
1.1.0 2026-04-11 MiniJinja template engine
1.0.1 2026-02-21 Cross-platform fixes (Windows, ARM), async compatibility
1.0.0 Production-stable release