Skip to main content
Citry

Citry - Refreshingly simple UI

PyPI - Version PyPI - Python Version License CI Docs Discord

Citry is a fast, simple, and smart frontend framework for Python that brings the best of Vue, React, Django, Jinja, and LiveWire.

Compatible with FastAPI, Django, and other web servers.

from citry import Component

# Define
class Welcome(Component):
    class Kwargs:
        title: str
        messages: list[str]

    # HTML
    def template_data(self, kwargs, slots):
        return {
            "title": kwargs.title,
            "count": len(kwargs.messages),
        }

    template = """
      <div
        class="card"
        x-bind:title="count + ' new messages'"
      >
        <h1>{{ title }}</h1>
        <p>You have {{ count }} new messages.</p>
      </div>
    """

    # Browser data. Top-level keys are Alpine variables.
    def js_data(self, kwargs, slots):
        return {"count": len(kwargs.messages)}

    # CSS
    def css_data(self, kwargs, slots):
        return {"accent": "tomato"}

    css = """
      .card {
        border-top: 3px solid var(--accent);
      }
    """

# Compose
component = Welcome(
    title="Welcome back",
    messages=["a", "b", "c"],
)

# Render
html = str(component)

Why Citry?

Use Citry to build UI, HTML, XML, SVG, or anything that serializes to text.

Citry is:

  • Familiar - if you know HTML and Vue/React, you are ready
  • Simple - just 2 rules and 15 built-in tags
  • Fast - Rust-powered parsing
  • Safe - expressions are sandboxed to block dangerous operations
  • Smart - manages JS and CSS scripts for you
  • Reliable - typos and missing props fail at compile time, not in production
  • Universal - one template language for your entire stack

Quickstart

pip install citry

Define a component by subclassing Component and giving it a template. Use template_data to prepare the values the template reads. Render it by turning the component into a string:

from citry import Component

class Welcome(Component):
    template = """
      <div class="card">
        <h1>{{ title }}</h1>
        <p>You have {{ count }} new messages.</p>
      </div>
    """

    # template_data prepares the values your template can read.
    # Run any computation here, in plain Python.
    def template_data(self, kwargs, slots):
        return {
            "title": kwargs["title"],
            "count": len(kwargs["messages"]),
        }

component = Welcome(
    title="Welcome back",
    messages=["a", "b", "c"],
)
html = str(component)

html is now:

<div class="card">
  <h1>Welcome back</h1>
  <p>You have 3 new messages.</p>
</div>

Components compose by name. A <c-Welcome> tag renders the Welcome class. Pass dynamic props with the c- prefix and static ones without:

class Page(Component):
    template = """
      <main>
        <c-Welcome c-title="user.name" c-messages="user.inbox" />
      </main>
    """

    def template_data(self, kwargs, slots):
        return {"user": kwargs["user"]}

Two simple rules

Citry extends HTML with two rules:

1. <c-*> tags are components

Any tag starting with c- is a component or a built-in tag.

<c-Card> -> Card component.

<!-- Static HTML -->
<div class="container">
  <!-- A component -->
  <c-card title="Hello"></c-card>
</div>

2. c-* attributes are dynamic

Any attribute starting with c- is evaluated as an expression. The c- prefix is stripped from the output:

<div c-title="data.title"> -> <div title="...">

<!-- Static HTML attribute -->
<div class="container">
<!-- Dynamic attribute (evaluated as an expression) -->
<div c-title="data.title">
<!-- Component with dynamic attribute -->
<c-card c-title="data.title">

If you know HTML, you already know most of Citry.

Built-in tags

Beyond your own components, Citry provides 15 built-in tags. With these, Citry is as expressive as Vue or React.

Tag Purpose
<c-if> Conditional branch
<c-elif> Else-if branch
<c-else> Else branch
<c-for> Loop over an iterable
<c-empty> Empty state for a <c-for> loop
<c-slot> Define a content insertion point
<c-fill> Fill a slot when using a component
<c-component> Render a component chosen at render time
<c-element> Render an HTML element whose tag name is chosen at render time
<c-provide> Provide a value to descendant components
<c-cache> Cache and replay a named transparent template region
<c-error-fallback> Render fallback content when its body raises
<c-css> Render the collected component CSS here
<c-js> Render the collected component JS here
<c-raw> Treat the contents as literal text

