Skip to main content

An opinionated, msgspec-first ASGI micro-framework

Project description

jero

PyPI Build status codecov Python versions License

An opinionated, msgspec-first ASGI micro-framework for Python 3.14.

Probably about as fast as idiomatic Python gets. 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):
    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(), path="/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 Co-leads the fastest Python ASGI frameworks — within a few percent of Go on the hot path (see Performance). 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 msgspecjson, params, path, headers, form, user; malformed → 400, schema-invalid → 422, all resolved once at startup.
  • Typed responses and typed headersJSONResponse[Body, Headers] keeps both schemas (no erasure), status_code overrides the status, and raw_headers is 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 user type 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 a BaseFactory.
  • 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 a factory= 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 httpx
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: httpx.AsyncClient

    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", content=json_encode(data))
        return json_decode(resp.content, type=Widget)


@dataclass
class WidgetResource(Resource):
    _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(httpx.AsyncClient(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), path="/widgets")


app = App()

Performance

jero is fast — very fast. It co-leads the quickest Python ASGI frameworks, and on a narrow, favorable benchmark lands within a few percent of a hand-written Go (Gin) service. That near-Go figure is a best case under specific conditions — not a claim that jero is as fast as Go in general. It isn't, and we're not saying it is.

The numbers below are from the authed write path — POST /movies (bearer auth → msgspec decode → handler → encode → 201) — run natively under granian with a single worker (Go pinned to GOMAXPROCS=1), driven by oha at concurrency 200:

Framework Requests/sec Relative to jero
Go / Gin (reference) ≈ 45,200 1.03×
jero ≈ 44,000 1.00×
Blacksheep ≈ 43,000 0.98×
Litestar ≈ 22,000 0.50×
Robyn ≈ 15,000 0.34×
FastAPI ≈ 7,300 0.17×

A statistical tie with Blacksheep, ~2× Litestar, ~3× Robyn, and ~6× idiomatic FastAPI — at ~97% of raw Go on the same machine (and ~91% on a plain GET). Those near-Go ratios hold only under these ideal, constrained conditions — single worker, Go pinned to one core, localhost, this one hot path, partly client-bound. Treat them as indicative, not a general "as fast as Go" claim; the benchmark harness lives in a separate repo.

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


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

jero-0.0.21.tar.gz (30.7 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

jero-0.0.21-py3-none-any.whl (32.0 kB view details)

Uploaded Python 3

File details

Details for the file jero-0.0.21.tar.gz.

File metadata

  • Download URL: jero-0.0.21.tar.gz
  • Upload date:
  • Size: 30.7 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

Hashes for jero-0.0.21.tar.gz
Algorithm Hash digest
SHA256 72e6b25bbdd70ddefdd3d7fd4e97b1daf13e60dffd867f1cd365070104a1deb5
MD5 66c4382e9c4f3d68c94806972f9ae4e2
BLAKE2b-256 d9e8f690ecaea526e4ebc8eb37269eb3eefc55a1ca02ffcf38527c7b980a91d4

See more details on using hashes here.

File details

Details for the file jero-0.0.21-py3-none-any.whl.

File metadata

  • Download URL: jero-0.0.21-py3-none-any.whl
  • Upload date:
  • Size: 32.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

Hashes for jero-0.0.21-py3-none-any.whl
Algorithm Hash digest
SHA256 f9c1441d05db5de03f61f6dd8f4ff6d926626b717929fb35e4224e0fd8346b92
MD5 040832e090ad95c47325e08d0db9d09d
BLAKE2b-256 6563679d476bc2ac27c729630073f5da5522a61ff82e638f02da117520a80162

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page