An opinionated, msgspec-first ASGI micro-framework
Project description
An opinionated, msgspec-first ASGI micro-framework for Python 3.14.
Engineered for performance from the ground up
Strictly typed end to end
A joy to build on
GitHub · Documentation
jero builds typed JSON/REST APIs from plain classes. Annotate your handlers with msgspec Structs — jero does the rest: routing, validation, serialization, auth, streaming, and resource lifecycle.
from msgspec import Struct
from jero import BaseApp, Resource
class Widget(Struct):
id: str
name: str
class WidgetPath(Struct):
widget_id: str
class Widgets(Resource, path="/widgets"):
async def read_one(self, path: WidgetPath) -> Widget: # GET /widgets/{widget_id}
return Widget(id=path.widget_id, name="gizmo")
class App(BaseApp):
async def _wire(self) -> None:
self._include_resource(Widgets())
app = App()
granian --interface asgi myapp:app # or uvicorn, or any ASGI server
No decorators, no dict returns, no runtime surprises — the Struct types are the
request/response contract, and they're verified at startup.
Why jero?
| ⚡ Fast | The fastest Python ASGI framework across every workload in our benchmark. All introspection happens once, at startup; the request path is just dict lookup → decode → call → encode. |
| 🎯 Opinionated | One blessed way to do each thing, so you can't get it wrong. Contracts fail loud at startup with a precise WiringError, never quietly at runtime. |
| 🔒 Typed | Fully static under pyright-strict, leaning hard into modern Python typing — PEP 695 generics (JSONResponse[Body, Headers], BaseApp[Factory]), bounded type-params, generic inheritance, Protocols. A handler's signature is its schema, and the source of the coming OpenAPI spec. |
No DI container, either: dependencies are hand-wired in _wire; the framework adds
only lifecycle — the one thing plain Python doesn't give you.
What you get
- Resources & Endpoints — REST CRUD by method name, or bare verbs for one-off routes.
- Bind by name, validated by msgspec —
json,params,path,headers,form,user; malformed → 400, schema-invalid → 422, all resolved once at startup. - Typed responses and typed headers —
JSONResponse[Body, Headers]keeps both schemas (no erasure),status_codeoverrides the status, andraw_headersis the escape hatch for cookies and the exotic tail. - Streaming, typed end to end — NDJSON, Server-Sent Events, and raw byte streams, with lifecycle teardown and client-disconnect handling done for you.
- Multipart forms & uploads — typed parts, file uploads, per-part headers.
- Auth checked at startup — the
usertype is verified against your authenticator before a single request is served, not at runtime. - Lifecycle without a DI container — hand-wire in
_wire, open resources on exit stacks, group construction in aBaseFactory. - REST semantics for free — 404/400/422/401/405, auto
HEAD+OPTIONS, camelCase on the wire. - A real test story — a sync, in-process
TestClient(no socket), streaming support, and afactory=seam for mocking.
Start with Getting Started, or browse the full Guide.
A real app
For anything real, a resource delegates to a service, and a Factory builds that
service — opening any resources it needs (HTTP clients, DB pools, …) on the app's
exit stacks, which jero closes in reverse at shutdown. The app is parameterised with
the factory type (BaseApp[Factory]), exposing it as self._factory in _wire.
from dataclasses import dataclass
import niquests
from msgspec import Struct
from msgspec.json import decode as json_decode
from msgspec.json import encode as json_encode
from jero import BaseApp, BaseFactory, HTTPError, Resource
class WidgetPath(Struct):
widget_id: str
class WidgetIn(Struct):
name: str
class Widget(WidgetIn):
id: str
@dataclass
class WidgetService:
"""Owns the upstream HTTP client; built once by the factory."""
_client: niquests.AsyncSession
async def fetch(self, widget_id: str) -> Widget:
resp = await self._client.get(f"/widgets/{widget_id}")
if resp.status_code == 404:
raise HTTPError(404, "widget not found")
return json_decode(resp.content, type=Widget)
async def create(self, data: WidgetIn) -> Widget:
resp = await self._client.post("/widgets", data=json_encode(data))
return json_decode(resp.content, type=Widget)
@dataclass
class WidgetResource(Resource, path="/widgets"):
_service: WidgetService
# called as: POST /widgets
async def create(self, json: WidgetIn) -> Widget:
return await self._service.create(json)
# called as: GET /widgets/{widget_id}
async def read_one(self, path: WidgetPath) -> Widget:
return await self._service.fetch(path.widget_id)
class Factory(BaseFactory):
async def create_widget_service(self) -> WidgetService:
client = await self._aenter(niquests.AsyncSession(base_url="https://api.example.com"))
return WidgetService(client)
class App(BaseApp[Factory]):
async def _wire(self) -> None:
widget_service = await self._factory.create_widget_service()
self._include_resource(WidgetResource(widget_service))
app = App()
Performance
In a side-by-side benchmark against seven other frameworks — Python (Litestar, FastAPI,
Blacksheep, Robyn, Flask), Go (Gin), and Bun (Elysia) — jero is the fastest Python
framework in every scenario tested. On the pure framework hot path (a typed JSON
GET) it tops the table outright, ahead of both the Go and the Bun service:
| Framework | GET /info req/s |
Relative to jero |
|---|---|---|
| jero | 43.4k | 1.00× |
| blacksheep | 39.7k | 0.91× |
| gin (Go) | 39.2k | 0.90× |
| elysia (Bun) | 38.6k | 0.89× |
| litestar | 33.8k | 0.78× |
| fastapi | 25.7k | 0.59× |
On I/O-bound paths — proxying an upstream, reading from a database — Go pulls well clear, because there the bottleneck is the HTTP-client and database-driver ecosystem, not the framework. jero stays the fastest Python option, but it isn't as fast as Go in general, and we're not claiming it is.
These are favourable, constrained conditions — single worker, single core, localhost, best-of-N — and a microbenchmark is not your application. See the full methodology and all four scenarios in the Performance docs.
Development
task install # create the venv and install pre-commit hooks
task check # lock check + ruff, pyright, deptry, pylint (via prek)
task test # run the test suite with coverage
See AGENTS.md for the design philosophy and the contract, and
style-guide.md for project conventions.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file jero-0.0.29.tar.gz.
File metadata
- Download URL: jero-0.0.29.tar.gz
- Upload date:
- Size: 39.1 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: uv/0.10.12 {"installer":{"name":"uv","version":"0.10.12","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
116fdb35fd70c606cadee028b0523b15978b8f65092f04431e1f87e91d14093a
|
|
| MD5 |
29e062e273f6956250f646c2fbc0e145
|
|
| BLAKE2b-256 |
5aa761f1360482ce383a33e335a81c42f5d1b6304b5f7e5438c06b5ab941893f
|
File details
Details for the file jero-0.0.29-py3-none-any.whl.
File metadata
- Download URL: jero-0.0.29-py3-none-any.whl
- Upload date:
- Size: 41.0 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: uv/0.10.12 {"installer":{"name":"uv","version":"0.10.12","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
327b0d9f0ec203ef6e7fe58636d7cb782bbfb790628b71947dfcc6dd48cba3a2
|
|
| MD5 |
94c9a29e43e3ea13d951076ccc5037c2
|
|
| BLAKE2b-256 |
a5b9eae78cec64ee3bf64a293452dd694bf9b159dc011a9877c8b1fd6e886571
|