How templates look

A short tour. The template syntax reference covers every feature in depth.

Expressions

With {{ }}, written in Python:

<p>{{ user.name }}</p>
<p>{{ 'Member' if user.is_active else 'Guest' }}</p>

Dynamic attributes

With the c- prefix. A True value renders the attribute bare, False or None omits it:

<button
  c-disabled="is_loading"
  c-class="['btn', { 'active': is_open }]"
>
  Submit
</button>

Control flow

Write if branches and for loops directly in templates.

Long form:

<c-if cond="is_admin">
  <p>Admin</p>
</c-if>
<c-else>
  <p>Guest</p>
</c-else>

<ul>
  <c-for each="item in items">
    <li>{{ item.name }}</li>
  </c-for>
  <c-empty>
    <li>No items found</li>
  </c-empty>
</ul>

Short form:

<p c-if="is_admin">Admin</p>
<p c-else>Guest</p>

<ul>
  <li c-for="item in items">{{ item.name }}</li>
  <li c-empty>No items found</li>
</ul>

Slots

Let a component accept content from its caller. Define insertion points with <c-slot>, and fill them with <c-fill>:

<!-- Modal.html -->
<div class="modal">
  <header>{{ title }}</header>
  <main>
    <c-slot /> <!-- Insertion point -->
  </main>
</div>

<!-- Using the component -->
<c-Modal title="Confirm">
  <p>Are you sure?</p>
</c-Modal>

Beyond templates

Citry components are more than templates. A few of the things you can do:

Build a component once, then compose and reuse it

Component(...) returns a value you can render on its own or pass into another component, and the same instance works in more than one place:

class Layout(Component):
    template = """
      <main>
        {{ body }}
      </main>
    """

    def template_data(self, kwargs, slots):
        return {"body": kwargs["body"]}

card = Card(title="Welcome")

standalone = str(card)        # render the card to HTML on its own
page = Layout(body=card)      # or pass the same card into another component

Ship JS and CSS with your components

As a page renders, Citry collects every rendered component's CSS and JS scripts, and injects them where you place <c-css /> and <c-js /> (typically <head> and the end of <body>). No bundler, no hand-managed <link> or <script> tags:

class Page(Component):
    template = """
      <html>
        <head>
          <c-css />
        </head>
        <body>
          <c-Chart c-points="[1, 2, 3]" />
          <c-js />
        </body>
      </html>
    """

Pass Python data to browser behavior and CSS

js_data() seeds values from Python directly into that component's Alpine scope. css_data() exposes values to its CSS as custom properties. Both are scoped to one rendered instance, with no manual data wiring:

class Chart(Component):
    template = """
      <div class="chart">
        <span x-text="'Points: ' + points.length"></span>
      </div>
    """
    css = """
      .chart {
        height: var(--h);
      }
    """

    def js_data(self, kwargs, slots):
        # Available directly in Alpine expressions as `points`.
        return {"points": kwargs["points"]}

    def css_data(self, kwargs, slots):
        # Available to CSS as `var(--h)`.
        return {"h": "240px"}

Use $component when you need advanced setup such as calling an imperative JavaScript library, declaring reactive client props, installing effects, or registering cleanup. When a callback exists, Citry seeds the scope first and passes the same instance-local snapshot as data. Identical JSON is transported once, but sibling components receive separate nested arrays and objects.

Provide data to a whole subtree

Set a value with <c-provide> and read it anywhere below with inject(), so you do not thread props through every layer:

class Page(Component):
    # <c-Greeting /> renders <p>Dark mode</p>
    template = """
      <c-provide key="theme" label="Dark mode">
        <c-Greeting />
      </c-provide>
    """

