Theme
Version
GitHub PyPI Discord
On this page

Migrate from Tetra

You have Tetra components whose templates call Python methods and await their results, and you want them to keep working in Citry. This guide shows where each part of a Tetra component goes: its public attributes, its @public methods, its JavaScript calls, and its client callbacks.

The core idea carries over: a template calls a Python method on the server, and the page updates. Debounce settings, awaiting a method's return value from JavaScript, and sending a browser event from Python all have direct equivalents.

What changes most is what survives between calls. Tetra keeps the whole component, pickled and encrypted, in a token. Citry keeps only the values you declare, as JSON. The browser side also uses Vue instead of Alpine.js. Three Citry terms come up throughout:

  • State lists the values a later call needs. Citry keeps them between calls, either on the server or in the page. See Event state.
  • A handler is a public method in the component's nested class Events. The browser calls it by name. See Server events.
  • An action is a value a handler returns to tell the browser what to do next, such as actions.Render or actions.Dispatch. Returning a component re-renders it. See Event actions.

The Citry examples assume the citry_app instance configured in Server events and these imports:

from typing import Any

from citry import Component
from citry.ext.events import actions, event

Syntax mapping

TetraCitry
count = public(0)class State: count: int = 0
Private component attributeReload it in the handler, or compute it during the render
@public methodPublic method inside class Events
@public.debounce(200)@event(debounce=200)
@public.watch("query"):c-query="refresh", or a Vue watcher that calls this.$sendEvent(...)
@click="increment()"@c-click="increment"
await this.method(...)await this.$sendEvent(name, data)
Method return valueReturn actions.Data(value) or a dict
Automatic re-renderReturn a new component or actions.Render(...)
self.client._dispatch(...)actions.Dispatch(name, detail)
Other self.client.* callbacksAn action, or a dispatched event that browser code handles
History changes from Pythonactions.PushUrl(url) / actions.ReplaceUrl(url)
Encrypted component tokenSigned JSON State, or _storage = "server"
Alpine component scriptVue options in $component({...})
x-data / x-modelVue data() / v-model; $state and :c-* reach Citry State

The sections below show the common rows in context.

Fix dynamic attributes

When you copy a template over, check its attributes first. An ordinary attribute value in a Citry template is a literal string, so Django-style braces inside it are not evaluated:

<!-- Wrong in a Citry template: braces appear in the final URL. -->
<a href="{{ task_url }}">Open task</a>

Prefix the attribute with c-, and Citry evaluates its value as a Python expression:

<!-- Right: task_url is evaluated during rendering. -->
<a c-href="task_url">Open task</a>

The same applies to action, src, class, and any other attribute. Text between tags still uses {{ expression }}. See Attributes.

Port a component

A Tetra counter has a public attribute and a decorated server method, and re-renders after the method runs:

from tetra import Component, public

class Counter(Component):
    count = public(0)

    @public.debounce(200)
    def increment(self, amount=1):
        self.count += amount

    template = """
      <button
        {% ... attrs %}
        @click="increment(1)"
        x-text="count"
      ></button>
    """

In Citry, count becomes a State field and increment moves into Events. The handler returns two things: a new Counter to re-render, and a JSON value for JavaScript that awaits the call:

class StepIn:
    amount: int = 1

class Counter(Component):
    citry = citry_app

    class Kwargs:
        count: int = 0

    class State(Kwargs):
        pass

    class Events:
        @event(debounce=200)
        def increment(self, data: StepIn, state):
            state.count += data.amount
            return [
                Counter(count=state.count),
                actions.Data({"count": state.count}),
            ]

    def template_data(
        self,
        kwargs: Kwargs,
        slots,
    ) -> dict[str, Any]:
        return {"count": kwargs.count}

    template = """
      <button @c-click="increment({ amount: 1 })">
        Count: {{ count }}
      </button>
    """

    js = """
      $component({
        methods: {
          async addOne() {
            const result = await this.$sendEvent("increment", {
              amount: 1,
            });
            return result.count;
          },
        },
      });
    """

The data parameter receives the call's arguments, here { amount: 1 }, checked against StepIn. @event(debounce=200) sets the default debounce for every @c-* and :c-* binding that calls this handler. A binding can override it with its own .debounce.<time> modifier. Debounce only spaces out requests from the page; add rate limits on the server where you need them.

