Theme
Version
GitHub PyPI Discord
On this page

Server events

Some clicks and form submits need Python: save a record, run a query, check a permission. Server events let a button in your component call a Python method on that component, then update the page with the result. The page does not reload, and you write no JavaScript or API endpoint.

Events are built in. There is nothing to install, and pages that use them load the browser code automatically.

Call Python on click

Put the method in a nested class Events, and name it in an @c-click attribute:

from citry import Component

class Counter(Component):
    citry = citry_app

    class Kwargs:
        count: int = 0

    class State(Kwargs):
        pass

    class Events:
        def increment(self, state):
            state.count += 1
            return Counter(count=state.count)

    def template_data(self, kwargs, slots):
        return {"count": kwargs.count}

    template = """
      <button @c-click="increment">
        Clicked {{ count }} times
      </button>
    """

Clicking the button calls increment on the server. The handler returns a new Counter, and Citry renders it and puts it in place of the old one, so the button text changes.

State holds the values the next call needs, here the current count. Citry sends it to the browser and back with each call. citry_app needs a signing secret for this, as the next section shows.

Every public method on Events can be called from the browser. A method whose name starts with an underscore cannot, so use that for helpers.

Set a signing secret

By default, Citry signs State before sending it to the browser, so it can detect when someone changes it. Signing needs a secret. Rendering a component that has State without one raises an error.

Give your Citry instance a long random secret, then register and mount that same instance:

import os
from contextlib import asynccontextmanager

from fastapi import FastAPI

from citry import Citry
from citry.contrib.fastapi import mount

citry_app = Citry(secret=os.environ["CITRY_SECRET"])

@asynccontextmanager
async def lifespan(_app):
    citry_app.initialize()
    yield

web_app = FastAPI(lifespan=lifespan)
mount(web_app, citry_app)

In Django, pass citry.contrib.django.secret() to reuse Django's own secret. When you rotate Django's key with SECRET_KEY_FALLBACKS, Citry follows the rotation: SECRET_KEY signs new values, and a value signed with a fallback key still verifies, so pages that visitors already have open keep working until you remove the old key from the fallbacks.

To rotate a secret of your own, pass a list: the first entry signs new values, and Citry checks values against every entry. Citry accepts a value signed with any entry, so an empty entry would let anyone sign one. Citry(...) raises an error for an entry that is empty or only whitespace, so leave out a key that is not set:

keys = [
    os.environ["CITRY_SECRET"],
    os.environ.get("CITRY_OLD_SECRET"),
]
citry_app = Citry(secret=[key for key in keys if key])

Web frameworks shows how to mount Citry in other frameworks.

Choose your next step

  • Event state: decide which values survive from one call to the next.
  • Handle and validate forms: receive form fields as typed Python data and show validation errors.
  • Bind events in templates: call handlers on any DOM event, connect inputs to State, and show loading and errors.
  • Event actions: decide what happens after the handler runs, such as re-rendering, notifying other code, or redirecting.
  • Event routes: call handlers by URL from plain HTML forms, htmx, or GET requests.

Migrate from a library

Each migration guide starts from how the other library works and shows the Citry equivalent:

Before porting a component that uploads files, changes browser history, or receives updates pushed from the server, check the Events migration parity matrix.