# situ — complete reference for AI coding tools > situ is a Python→JS compiler plus a framework-neutral component mount for reactive web UIs. Each piece of state declares its SITE (`Local` browser / `Url` query string / `Server` database / `Synced` reserved) and everything else is derived: the client island is generated from the authored Python, the routes are derived from the handlers, and the client/server boundary is a compile-time invariant. The client dialect is TYPED — specified as a table of rules verified against CPython — so what compiles means in the browser what it means in Python, or it is a `CompileError`. The mount has a portable core (`situ.mount.core`, no framework imported) with three adapters: Litestar (the reference: `mount_component`), any ASGI host (`situ.mount.asgi.asgi_app` — FastAPI/Starlette/uvicorn, importing no web framework at all), and Flask (`situ.mount.flask.mount_flask`, WSGI); the Litestar imports are lazy, so neither the ASGI nor the Flask path pulls in Litestar. Authors write real HTML plus a bounded dialect of Python and zero client JavaScript. Alpha software; Python ≥ 3.12. `pip install situ` gives the compiler + the ASGI adapter and imports NO web framework; the Litestar and Flask mounts are extras (`situ[litestar]`, `situ[flask]`), and `situ[sqlalchemy]` / `situ[model-adapters]` add the bundled DB/DI helpers and declui's non-dataclass model adapters. So `mount_component` needs `situ[litestar]`; `situ.mount.asgi.asgi_app` needs nothing. This file is self-contained: everything needed to write correct situ code. Every code sample and error message in it is verified output. Rules an agent must never violate are marked RULE. ## 1. The component model A component is TWO SIBLING FILES SHARING A STEM: - `name.py` — module-level sited signal declarations + module-level handler functions. - `name.html` — real HTML; Jinja (`{% for %}`, `{{ x }}`) renders server state; `:`/`@` shorthand attributes bind client behavior. RULE: every component `.py` starts with `from __future__ import annotations` (markers are never evaluated at runtime). RULE: every reactive element (any `:x`/`@x` attribute) must be inside `
` OR inside the single `
`. Elements outside both are silently inert. `
` survives command swaps (put search boxes, dialogs, toasts there); the region is what commands re-render. (The compiler stamps the component's header `data-siting-header`, so serving the component inside an app shell with its own `
` is safe.) RULE: `data-region` must be the FIRST attribute on its `
`. RULE: components compile once per process — restart (or run uvicorn with `--reload`) to pick up edits. ## 2. Sites ```python from situ import Local, Url, Server, Synced search: Local[str] = "" # browser owns it; compiled to JS; zero network tags: Local[set] = set() # Local[set] is first-class (membership, size, mutation) filter: Url[str] = "all" # the query string IS the store; set by links only issues: Server[IssuesFacade] # server owns it: a facade in handlers, template data from context() # Synced[T] parses but is reserved/unimplemented — do not use it ``` Markers are transparent `Annotated` aliases: `Local[str]` IS `str` to type checkers. THE SEAM (enforced, verbatim compiler errors): - Client read of Server state → `CompileError: server-sited 'issues' cannot be read on the client; DB-backed state stays on the server` - Client write → `CompileError: cannot assign server-sited signal 'issues' on the client` - Loop over Server state in a client handler → `CompileError: refusing to compile a loop over server-sited 'issues': DB iteration stays on the server` - A client write to a `Url` signal is also a `CompileError` (URL state is set by navigation). Server data reaches the browser three ways ONLY: (1) Jinja renders it; (2) per-row `data-` attributes project named fields the client reads, with the row's type declared by `data-row` (see §4); (3) a command pushes values into client signals via `signal()`/`reset()`. TYPED TEMPLATES (opt-in, checked at mount): annotate `context()`'s return as a `TypedDict` and situ verifies the whole component template (its `
` + region) against it. It checks: every name the template reads is a declared key; a looped context key (`{% for t in coll %}`) is typed as a collection (a plain model — a forgotten `list[...]` — is a `CompileError`); each `t.field` in that loop exists on the row model; each `{{ obj.field }}` exists on `obj`'s type. The row/object model is any `TypedDict` / dataclass / attrs / msgspec / Pydantic class (fields via `get_type_hints`). A missing key/field or a non-iterable loop is a `CompileError` naming it, its template line, and what the type declares. A bare `Mapping`/`dict` return is UNCHECKED — the check is opt-in by annotation; reads behind a Jinja `| filter` or `{% macro %}` are the unchecked ceiling (fail closed, never a wrong verdict). declui hands you the class: `from situ.declui import context_stub; context_stub(screen, service_type=…)` prints the `TypedDict` to paste. NOTE: the loop row is the SERVER row `context()` returns (e.g. `items: list[Issue]`), a richer shape than the client `data-row` projection (§4) — type it as the server row. ## 3. Handlers RULE: a handler whose body contains ANY `await` is a SERVER COMMAND (compiled to a POST dispatch; body runs server-side with the facade injected under the Server signal's name). A handler with no `await` compiles to client JS. There is no decorator. ```python from collections.abc import Callable from typing import TYPE_CHECKING if TYPE_CHECKING: from typing import Protocol class IssuesFacade(Protocol): async def create(self, title: str) -> None: ... async def set_status(self, issue_id: int, status: str) -> None: ... reset: Callable[..., None] # declare the injected helpers you use (type-checker hygiene) def clear(): # → client JS function, zero network global search # RULE: declare `global` for bare-name signal writes search = "" async def close(id): # → POST {mount}/cmd/close/{id} (positional arg = path segment) await issues.set_status(id, "closed") async def create(new_title): # param binds from the submitted form field name="new_title" await issues.create(new_title) reset("new_title", "dialog_open") # reset local signals to defaults (rides the response) # signal("toast", "Created") # push an explicit value into a client signal ``` Command mechanics: form fields + listed local signals + current url params ride the POST; `Local[set]` serializes as a JSON array; the response is the re-rendered region HTML plus an `X-Siting-Signals` JSON patch applied before re-bind. In flight: the control is `disabled`, the region `aria-busy`. Non-2xx/network error: automatic rollback, no swap. ## 4. Events and binders (the template vocabulary) Events (value = `;`-separated statements: `sig = expr` assignments and handler calls): - `@click` `@dblclick` `@submit` `@input` `@escape` `@clickout` (a click anywhere outside the element — document-level, for dropdown/panel dismissal) - `@submit` goes on the `
` and preventDefaults; its command sends the form's fields. - RULE: inside a `:each` row, only `@click` and `@submit` are allowed (enforced). A server-rendered Jinja row additionally accepts `@clickout` (per-row dismissal) — each such row installs its own document-level listener, so prefer one `@clickout` host over per-row hosts on large tables. Pair `@clickout` with the element its `:show` controls: dismissal is decided from the element's visibility at the click's start, which is what keeps an opening click (a trigger outside the element) from instantly self-dismissing. - Optimistic updates: leading LOCAL assignments before a trailing server call are applied immediately and rolled back if the command fails: `@click="selected_id = t.id; open_msg(t.id)"`. Binders: - `:text="expr"` — textContent tracks the expression. - `:show="expr"` — sets/clears the `hidden` attribute. RULE: keep `[hidden] { display: none !important; }` in the page CSS; any author `display:` on the element defeats the UA hidden rule otherwise. - `:class="name: expr"` — toggles one class. - `:attr="name: expr"` — sets/removes an attribute (falsy/None removes; True → empty). - `:bind="sig"` — two-way input binding. RULE: the signal must be `Local`. `type=number` inputs store JS numbers. - `:each="row in sig"` — the keyed client reconciler over a `Local[list]` of dicts. RULE: put `:each` on a wrapper element with no other binders; rows are keyed by their `id` field. - `:tree="row.children"` — self-recursive row template (inside a `:each`). - `:virtual="handler(scroll)"` — windowed rows; needs a server handler + `mount_component(window_template=…)`. - `:autohide="sig"` — clears a status signal back to its default after ~4 s. - Kit widgets (need situ_ui's `widgets.js`): use the components ``, ``, ``, ``/``, `
`, ``; their binders (`:tabs`, `:table="rows"`, `:combobox="value"`, `:datagrid="rows"` + `:datagridsel="sel"`, `:menu`, `:palette`) are internal wiring, not the public API. `:sortable="list"` and `:cells="grid"` have no component — compose them onto your own markup, like `:each`. SERVER-ROW IDIOM (the standard list pattern — no `:each` involved): RULE: a row the CLIENT reads must declare its type. Put `data-row=""` on the row element and a matching `TypedDict` in the `.py`; every field a binder reads must be in that record AND shipped as `data-`. An undeclared client-read row is a `CompileError`. ```python # in the component .py from typing import TypedDict class IssueRow(TypedDict): # what the CLIENT reads off a row — and what each field IS id: int title: str ``` ```html
    {% for t in issues %}
  • {% endfor %}
