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, and the granian extra installs both in one step:

uv add "jero[granian]"

For production on Linux, Deployment recommends peregrine, the server the benchmarks run.

Python version

jero requires Python 3.13 or newer. Its generics use type-parameter defaults (PEP 696, e.g. JSONResponse[T: Struct, H: Struct | None = None]), which shipped in 3.13. If you need an earlier version, get in touch on GitHub Discussions.

Python 3.13, 3.14, and 3.15 are tested in CI against the full test suite and the project's type checks.

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.

Everything that crosses the wire is a Struct — bodies, params, path slots, headers, forms, etc. The Struct is what gives jero validation, fast serialization, startup checks, and the OpenAPI spec; a raw dict return is a startup error. Philosophy has the full reasoning.

The mental model

  • A Resource is a class with any of the CRUD methods create / read_one / read_many / update_full / update_partial / 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, cookies, form, user, plus raw content: bytes / content_stream / raw_headers. See Request binding.
  • Returns are typed: a Struct, list[Struct], str, bytes, or a response wrapper (JSONResponse[T], BytesResponse, a streaming response, etc.) 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:

# doc-example: fragment (illustrative snippet, not a runnable app)
from jero.testing 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

Everything else — streaming, forms, auth, errors, deployment, etc. — is in the Guide.