Philosophy¶
jero exists because the current Python web framework defaults are not the only way to build APIs.
FastAPI, BlackSheep, Litestar, Django, Flask, Starlette, and the rest have all pushed Python web development forward. jero is not a rejection of that work. It is a different set of bets: fewer extension points, fewer runtime decisions, stronger static contracts, and a narrower idea of what a JSON API framework should be.
No decorators, no DI container¶
Route decorators are the common default, and what most frameworks reach for:
@app.get("/widgets/{widget_id}")
async def read_widget(widget_id: str) -> Widget:
...
For a single handler this reads cleanly: path, verb, and function sit together. The
tradeoffs surface across a whole API, and the deepest one is dependencies. A plain
function has no self to hold what a handler depends on (a database pool, an HTTP
client, a service), so those dependencies must arrive some other way. There are only
two:
- module-level globals (Flask's
current_app/g), or - a framework-specific dependency-injection system (FastAPI's
Depends, Litestar'sProvide).
Grouping is loose for the same reason: the operations on one collection are separate functions sharing a path prefix, but nothing is the collection. A router to group routes and a DI layer to feed them are largely recovering what a plain class gives for free.
jero starts from that class. A route is a Resource for a REST collection or an
Endpoint for a one-off route; the path lives on the class, method names carry the HTTP
semantics, and dependencies are ordinary constructor arguments:
@dataclass
class WidgetResource(Resource, path="/widgets"):
_service: WidgetService
async def read_one(self, path: WidgetPath) -> Widget:
return await self._service.get_widget(path.widget_id)
The collection is now one object whose operations live together because they are
together, and whose dependencies are passed to __init__. That is the whole dependency
story: Python's own injection mechanism, constructors. Build an object, pass it what it
needs. No globals, no resolver graph, no per-request container lookup, no dependency
protocol to learn. Wiring is ordinary async Python:
class App(BaseApp[Factory]):
async def wire(self) -> None:
service = await self._factory.create_widget_service()
self._include_resource(WidgetResource(service))
The framework adds one thing plain Python does not: lifecycle. Enter a resource that must
be opened and closed with _enter or _aenter, and the app closes it in reverse order at
shutdown; for larger apps a BaseFactory groups construction in the same explicit style.
Decorators are lighter for a handful of one-off routes. For a typed, REST-shaped JSON
API, the class is the better unit of design, and it doubles as a stable thing the
framework attaches to: a shape to validate at startup, the target for reverse-routed
Location / Link headers, and the anchor for OpenAPI generation.
msgspec first¶
jero is built on msgspec for performance.
The hot path is small: route lookup, msgspec decode, handler call, msgspec encode.
msgspec's Struct types give jero fast validation and serialization without
translating between separate framework models and wire models. That matters because
JSON APIs spend a lot of time turning bytes into objects and objects back into bytes.
Pydantic shaped modern Python API development. jero bets differently: for a strict, typed, JSON-focused framework, msgspec is the better foundation.
Struct everywhere¶
In jero, JSON request bodies and JSON responses are Structs. Query params, path
params, typed headers, form models, auth users, and typed response headers are also
Structs.
That is not ceremony for its own sake. The type is the contract.
When a handler accepts json: WidgetIn, jero knows the request body shape. When it
returns Widget, jero knows the response body shape. That single source drives
validation, serialization, startup checks, and the OpenAPI generator. A raw
dict does not carry enough information. It may be convenient in the moment, but it
turns the framework blind at exactly the boundary where the contract matters most.
JSON is typed or it is rejected at startup.
Startup validation¶
jero tries hard to fail before the app serves traffic.
If a route's path slots don't match its path Struct, that is a startup error. If a
handler declares user without auth, that is a startup error. If auth returns one user
type and the handler asks for another, that is a startup error. If a handler's user
annotation disagrees with whether its authenticator accepts anonymous callers — either
direction, including a handler that omits user on a route that serves them — that is a
startup error. If a response type
cannot be understood as a framework response contract, that is a startup error.
This is a DX choice as much as a performance choice. Runtime flexibility often means runtime surprise. jero would rather make invalid applications impossible to boot than let the first unlucky request discover the problem.
Startup validation also protects the request path. All introspection happens once during wiring. By the time a request arrives, the framework has already resolved the route, binders, decoders, auth contract, response sender, and status behavior.
Strictly typed, every checker¶
jero's core source is strictly type-checked with pyrefly. On top
of that, the public-facing interface is checked with every major type checker:
mypy, ty,
pyright, and zuban. That
interface is everything jero's test suite exercises: ./tests
and the shared demo_app it runs against.
This is the best of both worlds. The project picks a single fast checker for its own source, and at the same time guarantees that, whatever your favourite type checker is, jero's public API is fully supported and type-checks cleanly under it.
Class-based resources¶
The method names carry the REST semantics directly: create is POST on the collection,
read_many is GET on the collection, and read_one, update_full, update_partial, and
delete are the item operations. The set is small enough to learn quickly and strict
enough for the framework to enforce at startup.
Less freedom, but more shape. jero is comfortable with that tradeoff.
Opinionated¶
Opinionated does not mean "arbitrary." It means the framework has an answer.
How do I define routes? Use Resource or Endpoint.
How do I bind request data? Name the handler argument json, params, path,
headers, form, content, raw_headers, or user.
How do I return JSON? Return a Struct, list[Struct], or JSONResponse[T, H].
How do I wire dependencies? Construct them in wire or a BaseFactory, then pass
them to class constructors.
How do I manage app lifetime resources? Use _enter and _aenter.
This is the promise and the cost of jero. It is not a toolkit for assembling your own framework style. It is a framework with a style. If you try to fight it, it will feel too narrow. If you trust it, the reward is a smaller design space, earlier failures, faster request handling, and fewer choices to relitigate in every codebase.
Who jero is for¶
jero is for developers looking for a fresh, fast alternative to the current stalwarts: FastAPI, BlackSheep, Litestar, and similar frameworks.
It is for teams that like strict typing, explicit wiring, msgspec, REST-shaped APIs, startup validation, and framework conventions that remove debate. It is for people who would rather have the framework say "this is how you do it" than expose five extension points and let every project invent a local style.
It is not trying to be the most flexible Python web toolkit. It is not trying to make dynamic JSON blobs feel effortless. It is not trying to hide Python behind a container.
jero is narrow. That is the point.