State and the lifespan

A tutorial step: the service learns what time it is, and holds it in the one place a service holds anything.

Hold the clock as state

A calendar has to know the time, and reading it is the one thing the service does that is not a pure function of its input. So the service holds a clock. A StateKey names it; the service’s lifespan — an async context manager run once around the whole service — puts the clock in app.state under that key; and a build_application factory lets a caller pass a clock in, defaulting to the real one.

Note

Session state and service state are different. Session state is one caller’s own, lasting as long as the line is up (request.session). Service state is shared across callers for the life of the service, opened in the lifespan and read back through a StateKey (request.state). The clock is service state: every caller sees the same one.

Read it in a page

A page reads the clock from the request — never a global — so the whole service runs on whatever clock the lifespan opened, and a test can open a fixed one. The time page, *2#, shows it to the second, and *NOW# reaches it by keyword. The whole file so far, its menu now dated:

import calendar
from collections.abc import AsyncIterator, Callable
from contextlib import asynccontextmanager
from datetime import UTC, date, datetime

from sextile import (
    MenuItem, Page, PageRequest, PageRouter, Sextile, StateKey,
    keyed, keys, menu_page, notice_page, prose_page,
)

SERVICE_NAME = "CALENDAR"

type Clock = Callable[[], datetime]
CLOCK = StateKey[Clock]("clock")
router = PageRouter()


def _now(request: PageRequest) -> datetime:
    return request.state[CLOCK]()


def _today(request: PageRequest) -> date:
    return _now(request).date()


def _long_date(day: date) -> str:
    return day.strftime("%A %d %B %Y")


@router.page("1", name="main", title="The index", keywords=("MAIN", "INDEX"))
async def main(request: PageRequest) -> Page:
    app = request.app
    return menu_page(
        request,
        title=SERVICE_NAME,
        preamble=[_long_date(_today(request))],
        items=[app.menu_item("now"), app.menu_item("about")],
    )


@router.page(
    "2", name="now", title="The time now", detail="to the second", keywords=("TIME", "NOW"),
)
async def now_page(request: PageRequest) -> Page:
    moment = _now(request)
    return notice_page(
        request,
        _long_date(moment.date()),
        "",
        moment.strftime("%H:%M:%S"),
        moment.tzname() or "",
        "",
        f"Key {keyed(keys.REFRESH)} to ask again.",
    )


@router.page("42{day:date}", name="day", title="One day")
async def one_day(request: PageRequest, day: date) -> Page:
    _, week, _ = day.isocalendar()
    length = 366 if calendar.isleap(day.year) else 365
    return notice_page(
        request,
        _long_date(day),
        "",
        f"Day {day.timetuple().tm_yday} of {length}",
        f"Week {week}",
        f"ISO {day.isoformat()}",
    )


@router.page("9", name="about", title="About this service", keywords=("ABOUT", "HELP"))
async def about(request: PageRequest) -> Page:
    return prose_page(
        request,
        "A calendar, served as Viewdata frames.",
        "It exists to demonstrate that Sextile is a framework and not one "
        "service: nothing here knows about forums, and nothing in the framework "
        "knows about calendars.",
        "Everything it shows comes from the standard library.",
    )


def build_application(now: Clock | None = None) -> Sextile:
    reading = now or (lambda: datetime.now(UTC))

    @asynccontextmanager
    async def lifespan(app: Sextile) -> AsyncIterator[None]:
        app.state[CLOCK] = reading
        yield

    return Sextile(name=SERVICE_NAME, pages=[*router], lifespan=lifespan)


app = build_application(now=lambda: datetime(2026, 8, 1, tzinfo=UTC))
 CALENDAR                             1a
  
Saturday 01 August 2026                 
                                        
 1  The time now                        
   to the second                        
 2  About this service                  
                                        
                                        
                                        
                                        
                                        
                                        
                                        
                                        
                                        
                                        
                                        
                                        
                                        
                                        
                                        
  
 1-2 select, 0 index                    

The last line is the tutorial’s: it fixes the clock to 1 August 2026 so every frame here is the same on every build. Your file writes app = build_application(), for the real clock — the factory takes the clock as a parameter precisely so that a test, in step 7, can hand it a fixed one. Here is the time page it draws:

 THE TIME NOW                         2a
  
Saturday 01 August 2026                 
                                        
00:00:00                                
UTC                                     
                                        
Key *09# to ask again.                  
                                        
                                        
                                        
                                        
                                        
                                        
                                        
                                        
                                        
                                        
                                        
                                        
                                        
                                        
  
 0 index                                

One caller’s state

The clock is service state — shared, and the same for every caller. A page can also keep this caller’s own state in request.session, under a StateKey exactly as the clock sits in app.state, but writable, because it is nobody else’s to change. A day page could remember the last day this caller looked at:

LAST_SEEN = StateKey[date]("last-seen")


@router.page("42{day:date}", name="day", title="One day")
async def one_day(request: PageRequest, day: date) -> Page:
    request.session[LAST_SEEN] = day
    ...

request.session lasts as long as the line is up and is gone when the caller rings off, because the connection is the session — a viewdata terminal keeps nothing but the frame on screen. A worked example, bookmarks a caller collects as they move through a service, is Remember a caller.