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.

GitHub · Documentation

What is jero?

jero is a fast and modern Python web framework for building typed JSON/REST APIs on ASGI. You write resources and endpoints as plain classes and annotate handler inputs and outputs with msgspec Structs; jero handles the rest — routing, request/response validation, serialization, auth, and resource lifecycle — and runs under any ASGI server (granian, uvicorn, …).

It's opinionated on purpose, and makes one bet: that being aggressively prescriptive — rather than flexible — is exactly what lets a framework be both extremely fast and a joy to build on. Three pillars, all non-negotiable:

  1. Speed. Introspection happens once, at startup. The request path is dict lookup → msgspec decode → call → encode, and nothing else is ever added to it.
  2. Opinionated DX. One blessed way to do each thing, encoded so you can't get it wrong. Contracts fail loud at startup with a precise WiringError, never quietly at runtime.
  3. Strict typing. Fully static under pyright-strict — the types are the contract, and the source of the coming OpenAPI spec. If you don't like typing, this isn't your framework.

And no DI container: dependencies are hand-wired in _wire; the framework adds only lifecycle — the one thing plain Python doesn't give you.

Example

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):
    # called as: 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(), path="/widgets")


app = App()

Run it under any ASGI server, e.g. granian:

granian --interface asgi myapp:app

With a service layer and a factory

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:
        widgets = await self._factory.create_widget_service()
        self._include_resource(WidgetResource(widgets), path="/widgets")


app = App()

Performance

jero is fast — very fast. In fact, it co-leads the quickest Python ASGI frameworks and lands within a few percent of a hand-written Go (Gin) service on the same box.

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). These are localhost runs and partly client-bound, so treat them as indicative; 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.9.tar.gz (379.1 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.9-py3-none-any.whl (26.1 kB view details)

Uploaded Python 3

File details

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

File metadata

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

Hashes for jero-0.0.9.tar.gz
Algorithm Hash digest
SHA256 43dbf5311d316dac9d76bbb2cadb3befe24912dca9d54e1349c85665293eebf4
MD5 4315b61f707ee054181791f0c94af1c5
BLAKE2b-256 669f3b2ab9dbd34dfac69215d0cc48eafac3e6df1242071a92ca91b240869ece

See more details on using hashes here.

File details

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

File metadata

  • Download URL: jero-0.0.9-py3-none-any.whl
  • Upload date:
  • Size: 26.1 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.9-py3-none-any.whl
Algorithm Hash digest
SHA256 1353463c94cf0f627f8a46bc73ca9d86a3ecad1653dcd5eb01be259908acf039
MD5 ff32aa777a4a92e404b99bd2427987d7
BLAKE2b-256 582691a762dfe1cc31c533fe547aad3a046ef13e756185373a2c62093c03997b

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