class Greeting(Component):
    template = """
      <p>{{ label }}</p>
    """

    def template_data(self, kwargs, slots):
        # Read a value an ancestor provided, with no prop drilling.
        return {"label": self.inject("theme").label}

Handle render errors with grace

500s due to an error in the template is poor UX. Instead of breaking the whole page, wrap a section in <c-error-fallback> to render a fallback when error occurs. Boundaries nest, and the nearest one wins:

class Page(Component):
    # If <c-Widget /> raises while rendering, the page shows the fallback
    # text instead of letting the error break the page.
    template = """
      <c-error-fallback fallback="Could not load widget">
        <c-Widget />
      </c-error-fallback>
    """

A fallback slot can receive the error itself if you want a custom message.

Catch errors early with input types

Declare a component's inputs with plain annotated classes. A wrong prop or slot name then fails when the template compiles:

from citry import Component, SlotInput

class Card(Component):
    class Kwargs:
        title: str          # required
        size: int = 10      # optional

    class Slots:
        header: SlotInput

    template = """
      <div>
        {{ title }}
        <c-slot name="header" />
      </div>
    """
<c-Card title="Hi" bogus="1" />      <!-- error: unknown prop -->
<c-Card />                           <!-- error: missing required `title` -->
<c-Card title="Hi">
  <c-fill name="headr">...</c-fill>  <!-- error: typo'd slot name -->
</c-Card>

Support for HTML fragments (HTMX-style)

Fragments are rendered components that can be inserted into a page.

In the browser, Citry adopts a client-active fragment's ownership graph and loads its JS/CSS assets. Alpine directives, component props and boundary handlers, slots, and Events therefore keep the same ownership rules as a full document. See Client interactivity.

Render a component specifically as a fragment with .serialize():

card = Card(title="Welcome")
card.render().serialize(deps_strategy="fragment")

To use fragments, you must mount a web framework.

Performance - Render the constant parts once

If you have inputs that don't change between renders, wrap them in Const(...).

Citry pre-renders and caches the parts of the template that depend only on the constant inputs.

from citry import Const

class Row(Component):
    template = """
      <tr>
        <td>{{ label }}</td>
        <td>{{ value }}</td>
      </tr>
    """

    def template_data(self, kwargs, slots):
        return {
            "label": kwargs["label"],
            "value": kwargs["value"],
        }

# The <td>{{ label }}</td> part is computed once and reused down the loop;
# only `value`, which varies per row, is recomputed.
rows = [
    Row(label=Const("Name"), value=v)
    for v in values
]

And more

  • Templates support infinite depth;
  • Extension system;
  • Dynamic components/HTML tags with <c-component> / <c-element>

See the changelog for the full list.

Use with web framework

Some Citry features need a web server to work.

Citry can be easily integrated with popular Python web frameworks:

from citry import citry  # the default instance
from citry.contrib.fastapi import mount

# `app` is your web framework's application object
mount(app, citry)

# Run this from the framework's startup lifecycle before request threads start.
citry.initialize()

initialize() imports configured component Python modules, registers built-ins, and builds parse-time tag rules. Template, JavaScript, and CSS asset files stay lazy. See the web-framework guide for the right startup hook in each supported host.

Supported hosts:

Host Entry point
FastAPI / Starlette citry.contrib.fastapi.mount(app, citry)
Flask citry.contrib.flask.mount(app, citry)
Django citry.contrib.django.urlpatterns(citry), added to your urls.py
Any ASGI server citry.contrib.asgi.asgi_app(citry)
Any WSGI server citry.contrib.wsgi.wsgi_app(citry)

Cache backends plug in the same way, through Citry(cache=...): citry.contrib.caches.RedisCache, citry.contrib.caches.DiskCache, and citry.contrib.django.DjangoCache.

Command line

Installing Citry puts a citry command on your PATH.

Scaffold a new component (no project setup needed):

citry create MyButton        # writes my_button.py, ready to edit

You get a ready-to-edit starting point:

# my_button.py
"""A Citry component."""

from citry import Component


class MyButton(Component):
    class Kwargs:
        title: str

    class Slots:
        pass

    template = """
      <div>
        <h1>{{ title }}</h1>
      </div>
    """

