6. Growing into a project¶
One file was perfect to learn with. Real applications deserve a structure where each module has one job. Here is the same guestbook as a project:
guestbook/
├── app.py # the composition root: builds the app from its parts
├── settings.py # settings from the environment
├── models/
│ ├── database.py # the engine and the declarative base
│ └── entry.py # the table, and the shapes that cross the API and forms
├── services/
│ └── guestbook.py # business logic (+ the JSON API)
├── views/
│ ├── styles/ # the theme and shared styles
│ ├── components/
│ │ └── entry_card.py
│ └── pages/
│ └── home.py # the page, declared on a Router
└── tests/
├── conftest.py
└── test_guestbook.py
Pages on a router¶
A module can’t use @app.page without importing the app, and the app imports the pages:
that’s a circular import. A Router records pages instead, and the app
includes it:
@router.page("/")
class Home(Page):
title = "Guestbook"
entries = State(factory=list)
async def mount(self):
await self.reload()
async def reload(self):
self.entries = await self.use(GuestbookService).entries()
async def sign(self, form: SignForm):
await self.use(GuestbookService).sign(form.name, form.message)
await self.reload()
def render(self):
return Container(
Row(
Heading("Guestbook", level=1),
Button("Dark mode", variant="outline", size="sm", on_click=js.toggle_theme()),
justify="between",
),
Row(
Card(
Form(
Input("Your name", name="name", required=True),
Textarea("Message", name="message", required=True, rows=4),
Button("Sign the guestbook", type="submit", full_width=True),
on_submit=self.sign,
key=f"form-{len(self.entries)}",
),
style=Style(width=320, shrink=0, viewport={"mobile": Style(width="100%")}),
),
Column(
Grid(*[EntryCard(entry=e, key=e.id) for e in self.entries], min_column_width=240),
Text("API: /api/guestbook/entries · interactive docs: /api/docs", muted=True, size="sm"),
style=Style(grow=1, min_width=0),
),
align="start",
collapse="mobile",
),
tag="main",
max_width=1000,
style=Style(layout="vertical", gap="lg", padding_y="xl"),
)
The composition root¶
app.py is the only place that knows how the pieces fit together:
app.py¶
"""The composition root: build the app from its parts.
Run: python app.py --dev # hot reload, readable errors
python app.py --prod # production server
"""
from rational import App
from models import create_tables, database
from services import GuestbookService
from views.pages import router
app = App(title="Guestbook", theme="soft", database=database)
app.include(router)
app.service(GuestbookService)
app.on_startup(create_tables)
if __name__ == "__main__":
app.run()
Settings¶
Configuration comes from the environment, with safe defaults for development:
settings.py¶
"""Settings, read once from the environment."""
import os
from pathlib import Path
PROJECT_DIR = Path(__file__).resolve().parent
DATABASE_URL = os.environ.get("DATABASE_URL", f"sqlite+aiosqlite:///{(PROJECT_DIR / 'guestbook.db').as_posix()}")
The other modules¶
models/database.py¶
"""One engine for the app, and the declarative base for its tables."""
from sqlalchemy.orm import DeclarativeBase
from rational.backend import Database
import settings
class Base(DeclarativeBase):
"""Any declarative base works; ``rational.backend.Model`` is a ready-made one."""
database = Database(settings.DATABASE_URL)
async def create_tables() -> None:
"""Development convenience: use Alembic migrations in production."""
await database.create_all(Base)
models/entry.py¶
"""The entry table, and the shapes that cross the API and forms."""
from datetime import UTC, datetime
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy import String
from sqlalchemy.orm import Mapped, mapped_column
from .database import Base
class EntryRow(Base):
__tablename__ = "entries"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(40))
message: Mapped[str] = mapped_column(String(280))
created_at: Mapped[datetime] = mapped_column(default=lambda: datetime.now(UTC))
class Entry(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
name: str
message: str
class SignForm(BaseModel):
name: str = Field(min_length=1, max_length=40)
message: str = Field(min_length=1, max_length=280)
services/guestbook.py¶
"""Read and sign the guestbook; the ``@remote`` methods are also a JSON API."""
from sqlalchemy import select
from rational import FieldError
from rational.backend import Service, remote
from models import Entry, EntryRow
class GuestbookService(Service):
"""Read and sign the guestbook."""
@remote(method="GET")
async def entries(self) -> list[Entry]:
"""Every entry, newest first."""
async with self.db.session() as db:
rows = await db.scalars(select(EntryRow).order_by(EntryRow.id.desc()))
return [Entry.model_validate(row) for row in rows]
@remote
async def sign(self, name: str, message: str) -> Entry:
"""Add an entry."""
if "http://" in message or "https://" in message:
raise FieldError("message", "Links are not allowed")
async with self.db.transaction() as db:
row = EntryRow(name=name.strip(), message=message.strip())
db.add(row)
await db.flush()
return Entry.model_validate(row)
Run it from the project folder, exactly as before:
$ cd guestbook
$ python app.py --dev
Every example in the repository follows this layout; see Examples.
Next: 7. Testing.