A Stario app with one screen fits in one file. With two or more, split it by screen: one folder per feature, the same three files in each, and a bootstrap that wires them together. This page states the rules. Chat room is the example that follows them.
The tree#
app/ main.py bootstrap: config, shared objects, features config.py Config.from_env() db.py the database handle assets.py ASSETS = Assets(...) common/ shared shell, identity, helpers features/ billing/ urls.py Route values views.py render(...) and smaller views handlers.py handler factories and register_billing() models.py dataclasses this feature owns (optional) data.py SCHEMA and queries (optional)tests/ conftest.py client fixture over TestClient(bootstrap) test_billing.pyA feature is a screen or a resource with its own URL. It owns its routes, its HTML, and its handlers. It owns tables only if no other feature does; the chat-room lobby has no data.py because it reads the room's queries.
Name modules after what they hold. db.py and config.py say what is inside; deps.py and utils.py do not.
Routes in urls.py#
Routes are values, so they live in a module that imports only stario. Views import it to build links. Handlers import it to register. Neither imports the other.
# app/features/billing/urls.pyfrom stario import Route INVOICES_PATH = "/billing/invoices"INVOICES = Route("GET", INVOICES_PATH)INVOICE = Route("GET", INVOICES_PATH + "/{invoice_id}")LIVE = Route("GET", INVOICES_PATH + "/live")PAY = Route("POST", INVOICES_PATH + "/{invoice_id}/pay")A screen with no action is one GET. A screen with any action has three kinds of route: the page, a /live stream that pushes HTML, and POST commands that reply 204 and publish. Live pages explains why.
Views in views.py#
A view is a function from state to HTML. The main one is render, and it returns the whole document. Smaller views are plain functions it calls. Views import urls.py for href() and nothing from handlers.py.
# app/features/billing/views.pydef render(invoices: list[Invoice], *, user: User) -> HtmlElement: return shell( h.Main( data.init(at.get(LIVE.href(), retry="always")), h.H1("Invoices"), invoice_table(invoices), ), user=user, )Handlers in handlers.py#
One shape for every feature: a factory per handler, and one register_<feature> that adds every route. The factory takes the shared objects the handler needs and returns the handler; register takes them all.
# app/features/billing/handlers.pydef show_billing(db: Database): async def handler(c: Context, w: Writer) -> None: w.html(render(data.list_invoices(db), user=current_user(c))) return handler def billing_live(db: Database, relay: Relay[str]): async def handler(c: Context, w: Writer) -> None: async with relay.subscribe("billing.*") as events: sse = SSE(w) sse.patch_elements(render(data.list_invoices(db), user=current_user(c))) async for _ in c.alive(events): sse.patch_elements(render(data.list_invoices(db), user=current_user(c))) return handler def pay_invoice(db: Database, relay: Relay[str]): async def handler(c: Context, w: Writer) -> None: invoice_id = c.match.params["invoice_id"] w.empty() data.mark_paid(db, invoice_id) relay.publish("billing.paid", invoice_id) return handler def register_billing(app: App, db: Database, relay: Relay[str]) -> None: app.add(INVOICES, show_billing(db)) app.add(LIVE, billing_live(db, relay)) app.add(PAY, pay_invoice(db, relay))A static screen's factory may take one config value; a live screen's factories take db and relay. Do not wrap handlers in a class — a factory closes over the same objects with less machinery.
Bootstrap in main.py#
bootstrap is the composition root. It reads config, opens shared objects once, attaches assets, and registers each feature. Nothing under app/features/ imports it.
# app/main.pyasync def bootstrap(app: App, span: Span): config = Config.from_env() db = Database(config.db_path) db.apply_schema(billing_data.SCHEMA) relay: Relay[str] = Relay() await ASSETS.attach(app) register_billing(app, db, relay) yield db.close()Code before yield runs at startup. Code after it runs at shutdown. Runtime has the details.
Middleware#
Register prefix middleware with app.use in bootstrap, in any order, or pass middleware=[...] to app.add for one route.
app.use("/app", session.attach_user())app.use("/app", session.require_user())register_billing(app, db, relay)Routing states the composition rules.
Tests#
Put tests/ next to app/. The client fixture opens TestClient(bootstrap), the same bootstrap production runs, so tests use the real routes and schema. One test per screen opens the stream, sends a command, and reads the repaint. Chat room shows that test.
By the way#
Keep request parsing that several features share in
app/common/. A feature-specificsignals.pyis fine when only one feature reads that shape.Assetsis built at import time inassets.pyand attached inbootstrap. Views receiveASSETS.href("css/app.css")as a string. Static assets has the recipe.The complete example is
examples/chat-room.