``` Rows are server-rendered Jinja; each row carries `data-id` plus a `data-` per field the CLIENT reads. In binders on/inside the row, `t.` compiles to a dataset read from the nearest `[data-id]` ancestor, and so does `.` when `` is the loop variable the row's `data-id` reads (`{% for issue in issues %}` + `data-id="{{ issue.id }}"` makes `issue.title` the row's `data-title`). A DOM attribute is always a STRING (`data-id="3"` is `"3"`), so the compiler DECODES `t.` to the type your `data-row` record declares — `t.id` where `id: int` is an integer, decoded once at the read (and it throws if the value is not really an int). A `str` field needs no decode. This is why `selected_id = t.id` genuinely stores an int, and why comparisons mean what the Python reads: `selected_id == t.id` is an int compare, `t.id in selected` (a `Local[set[int]]`) is a real set-membership test. A `data-` value must be a bare `{{ . }}` (optionally `|wire`) — a filter/transform there (`{{ t.title|lower }}`) makes the client see a different value than the source says, and is a `CompileError`. RULE: to lowercase for search, ship the field and lowercase where you read it (`search.lower() in t.title.lower()`), never in the `data-*`. ## 5. The dialect (what compiles to the client) The dialect is TYPED: every construct compiles from a rule verified against CPython, so it means in the browser what it means in Python — or it is a `CompileError`. Signal types come from their annotation (`Local[int]`) or, for a bare `Local`, their default literal (`x: Local = 0` is int); row-field types come from `data-row` (§4); annotated handler params (`def go(id: int)`) are typed in the body, and a BARE param is inferred from its call sites (`@click="go(count)"` types `go`'s param as `count`'s type) — but only if every call site supplies the same type, else the param stays untyped. An expression the compiler cannot type fails closed: a comparison or membership test with an untyped operand is a `CompileError`, not a runtime guess — fix it by giving one side a type (annotate the param, declare the signal's element type, or declare the row with `data-row`). There is no "unknown that passes through." Expressions: names (signals, row vars) · str/int/float/bool/None literals · `and`/`or`/`not` (Python truthiness — `[]` and `{}` are FALSY, so `:show="not items"` shows on an empty list; `and`/`or` return an OPERAND, so `name or 'anon'` renders the name) · `== !=` (structural for `list`/`dict`/`set`; a Python `bool` IS an int; comparing types Python can never equate — `int == str` — is a `CompileError`, it is dead code) · `< <= > >=` (`1 < 'a'` RAISES in Python, so it is a `CompileError`) · `+ - * / // %` (`%` floor-mods; `//` floors; `'ab' * 3` repeats; `/` always yields float and THROWS on ÷0) · `x if c else y` · `in`/`not in` (substring in a `str`; scan of a `list`/`set`; **KEY test on a dict** via `Object.hasOwn`) · set algebra `- | & ^` on `set`s · f-strings via Python's `str()` (`f"{[1,2]}"` is `"[1, 2]"`, not JS's `"1,2"`; conversions `!r` and format specs `:.2f` are rejected) · subscripts (`xs[-1]` indexes from the end and throws out of range) and slices (no step) · dict/list literals (string-constant keys) · calls: `len` `sum()` `min` `max` `abs` `round` `str` `today()` · string methods: `strip lower upper startswith endswith`. Statements (client handler bodies): single-target assignment · `+= -= *= /= //= %=` (desugar to the binary-op rule) · `if/elif/else` · `return` (incl. early) · `set.add/.discard`, `list.append`, `remove_row(list, id)` (drop the row whose id matches — NOT Python's `list.remove`) · `reset("sig", ...)` · `global` (`nonlocal` is REJECTED). Docstrings inside a handler are REJECTED (a bare string is a call statement — use `#` comments). REJECTED, always with a `CompileError` naming the construct: `for`/`while` loops · `with` `try` `yield` `lambda` · comprehensions (beyond the one `sum` generator) · `import` · nested `def`/`class` · `str + number` (Python raises; JS would concatenate) · a `data-row`-less client row read · unknown names, calls, attributes, methods — plus every seam case of §2. When rejected: move the logic to the server (add an `await`/do it in `context()`), or restructure into the dialect. Semantics: `Decimal` has no wire decoder — keep it string-valued (money precision); `today()` yields the local ISO date string, comparable against `` values; set/list mutations are immutable under the hood so subscribers always fire. ## 6. The mounts (framework-neutral core + adapters) The mount has a portable core (`situ.mount.core`: `dispatch_command` / `page_data` / `view_from_query` + the compile cache + `Hub`, no web framework imported) wrapped by a per-framework adapter. Litestar is the reference adapter (extra: `situ[litestar]` — since 0.4.0 the base install carries no web framework); the Litestar names below load lazily (PEP 562), so `import situ`, the ASGI path and the Flask path import no Litestar. ```python import situ from situ import mount_static_component, mount_component, mount_tree, Context mount_static_component(path="/x", stem=DIR / "counter", template="page.html", meta={"name": "Counter"}) # pure client mount_component(path="/x", stem=DIR / "components" / "tracker", template="page.html", meta={"name": "Tracker"}, service_type=IssueStore, # resolved per request from Dishka facade=None, # optional wrapper; default: the service itself context=context, # async (service, view) -> template dict components=Context.from_dir(DIR), # optional component tree overrides=None, unified=False, live=False, hub=None, window_template=None) # ENTITY mount: /things/{id:int} -> context reads view['id'] from the path; one island, any id mount_component(path="/things/{id:int}", stem=DIR / "components" / "thing", template="page.html", meta={"name": "Thing"}, service_type=ThingStore, context=context) mount_tree(path="/x", root=DIR / "board", components=Context.from_dir(DIR), template="page.html", meta={"name": "Board"}) # pure-client tree ``` RULE: `mount_component` requires a Dishka container on the app (`setup_dishka(make_async_container(Provider()), app)`) — it resolves `service_type` from `request.state.dishka_container`. Wiring: serve `situ.static_dir()` at `/static` (the shim `_rt.js`); template dirs `[yours, situ.templates_dir()]`; kit users also serve `situ_ui.STATIC` and load `widgets.js` after `_rt.js`, before the island. Derived routes per mount: `GET /` (page) · `GET /island.js` · per server handler `POST /cmd/{name}` or `POST /cmd/{name}/{arg:int}` · with `live=True` (or `hub=`): `GET /feed` (SSE "dirty" broadcast) + `GET /region` · with `window_template=`: `GET /window`. ENTITY MOUNTS: a parametrized `path` (`/things/{id:int}`) serves one page per entity — the path converters merge into the `view` context() reads (`context(service, view)` reads `view["id"]` from the PATH, stringified, like a query param), and the command/island URLs bake RELATIVE (`cmd/…`, `island.js`) so ONE compiled island serves every id. A converter named `name`/`arg` (the command routes' own params) raises at mount; a bare-URL request (`/things/1`, no slash) 307-redirects to the trailing-slash form so a bookmark still boots a working island. Query-string selection (a `Url` signal) stays the default. To reach the entity from a command, seed the id into a `Local[int]` (via `_local` in context, see §7's `_local`) and pass it as the command's path arg: `@click="inc(tid)"` → `cmd/inc/{tid}`. FALLBACK: `mount_component(fallback="

