GUIDE / 25 CHAPTERS
Zero to production.
From an empty file to a production backend, and from using the framework to
understanding it — in five parts. Every snippet is self-contained and self-asserting:
tests/test_guide.py in the repo executes all of them on every push,
so this page cannot drift. Canonical source:
docs/GUIDE.md.
Prefer paper: download the PDF (35 pages, same content).
Part I
Basics
Install the package, serve the first route, and learn the request path: routing, bodies, validation, dependencies.
CHAPTER 01
Install
One package, zero required dependencies. The core runs on stdlib alone; servers, drivers, and test tools arrive as extras. This snippet asserts the installed package, so CI proves the floor this guide stands on.
Install it:
Notes: pip install -e ".[dev]" from a checkout gets everything at once. Requires Python 3.10 or newer; CI covers 3.10 through 3.13.
Runnable — copy into a file and run with python. Executed in CI by tests/test_guide.py.
from importlib.metadata import version
v = version("ikarem")
major = int(v.split(".")[0])
assert major >= 1, v
CHAPTER 02
First light
Create an app, return a dict. Dicts, lists, strings, and bytes become responses automatically — ceremony is reserved for the cases that need it.
Serve it for real with any ASGI server (app.run() needs pip install ikarem[server]), or keep driving it in-process with TestClient — every chapter below uses the client, and your test suite should too. Notes: debug=True adds tracebacks to 500s (never in production); enable_docs=False hides /openapi.json and /docs but never the health probes.
Runnable — copy into a file and run with python. Executed in CI by tests/test_guide.py.
from ikarem import Ikarem
from ikarem.testing import TestClient
app = Ikarem()
@app.get("/")
async def home(req):
return {"hello": "ikarem"}
c = TestClient(app)
r = c.get("/")
assert r.status_code == 200 and r.json() == {"hello": "ikarem"}
assert c.get("/missing").status_code == 404
CHAPTER 03
Routing with intent
Routes declare converters ({uid:int}, plus float, uuid, path), so bad segments 404 instead of crashing handlers. A path that matches nothing is 404; a path that matches with the wrong verb is 405. Name every route for free with the handler name, and reverse it with url_for.
Notes: converters compile to regex once at registration; static routes resolve in O(1). Unknown converters fail at startup with the valid list, not on first traffic. Close misses get "Did you mean" 404s.
Runnable — copy into a file and run with python. Executed in CI by tests/test_guide.py.
from ikarem import Ikarem
from ikarem.testing import TestClient
app = Ikarem(enable_docs=False)
@app.get("/users/{uid:int}")
async def get_user(req, uid: int):
return {"uid": uid}
c = TestClient(app)
assert c.get("/users/7").json() == {"uid": 7}
assert c.get("/users/abc").status_code == 404
assert c.post("/users/7", body={}).status_code == 405
assert app.router.url_for("get_user", uid=7) == "/users/7"
CHAPTER 04
Bodies with boundaries
Read bodies explicitly (await req.json(), await req.form(), await req.body(max_bytes=...)) so oversized payloads become 413s at a limit you chose, not memory pressure you didn't. The app-wide max_body_bytes= sets the floor; per-call max_bytes= overrides it.
Notes: Request.json() caps at 10MB by default. Streaming uploads should always pass an explicit cap. 413 responses still travel the middleware pipeline, so observability headers survive them.
Runnable — copy into a file and run with python. Executed in CI by tests/test_guide.py.
from ikarem import Ikarem
from ikarem.testing import TestClient
app = Ikarem(enable_docs=False)
@app.post("/echo")
async def echo(req):
return await req.json()
@app.post("/small")
async def small(req):
return {"n": len(await req.body(max_bytes=10))}
c = TestClient(app)
assert c.post("/echo", body={"a": 1}).json() == {"a": 1}
assert c.post("/small", body="12345").json() == {"n": 5}
assert c.post("/small", body="x" * 100).status_code == 413
CHAPTER 05
Validation that reads like the docs
Schema models coerce and check; Field() states bounds once and they surface in errors, OpenAPI, and MCP schemas together. extra="forbid" rejects unknown keys — the default ignores them, which is how mass assignment sneaks in. Body-only handlers (no req) are allowed wherever the request itself isn't needed.
Notes: nested models validate recursively; coerced values ("2" to 2) are what the handler receives. json_schema() powers OpenAPI and MCP input schemas from the same definition.
Runnable — copy into a file and run with python. Executed in CI by tests/test_guide.py.
from ikarem import Field, Ikarem, Schema
from ikarem.testing import TestClient
class Item(Schema):
name: str = Field(..., min_length=1, max_length=80)
qty: int = Field(1, ge=1, le=99)
class Order(Schema, extra="forbid"):
item: Item
express: bool = False
app = Ikarem(enable_docs=False)
@app.post("/orders")
async def create(order: Order):
return {"name": order.item.name, "qty": order.item.qty, "express": order.express}
c = TestClient(app)
good = {"item": {"name": "apple", "qty": "2"}, "express": True}
assert c.post("/orders", body=good).json() == {"name": "apple", "qty": 2, "express": True}
assert c.post("/orders", body={"item": {"qty": 1}}).status_code == 400
assert c.post("/orders", body={"item": {"name": "x"}, "hack": 1}).status_code == 400
CHAPTER 06
Dependencies without magic
Depends() declares inputs; the framework builds them per request, caches repeats, and runs yield-dependency finalizers after the response is sent — even on the exception path. Dependencies are plain callables: test them without the framework.
Notes: nesting works to any depth; cycles fail at startup with the path. Depends(fn, use_cache=False) opts out of the per-request cache. Bare Depends() with no callable is a startup error naming the parameter.
Runnable — copy into a file and run with python. Executed in CI by tests/test_guide.py.
from ikarem import Depends, Ikarem
from ikarem.testing import TestClient
closed = []
def settings():
return {"currency": "USD"}
async def ledger_conn(req):
conn = {"open": True}
yield conn
closed.append("closed")
app = Ikarem(enable_docs=False)
@app.get("/balance")
async def balance(req, cfg=Depends(settings), db=Depends(ledger_conn)):
return {"currency": cfg["currency"], "open": db["open"]}
c = TestClient(app)
assert c.get("/balance").json() == {"currency": "USD", "open": True}
assert closed == ["closed"]
Part II
Security
Authentication, sessions, middleware order, and errors. This part is deliberately strict: people copy guide code, so every snippet here is written the way production should look.
CHAPTER 07
Auth that says no clearly
Stateless JWT (HS256, stdlib only) plus role and scope guards. 401 means anonymous, 403 means authenticated but not allowed — clients can act on the difference. Secrets come from config and are refused at startup when they are still defaults.
Notes: production sets IKAREM_AUTH_SECRET (the strict env-or-raise shape is chapter 8's); tokens always carry an explicit expires_in — readers copy this call, so the default here is 15 minutes, not forever. verify_token rejects algorithm confusion (alg=none), expired tokens, and missing sub. Service-to-service keys use APIKeyAuth with static keys or an async lookup=. Passwords hash with pbkdf2 — see the next chapter for the full login shape.
Runnable — copy into a file and run with python. Executed in CI by tests/test_guide.py.
import os
import time
from ikarem import Depends, Ikarem, create_token, require_roles, require_scopes, verify_token
from ikarem.testing import TestClient
SECRET = os.environ.get("IKAREM_AUTH_SECRET") or "guide-dev-only-secret"
app = Ikarem(enable_docs=False, auth_secret=SECRET)
@app.get("/admin")
async def admin(req, claims=Depends(require_roles("admin"))):
return {"sub": claims["sub"]}
@app.get("/files")
async def files(req, claims=Depends(require_scopes("read"))):
return {"n": 2}
c = TestClient(app)
assert c.get("/admin").status_code == 401
user = create_token("u2", SECRET, roles=["user"])
assert c.get("/admin", headers={"authorization": f"Bearer {user}"}).status_code == 403
root = create_token("u1", SECRET, expires_in=900, roles=["admin"], scope="read")
claims = verify_token(root, SECRET)
assert 0 < claims["exp"] - time.time() <= 900 # explicit 15-minute life
assert c.get("/admin", headers={"authorization": f"Bearer {root}"}).json() == {"sub": "u1"}
assert c.get("/files", headers={"authorization": f"Bearer {root}"}).json() == {"n": 2}
CHAPTER 08
Logins without shortcuts
Passwords hash with pbkdf2 and verify in constant time — never == against plaintext, never stored plaintext. The session secret loads from the environment (no hardcoded fallback that ships to production), and a successful login starts a fresh session so a pre-login cookie can't be fixed onto a victim.
Notes: a random per-process fallback would sign cookies with a different key in every uvicorn worker (chapter 21) — users logged out at random, and nothing would warn you because random is not a default. So the fallback here requires the explicit IKAREM_DEV=1 flag, and anything else raises with the remedy. Unknown usernames verify against a dummy hash, so timing reveals nothing. Cookies arrive HttpOnly with SameSite=Lax by default; Secure is opt-in via SessionMiddleware(secure=True) — turn it on the moment you serve HTTPS. The TestClient cookie jar persists login across requests, so flows test exactly as browsers behave.
Runnable — copy into a file and run with python. Executed in CI by tests/test_guide.py.
import os
from ikarem import (
CSRFMiddleware,
Ikarem,
RateLimitMiddleware,
SessionMiddleware,
Unauthorized,
check_password,
csrf_token,
hash_password,
)
from ikarem.testing import TestClient
def resolve_secret():
secret = os.environ.get("IKAREM_SESSION_SECRET")
if secret:
return secret
if os.environ.get("IKAREM_DEV") == "1":
return "dev-only-insecure-secret" # throwaway local runs
raise RuntimeError("set IKAREM_SESSION_SECRET to a long random value (or IKAREM_DEV=1 locally)")
os.environ.setdefault("IKAREM_DEV", "1") # delete this line in production
SECRET = resolve_secret()
# the guard, proven both ways: the production shape refuses to boot
_saved = (os.environ.pop("IKAREM_DEV", None), os.environ.pop("IKAREM_SESSION_SECRET", None))
try:
try:
resolve_secret()
assert False, "must refuse without secret or dev flag"
except RuntimeError as e:
assert "IKAREM_SESSION_SECRET" in str(e)
finally:
if _saved[0] is not None:
os.environ["IKAREM_DEV"] = _saved[0]
if _saved[1] is not None:
os.environ["IKAREM_SESSION_SECRET"] = _saved[1]
DUMMY_HASH = hash_password("no-such-user") # unknown users verify here: same timing
USERS = {"amy": hash_password("s3cret")} # seeded hash, never the password
app = Ikarem(enable_docs=False, session_secret=SECRET)
app.use(RateLimitMiddleware(per_minute=11)) # logins are brute-forced: budget them
app.use(SessionMiddleware())
app.use(CSRFMiddleware())
@app.get("/csrf")
async def csrf(req):
return {"t": csrf_token(req)}
@app.post("/login")
async def login(req):
form = await req.form()
pw_hash = USERS.get(form.get("user", "")) or DUMMY_HASH
if not check_password(form.get("pw", ""), pw_hash):
raise Unauthorized("bad credentials")
req.session.clear() # fresh session on login: fixation-safe
req.session["uid"] = "u1"
return {"ok": True}
@app.post("/logout")
async def logout(req):
req.session.clear()
return {"ok": True}
@app.get("/me")
async def me(req):
uid = req.session.get("uid")
if not uid:
raise Unauthorized("anonymous")
return {"uid": uid}
CT = "application/x-www-form-urlencoded"
c = TestClient(app)
assert c.get("/me").status_code == 401
def token():
return c.get("/csrf").json()["t"]
assert (
c.post("/login", body="user=amy&pw=wrong", content_type=CT, headers={"x-csrf-token": token()}).status_code
== 401
)
assert (
c.post("/login", body="user=noone&pw=x", content_type=CT, headers={"x-csrf-token": token()}).status_code
== 401
)
good = c.post("/login", body="user=amy&pw=s3cret", content_type=CT, headers={"x-csrf-token": token()})
assert good.json() == {"ok": True}
sc = "; ".join(v for k, v in good.headers_list if k.lower() == "set-cookie")
assert "httponly" in sc.lower() and "samesite=lax" in sc.lower() and "secure" not in sc.lower()
assert c.get("/me").json() == {"uid": "u1"}
assert c.post("/logout", headers={"x-csrf-token": token()}).status_code == 200
assert c.get("/me").status_code == 401
assert c.get("/me").status_code == 429 # budget of 11 spent: brute force pays here
CHAPTER 09
Middleware on purpose
The onion: each layer sees the request going in and the response coming out, or short-circuits. Order is the feature — request IDs first, gates early, headers last. A validate_config(app) hook lets middleware fail at boot with the remedy.
Notes: raising Unauthorized/Forbidden inside middleware short-circuits with the same rendering as handler errors. Sessions must precede CSRF — reversed order refuses to boot. Keep middleware free of business logic: gates and headers, nothing else.
Runnable — copy into a file and run with python. Executed in CI by tests/test_guide.py.
from ikarem import Ikarem, RequestIDMiddleware, SecurityHeadersMiddleware
from ikarem.testing import TestClient
order = []
app = Ikarem(enable_docs=False)
app.use(RequestIDMiddleware())
app.use(SecurityHeadersMiddleware())
async def audit(req, call_next):
order.append("in")
resp = await call_next(req)
order.append("out")
resp.headers["x-audited"] = "yes"
return resp
app.use(audit)
@app.get("/")
async def home(req):
return {"ok": True}
c = TestClient(app)
r = c.get("/")
assert r.headers["x-request-id"] and r.headers["x-frame-options"] == "DENY"
assert r.headers["x-audited"] == "yes" and order == ["in", "out"]
CHAPTER 10
Errors with remedies
abort(status, detail) fails tersely through the pipeline so error responses keep request IDs and security headers. Custom handlers map exception types, most-specific first. Framework law: every error names the fix.
Notes: unhandled exceptions are 500s with no leak (tracebacks only under debug=True). 404s suggest close matches. Handler errors render inside the middleware pipeline — headers included.
Runnable — copy into a file and run with python. Executed in CI by tests/test_guide.py.
from ikarem import Ikarem, JSONResponse, abort
from ikarem.testing import TestClient
app = Ikarem(enable_docs=False)
@app.exception_handler(ValueError)
async def _bad(req, exc):
return JSONResponse({"detail": f"bad value: {exc}"}, status_code=422)
@app.get("/widget/{wid:int}")
async def widget(req, wid: int):
if wid == 0:
abort(404, "no such widget")
return {"wid": wid}
@app.get("/parse")
async def parse(req):
raise ValueError("qty")
c = TestClient(app)
assert c.get("/widget/0").status_code == 404
assert c.get("/parse").status_code == 422
assert c.get("/widget/3").json() == {"wid": 3}
Part III
Data and work
Persistence, background work, time, and the blocking-code trap. The throughline: the framework never hides durability semantics — fire-and-forget, at-least-once, and transactional each look different.
CHAPTER 11
Configuration without surprises
Layered and explicit: defaults, then kwargs, then dict, then IKAREM_* environment variables. Typed reads with cast=. No settings module, no import-time environment sniffing.
Notes: secrets (auth_secret, session_secret) also read from IKAREM_AUTH_SECRET / IKAREM_SESSION_SECRET. Startup validation refuses to serve authenticated routes on default secrets.
Runnable — copy into a file and run with python. Executed in CI by tests/test_guide.py.
from ikarem import Ikarem
app = Ikarem(enable_docs=False, debug=False, page_size=20)
assert app.config.get("page_size") == 20
assert app.config.get("missing", "fallback") == "fallback"
assert app.config.get("page_size", cast=str) == "20"
assert app.config.get("debug") is False
CHAPTER 12
Data that survives
One connector interface, four engines. Placeholders are always ?; rows are plain dicts; drivers import lazily with the exact extra named on failure. Transactions commit on clean exit and roll back on error.
Notes: SQLite serializes on a worker-side lock (single-lane by design, WAL on); Postgres/MySQL pools rebuild per event loop. Point IKAREM_DB_URL at Postgres when writes outgrow one lane — the suite is green on both.
Runnable — copy into a file and run with python. Executed in CI by tests/test_guide.py.
import asyncio
from ikarem import Ikarem
from ikarem.db import DatabasePlugin
app = Ikarem(enable_docs=False)
app.register(DatabasePlugin("sqlite:///:memory:"))
async def main():
await app.startup()
try:
db = app.state_db
await db.execute("CREATE TABLE IF NOT EXISTS notes (id INTEGER PRIMARY KEY, text TEXT)")
async with db.transaction():
await db.execute("INSERT INTO notes (text) VALUES (?)", "buy milk")
rows = await db.fetch_all("SELECT * FROM notes")
assert [r["text"] for r in rows] == ["buy milk"]
finally:
await app.shutdown()
asyncio.run(main())
CHAPTER 13
Work that outlives the deploy
BackgroundTasks fire after the response and vanish on restart. The durable queue survives it: portable leases, exponential-backoff retries, parked dead jobs instead of silent loss. Drain with ikarem worker.
Notes: unknown task names and bad payloads fail the job, not the worker. depth() exposes queue length for /readyz-style checks and dashboards.
Runnable — copy into a file and run with python. Executed in CI by tests/test_guide.py.
import asyncio
from ikarem import BackgroundTasks, Ikarem
from ikarem.db import DatabasePlugin
from ikarem.queue import QueuePlugin, task
from ikarem.testing import TestClient
fired, done = [], []
@task("guide-receipt")
async def receipt(order_id: int):
done.append(order_id)
app = Ikarem(enable_docs=False)
app.register(DatabasePlugin("sqlite:///:memory:"))
app.register(QueuePlugin())
@app.post("/orders")
async def order(req, bg: BackgroundTasks):
bg.add(fired.append, "ack")
await req.app.state_queue.enqueue("guide-receipt", {"order_id": 7})
return {"ok": True}
async def main():
await app.startup()
try:
await app.state_queue.enqueue("guide-receipt", {"order_id": 7})
assert await app.state_queue.run_one() is True
assert done == [7]
finally:
await app.shutdown()
asyncio.run(main())
assert TestClient(app).post("/orders", body={}).json() == {"ok": True}
assert fired == ["ack"]
CHAPTER 14
Time, kept by the app
Cron plus intervals with an explicit start — nothing runs unless started. All timing flows through tick(now), so tests travel through time instead of sleeping. In production, SchedulerPlugin ties the loop to lifespan; app.scheduler() is the public accessor either way.
Notes: overlapping runs of one job never stack — a still-running job is skipped that tick. Failures are counted and logged with tracebacks, never swallowed.
Runnable — copy into a file and run with python. Executed in CI by tests/test_guide.py.
import asyncio
import time
from ikarem import Ikarem
from ikarem.scheduler import SchedulerPlugin
app = Ikarem(enable_docs=False)
ran = []
@app.every(60)
async def heartbeat():
ran.append(1)
@app.cron("@daily")
async def rollup():
ran.append("daily")
async def main():
sched = app.scheduler()
assert [j.name for j in sched.jobs] == ["heartbeat", "rollup"]
await sched.tick(now=time.time() + 61)
assert ran == [1]
app.register(SchedulerPlugin(poll=0.01))
await app.startup()
assert getattr(app, "state_scheduler", None) is sched
await app.shutdown()
asyncio.run(main())
CHAPTER 15
Blocking code belongs in threads
One event loop serves every request on a worker. A blocking call — time.sleep, a sync driver, DNS — stalls all of them, and the stall is invisible in profiles of your code because the loop is simply absent. Push blocking work to threads; keep async handlers non-blocking. The unambiguous rule, proven by the thread IDs below: IKAREM calls async handlers on the event-loop thread, and def handlers on that same thread — there is no hidden threadpool. A def handler is fine if it returns fast; a blocking call anywhere on the loop stalls every request on that worker.
Notes: this is the most common Flask/Django carryover bug — sync ORM calls pasted into async handlers. Convert the driver first (asyncpg, aiomysql), thread the rest with asyncio.to_thread (default pool: at most 32 threads, fewer on small machines). Never asyncio.Lock around threaded work; SQLite already serializes on a worker-side threading lock.
Runnable — copy into a file and run with python. Executed in CI by tests/test_guide.py.
import asyncio
import threading
import time
from ikarem import Ikarem
from ikarem.testing import TestClient
def slow_hash(n):
time.sleep(0.01) # blocking stand-in: hashing, sync drivers, DNS
return n * 2
app = Ikarem(enable_docs=False)
@app.get("/loop")
async def loop_h(req):
return {"t": threading.get_ident()}
@app.get("/sync")
def sync_h(req):
return {"t": threading.get_ident()}
@app.get("/hash/{n:int}")
async def hash_h(req, n: int):
return {"r": await asyncio.to_thread(slow_hash, n)}
@app.get("/thread")
async def thread_h(req):
return {"t": await asyncio.to_thread(threading.get_ident)}
c = TestClient(app)
t_loop = c.get("/loop").json()["t"]
assert c.get("/sync").json()["t"] == t_loop # same thread: blocking stalls all
assert c.get("/thread").json()["t"] != t_loop # to_thread escapes the loop
assert c.get("/hash/21").json() == {"r": 42}
Part IV
Structure and shipping
Realtime, files, organization, testing, lifespan, deployment. The part where a project stops being an app and starts being a system.
CHAPTER 16
Talking back live
WebSocket routes plus in-process rooms: join on connect, broadcast to everyone but the sender, leave in a finally. Disconnects raise the specific WebSocketDisconnect — catch that, not bare RuntimeError, so real bugs still surface. The snippet proves both halves: route wiring through a real connection, and the broadcast itself with two members.
Notes: rooms are single-process by design (chapter 21 covers what that implies under workers); an external broker slots behind the same join/broadcast shape. receive_text skips handshake frames. Two live scripted clients can't overlap deterministically through one ws_connect driver, so the member half is proven against the same Room object the route uses — no mocks of the broadcast itself.
Runnable — copy into a file and run with python. Executed in CI by tests/test_guide.py.
import asyncio
from ikarem import Ikarem, Room, WebSocketDisconnect
from ikarem.testing import TestClient
room = Room()
app = Ikarem(enable_docs=False)
@app.websocket("/chat")
async def chat(ws):
await ws.accept()
await room.join(ws)
try:
while True:
await room.broadcast(await ws.receive_text(), exclude=ws)
except WebSocketDisconnect:
pass
finally:
room.leave(ws)
class FakeWS:
def __init__(self):
self.sent = []
async def send_text(self, text):
self.sent.append(text)
async def main():
spy = FakeWS()
await room.join(spy) # a live member before the scripted client arrives
sent = await TestClient(app).ws_connect(
"/chat", incoming=[{"type": "websocket.connect"}, {"text": "hi-there"}]
)
assert "websocket.accept" in [m["type"] for m in sent]
# the /chat route really broadcast through the room: the spy got it,
# and the sender was excluded (nothing else was in the room to echo)
assert spy.sent == ["hi-there"]
room.leave(spy)
asyncio.run(main())
assert len(room) == 0
CHAPTER 17
Files in, files out
Static directories serve with symlink-escape and prefix-collision guards — both were real bugs, so both are asserted here, not just ... FileResponse streams downloads with Content-Disposition surviving middleware. Uploads arrive parsed as UploadFile with size caps.
Notes: mount helpers fail loudly on missing directories. The realpath guard compares both sides, so neither .. nor symlinks escape the root. The symlink branch skips where the OS refuses symlinks (some Windows runners) — the enforcement that always runs is tests/test_hardening.py::test_static_blocks_symlink_escape on Linux CI.
Runnable — copy into a file and run with python. Executed in CI by tests/test_guide.py.
import os
import tempfile
from pathlib import Path
from ikarem import FileResponse, Ikarem
from ikarem.testing import TestClient
base = Path(tempfile.mkdtemp(prefix="guide-static-"))
pub = base / "pub"
pub.mkdir()
(pub / "ok.txt").write_text("hello file")
sibling = base / "pub-evil" # shared prefix, different directory
sibling.mkdir()
(sibling / "secret.txt").write_text("nope")
outside = base / "outside.txt"
outside.write_text("nope")
try:
os.symlink(outside, pub / "link.txt")
link_ok = True
except OSError:
link_ok = False # symlinks need privileges on some machines
app = Ikarem(enable_docs=False)
app.mount_static("/static", str(pub))
@app.get("/receipt")
async def receipt(req):
return FileResponse(str(pub / "ok.txt"), filename="receipt.txt")
c = TestClient(app)
assert c.get("/static/ok.txt").text == "hello file"
assert c.get("/static/../pub-evil/secret.txt").status_code == 404
if link_ok:
assert c.get("/static/link.txt").status_code == 404
dl = c.get("/receipt")
assert dl.status_code == 200 and "attachment" in dl.headers["content-disposition"]
CHAPTER 18
Structure that scales
Blueprints group routes with their own hooks and error handlers; MethodView puts one resource's verbs in one class with full DI per method. Both compile to the same plans as plain routes — zero per-request overhead for the organization.
Notes: blueprint routes are named blueprint.handler, so url_for stays unambiguous across groups. Blueprint error handlers scope to their routes; app handlers catch the rest.
Runnable — copy into a file and run with python. Executed in CI by tests/test_guide.py.
from ikarem import Blueprint, Ikarem, MethodView, Schema
from ikarem.testing import TestClient
class ItemIn(Schema):
name: str
class Items(MethodView):
async def get(self, req):
return {"items": []}
async def post(self, req, item: ItemIn):
return {"name": item.name}, 201
api = Blueprint("api", url_prefix="/api")
app = Ikarem(enable_docs=False)
@api.get("/ping")
async def ping(req):
return {"pong": True}
app.register_blueprint(api)
app.route("/items", Items.methods())(Items.as_view("items"))
c = TestClient(app)
assert c.get("/api/ping").json() == {"pong": True}
assert c.get("/items").json() == {"items": []}
assert c.post("/items", body={"name": "a"}).status_code == 201
assert c.post("/items", body={}).status_code == 400
assert app.router.url_for("api.ping") == "/api/ping"
CHAPTER 19
Tests that behave like browsers
TestClient keeps cookies, speaks every verb, and runs startup — login flows test exactly as browsers behave. app.check() audits handlers statically: cycles, bare Depends(), duplicate routes, auth gaps.
Notes: one client per thread — loop-bound resources (pools) survive across requests, matching production. Gate deploys on check in CI.
Runnable — copy into a file and run with python. Executed in CI by tests/test_guide.py.
from ikarem import Ikarem
from ikarem.testing import TestClient
app = Ikarem(enable_docs=False)
@app.get("/users")
async def users(req):
return {"n": 1}
report = app.check()
assert report["errors"] == []
assert any(r["path"] == "/users" for r in report["routes"])
c = TestClient(app)
assert c.get("/users").json() == {"n": 1}
miss = c.get("/user")
assert miss.status_code == 404 and "Did you mean" in miss.text
CHAPTER 20
Lifespan, wired once
on_startup / on_shutdown order your boot: tables, queues, schedulers. Plugins hook the same lifecycle with dependency sorting. ASGI lifespan messages drive it all under a real server.
Notes: startup compiles every handler plan, validates config (secrets, middleware order), then runs hooks and plugin startups. Shutdown reverses plugins before app hooks.
Runnable — copy into a file and run with python. Executed in CI by tests/test_guide.py.
import asyncio
from ikarem import Ikarem
events = []
app = Ikarem(enable_docs=False)
@app.on_startup
async def up():
events.append("up")
@app.on_shutdown
async def down():
events.append("down")
async def main():
await app.startup()
await app.startup() # idempotent: second call is a no-op
await app.shutdown()
asyncio.run(main())
assert events == ["up", "down"]
CHAPTER 21
Deployment without drama
One process per worker, so single-process state (rooms, memory caches, rate-limit buckets) multiplies per worker. Share what must be shared (RedisCache behind the same interface), pin sticky routing or accept room locality, configure from the environment, and gate the deploy on check. The full production shape — Dockerfile, compose with Postgres, env table — lives in docs/DEPLOY.md.
Serve it: uvicorn myapp:app --workers 4 (pip install ikarem[server]). One worker is one room, one memory cache, one rate-limit table — size ConcurrencyLimitMiddleware and idempotency TTLs for that reality, or share them through Redis. Every worker also needs the same IKAREM_SESSION_SECRET: chapter 8's guard refuses to boot without one, because a per-process secret would log users out at random. Health: /healthz for liveness, /readyz for readiness (503 while the DB is down), /metrics for latency and queue depth.
Runnable — copy into a file and run with python. Executed in CI by tests/test_guide.py.
import os
from ikarem import Ikarem
os.environ["IKAREM_PAGE_SIZE"] = "25"
try:
app = Ikarem(enable_docs=False, page_size=20)
assert app.config.get("page_size") == 25 # env wins over kwargs
finally:
del os.environ["IKAREM_PAGE_SIZE"]
assert app.check()["errors"] == []
Part V
Mastery
Docs, plans, agents, maintenance. Using the framework ends here; understanding it starts here.
CHAPTER 22
Docs and tools for free
OpenAPI, the route manifest, and MCP tools all derive from the same compiled plans — query shapes, bodies, and auth boundaries cannot drift between your docs, your agents, and your runtime.
Notes: ikarem inspect prints the manifest for LLM context; site/llms.txt is the one-page framework manual. Serve routes as tools with ikarem mcp myapp:app.
Runnable — copy into a file and run with python. Executed in CI by tests/test_guide.py.
import asyncio
import os
from ikarem import Depends, Ikarem, Schema, require_roles
from ikarem.compiled import describe_app
from ikarem.openapi import build_openapi
SECRET = os.environ.get("IKAREM_AUTH_SECRET") or "guide-dev-only-secret"
class Item(Schema):
name: str
app = Ikarem(enable_docs=False, auth_secret=SECRET)
@app.post("/items")
async def create_item(item: Item):
"""Create an item."""
return {"name": item.name}
@app.get("/admin")
async def admin(req, claims=Depends(require_roles("admin"))):
return {"sub": claims["sub"]}
spec = build_openapi(app)
assert spec["paths"]["/items"]["post"]["summary"] == "Create an item."
assert spec["paths"]["/admin"]["get"]["security"] == [{"bearerAuth": []}]
manifest = describe_app(app)
assert manifest["count"] == 2
tools = {t["name"]: t for t in app.mcp_tools()}
assert set(tools) == {"create_item", "admin"}
out = asyncio.run(app.mcp_call("create_item", {"name": "apple"}))
assert out["isError"] is False
CHAPTER 23
Under the hood: plans, not reflection
Signatures parse once into handler plans; per-request resolution is dict lookups. get_plan caches by handler identity — the identity check below proves compilation happens once, whatever the request volume. That is the core of the performance story; the speed itself is proven by bench/bench_switch.py (same harness, same process) and bench/load.py (~75k sustained requests over real uvicorn, zero 5xx).
Notes: touch the hot path and the bench decides, not adjectives. No per-request inspect.signature, no regex where a dict works — compile once, then dict lookups. Read compiled.py to verify, bench/ to measure.
Runnable — copy into a file and run with python. Executed in CI by tests/test_guide.py.
from ikarem import Ikarem
from ikarem.compiled import get_plan
from ikarem.testing import TestClient
app = Ikarem(enable_docs=False)
@app.get("/u/{uid:int}")
async def u(req, uid: int, limit: int = 5):
return {"uid": uid, "limit": limit}
assert get_plan(u) is get_plan(u)
c = TestClient(app)
assert c.get("/u/3", query="limit=2").json() == {"uid": 3, "limit": 2}
CHAPTER 24
Agents are users too
LLM clients consume the same contracts: manifest for planning, MCP tools for calling, llms.txt for the manual, AGENTS.md for contributor laws. Build agent-facing features by describing routes, not by special cases.
Notes: auth boundaries propagate into tool descriptions, so agents see Requires Authorization before they call. Keep docstrings to one true first line — it becomes the tool summary.
Runnable — copy into a file and run with python. Executed in CI by tests/test_guide.py.
from ikarem import Ikarem
from ikarem.compiled import describe_app, describe_route
app = Ikarem(enable_docs=False)
@app.get("/orders/{oid:int}")
async def order(req, oid: int, verbose: bool = False):
"""Fetch one order."""
return {"oid": oid}
(route,) = [r for r in app.router.routes if r.path == "/orders/{oid:int}"]
desc = describe_route(route)
assert desc.path_params == ["oid"] and desc.doc.startswith("Fetch one")
entry = describe_app(app)["routes"][0]
assert entry["handler"] == "order" and entry["summary"] == "Fetch one order."
CHAPTER 25
Staying honest in production
The framework stays fast and honest the same way your app does: every behavior ships with a test, errors name remedies, deprecations warn with versions and replacements, and the changelog is cut from commits. Your next moves: claim a row in docs/ECOSYSTEM.md, steal a recipe from docs/COOKBOOK.md, and read CONTRIBUTING.md before the first PR.
Runnable — copy into a file and run with python. Executed in CI by tests/test_guide.py.
import warnings
from ikarem import Ikarem, deprecated
from ikarem.testing import TestClient
@deprecated("use total_v2() instead", since="1.1.0", removal="2.0.0", use_instead="total_v2")
def total(items):
return sum(items)
def total_v2(items):
return sum(items)
app = Ikarem(enable_docs=False)
@app.get("/total")
async def total_h(req):
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
n = total([1, 2, 3])
assert any("1.1.0" in str(w.message) and "total_v2" in str(w.message) for w in caught)
return {"n": n}
assert TestClient(app).get("/total").json() == {"n": 6}