Skip to content

The compiled dialect

situ compiles a bounded subset of Python to JavaScript and rejects everything else at compile time with a CompileError that names the problem and usually the fix. This is the same design stance as the numeric subset-of-Python compilers (Numba, JAX): a well-understood subset with loud rejection, so a missing feature surfaces as an error during development.

The compiler carries 111 distinct rejection sites; the greatest hits are catalogued in Compile errors.

Expressions

Usable in binder values (:show="…", :text="…") and event actions:

Construct Compiles to Notes
names signal reads (S.get) or row-var reads unknown names are rejected
literals JS literals str/int/float/bool/None
and / or / not Python's truthiness, not JS's [] and {} are FALSY in Python and TRUTHY in JS, so every condition (:show, :class, a ternary's test, an if) is compiled against Python's rule — :show="not items" shows on an empty list, as the Python reads. and/or return an operand, not a bool (name or 'anonymous' renders the name), and situ returns the one Python would
== / != chosen from the typing rules, per operand-type pair str == str===; int == intNumber()-normalized (a Python bool IS an int); list == list → a structural compare (JS === is reference identity); comparing types Python can never equate (int == str) is a compile error, because in Python it is always False — dead code
< <= > >= Number()-normalized, or lexicographic on strings 1 < 'a' raises in Python (no cross-type ordering), so it is a compile error — the untyped compiler produced NaN < NaN, false forever. A list/set has no < you would want (it is a lexicographic / subset test JS does not share), so those are rejected too
+ - * / // % chosen from the operand types with types, the untyped emitter's bans come back correct: n % m floor-mods (Python's sign-of-divisor), n // m floors, 'ab' * 3 repeats, [1] + [2] concatenates via spread, xs - ys is set difference. / always yields a float and throws on a zero divisor, as Python does. str + number stays a compile error (a TypeError in Python)
x if c else y ternary
x in y / not in chosen from the container's element type substring on a str; a scan on a list/set (an element-wise compare, because JS Set.has would miss that Python holds True and 1 as ONE member); k in d tests a dict's KEYS via Object.hasOwn — this used to be rejected outright, since JS .includes searches values. Rejected over a scalar (x in 3 raises in Python), or when the container cannot hold the needle (1 in 'ab' raises; 'a' in [1, 2] is always False). Can't chain with relational ops
strings + concatenates, * int repeats 'ab' * 3 repeats (JS * on a string is NaN); 'ab' * 2.5 is rejected (a repeat count must be an int, as in Python); str - x and str + number are rejected
f-strings template literals, each value through Python's str() JS stringifies differently: f"{[1,2]}" is "[1, 2]" (JS ${} gives "1,2"), a bool is "True" not "true". Conversions (!r) and format specs (:.2f) are rejected
t.field (row var) a row-object read (:each), or a decoded dataset read (server rows) on a server row the element must carry data-<field> AND declare data-row="<Record>" — see below
subscripts, slices .at() / .slice(), bounds-checked x[-1] now works (Python indexes from the end; .at() does too), and an out-of-range index throws an error rather than reading undefined, as Python's IndexError does. No slice step; a number is not subscriptable and a set has no order, so both are rejected
dict / list literals object / array literals dict keys must be string constants. An empty [] / {} is list[never] / dict[never, never]: it is a valid emptiness check (xs == []) but cannot be ordered or indexed
a and b, a or b Python's truthiness, returning an operand the result is a's type when they agree, else a union (name or 0 is str | int). A union may be rendered but not computed with: (name or 0) + 1 is a compile error, as in Python

Whitelisted calls: len(x) (→ .length/.size), sum(<generator>) (→ .reduce; one generator, no filter, no start value), min/max (≥ 2 args), abs(x), round(x[, n]), today() (→ the local ISO date), and the string methods strip lower upper (no arguments) and startswith endswith (exactly one — JS ignores or reinterprets a position argument).

Whitelisting a name is not whitelisting a call: the arity is checked too, so x.strip('/') and len(a, b) are rejected rather than silently mis-lowered.

Statements (local handler bodies)

Accepted Notes
name = expr a local-signal write (S.set) or a plain let — single target only
x += -= *= /= //= %= augmented assignment — desugars to the binary op, so it inherits its rule (rows += [x] concatenates, count %= 3 floor-mods) and its rejections (name *= 3 is a str-times-float error)
if / elif / else
return (incl. early)
s.add(x) s.discard(x) l.append(x) on Local[set] / Local[list] — immutable shim helpers, so subscribers always fire
remove_row(rows, id) drop the row whose id matches, from a Local[list]. Not list.remove — Python's list.remove(x) deletes an element equal to x, which is a different operation, so situ spells the row-by-id drop with its own name
reset("name", ...) back to declared defaults
global emits nothing — it makes bare-name signal writes valid CPython (nonlocal is rejected: there is no enclosing scope to reach)
pass, docstrings

Rejected — always loudly

for / while / with / try / yield / lambda / comprehensions (beyond the one sum generator) / import / nested def or class / nonlocal / unknown calls or attributes — and every seam violation.

A handler's signature must be plain positional parameters: a default, *args, **kwargs, a keyword-only or positional-only parameter, or a decorator is rejected (each would be silently dropped). At a call site, keyword arguments (bump(step=2)), */** spreads, and a wrong argument count are rejected.

Each rejection is a one-line message; the catch-all reads statement not in the compiled dialect: <NodeType>. Malformed Python in a component's .py, or in an inline @click="…" action, is a CompileError naming the line — never a raw traceback.

Declaring a row: data-row

A server-rendered row is HTML, and every data-* attribute on it is a string. data-id="3" is "3", never 3. So when a client expression reads t.id, the compiler has to know what that string is — otherwise it can only guess at the coercion, and a guess is how "3" + 1 becomes "31".

The row says so itself:

components/tracker.py
class IssueRow(TypedDict):
    id: int
    title: str
components/tracker.html
<li data-row="IssueRow" data-id="{{ t.id }}" data-title="{{ t.title }}"
    :class="selected: selected_id == t.id">

data-row names the record; the compiler then decodes each field at the read, from the type you declared. t.id is an int wherever it appears — in a comparison, in an assignment, as a handler argument — so selected_id = t.id really does put an int in an int signal. A str field needs no decoding, because a string already is one.

Three rules, all enforced at compile time:

  1. A row the client reads must declare a record. No data-row, no read — a CompileError, not an undefined in the browser.
  2. Every field read must be in the record, and shipped as data-<field>. Read what the row doesn't carry and you get an error naming what it does carry.
  3. A data-* value must be a bare {{ row.field }} (optionally |wire). No filters, no expressions: data-title="{{ t.title|lower }}" would mean the client's t.title is the lowercased title while your source says otherwise — the Python you read would not be the JavaScript that runs.

data-row is a compile-time declaration, like a site marker: it is stripped from the output, so declaring a row's type costs zero shipped bytes.

In the unified idiom, the projection is the same declaration written once: :each="t in mailbox ~ Message[id, subject]". In declui, the record is derived from your model — you never write it.

Semantics worth knowing

  • Numbers stay numbers. A :bind on <input type="number"> stores a JS number, so count == 0 and count > 50 behave like Python. Comparisons are strict equality after explicit numeric coercion.
  • Decimal stays a string (in declui and by convention): a JS float would corrupt money. Equality works; relational comparison on Decimals is rejected where it would be lexical.
  • today() lowers to the local date as ISO YYYY-MM-DD — safe to compare lexically against <input type="date"> values.
  • sum(x.price for x in items) works on a Local[list] — the one comprehension-shaped construct, chosen because rollups are ubiquitous.

When you hit the wall

The wall is the feature. When the dialect rejects your code, the fix is one of:

  1. Move it to the server — add an await (a facade call) and let it become a command; loops over data belong in context() or the facade.
  2. Restructure into dialect — e.g. replace a loop-accumulate with sum(...), or a try with a validity check.
  3. It is runtime user input — then it can't be compiled by anyone; that's what the one sanctioned interpreter boundary looks like (the spreadsheet demo's :cells formula engine).

If a rejection message doesn't point you at the fix, that's a bug in the message — the error texts are maintained as part of the API.