Skip to content

Getting started

jero targets Python 3.13+ and runs under any ASGI server.

Install

uv add jero

You'll also want an ASGI server to run it. granian is a good default:

uv add granian

Python version

jero requires Python 3.13 or newer. Python 3.12 and earlier are not supported.

The reason is generics. jero's response wrappers (and several other types) declare type-parameter defaults, like the optional typed-headers parameter H:

class JSONResponse[T: Struct, H: Struct | None = None]: ...

The = None default on a type parameter is PEP 696, which shipped in Python 3.13. The generic syntax itself (PEP 695) arrived in 3.12, so 3.12 can parse [T: Struct] but not the [H: Struct | None = None] default. jero relies on those defaults throughout, so 3.13 is the current floor, and there are no current plans to lower it. If you have a need to run on an earlier version, please get in touch on GitHub Discussions.

Your first app

A jero app is a BaseApp subclass that wires up resources (REST collections) and endpoints (single routes). Handler inputs and outputs are msgspec Structs — the types are the request/response contract.

jero has no route decorators. Instead of writing @app.get(...), you define a class, declare its path on the class, and let method names carry the HTTP semantics.

from msgspec import Struct

from jero import BaseApp, Resource


class WidgetPath(Struct):
    widget_id: str


class Widget(Struct):
    id: str
    name: str


class WidgetResource(Resource, path="/widgets"):
    # GET /widgets/{widget_id}
    async def read_one(self, path: WidgetPath) -> Widget:
        return Widget(id=path.widget_id, name="widget-name")


class App(BaseApp):
    async def _wire(self) -> None:
        self._include_resource(WidgetResource())


app = App()

Run it:

granian --interface asgi myapp:app
curl localhost:8000/widgets/abc # -> { "id": "abc", "name": "widget-name" }

That's the whole loop: a Struct for the URL slots (path), a Struct for the response, and a method name (read_one) that maps to GET.

The Struct requirement is deliberate. JSON request bodies, JSON responses, query params, path params, headers, forms, auth users, and response headers all use typed contracts. That is what gives jero validation, fast msgspec serialization, startup errors for invalid wiring, and the source material for the coming OpenAPI generator. If a handler returns a raw dict, jero can't prove or document its shape, so it is a startup error.

The mental model

  • A Resource is a class with any of the CRUD methods create / read_one / read_many / update / partial_update / delete, mapped to POST / GET (item) / GET (collection) / PUT / PATCH / DELETE. See Resources & Endpoints.
  • An Endpoint is a class with bare verb methods (get/post/…) for non-resource routes — health checks, webhooks, actions.
  • Handler arguments bind by name, each a Struct: json, params, path, headers, form, user, plus raw content: bytes / raw_headers. See Request binding.
  • Returns are typed: a Struct, list[Struct], bytes, or a response wrapper (JSONResponse[T], BytesResponse, a streaming response) when you need to control headers or status. See Responses & headers.
  • Dependencies are hand-wired in _wire — no DI container. The framework adds the one thing plain Python doesn't: resource lifecycle. See Wiring & lifecycle.
  • For a complete application shape, see the complete example.

Test it without a server

jero ships a synchronous, in-process TestClient — no socket, no running server:

from jero import TestClient


def test_read_one():
    with TestClient(App()) as client:
        resp = client.get("/widgets/abc")
        assert resp.status_code == 200
        assert resp.json() == {"id": "abc", "name": "widget-name"}

Where next