By default, Citry signs State before sending it to the browser, so the server can detect a changed value. Anyone can still read it in the page source. Do not put model instances or secrets in State; reload records and check permissions in each handler.

Await a result in JS

Tetra generates a method on the browser object that returns a Promise:

const value = await this.increment(1);

In Citry, call this.$sendEvent(name, data) from the component's JavaScript, as addOne does in the js of the counter above. The Promise resolves with the actions.Data value or dict the handler returns. Any render in the same list still happens, in list order.

Two differences from Tetra:

  • $sendEvent does not apply the @event(debounce=...) default. Debounce the call in JavaScript when it needs one.
  • An @c-* attribute does not give you the return value. Use it when you only need the call to happen. When other browser code must react to it, return actions.Dispatch(...) instead.

Replace client calls

Current Tetra uses _dispatch as its general callback; custom self.client.* calls shown in older Tetra documentation are blocked by its allowlist:

@public
def complete(self, task_id):
    mark_complete(task_id)
    self.client._dispatch(
        "TaskEditor:completed",
        {"message": "Task complete"},
    )
    self.update_html()

A Citry handler returns a list of actions instead. actions.Dispatch fires a named browser event for browser code to handle, and actions.Render re-renders the component:

class TaskIn:
    task_id: int

class TaskEditor(Component):
    citry = citry_app

    class Kwargs:
        task_id: int = 42
        status: str = "pending"

    class Events:
        def complete(self, data: TaskIn):
            mark_complete(data.task_id)
            editor = TaskEditor(
                task_id=data.task_id,
                status="complete",
            )
            return [
                actions.Dispatch(
                    "TaskEditor:completed",
                    {"taskId": data.task_id},
                ),
                actions.Render(editor),
            ]

    def template_data(self, kwargs: Kwargs, slots) -> dict[str, Any]:
        return {"task_id": kwargs.task_id, "status": kwargs.status}

    template = """
      <button @c-click="complete({ task_id: 42 })">
        Complete task
      </button>
      <p>Task {{ task_id }}: {{ status }}</p>
    """

In the component's JavaScript, listen with the onEvent function that onServerRender receives, or with addEventListener on an element above the component. With the Dispatch first, listeners from the current render hear it. When a Render replaces a component that contains this one, the Dispatch must come first, without delay or wait=False; see Action lists.

Move Alpine code to Vue

Tetra merges public state, server methods, and component JavaScript into one Alpine.js object. In Citry, a component is also a Vue component, and each kind of value has its own place:

  • State belongs to the component and reaches Vue as $state.
  • $component({...}) in the component's js adds Vue data(), methods, computed values, and props. See Component options.
  • A Vue expression written on a child component's tag belongs to the parent. Pass values down with Vue props and listen to the child's events with Vue listeners such as @select.

The same holds for slots: content passed into a slot reads names from the component that wrote it. A component with several top-level elements, or none, is still one Vue component.

Plan for differences

Hide secret State

Citry does not pickle components, models, or forms into State. State holds JSON values and is signed, not encrypted. To keep a value out of the page, set _storage = "server" and leave the field out of _public. Server storage keeps the values in the server cache, but fields listed in _public (all fields by default) still reach the browser. See Treat State as input.

Poll instead of push

Citry's server events run over HTTP, and the server cannot push updates to the page. To refresh a part of the page on a timer, use @c-poll; see @c-poll on a timer.

Accept file uploads

Citry does not read multipart file uploads by default. To accept files, register a custom payload codec, a class that turns the request body into handler input, and have it produce UploadedFile values. Downloads work without extra setup: a handler marked @event(bundle=False) can return actions.Download(...); see Event actions.

Handle offline calls

Citry does not queue calls while offline, because replaying changes after a deployment needs rules only your application knows. It also has no general way for Python to call a browser function by path; use actions.Dispatch.

Finish the port

Before shipping a migrated component, check that:

  • every public Tetra attribute is now a render input (Kwargs), a State field, values that js_data() computes for browser code on each render, or Vue data() (see Event state for how to choose);
  • State holds only JSON values and no secrets;
  • each handler returns a render, data, event, or redirect;
  • client callbacks have become named actions.Dispatch events;
  • database rows are reloaded and permissions checked on each call; and
  • components that need server push or file uploads are checked against the parity matrix.

The Events migration parity matrix compares the rest of Tetra's features with Citry. Bind events in templates and Event actions cover the full workflow.