…

")` serves that markup in the region when building the page raises (a DB timeout, a facade bug) — the page, shell and island still ship, the exception is logged, and the response is 200. A deliberate `HTTPException` (a context() raising NotFoundException) passes through as itself, so a real 404 stays a 404. On the fallback path `_local` seeding is lost, so client signals boot from their declared defaults. RULE: the markup is TRUSTED and injected unescaped — pass a constant, never user input or an exception's text. Also on `asgi_app`. LIVE FROM OUTSIDE A REQUEST: pass your own `hub=Hub()` (implies `live=True`) and hold the handle; a background worker / webhook / domain event calls `hub.publish_threadsafe()` to ping peers to re-fetch (a no-op until a peer subscribes). `live=True` alone only re-pings after this mount's OWN commands. ### Any ASGI host — `situ.mount.asgi.asgi_app` (no extra, no framework imported) RULE: this adapter imports NO web framework (not even Starlette) and adds no dependency; it is the ASGI protocol itself. Mount it wherever the host mounts sub-apps. ```python from fastapi import FastAPI from situ.mount.asgi import asgi_app from situ.mount.core import compile_mount app = FastAPI() app.mount("/board", asgi_app( app_factory=lambda cmd_base: compile_mount(STEM, cmd_base), template="page.html", meta={"name": "Board"}, resolve=lambda: store, # sync OR async; no DI container required context=context, # async (service, view) -> dict fallback="

