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 == 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 — 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:
<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:
- A row the client reads must declare a record. No
data-row, no read — aCompileError, not anundefinedin the browser. - 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 filters, no expressions:data-title="{{ t.title|lower }}"would mean the client'st.titleis 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
: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]— 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 — e.g. 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.