Run the limited static check over literal component templates under the current directory without importing project code:

citry check --static

The other commands act on a Citry engine. Point them at the one you configured with --app, given as module:attribute:

citry --app myproject.app:engine list        # components registered on that engine
citry --app myproject.app:engine check       # templates plus registered component contracts
citry --app myproject.app:engine ext list    # extensions installed on it

The explicit engine lets check validate registered component names, inputs, slots, and free template variables. Unknown roots are errors by default; configure the shared checker/editor policy with LintSettings on the engine. Runtime template_globals are recognized automatically. Warnings are reported without failing the command. If the engine cannot import or finish discovery, the command reports that failure, continues with syntax-only checking, and returns a nonzero status. Checking always requires one of these explicit modes; bare citry check is a usage error.

Extensions can ship their own commands; run one with citry --app ... ext run <extension> <command> [args]. Run citry --help to see everything, and citry --version to print the installed version.

Documentation

Performance

Rendering a large page (~325 component instances, ~205 KB of HTML):

Citry vs Django vs django-components rendering a large page. Lower is better.

  • Versus django-components (the fair component-to-component comparison), Citry is about 1.7x faster on first render and 3.4x faster on repeat renders, and about 2x faster to start up and import.
  • Versus bare template engines (Django and Jinja2 render no components), Citry pays for the component lifecycle they skip, yet its repeat render is only about 1.3x a Django template.
  • Jinja2 is the fast no-component baseline: fastest to start up and fastest once warm, because each component is just a precompiled macro. It has no component model, and it pays on first render, recompiling its whole macro library at once.

These are relative numbers from a single machine. See benchmarks/ for the methodology and how to reproduce them, and the performance notes for where the remaining time goes.

Help bring Citry to your language

Today Citry ships as a Python package, but designed to work with any language. The code inside {{ }} and c-* attributes is the only host-language-specific logic.

If you want Citry in your stack, this is a great place to contribute. Star the repo to follow along, and open an issue if you would like to help port it.

Language Status Binding
Python Ready PyO3/maturin
JS/TS Planned wasm-bindgen
PHP Planned FFI
Go Planned cgo
Rust Planned Native

License

MIT License - see LICENSE for details.

Acknowledgments

This project is the continuation of work originally done in django-components and django-components/djc-core.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

citry-0.4.0.tar.gz (1.6 MB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

citry-0.4.0-py3-none-any.whl (1.0 MB view details)

Uploaded Python 3

File details

Details for the file citry-0.4.0.tar.gz.

File metadata

  • Download URL: citry-0.4.0.tar.gz
  • Upload date:
  • Size: 1.6 MB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for citry-0.4.0.tar.gz
Algorithm Hash digest
SHA256 0b40e0d6b2e8e37e0c0bfc3b01746a8fff9942783dac87aa6bd97a302d896337
MD5 93dad26dec88fb7eb6076a8b8df3eb6c
BLAKE2b-256 8b5574c430ce9b8d33a572f6e4def06869e70aa4556a356d8e4f03260387798e

See more details on using hashes here.

Provenance

The following attestation bundles were made for citry-0.4.0.tar.gz:

Publisher: py--citry--publish.yml on citry-dev/citry

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file citry-0.4.0-py3-none-any.whl.

File metadata

  • Download URL: citry-0.4.0-py3-none-any.whl
  • Upload date:
  • Size: 1.0 MB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for citry-0.4.0-py3-none-any.whl
Algorithm Hash digest
SHA256 037795782361dac174fb0f6d13ae6126337f5ea7fa251480b691ba62b59b3295
MD5 a4f3dc0bb5fffb220ccbbf81a229be23
BLAKE2b-256 0a89b1504e465d89a5a8360f496367b10c81f607e62c5826963685a7edd0c4bf

See more details on using hashes here.

Provenance

The following attestation bundles were made for citry-0.4.0-py3-none-any.whl:

Publisher: py--citry--publish.yml on citry-dev/citry

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page