Unavailable.

", # optional )) ``` The mount core is already async, so an ASGI host awaits it natively — no `asyncio.run` bridge (and none of the Flask adapter's loop-affinity limits for async DB resources). Every baked URL is RELATIVE, so the adapter never needs its own prefix; a slash-less page request 307-redirects to the trailing-slash form. Entity selection is by QUERY STRING here (path converters belong to the host router, which an ASGI sub-app does not see). A `:virtual` component raises at mount (no /window route). Same CSRF baseline as Flask: a command POST must carry `X-Siting: 1`. The app answers the ASGI lifespan protocol, so `uvicorn module:app` boots clean; a wrong method on a command path is 405. ### Flask (WSGI) — `situ.mount.flask.mount_flask` (extra: `situ[flask]`) ```python from flask import Flask from situ import compile_app from situ.declui import generate_server_tracker from situ.mount.flask import mount_flask app = Flask(__name__, static_folder=None) # free /static for situ's shim app.register_blueprint(mount_flask( path="/issues", app_factory=lambda cmd: compile_app(generate_server_tracker(SCREEN, context_key="items"), cmd), template="page.html", meta={"name": "Issues"}, resolve=lambda: store, # the service (no Dishka); a plain callable facade=Issues, # optional wrapper for the handlers context=context, # async (service, view) -> template dict head='')) # declui scaffold styling ``` Same four routes as `mount_component`, over a Flask `Blueprint` (no Dishka, no Litestar on the path). `path` may carry Werkzeug converters (`/members/` — an entity mount): converter values join the `view` mapping context() reads, and command/island URLs are baked RELATIVE (`cmd/…`) so one island serves every entity; converter names must not shadow `name`/`arg`. `app_factory(cmd_base)` returns the compiled `EmittedApp` (the caller compiles, so the command URLs match the mount). To reuse the app's Jinja env pass `page_render=lambda name, ctx: flask.render_template(name, **ctx)` (not bare `render_template`). `head` is trusted HTML for the page `` — pass declui's stylesheet link (generated `declui-*` markup is otherwise unstyled). Commands run the async core via `asyncio.run` — fine for an in-memory service; a loop-bound async resource (a SQLAlchemy pool) needs a persistent loop or an ASGI adapter. Serve situ's static dir (`_rt.js` + `declui.css`) at `/static` yourself (`static_folder=None` frees the path; Flask's built-in static route would otherwise shadow it). ## 7. A complete, verified minimal app Layout: `app.py`, `store.py`, `components/tracker.py`, `components/tracker.html`, `templates/page.html`. Run: `uvicorn app:app --reload`. ```python # store.py — the domain service: DI service_type AND the facade the handlers await from __future__ import annotations from dataclasses import dataclass @dataclass class Issue: id: int title: str status: str = "open" class IssueStore: def __init__(self) -> None: self._items = [Issue(1, "Login button misaligned on mobile")] self._next = 2 async def create(self, title: str) -> None: title = title.strip() if title: self._items.append(Issue(self._next, title)); self._next += 1 async def set_status(self, issue_id: int, status: str) -> None: for t in self._items: if t.id == issue_id: t.status = status def visible(self, which: str) -> list[Issue]: if which == "all": return list(self._items) return [t for t in self._items if t.status == which] ``` ```python # app.py from __future__ import annotations from collections.abc import Mapping from pathlib import Path import situ from dishka import Provider, Scope, make_async_container, provide from dishka.integrations.litestar import setup_dishka from litestar import Litestar from litestar.plugins.jinja import JinjaTemplateEngine from litestar.static_files import create_static_files_router from litestar.template.config import TemplateConfig from situ import mount_component from store import IssueStore HERE = Path(__file__).parent class AppProvider(Provider): scope = Scope.APP @provide def store(self) -> IssueStore: return IssueStore() async def context(service: IssueStore, view: Mapping[str, str]) -> Mapping[str, object]: which = view.get("filter", "all") return {"issues": service.visible(which), "filter": which} def create_app() -> Litestar: app = Litestar( route_handlers=[ mount_component(path="/", stem=HERE / "components" / "tracker", template="page.html", meta={"name": "Tracker"}, service_type=IssueStore, context=context), create_static_files_router(path="/static", directories=[situ.static_dir()]), ], template_config=TemplateConfig(directory=[HERE / "templates", situ.templates_dir()], engine=JinjaTemplateEngine), ) setup_dishka(make_async_container(AppProvider()), app) return app app = create_app() ``` ```python # components/tracker.py from __future__ import annotations from collections.abc import Callable from typing import TYPE_CHECKING, TypedDict from situ import Local, Server, Url class IssueRow(TypedDict): # what the CLIENT reads off a row (t.id, t.title) — and what each field IS id: int title: str if TYPE_CHECKING: from typing import Protocol class IssuesFacade(Protocol): async def create(self, title: str) -> None: ... async def set_status(self, issue_id: int, status: str) -> None: ... reset: Callable[..., None] search: Local[str] = "" selected_id: Local[int] = 0 dialog_open: Local[bool] = False new_title: Local[str] = "" filter: Url[str] = "all" issues: Server[IssuesFacade] async def create(new_title): await issues.create(new_title) reset("new_title", "dialog_open") async def close(id): await issues.set_status(id, "closed") async def reopen(id): await issues.set_status(id, "open") ``` ```html
    {% for t in issues %}
  • {{ t.status }}
  • {% endfor %}
{% for t in issues %}

