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.
The exhaustive version
This page teaches the dialect. The generated reference lists every rule the compiler will apply: each operand-type pair, the JavaScript it lowers to, and why that JavaScript means what Python means. See Dialect rules, which is generated from the compiler's own rule table and cannot drift from it.
Expressions¶
These are 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 | [] 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 (name or 'anonymous' renders the name): the one Python would return |
== / != |
chosen from the typing rules, per operand-type pair | str == str → ===; int == int → Number()-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, which makes it 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). An out-of-range index throws an error, as Python's IndexError does, where JS would read undefined. 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, with neither a filter nor a 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.
Statements (local handler bodies)¶
| Accepted | Notes |
|---|---|
name = expr |
a local-signal write (S.set) or a plain let, single target only |
x += -= *= /= //= %= |
augmented assignment: it 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], through 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, and is required for a bare-name signal write, as in CPython: without it the assignment binds a local, so it is a CompileError (nonlocal is rejected: there is no enclosing scope to reach) |
pass, docstrings |
Rejected, always with a message¶
The compiler rejects for / while / with / try / yield / lambda / comprehensions (beyond the one sum generator) / import / nested def or class / nonlocal / unknown calls or attributes, plus 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 all 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, so 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, which is how "3" + 1 becomes "31".
The row says so itself:
<li data-row="IssueRow" data-id="{{ t.id }}" data-title="{{ t.title }}"
:class="selected: selected_id == t.id">
Inside the row, a binder reads it as t, or by the loop variable its data-id reads: in {% for issue in issues %} with data-id="{{ issue.id }}", issue.title and t.title are the same data-title. A binder reads the innermost row it sits in, as closest('[data-id]') finds it; inside a :each, it reads that row object instead.
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 govern it, all enforced at compile time:
- A row the client reads must declare a record. No
data-row, no read: you get aCompileErrorwhere the browser would have given you anundefined. - 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. - A
data-*value must be a bare{{ row.field }}(optionally|wire). No filter or expression:data-title="{{ t.title|lower }}"would mean the client'st.titleis the lowercased title while your source says otherwise, so the Python you read would diverge from 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, so you never write it.
Semantics that differ from JavaScript¶
- Numbers stay numbers. A
:bindon<input type="number">stores a JS number, socount == 0andcount > 50behave like Python. Comparisons are strict equality after explicit numeric coercion. Decimalstays 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 ISOYYYY-MM-DD, safe to compare lexically against<input type="date">values.sum(x.price for x in items)works on aLocal[list]. It is 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:
- Move it to the server. Add an
await(a facade call) and let it become a command; loops over data belong incontext()or the facade. - Restructure into dialect. Replace a loop-accumulate with
sum(...), or atrywith a validity check. - 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
:cellsformula 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.