Skip to content

Serving over Uvicorn

A raw ASGI callable and a converted FastAPI application, both served on Uvicorn through the same plugin. service.ppy is the raw ASGI callable, and api.ppy is a FastAPI application converted from api.py with no hand editing. The whole folder checks under strict = true, route handlers included, as their author wrote them.

Run it

ppy service.ppy
ppy api.ppy
ppy run api.ppy

What it prints

ppy service.ppy

{"hello": "/", "count": 3} 200 404

ppy api.ppy, ppy run api.ppy

{'service': 'inventory'}
{'item_id': 7, 'q': 'fast'}
{'label': 'bolt x12', 'cost': 6.0}

TestClient exercises the app in-process, which is why the output is deterministic.

The helpers gain types; the routes keep theirs

@ppy.pure
def describe(item: Item) -> str:
    return item.name + " x" + str(item.count)


@app.post("/items")
def create_item(item: Item):
    return {"label": describe(item), "cost": total_cost(item.price, item.count)}

describe and total_cost got their types from the call sites and @ppy.pure from the checker. describe reads pydantic fields through the pydantic plugin.

create_item was left alone. @app.get is a decorator nobody can vouch for, and FastAPI reads __annotations__ at import to build its validation, so the conversion policy refuses to touch the signature.

What the plugin models

FastAPI(), APIRouter(), the route decorators, dependency markers (Depends, Query, …), and the in-process TestClient with its responses all have signatures under strict mode, so client.get("/").json() type-checks.

  • uvicorn.run(app) with a statically resolvable application skips the per-worker re-import by module string.
  • The reloader is told to watch .ppy alongside .py.

Where the code comes from

service.ppy is hand-written. api.ppy is Generated, not hand-written: exactly what ppy convert api.py writes, and examples/verify_conversions.py checks that on every run.

Read on: Pydantic · Plugins: Uvicorn and FastAPI

27_uvicorn/api.ppy

import ppy
import uvicorn
from fastapi import FastAPI
from fastapi.testclient import TestClient
from pydantic import BaseModel

app: FastAPI = FastAPI()


class Item(BaseModel):
    name: str
    price: float
    count: int = 1


@ppy.pure
def total_cost(price: float, count: int) -> float:
    return price * count


@ppy.pure
def describe(item: Item) -> str:
    return item.name + " x" + str(item.count)


@app.get("/")
def read_root():
    return {"service": "inventory"}


@app.get("/items/{item_id}")
def read_item(item_id: int, q: str | None = None):
    return {"item_id": item_id, "q": q}


@app.post("/items")
def create_item(item: Item):
    return {"label": describe(item), "cost": total_cost(item.price, item.count)}


def serve() -> None:
    uvicorn.run("api:app", host="127.0.0.1", port=8000)


def main() -> None:
    client = TestClient(app)
    print(client.get("/").json())
    print(client.get("/items/7?q=fast").json())
    print(client.post("/items", json={"name": "bolt", "price": 0.5, "count": 12}).json())


if __name__ == "__main__":
    main()

27_uvicorn/service.ppy

from typing import Any, Awaitable, Callable

import ppy
import uvicorn

Scope = dict[str, Any]
Receive = Callable[[], Awaitable[Scope]]
Send = Callable[[Scope], Awaitable[None]]


@ppy.pure
def render(name: str, count: int) -> bytes:
    return b'{"hello": "' + name.encode() + b'", "count": ' + str(count).encode() + b"}"


@ppy.pure
def status_for(path: str) -> int:
    if path == "/":
        return 200
    return 404


async def app(scope: Scope, _receive: Receive, send: Send) -> None:
    path: str = str(scope.get("path", "/"))
    status: int = status_for(path)
    body: bytes = render(path, len(scope))
    await send(
        {
            "type": "http.response.start",
            "status": status,
            "headers": [(b"content-type", b"application/json")],
        }
    )
    await send({"type": "http.response.body", "body": body})


def serve() -> None:
    uvicorn.run("service:app", host="127.0.0.1", port=8000)


def develop() -> None:
    uvicorn.run("service:app", host="127.0.0.1", port=8000, reload=True)


if __name__ == "__main__":
    print(render("/", 3).decode(), status_for("/"), status_for("/missing"))

Source: examples/27_uvicorn.