{{ t.title }}

#{{ t.id }} · {{ t.status }}

{% if t.status == 'open' %} {% else %}{% endif %}
{% endfor %}
``` ```html {{ demo.name | default("situ") }} {{ body | safe }} ``` This app derives `POST /cmd/create`, `POST /cmd/close/{id}`, `POST /cmd/reopen/{id}`; its island is a few KB. Selection and search survive command swaps (signals live at page scope; the header is outside the region). ## 8. Composition Resolution — a `Context` maps PascalCase tags to component stems: ```python from situ import Context import situ_ui ctx = situ_ui.kit().merge(Context.from_dir(COMPONENTS)).with_override("Toast", HERE / "fancy_toast") mount_tree(path="/app", root=COMPONENTS / "page", components=ctx, template="page.html", meta=META) ``` Static check (run it in lint): `python -m situ.check path/to/root --from-dir path/to/components [--kit]`. Contracts — a child declares `Prop[T]` (parent→child expression) and `Emit[T]` (child→parent event); the parent binds them at the placement site. Matching is BY NAME; missing/extra/duplicate/unused bindings are CompileErrors. ```python # confirm.py (child) from situ import Prop, Emit open: Prop[bool] confirmed: Emit[None] ``` ```html Body fills the default slot. ``` Slots: ``/`` in the child; content is spliced in the PARENT's scope (a passed `@click="h()"` calls the parent's handler). Content to a slot-less child is a CompileError. Provide/Inject: an ancestor declares `theme: Provide[str] = "dark"`; any descendant declares `theme: Inject[str]` and reads `theme` like a signal. An Inject with no ancestor Provide is a CompileError. Uncontrolled components: a child that owns `Local`s + handlers needs zero parent wiring; placed N times it is monomorphized into N independent states at compile time. Scoped CSS: a CHILD component may carry its own `