Theme
GitHub PyPI Discord
On this page

Development

Citry is a Rust and Python monorepo. The core lives in Rust crates under crates/, is exposed to Python through the citry-core bindings. The pure-Python citry package sits on top.

This page is the hands-on setup: how to get the workspace building, run the checks the way CI does, and preview the docs.

For how the pieces fit together, see docs/codebase.md in the repository. For a shorter contributor overview, see the contributing guide.

Getting set up

You need four things on your machine:

  • uv - Python dependency manager.
  • Rust via rustup - Installs cargo and manages Rust toolchains. The repository's rust-toolchain.toml selects the required nightly toolchain automatically when you run cargo here.
  • Node.js - Used for dev tools: pyright, TypeScript, and Biome. Current LTS Node works.
  • pnpm - Node dependency manager.

Citry also uses Ruff's internal packages to parse Python code inside Citry templates. Because these packages are unpublished, the Ruff project is included as a git submodule.

When you download citry project, use --recurse-submodules to include the Ruff submodule:

# Clone fresh
git clone --recurse-submodules https://github.com/citry-dev/citry
cd citry

# Or if you already cloned without --recurse-submodules:
git submodule update --init --recursive

Then run uv sync from the repository root to install all Python dependencies for all packages:

uv sync --all-packages

uv sync --all-packages is the one-step bootstrap:

  1. Builds the citry_core Rust extension through the maturin backend,
  2. Installs the citry package in editable mode,
  3. Installs every package's dev dependencies (pytest, ruff, mypy, maturin, and each package's own test deps).

Because the lockfile knows every package, a later uv sync will not remove the editable installs.

Lastly, install the Node dependencies:

pnpm install

This installs the dependencies from pnpm-lock.yaml.

npm is not a substitute: pnpm is used also for managing monorepo dependencies (pnpm-workspace.yaml).

Confirm your setup works

Run codebase-wide commands (uv sync, uv run, python scripts/check.py) from the repository root, since they read the root pyproject.toml for configuration.

A quick way to check that the Rust extension built and the packages installed is to render a component. Save this as confirm_setup.py:

from citry import Component

class Greeting(Component):
    class Kwargs:
        name: str

    template = """
      <p>Hello, {{ name }}!</p>
    """

greet = Greeting(name="citry")
print(str(greet))

Then run it from the repository root:

uv run python confirm_setup.py

Calling Greeting(...) returns a CitryElement, a lightweight description of what to render, and str() runs the full render and serializes it to HTML. If your setup is working, the script prints a <p>Hello, citry!</p> element.

Running the checks

One command runs the whole gate the way CI does, and it is the single source of truth for "does everything pass":

python scripts/check.py

It runs every phase, then reports all results together:

  • Cargo - cargo fmt, cargo clippy, cargo test
  • Ruff - ruff check, ruff format
  • Python type checks - mypy, pyright
  • JS type checks - TypeScript, Biome lint and format
  • Python Test - pytest
  • Custom validators

Every phase runs even after an earlier one fails, so you see all the failures in one pass instead of one at a time. Pass --reporter agent for a single JSON object you can parse:

python scripts/check.py --reporter agent

There is no pre-commit hook by design. Nothing runs on git commit, so the tools never rewrite your files behind your back; you run the gate when you choose and fix what it reports. Please make sure it passes before opening a pull request.

While iterating you can run a single tool instead of the whole gate:

# Python tests
uv run pytest

# Rust tests
# Use `-p <crate_name>`, so cargo skips Ruff's crates
cargo test -p citry_core_py -p citry_html_transform \
           -p citry_template_parser -p python_safe_eval

# Formatting and linting
cargo fmt
cargo clippy
uv run ruff format .
uv run ruff check .
uv run mypy packages/py/citry_core

# Just the custom validators (fast; no compiling or tests)
python scripts/validate.py

The custom validators are small modules under scripts/validators/ that guard repo-specific rules. Usually cross-language or cross-package constraints that other tools can't cover:

  • Every Python package has an entry in dependabot.yml,
  • That, for the Rust code exposed to Python, the Rust-side API matches the Python-side,
  • That every Rust crate is captured in Cargo.toml.

They are auto-discovered, so adding a check is just dropping a new file into that directory.

Working on the Rust core

uv sync rebuilds citry_core when the Rust sources change, but for a tighter inner loop while working on Rust, build the extension directly:

cd packages/py/citry_core
uv run maturin develop

Both maturin develop and the uv sync build produce a debug (unoptimized) extension. That is fine for tests, but it makes the Rust-backed paths several times slower, so pass --release before running any benchmark:

uv run maturin develop --release

Working on the docs site

The documentation site lives under docs_site/ and renders these markdown pages through citry itself. Run every command with python -m docs_site <command> from the repository root.

While editing, run the dev server for a live preview. It renders each page on the fly through the same pipeline as the build, so you edit a markdown file and reload:

python -m docs_site serve   # http://127.0.0.1:8000/

Build the whole static site to disk (defaults to <repo>/site):

python -m docs_site build

The gate that CI runs on every change builds the site and runs the post-build guards (links, anchors, fences, navigation drift). Pass --strict to also fail on warnings, not just errors:

python -m docs_site build-check --strict

To preview the site exactly as it deploys, build it with docs_site build, then serve it over plain HTTP:

python -m docs_site serve-built

Writing docstrings

Most API Reference pages under /reference/ are generated from the docstrings on Citry's public Python API. The build introspects the citry package with griffe and renders one entry per public symbol (the names exported from citry), so the docstring you put on a public class, function, or attribute is exactly what a reader sees on its reference page.

The Built-in tags page is the exception. Not all Citry <c-* tags have a Python equivalent. That's why their reference has to be managed manually in docs_site/content/reference/builtins.md:

  • Tags that are Python components (inherit Component) - their source of truth is their Component subclass:
    • <c-component>, <c-element>
    • <c-provide>
    • <c-cache>
    • <c-error-fallback>
    • <c-css>, <c-js>
  • Tags with no Python equivalent - source of truth is in builtins.md:
    • <c-if>, <c-elif>, <c-else>
    • <c-for>, <c-empty>
    • <c-slot>, <c-fill>
    • <c-raw>

The Browser APIs page is authored too. Its public surface spans the $component source transform, the dependency manager, and the Events runtime, so no Python docstring owns the whole contract. Declare each linkable name and stable anchor in docs_site/_internal/reference_pages.py, then document that anchor in docs_site/content/reference/browser-apis.md. The authored_reference guard reports a missing, duplicated, or undeclared anchor.

Write the docstrings for the reader: someone meeting the symbol for the first time, in plain language, not for a contributor reading the source.

A few conventions keep the generated pages consistent:

  • Open with a one-sentence summary. The first line is a short description of what the symbol is or does. Keep it to a single sentence and leave any detail for the paragraphs below it.
  • Use Google-style sections. citry's docstrings are parsed in Google style, and the reference turns these sections into their own labelled fields: Args: (shown as Parameters), Returns:, Raises:, and Attributes:. An Example: (or Examples:) section renders as a titled example block, so a usage snippet in the docstring shows up on the reference page too.
  • Link other symbols with a bracket cross-reference. In the prose, write a reference link to another symbol's entry like this:
[`CitryElement`][citry.CitryElement]

The text in the first brackets is what the reader sees; the second brackets hold the full dotted path of a real citry symbol (a member such as citry.Component.template_data works too).

Only symbols that actually exist resolve; an unknown key falls back to plain text with no link, so reference only names that are really there.

Putting it together, a documented method reads like this:

def render(
    self, *,
    template_globals: Mapping[str, Any] | None = None
) -> CitryRender:
    """
    Render this element to a [`CitryRender`][citry.CitryRender].

    Args:
        template_globals: Extra global names to expose to the template
            while it renders.

    Returns:
        The rendered output, ready to serialize to HTML.
    """
    ...

Making a change

  • Write tests. New or changed behavior comes with tests. They are the evidence the change works and the guard against regressions.
  • Keep the changelog honest. Add a CHANGELOG.md entry only when the change is observable to a user of the package (a fixed behavior, a new or changed public API, a default change). Skip it for internal refactors and tooling.
  • Mind the cross-language contract. The grammar, the AST, and the compiler output are a contract shared across the host-language bindings. If you touch one of them, read the high-risk notes in CLAUDE.md first.

Releases

Releases are per-package and triggered by pushing a git tag named for the package and version:

  • citry@X.Y.Z for the Python package
  • citry-core@X.Y.Z for the Rust-backed bindings
  • pygments-citry@X.Y.Z for the Pygments lexer package

The tagged version must match the package's pyproject.toml; the publish workflow checks this and fails the release on a mismatch.

The packages version and release independently. Because citry depends on citry-core, when you bump both, publish citry-core first and let it reach PyPI before tagging citry. pygments-citry has no cross-package release ordering requirement.

Publishing uses PyPI Trusted Publishing (OIDC), so there is no stored API token. See docs/codebase.md for the full release flow.

How CI is layered

CI is a single-environment gate plus per-language matrices that add cross-version and cross-OS breadth on top of it.

WorkflowWhat it runsWhen
repo--check.ymlThe full gate (python scripts/check.py) on Python 3.13 with the Rust nightly toolchain and Node for the pyright and citry-client phasesEvery push and pull request, no path filters
rust--tests.ymlRust crate tests (cargo test -p ...) on Ubuntu and WindowsChanges under crates/, third_party/, .github/, or .gitmodules
py--tests.ymlPython tests: uv sync --locked --all-packages, then uv run --no-sync pytest, across Python 3.10 to 3.14 on Ubuntu and Windows plus a macOS smoke pairChanges under packages/py/, crates/, third_party/, .github/, or .gitmodules
repo--docs-check.ymlThe docs gate (python -m docs_site build-check) plus the docs-site unit testsChanges under docs_site/, packages/py/, crates/, and related paths
repo--docs-deploy.ymlBuilds and publishes the docs site to GitHub PagesPushes to main
repo--ruff-upstream.ymlChecks stable Ruff releases for changes in the internal crates Citry uses, then opens one tracking issueMonthly or manual

Running python scripts/check.py locally is the same gate repo--check.yml runs, so a clean local run is the best predictor of a green pull request.