This document specifies how each Python construct lowers into Mantle, the project's typed SSA
intermediate representation. It is written for whoever implements the translator from the
Python AST (StrataPython.stmt / StrataPython.expr) to a Mantle Module over Python's
environment. Section 1 introduces as much Mantle as the examples need. Mantle itself is
specified in Mantle.md.
The scope is the Python subset the front end analyses: functions, classes, closures,
control flow, exceptions, with, and comprehensions. Generators, async and match are
rejected (§6.9). The gaps this spec found in the environment and builder are listed in §8.
The translator is PyTranslate.translate in StrataPython/Mantle/Translate.lean. §11
marks what it implements, and the module docstring has a recipe for adding a construct.
pymantle mantle FILE prints the diagnostics and the module for a .py or Python Ion file.
The tests are the programs NAME.py in StrataPythonTest/Mantle/mantle_tests/.
StrataPythonTestExtra/MantleTranslateTest.lean runs every one, as listed in
StrataPythonTest/Mantle/mantle_tests.txt, and compares each one marked supported with its
golden, NAME.expected.mantle beside NAME.py.
Design decisions. Every local is a cell declared in the entry block. The translator does
no SSA construction: promoting cells to SSA values is a later ref-to-reg pass. Control flow is
plain blocks plus completions (§4, §7). The one region-based construct is the comprehension
(§6.8). Loops and try as regions are a future refinement (§9, Q1). Name resolution and
comprehension scoping follow CPython 3.12.
Environments and programs. An environment declares types and instructions. A
program is a module of functions written against one environment. The base
environment declares the datatype base.Unit with the one constructor base.unit,
base.Bool, base.Int, base.Float64, base.String, base.Sequence a, base.Ref a (a
mutable cell), base.Code (an opaque code pointer) and the datatype base.Except e a with
constructors base.ok and base.error. It also declares
the cell instructions base.refNew, base.refGet and base.refSet. Python's environment
extends the base (§2).
Signatures. An instruction's signature is written in the environment DSL:
insn refGet [a] (cell : Ref a) : a -- type parameter a
insn add (lhs rhs : Value) (^err (exc : Value)) : Value -- a successor named err
insn try [a] (&body : Completion a) : Completion a -- a region named body
Functions and blocks. A function is a list of blocks. The first block is the entry,
and its parameters are the function's parameters. Each block has a label such as bb.3,
takes typed parameters, runs a sequence of instructions, and ends with one terminator.
Every value is defined exactly once, either as a block parameter or as an instruction
result, and has a type. Block parameters take the place of phi nodes.
Instructions. There are two kinds, and each defines a result:
| Kind | Meaning |
|---|---|
const |
a literal of a base type (unit, bool, int, float, str), or const @m.f, a base.Code pointer to the function m.f of the same module |
apply |
an application of a declared instruction, including a datatype constructor, at explicit type arguments |
Successors. An instruction may declare successors: blocks it transfers to instead of
falling through. A successor site is a label with a prefix of that block's arguments already
bound. The instruction supplies the rest. The err successor of a raising Python operation
supplies the exception. So ^h(%7) passes %7 and then the exception to block h.
Terminators. A block ends with an application of a terminal instruction: one that never falls through and leaves by one of its successors. A terminal instruction has no result type. The base declares the control flow:
terminal insn jump (^k) -- go to k
terminal insn branch (cond : Bool) (^t ^f) -- t if cond holds, else f
terminal insn unreachable -- control never gets here
For each datatype T, Env.addData declares the terminal instruction T.case with the
datatype, so a hand-written T.case is rejected:
terminal insn T.case [params] (scrutinee : T params) (^c₁ (fields…)) …
It has one successor per constructor, in declaration order, and each receives that
constructor's fields after its own pre-bound arguments. base.Except.case has the
successors ok (value : a) and error (err : e).
Returning. Each function and each region has an implicit exit label, Label.exit, whose
parameters are its result type. ret v is a jump to the exit passing v. No block is
labelled with the exit.
Regions. An instruction may own regions: nested lists of blocks, like MLIR's. A transfer
may name only a block of its own region, or that region's exit. An inner region's exit is its
own, so ret inside a region leaves the region with a value. Values defined outside are
visible inside.
Well-formedness, as StrataMantle/WF.lean checks it. These rules constrain the translator:
labels are unique function-wide; no transfer targets an entry block; every operand is
defined, with the type its use demands; every successor supplies exactly what its target's
parameters declare; a terminator applies a terminal instruction, and no other instruction
does. The checker does not yet check where a value is used: it accepts a use before its
definition, a use of a value outside the region that defines it, and a handler's use of the
result of the operation that failed. The translator must still make every use dominated by
its definition, because ref-to-reg will rely on it.
Printed syntax. This is what Func.toString prints. Each example in this document is in
that form. -- comments and … elisions are added for the reader. Readable labels such as
head.0 are what the translator passes to freshLabel. A function name prints with an @
prefix, in its definition and in a const. Each successor prints with a ^ prefix. A
terminator prints as an application without a result, so a case is
base.Except.case[base.String, base.Int] %4 ^ok.0() ^err.0(). The base's terminal
instructions drop base.: jump ^done.0(), branch %0 ^bump.0() ^done.0() and
unreachable. A jump to the exit prints as ret %5.
func @m.f(%0 : py.Value) -> base.Except(py.Value, py.Value) {
entry.0(%0 : py.Value):
%4 : base.Int = const int 1
%5 : py.Value = py.intLit %4 -- apply, no successors
%6 : py.Value = py.add %0 %5 ^propagate.0() -- err successor, nothing pre-bound
%9 : base.Except(py.Value, py.Value) = base.ok[py.Value, py.Value] %6
ret %9 -- jump to the exit, passing %9
propagate.0(%10 : py.Value): -- receives the exception
…
}
The environment is StrataPython/Mantle/Env.lean, namespace py. The emission helpers
are in StrataPython/Mantle/Build.lean.
- One value type. Every Python value has type
py.Value. Base types appear only where a value is not a Python value: abase.Stringattribute name, abase.Intliteral before boxing, thebase.Boolthat abranchneeds, and thebase.Unitresult of an operation that only acts. - Raising operations. Every operation that can raise declares an
errsuccessor, which receives the exception as apy.Value. The operation's result is its success value. The translator passes the label of the enclosing handler aserr. - Total operations have no successors. These are the literals,
undef,isDefined,is/isNot,mkTuple/mkList/mkKwargs,listToTuple,mkSlice,tupleLen,dictLen,dictGet,dictFirstKey,dictDiscard,listAppend,isStopIteration,strConcat,globalCell,mkClosureandunsupported.mkSetandmkDictraise, because hashing a key runs__hash__and__eq__. - Cells. A cell is a
base.Ref(py.Value), a mutable location. Every Python local is a cell:refNewcreates it,refSetwrites it andrefGetreads it. A cell that has not been assigned holdspy.undef "x". A later ref-to-reg pass promotes cells that do not escape into SSA values and block parameters. The translator never decides block parameters for locals. - Globals.
py.globalCell module nameis the cell of a module global: the same cell for the same arguments, read and written like a local's. It holdsundefuntil the first assignment, anddelwritesundefback.moduleis always fully qualified ("a.b"). - Imports.
py.importModule modulereturns the module object, importing it and each package above it first if need be.py.importFrom module nameimportsmoduleand reads its attributename, falling back to the submodulemodule.nameas CPython'sIMPORT_FROMdoes. Both raiseImportError, or whatever the module body raises.py.qualifiedRef module namereadsmodule.nameat each use; the translator uses it only for builtins. - Definedness. Only
isDefined,requireDefinedandrequireUndefinedmay consume a value that could beundef.requireDefined v excType msgpassesvthrough, or raisesexcType(msg)ifvis undefined. - Completions.
py.Completionrecords how a protected block finished. It is a datatype whose five constructors are instructions:completionNormal,completionReturn (value),completionRaise (exc),completionBreakandcompletionContinue.py.Completion.casehas five successors in that order. Only thereturnandraisesuccessors receive a payload. - Closures.
const @m.fis abase.Code, andpy.mkClosure code cells…pairs it with captured cells to make a callablepy.Value.py.call f args kwargscalls any callable. - Function results. A translated function returns
base.Except(py.Value, py.Value).base.ok vis a normal return andbase.error epropagates an exception to the caller.py.callreceiveserror eand transfers it to its ownerrsuccessor.
Function shape. Every function the translator emits is built by
PyBuild.buildFuncTypedWith, which also returns the body's result; the translator returns its
own state through it (§4).
Its parameters are:
| Function | Parameters, in order |
|---|---|
a def, lambda, or class body |
captured cells base.Ref(py.Value)…, then args : py.Value (a tuple) and kwargs : py.Value (a dict) |
| the module body | none |
buildFuncTypedWith installs propagate.0(exc), which returns base.error exc. It is the
handler wherever no try or with encloses the code.
Entry block. In order: one declareLocal per local and cell variable of the scope
(§5.1), then the argument-binding prologue (§5.4), then the body. Every cell is declared in
the entry, so it dominates every use. A comprehension's iteration variables are the
exception (§6.8).
Function names. A Mantle module holds one Python module m, and is named m, one
segment per dotted component (a.b). Its functions are named under m, following the
scope's __qualname__. Function names and instruction names are separate namespaces, and a
function name prints with an @ prefix: @m.add is a function, py.add an instruction. A
segment in angle brackets cannot collide with a Python identifier; the printer quotes it, as
in @m.|<module>|. The translator implements the first, second and last rows (allocName).
| Source | Name |
|---|---|
| module body | m.<module> |
def f at top level, def g inside f |
m.f, m.f.g |
method meth of class C, body of C |
m.C.meth, m.C.<body> |
lambda |
m.f.<lambda>.k, where k counts per enclosing function |
| a name the module already uses | a numeric suffix: a second def f is m.f.1 |
Annotations. The translator wraps each statement and each expression in
withRange node.ann, which sets the builder's default annotation (withInfo). Everything the construct emits, including synthesized blocks and
prologue code, carries that source range.
Reachability. The builder has an open block when instructions can still be emitted
(Build.State.label is some label). A statement that ends the open block is return,
raise, break or continue, or a compound statement all of whose paths do so. After it,
the remaining statements of the same body are unreachable, and the translator skips them.
A join block is started only if some transfer targets it.
Rejection. The translator rejects any construct listed as unsupported, and, for now, any
construct it does not implement yet (§11). Rejection records a diagnostic at the construct's
range in Result.diagnostics, and translation continues so that every error is reported. A
result with a diagnostic is failed (Result.ok is false). A rejected expression becomes
py.unsupported applied to a py.strLit of the construct's name, which keeps the function
well formed. A rejected statement emits nothing. A rejected function (a generator or
async def) is emitted as a stub whose entry block returns py.unsupported. Consumers must
not analyse a failed result. A compile-time error that the scope pass reports (§5.1) is
handled the same way.
The translator runs in
TransM = ReaderT Ctx (StateT TState (PyM SourceRange)), with
PyM α = StateT Frame (BuildM Py.env α) below it. Build.State, the generic builder state,
holds value ids, labels, the open block and the default annotation. Frame holds the
emission state of the Python builder. Ctx is fixed while one function is translated, and
TState accumulates:
| State | Field | Exists? | Set by |
|---|---|---|---|
enclosing handler: the block every raising operation names as err |
Frame.handler : BlockValue |
yes | buildFuncTypedWith (propagate.0), withHandler and withHandlerTo (try, with, for) |
| locals: name ↦ cell | Frame.locals |
yes | declareLocal |
scope: the PyScope.Table and the current ScopeId, giving each name's kind (local, cell, free, globalExplicit, globalImplicit). Imports are Table.imports, a separate list of fully qualified targets |
Ctx.table, Ctx.scope |
yes | PyScope.analyze (§5.1), transFunc |
the Python module name, the Mantle module name, the names some scope binds as globals, the function's __qualname__ |
Ctx.module, Ctx.moduleName, Ctx.globals, Ctx.qualname |
yes | translate, transFunc |
| diagnostics | TState.diagnostics |
yes | reject |
| functions to emit after the current one, and the names taken | TState.pending : Array FuncJob, TState.funcNames |
yes | defStmt, allocName |
| exits stack (below) | TState.exits : Array Exit |
yes, loop only |
withExit: loops; later try/finally, with, except … as |
| the labels some emitted transfer names, for starting join blocks | TState.targeted |
yes | jump, branch |
exception being handled, for bare raise |
currentExc : Option ValId |
add | except bodies |
| namespace dict, inside a class body | classNs : Option ValId |
add | class body functions |
| default annotation | Build.State.info |
yes | withRange |
A def binds its name where it runs, and queues a FuncJob; translate emits the module
body first, then each queued function, in order.
The exits stack records every enclosing construct that a break, continue or return
must pass through, innermost last. Exit has the loop constructor; cleanup and unbind
are to add:
inductive Exit
| loop (brk cont : Label) -- break → brk, continue → cont
| cleanup (fin : Label) -- try/finally or with: fin takes a py.Completion
| unbind (cell : ValId) -- except … as e: clear e when leaving
The exit walk (exitWalk). An abrupt exit k (break, continue, or return v) is
emitted by walking the stack from the innermost entry outward:
| Entry | break |
continue |
return v |
|---|---|---|---|
loop brk cont |
jump ^brk(), stop |
jump ^cont(), stop |
skip |
cleanup fin |
%c = py.completionBreak, jump ^fin(%c), stop |
py.completionContinue, likewise |
py.completionReturn v, likewise |
unbind cell |
write undef to cell, keep walking |
same | same |
| stack empty | a diagnostic | a diagnostic | emitReturn v |
Exceptions do not use the walk. Frame.handler already names the right block at every
point, because each construct that intercepts exceptions installs its own handler.
Name resolution runs once per module, before any emission:
PyScope.analyze (StrataPython/Mantle/Scope.lean) returns a Table, matching CPython
3.12's symtable. The table has one Scope per module, function, lambda, class body,
generator expression and list, set or dict comprehension, in preorder. Each scope records its kind, __qualname__, parameters,
cell variables (cellVars), free variables (freeVars), and a Symbol per name with one of
five kinds:
| Kind | Meaning |
|---|---|
local |
bound in this scope (assignment, augmented assignment, for/with/except … as target, def, class, import, parameter, walrus, del) and not declared global or nonlocal. At module level a local is a module global |
cell |
a function's local that a nested scope uses. It is the same cell, captured |
free |
bound in an enclosing function, or declared nonlocal. It arrives as a captured cell parameter. A function between the binding and the use gets the name as free too, and passes the cell on |
globalExplicit |
declared global |
globalImplicit |
bound in no enclosing function: a module global or a builtin. Which one is decided at run time |
The pass also:
- skips class bodies when resolving names in nested scopes, as Python does;
- mangles private names in a class (
__xin classCis_C__x); symbols hold the mangled name; - sets
Scope.needsClassCellon a class body when a method reads__class__orsuper(any load of the name, not only a zero-argument call); - reports CPython's compile-time scope errors (
nonlocalwith no binding, a name used before itsglobaldeclaration, a duplicate parameter, the walrus restrictions in comprehensions,'yield' inside list/set/dict comprehensionand'yield' inside generator expression), and the compiler'skeyword argument repeated, assyntaxErrordiagnostics; - rejects relative imports,
import *,match,typestatements and type parameters withunsupporteddiagnostics; - lists every import in
Table.imports, separately from the symbols. EachImportholds the bound name, its fully qualified target (module "a.b"ormember "a.b" "x"), and the module the statement loads.
Comprehensions. A generator expression is a comprehension scope: a function whose one
parameter, .0, is the outermost iterable, evaluated by the enclosing scope. A list, set or
dict comprehension is an inlinedComprehension scope (PEP 709): a region of the enclosing
scope, which also evaluates its outermost iterable. Its symbols say how names resolve inside
it:
localorcell: an iteration variable, bound in the region apart from any binding of the same name outside it.Scope.regionLocalslists them.free: the name as the enclosing scope resolves it. In a class body this skips the class dict.globalExplicitorglobalImplicit: a global.
The enclosing scope also lists each name the comprehension uses that it does not already
have. A name the comprehension only reads keeps the enclosing scope's classification and
is not a cell. An iteration variable that a scope nested in the comprehension captures is
flagged comp_cell (Symbol.uses.compCell) in the enclosing scope. There it is a cell,
except in a class body, where it stays local.
A walrus target is the enclosing function's local, and a cell only if a nested scope
captures it; at module level it is a global. Lambdas and generator expressions nested in a
comprehension are children of the enclosing scope, with qualnames such as
f.<locals>.<lambda>.
The reference is CPython 3.12's symtable (3.13 gives the same output).
StrataPythonTestExtra/PyScopeTest.lean runs the pass over every program in
StrataPythonTest/Mantle/mantle_tests/, the translator's tests too, and compares it with
NAME.symtable, the program's symtable output, and NAME.expected.scope, a golden dump.
e01–e21 are programs with compile-time errors. p38 uses type statements, which the pass
rejects, so it has no symtable comparison.
Reading a name. The lowering depends on the name's kind and on the kind of scope it is read in:
| Kind, scope | Lowering | Exception if unassigned |
|---|---|---|
local, cell, free, in a function |
refGet the cell, then requireDefined |
UnboundLocalError: cannot access local variable 'x' where it is not associated with a value; for free, NameError: cannot access free variable 'x' … |
globalExplicit, globalImplicit, or local at module level; not a builtin name |
readGlobal m "x": refGet (globalCell m "x"), then requireDefined |
NameError: name 'x' is not defined |
the same, x a builtin name that some scope binds as a global |
refGet (globalCell m "x"), isDefined, then a branch. Defined: use the value. Undefined: py.qualifiedRef "builtins" "x" |
none |
the same, x a builtin name that no scope binds as a global |
py.qualifiedRef "builtins" "x" |
none |
| any kind, in a class body | §6.7 |
An imported name is read as any other name of its kind: the import statement wrote its cell
(§6.1). The builtin names are those of CPython 3.12's builtins module
(PyTranslate.builtinNames). The names some scope binds as globals are those the module
binds, those a function declares global and binds, and __name__ (moduleGlobals).
%12 : py.Value = base.refGet[py.Value] %3
%13 : base.String = const str "UnboundLocalError"
%14 : base.String = const str "cannot access local variable 'y' where it is not associated with a value"
%15 : py.Value = py.requireDefined %12 %13 %14 ^propagate.0()
The builtin fallback, for len read in a function:
%20 : base.Ref(py.Value) = py.globalCell %18 %19 -- "m", "len"
%21 : py.Value = base.refGet[py.Value] %20
%22 : base.Bool = py.isDefined %21
branch %22 ^join.0(%21) ^builtin.0()
builtin.0():
… -- %25 = py.qualifiedRef "builtins" "len"
jump ^join.0(%25)
join.0(%26 : py.Value):
requireDefined is emitted on every read. Removing redundant checks is a later pass.
Writing a name is refSet on its cell: writeCell for a function's cell, writeGlobal m "x" for a global or a module-level local. In a class body, writing is
py.setItem ns "x" v (§6.7).
The top-level statements become m.<module>, with no parameters. Every name the module
binds is a global: reads and writes go through globalCell m "x" (§5.1), and the body
declares no locals. Module-level def, class and import statements run in place and bind
their name when control reaches them. The function falls off the end with emitReturnNone.
__name__ is a global holding py.strLit m.
At the definition site, in source order:
- Evaluate the decorators, top to bottom.
- Evaluate each non-constant default value into a fresh default cell.
- Emit
const @m.f…andpy.mkClosure code cells…. The cells are the callee's free variables infreeVarsorder (§6.6), then its default cells. A top-level function has only default cells. - Apply the decorators bottom to top, each with
py.call d (mkTuple f) mkDict. - Write the result to the name's cell (a
lambdais an expression and has no name).
%20 : base.Code = const @m.outer.inner
%21 : py.Value = py.mkClosure %20 %3 -- %3: outer's cell for x
%22 : base.Unit = base.refSet[py.Value] %4 %21 -- inner = …
Annotations are not evaluated, as under Python 3.14's deferred annotations.
Python semantics to preserve: defaults are evaluated once, at definition time, in the enclosing scope. Free variables are bound late: a read sees the latest write to the cell.
A call passes one positional tuple and one keyword dict. Binding them to the source
signature is the callee's job, done by a branch-free prologue of ordinary instructions at
the top of the entry block. Each check raises, so the prologue stays in one block.
PyTranslate.prologue emits it. Each bound parameter is written to its cell, a
non-constant default is read from its default cell, and the raising checks name
Frame.handler as err. The translator implements constant defaults; it rejects
non-constant ones.
fill holds one slot per positional parameter: its default, or undef if it has none.
pad is the argument tuple padded to full length, so parameter i is pad[i] with no
bounds test. A parameter may arrive by keyword instead, so its value is
kwargs.get(name, pad[i]), one instruction and no branch; a positional-only parameter skips
that lookup, which excludes it from keyword matching. *rest and **kw are fresh, as in
CPython: **kw is kwargs with every named parameter discarded, leaving exactly the
unmatched keywords.
| Step | Lowering |
|---|---|
| fill | fill = mkTuple [default or undef "p", …], one slot per positional parameter |
| pad | pad = py.add args (getSlice fill (tupleLen args) None None) |
positional p at index i |
getItem pad i, then dictGet kwargs "p" ⟨that⟩, then dictDiscard kwargs "p". A positional-only parameter skips the keyword lookup |
*rest |
getSlice args maxPos None None |
keyword-only p |
dictGet kwargs "p" (default or undef), then dictDiscard |
**kw |
kwargs after all the discards |
| check 1 | given both positionally and by keyword: py.in, py.lt, py.mult, then requireAtMost … 0 |
| check 2 | an unexpected keyword (only without **kw): dictFirstKey kwargs (undef), then requireUndefined |
| check 3 | too many positional arguments (only without *rest): requireAtMost (tupleLen args) n |
| check 4 | a required parameter is missing: requireDefined |
The checks run in CPython's error-precedence order, 1 to 4, after every value has been
computed, so a call wrong in several ways reports what CPython reports. Check 1 tests keyword
membership before the dictDiscard that consumes the name. dictDiscard mutates kwargs in
place. That is safe because every call site builds a fresh dict (§6.3).
The prologue differs from CPython only in error wording:
- several missing parameters are reported one at a time, where CPython reports them together ("missing 2 required positional arguments: 'x' and 'y'");
- a keyword naming a positional-only parameter is reported as an unexpected keyword, where
CPython says "got some positional-only arguments passed as keyword arguments". With
**kwit is absorbed into**kw, as in CPython.
Evaluate the value (None if absent), then perform the exit walk (§4). If no cleanup entry
is on the stack, the walk ends in emitReturn v, which emits base.ok and then ret, the
jump to the function's exit.
| Statement | Lowering | Semantics to preserve |
|---|---|---|
e |
evaluate it, then discard the result | |
pass |
nothing | |
x = e, a = b = e |
evaluate e once, then assign it to each target left to right |
|
target x |
refSet on the cell (§5.1) |
|
target o.a |
evaluate o, then py.setAttr o "a" v |
Python evaluates the right-hand side first, then o |
target o[k] |
evaluate o and k, then py.setItem o k v |
right-hand side first, then o, then k |
target a, b / [a, b] |
t = py.unpackSeq v 2 (§8), then getItem t i for each target, recursively |
an exact length check, ValueError: not enough / too many values to unpack |
target a, *b |
py.unpackEx v before after (§8) |
|
x op= e |
read x once (evaluating o and k once for o.a and o[k]), apply the in-place operation (py.iAdd … py.iBitXor), write back |
in place: xs += ys mutates xs |
x: T = e |
as x = e. The annotation is not evaluated |
x: T with no value only makes x local |
del x |
requireDefined (raises NameError or UnboundLocalError), then refSet of undef "x" |
|
del o.a, del o[k] |
rejected until py.delAttr and py.delItem exist (§8) |
|
assert c |
as assert c, m, with AssertionError called on no arguments |
|
assert c, m |
c as a condition (§6.2). The true target continues; the false target evaluates m, then py.calls the AssertionError class on it, then raises (§7.1) |
c is tested once; m is evaluated only when the assertion fails; AssertionError is the class itself, as CPython's LOAD_ASSERTION_ERROR, even if builtins.AssertionError is rebound |
global x, nonlocal x |
nothing: the scope pass makes x globalExplicit or free |
|
| imports | below |
Imports. Each Import in Table.imports is lowered where its statement is, and writes
the bound name's cell: writeGlobal at module level, writeCell in a function.
| Statement | Lowering | Binds |
|---|---|---|
import a |
importModule "a" |
a |
import a.b.c |
importModule "a.b.c", then importModule "a" |
a, to the second result |
import a.b as c |
importModule "a.b" |
c |
from a.b import x as y |
importFrom "a.b" "x" |
y |
from . import x, import * |
rejected by the scope pass |
importFrom runs once, at the import statement, and the bound value is a snapshot: a later
assignment to a.b.x does not change y. a.b.f after import a.b is two py.attr reads
on the module value.
%9 : base.Bool = py.truthy %8 ^propagate.0()
branch %9 ^then.0() ^else.0()
then.0():
…
jump ^join.0()
else.0(): -- the else body, or empty
jump ^join.0()
join.0():
elif is a nested if in the else branch. Truthiness is py.truthy, which raises, because
__bool__ and __len__ are arbitrary code.
Conditions. Every place Python tests a value for truth lowers the test to branches, as
CPython's compiler_jump_if does (transCond): if/elif, while, x if c else y,
assert, a comprehension's if and, once supported, a match guard. not x swaps the targets, and/or
branch on each operand, x if c else y branches on c, and a chained comparison branches on
each link. Any other test is truthy of its value. So if (a and b) or c: tests a,
b and c at most once each, while the value (a and b) or c tests a twice when it is
false, as in CPython.
| Expression | Lowering |
|---|---|
1, 1.5, "s", b"s", True, None |
PyBuild.intLit etc.: a const and its boxing operation |
... |
py.qualifiedRef "builtins" "Ellipsis" |
| complex literal, t-string | rejected |
| name | §5.1 |
a op b |
evaluate a, then b, then py.add/sub/mult/matMult/div/floorDiv/mod/pow/lShift/rShift/bitAnd/bitOr/bitXor |
-a, +a, ~a, not a |
py.uSub, py.uAdd, py.invert, py.not |
a < b (single comparison) |
py.lt, …, py.in, py.notIn, py.is, py.isNot |
a < b < c |
evaluate a, b, then %r = py.lt. truthy %r, then branch: true evaluates c and compares b with c; false jumps to join(%r). b is evaluated once |
a and b / a or b |
see the example below |
x if c else y |
c as a condition (§6.2). Each side jumps to join(v) |
f(…) |
see the calls paragraph below |
o.a |
py.attr o "a". Inside a class, a is mangled, as in CPython (self.__a reads _C__a); a keyword argument name is not |
o[k] |
py.getItem o k |
o[i:j:s] |
py.getSlice o i j s, with None for an absent bound |
o[i:j, k] (a slice inside a tuple) |
py.mkSlice i j None, as CPython's BUILD_SLICE, then k, then mkTuple, then py.getItem |
(a, b), [a, b], {a, b} |
py.mkTuple / mkList / mkSet over the evaluated elements |
[a, *xs, b], {a, *xs, b} |
as CPython: mkList (mkSet) of the elements before the first *x, then listExtend (setUpdate) for each *x and listAppend (setAdd) for each later element |
(a, *xs, b) |
the list display, then listToTuple |
{k: v, **d} |
as CPython: py.mkDict k v … for each run of pairs (keys and values interleaved, each key before its value), and py.dictUpdate for each **d, last one winning. The first run is the dict; a later run is built, then added by dictUpdate |
| a set of more than 30 elements, a long dict run | as CPython (STACK_USE_GUIDELINE): a set starts empty and adds each element as it is evaluated (setAdd). A dict run is cut into chunks of 17 pairs; a chunk of more than 15 pairs starts empty and adds each pair (dictSet), and each later chunk is added by dictUpdate. So an unhashable key raises before the next element is evaluated |
f"a{x}b" |
as CPython's FORMAT_VALUE and BUILD_STRING: a total py.strConcat over strLit parts and fields, or the lone part itself. A field {x!r:spec} evaluates x, then spec (itself an f-string, "" if absent), then applies py.repr (py.str, py.ascii), then py.fmtValue x spec. None of these looks up a builtin: CPython ignores builtins.repr = … here |
(x := e) |
evaluate e, write it to x (§5.1), and use the value e. In a comprehension, the target is the enclosing function's local, a cell only if a nested scope captures it (§6.8) |
lambda |
§5.3, as an expression |
| comprehensions | §6.8 |
yield, await, a generator expression, a starred expression elsewhere |
rejected |
Short-circuit operators. A join block takes the expression's value as its single parameter. Expression temporaries are the only values the translator passes through block parameters.
%5 : base.Bool = py.truthy %4 ^propagate.0() -- a and b
branch %5 ^and.0() ^join.0(%4)
and.0():
… -- %8 = b
jump ^join.0(%8)
join.0(%9 : py.Value):
Calls. Evaluate the callee, then the positional arguments left to right, then the
keyword arguments, as CPython does. args is the tuple display of the positional arguments.
A lone *x, as in f(*x, k=v), is evaluated in place, but py.argsTuple f x makes it a
tuple after the keyword arguments, as CALL_FUNCTION_EX does: f(*g(), k=h()) calls h()
before it iterates g(). kwargs is built as a dict display is, but each run of k=v pairs is
a total py.mkKwargs, as its keys are strLits, and each **x is py.dictMerge f d other. Merging rejects duplicate keys, as a call must.
Then emit py.call f args kwargs. A method call o.m(x) is py.attr, then
py.call: binding the method is attr's job. kwargs is always a fresh dict.
%14 : py.Value = py.mkTuple %12
%15 : base.String = const str "k"
%16 : py.Value = py.strLit %15
%17 : py.Value = py.mkKwargs %16 %13
%18 : py.Value = py.call %11 %14 %17 ^propagate.0() -- f(x, k=y)
while c: B else: E:
jump ^head.0()
head.0():
… -- %4 = c
%5 : base.Bool = py.truthy %4 ^propagate.0()
branch %5 ^body.0() ^else.0() -- ^exit.0() without an else
body.0(): -- exits += loop(exit.0, head.0)
…
jump ^head.0()
else.0(): -- E
jump ^exit.0()
exit.0(): -- started only if targeted
for x in xs: B else: E:
%9 : py.Value = py.getIter %8 ^propagate.0()
jump ^head.0()
head.0():
%10 : py.Value = py.next %9 ^stop.0() -- its own successor
%11 : base.Unit = base.refSet[py.Value] %3 %10 -- x = …
… -- B, raising to ^propagate.0()
jump ^head.0()
stop.0(%12 : py.Value):
%13 : base.Bool = py.isStopIteration %12
branch %13 ^else.0() ^propagate.0(%12) -- anything else propagates
else.0():
jump ^exit.0()
exit.0():
py.next'serrsuccessor isstop, notFrame.handler. The translator emits that one instruction underwithHandler stop. Every operation in the body uses the enclosing handler. The loop's exhaustion edge and the body's exception edges are therefore different successors on different instructions, and neither can shadow the other.stopforwards anything that is notStopIterationto the handler in effect at theforstatement.- The target is assigned inside the loop, as §6.1 describes. A raising assignment uses the body's handler.
Bruns withloop(exit, head)pushed onto the exits stack.breakskipselse, andcontinuegoes tohead.
Loops are plain blocks. There is no transfer to the entry block, because head is always a
fresh block.
These perform the exit walk (§4). Inside a try with a finally (or a with) inside the
loop, the walk reaches the cleanup entry first, so control goes to fin(completionBreak)
and the finally body runs before the loop exits.
A nested def or lambda becomes a separate function of the module (§5.3). Its leading
parameters are the cells it captures, then its default cells. The captured cells are in
Scope.freeVars order: the free names the scope uses, in order of first use, then the
names it only passes on to nested scopes, sorted by name. The mkClosure at the definition
site passes the same cells in the same order.
Capture emits no extra instructions. The inner function reads and writes the outer
function's own cell, which gives nonlocal and late binding. mkClosure takes
base.Ref(py.Value) operands, so capturing a value is a type error. A const @m.f naming a
function the module does not define fails Module.WF.
class C(B1, B2, k=v): body lowers to
C = __build_class__(<closure of m.C.<body>>, "C", B1, B2, k=v)
The result of __build_class__ is applied to the decorators, then written to C's cell.
The bases and keywords are evaluated left to right after the body closure is created.
__build_class__ is py.qualifiedRef "builtins" "__build_class__". Its model calls the
body closure with the new namespace dict as the only positional argument.
m.C.<body> is an ordinary function. Its prologue binds ns from args[0], and
classNs (§4) holds ns. In the body, names are mangled (§5.1) and:
Kind of x |
Write | Read |
|---|---|---|
local |
py.setItem ns (strLit "x") v |
dictGet ns "x" (undef "x"), isDefined, branch; if absent, the global read of §5.1 (LOAD_NAME) |
globalImplicit |
the same as local |
|
globalExplicit |
writeGlobal |
the global read of §5.1 |
free |
writeCell (nonlocal) |
dictGet ns, then the captured cell if absent (LOAD_FROM_DICT_OR_DEREF) |
- Methods are closures (§6.6). They never capture class-scope names.
- The body returns
None. - The body does not yet store
__module__,__qualname__or__doc__intons, as CPython's class body does before the first statement (§9, Q9). - Reads use
dictGet, which is total and never calls__getitem__, while writes usepy.setItem. They disagree when__prepare__returns a mapping that is not adict, asenumdoes; CPython calls that mapping's__getitem__and__setitem__(§9, Q10).
Zero-argument super() and __class__ are rejected. The scope pass marks the class
with needsClassCell. The cell must be created by the body and filled by __build_class__
(§9, Q6). super(C, self) works.
List, set and dict comprehensions follow Python 3.12 (PEP 709): they are inlined into the
enclosing function. Each for clause is a py.forEach whose body is a region of that
function. No function is created.
py.forEach is planned (§8). The sketch, to be finalised:
insn forEach (iterable : Value) (&body (item : Value) : Unit) (^err (exc : Value)) : Unit
It iterates iterable, runs body once per item, and falls through when the iterator is
exhausted. An exception from iter or next goes to err. A region cannot name an outer
block, so the body cannot reach Frame.handler. As sketched, the body has no way to raise.
Finalising it means giving the body a result that carries an exception, such as
py.raising Unit, with error e forwarded to err.
[e for x in xs if c for y in ys] lowers to:
- Evaluate
xsin the enclosing scope, under the enclosing handler. acc = mkList(mkSet,mkDict).- One cell per iteration variable (
x,y):refNew (undef "x"). These cells belong to the comprehension. Inside it they shadow the enclosing scope's names, and they are not inFrame.localsafterwards. There is one cell per variable per evaluation, so closures created in the comprehension share it, as in CPython. forEach xs ^body ^err(H),Hthe enclosing handler. The body region, with its ownpropagatehandler and an empty exits stack:- writes
itemtox's cell; - for
if c:cas a condition (§6.2). Its false successor ends the region; - for
for y in ys: evaluatesysand emits a nestedforEachwhoseerris the region's handler; - innermost:
py.listAppend acc e(setAdd;dictSetwith the key evaluated before the value), then ends the region.setAddanddictSetraiseTypeErroron an unhashable element or key, to the region's handler.
- writes
- The expression's value is
acc.
- No completion crosses a region boundary: a comprehension contains no
return,breakorcontinue. - A walrus target is the enclosing function's local, which the region can see. It is a
cellonly if a nested scope captures it. At module level it is a global. - A
lambdaor generator expression inside the comprehension is a child of the enclosing scope: inf, its__qualname__isf.<locals>.<lambda>. - In a class body, a free name inside the comprehension skips the class dict and resolves as in the scope enclosing the class. The outermost iterable is evaluated by the class body and sees the class's names.
Generator expressions keep their own scope. They and generator functions (any yield) are
rejected. The planned route is an opaque py.mkGenerator over a lifted function (§8).
async def, await, async for, async with, match, try/except*, type aliases
and PEP 695 type parameters, yield and yield from, relative imports, and import *.
Each is rejected as §3 describes.
| Source | Lowering |
|---|---|
raise E |
v = eval E, e = py.toException v (§8: it instantiates a class and raises TypeError for a non-exception), then jump ^handler(e) |
raise E from C |
as above, plus py.setAttr e "__cause__" c. __suppress_context__ is not modelled |
bare raise inside except |
jump ^handler(currentExc) |
bare raise elsewhere |
rejected. Python raises at run time based on the dynamic exception state, which this translation does not model |
raise is a jump. A Python function raises by transferring to Frame.handler, which is a
block taking the exception. At the outermost level that block is propagate.0, which
returns base.error.
Write S for the exits stack and H for the handler in effect at the try statement.
Labels are allocated up front:
catch(exc), if there areexceptclauses;fin(pending : py.Completion)andfinRaise(exc), if there is afinally;join.
Let H' be finRaise if there is a finally, and H otherwise. Let S' be S with
cleanup fin pushed if there is a finally, and S otherwise.
| Part | Handler | Exits stack | Normal end |
|---|---|---|---|
try body |
catch if there are clauses, else H' |
S' |
jump ^else() if there is an else; otherwise as else would end |
catch(exc): the clause tests |
H' |
S' |
see the clause chain below |
clause body i |
H', or clear_i if as e |
S' + unbind e |
write undef to e, then fin(completionNormal) or join |
else body |
H'. The except clauses do not cover it |
S' |
fin(completionNormal) or join |
finRaise(exc) |
%c = completionRaise exc, jump ^fin(%c) |
||
fin(pending): the finally body, emitted once |
H |
S |
dispatch pending |
The clause chain in catch(exc), for each clause except T as e: evaluate T,
%m = py.excMatch exc T (§8), then branch %m ^body_i() ^next_i(). A bare except: jumps
straight to its body. If the last clause does not match, the chain ends with
jump ^H'(exc). In body i, currentExc is exc and e's cell holds exc. clear_i(x)
writes undef to e, then jump ^H'(x).
Dispatch is the end of fin. PyBuild.dispatch emits one py.Completion.case pending,
with five successors: normal, return, raise, break and continue. Each successor performs,
from S and H, what the pending completion would have done at the position of the try
statement:
| Successor | Action |
|---|---|
| normal | jump ^join() |
return v |
the exit walk for return v from S |
raise e |
jump ^H(e) |
| break / continue | the exit walk from S. With no enclosing loop, its block is unreachable |
When a successor's action is a single transfer, the successor targets that block directly,
for example propagate.0 for raise or exit.0 for break. Otherwise it gets a fresh block.
PyBuild.dispatchArms takes each successor as either: Arm.to l, or Arm.block base emit
for a fresh block. PyBuild.tryFinally emits the whole lowering: the try body under
protect fin, which makes finRaise, then fin, then the dispatch.
When the finally body exits abruptly. Its own exit replaces the pending completion,
as in CPython; Python 3.14 (PEP 765) only adds a SyntaxWarning for return, break and
continue there. When it raises while an exception is pending, CPython also sets the new
exception's __context__ to the pending one. So the target lowering protects the finally
body: a failure goes to a superseded(pending, exc) block, which tryFinally (superseded := true) emits and PyBuildTest's k checks, and which chains exc to pending before
propagating it. The chaining operation belongs to the run-time exception state (§9, Q2).
tryFinally's default, which runs the finally body under H with stack S, has the same
control flow without the chaining.
Why a completion. CPython 3.9 and later copy the finally body once per exit, with no
bound on the copies, and exponentially many when nested finally bodies themselves exit
abruptly; up to 3.8 it emitted one copy. Mantle emits the body once, at the cost of a
linear dispatch on the completion. V8 and Kotlin's coroutines dispatch on a token the same
way. The JVM's jsr/ret was abandoned because its targets were dynamic, which made
verification intractable; a completion's targets are all static.
Example: c1 (§10). The prologue is elided, and cleanup is read as a global.
entry.0(%0 : py.Value, %1 : py.Value):
…
%8 : base.Int = const int 1
%9 : py.Value = py.intLit %8
%10 : py.Completion = py.completionReturn %9 -- return 1 → exit walk
jump ^fin.0(%10)
finRaise.0(%11 : py.Value): -- the try body's handler
%12 : py.Completion = py.completionRaise %11
jump ^fin.0(%12)
fin.0(%13 : py.Completion): -- finally, emitted once
… -- cleanup(), ^propagate.0()
py.Completion.case %13 ^join.0() ^ret.0() ^propagate.0() ^unreachable.0() ^unreachable.1()
ret.0(%20 : py.Value):
%21 : base.Except(py.Value, py.Value) = base.ok[py.Value, py.Value] %20
ret %21
unreachable.0():
unreachable
unreachable.1():
unreachable
join.0():
… -- falls off: return None
with E as t: B, where H and S are as in §7.2:
- Evaluate
mgr = E. Thenenter = py.attr mgr "__enter__"andexit = py.attr mgr "__exit__", both underH. v = py.call enter () {}, underH.- Protected region: handler
wexc, stackS+cleanup fin. It assignst = v(an assignment that raises still runs__exit__), then runsB. A normal end isjump ^fin(completionNormal). wexc(exc):ty = py.call builtins.type (exc), thenr = py.call exit (ty, exc, None), both underH. Thenb = py.truthy randbranch b ^join() ^H(exc). A true result suppresses the exception, and a false result re-raises the original exception.fin(pending):py.call exit (None, None, None)underH, then dispatchpendingas in §7.2. The raise successor cannot be reached on this path, but it must exist, so it goes toH.
The exception path calls __exit__ with the exception and never builds a completion. Every
other path goes through fin, which calls __exit__ once. with a, b: B is
with a: with b: B. Python looks __enter__ and __exit__ up on the type and raises
TypeError if either is missing. py.attr looks them up on the instance (§9, Q5).
Operations the rules need that Py.env does not declare:
| Operation | Signature (DSL) | Needed by |
|---|---|---|
forEach |
insn forEach (iterable : Value) (&body (item : Value) : Unit) (^err (exc : Value)) : Unit, a sketch: the body's result must carry an exception (§6.8) |
comprehensions |
excMatch |
insn excMatch (exc type : Value) (^err (e : Value)) : Bool |
except T (the alternative is py.call builtins isinstance, then truthy) |
toException |
insn toException (val : Value) (^err (e : Value)) : Value |
raise E |
unpackSeq, unpackEx |
(val : Value) (n : Int) (^err …) : Value (a tuple); (val : Value) (before after : Int) (^err …) : Value |
tuple targets |
delAttr, delItem |
as setAttr/setItem without val |
del o.a, del o[k] |
mkGenerator |
insn mkGenerator (code : Code) (cells : Ref Value…) : Value |
generators (stage 1, opaque) |
Declarations to fix:
unsupportedtakes its construct name as apy.Value; the other name-carrying operands arebase.String.
Builder (PyBuild) and translator (PyTranslate):
- The scope and the exits stack live in the translator's state,
TransM(§4).Exitlackscleanupandunbind, and the state lackscurrentExcandclassNs. - The prologue is
PyTranslate.prologue(§5.4). It has no default cells. PyBuild.buildFuncTypedWithbuilds a function and returns its body's result.transFuncdeclares onlyargsandkwargs; captured cells before them are to add.Frame.handleris aBlockValue, so a handler can pre-bind arguments (withHandlerTo). UnderOverridenothing needs that; a chaining policy'ssupersededblock does.dispatchanddispatchArmstakeLabels. Direct successors that pre-bind arguments needBlockValues.tryFinally,protectanddispatchArmsrun their bodies in anyMonadPymonad, so the translator can passTransMbodies. The translator does not call them yet.- No
raiseTo exchelper (jump ^handler(exc)).exitWalkhandlesloopentries only. PyMhas no wrapper forBuild.region, whichforEachneeds (§6.8).
-
Regions. Statements use plain blocks plus completions. Regions for loops and
tryare a future refinement, and need no new completion machinery.Construct Now Refinement if,and/or,x if c else yblocks none while,forblocks a terminal py.loopop with a body region, shaped likedemo.loopinSuccTesttry,withblocks plus completions py.trywith body and handler regions returningpy.Completion, shaped likereg.tryinRegionTest. The outside dispatches the completioncomprehensions forEachregions (§6.8)decided A region cannot name an outer label, so
propagate.0, a loop'sexitand an outerfinare out of reach. Every abrupt exit from a region becomes a returned completion, with its own handler inside: §7 moved to a region boundary. -
Exception chaining in
finally. Thesupersededblock must set__context__, and thefinallybody must see the pending exception as the one being handled (sys.exc_info()). Both need the run-time exception state, which is not designed yet. -
Definedness. Should definedness stay as
py.undefvalues in cells, or becomeRef (Option Value)? Onlyundef,isDefined,requireDefinedandrequireUndefinedwould change. -
Module objects and imports. Resolved. A module value is the result of
py.importModule, written to the bound name's cell.from a import xispy.importFrom, evaluated once at the import, so the binding is a snapshot.a.b.fispy.attron the module value (§6.1). -
Special-method lookup.
with, and implicitlytruthy/getIter, look methods up on the type in CPython.py.attron the instance differs when an instance attribute shadows the method. -
__class__cell. Zero-argumentsuper()needs a cell created by the class body and filled by__build_class__. The scope pass setsneedsClassCellon a class when one of its methods loads the namesuperor__class__, whether or notsuperis called with no arguments.base.refNewcreates the cell andpy.mkClosurecaptures it, but no instruction turns abase.Ref(py.Value)into apy.Value, so the body cannot store it asns["__classcell__"]for__build_class__to fill. That needs such an instruction, a body that returns the cell, or an operation that installs it. -
Unpacking. Should unpacking use two operations (
unpackSeqandunpackEx), or one with an optional star position? -
Global fallback. Resolved. The scope pass classifies a module global and a builtin alike as
globalImplicit, and the split is made at run time: read the global cell, thenisDefined, thenqualifiedRef "builtins" nameif undefined (§5.1). -
Implicit class-body stores. CPython's class body first stores
__module__(the module's__name__) and__qualname__into the namespace, and__doc__if the body starts with a docstring. §6.7 should emit these aspy.setItems at the top of the body. -
Namespace reads. Class-body reads use the total
dictGetand writes usepy.setItem. A namespace from__prepare__that is not adictneeds reads through its__getitem__, a raising operation that treatsKeyErroras absent.
Each case is an exception edge that an earlier translator lowered wrongly while still
passing its well-formedness check. Each becomes a golden test, plus an interpreter check that
counts cleanup() calls. The repros are c1–c7 and t30_loop_try_nested in
StrataPythonTest/Mantle/mantle_tests/, listed in mantle_tests.txt as not yet supported.
| Case | Python | Wrong lowering | Fixed by |
|---|---|---|---|
| c1 | try: return 1 / finally: cleanup() |
the return skipped cleanup() |
§5.5 and §4: return walks to fin(completionReturn 1). The return successor returns |
| c2 | try: / for x in xs: return x / finally: cleanup() |
same, from inside the loop | the walk skips the loop entry and reaches the cleanup entry |
| c3 | for x in xs: / try: break / finally: cleanup() / return 0 |
break jumped straight past cleanup() |
§6.5: fin(completionBreak). The break successor targets exit |
| c4 | as c3, with continue |
continue skipped cleanup(), exhaustion re-entered the loop, and return 0 was dead |
§6.5: the continue successor targets head. §6.4: exhaustion is next's own ^stop |
| c5 | for x in xs: / try: cleanup() / finally: note() / return 0 |
the finally captured next()'s StopIteration, and the loop exit was dead |
§6.4: py.next ^stop is emitted outside the try's handler |
| c6 | for x in xs: boom() / return 0 |
boom()'s ValueError ended the loop normally |
§6.4: body operations raise to Frame.handler, never to stop, and stop re-raises anything that is not StopIteration |
| c7 | with acquire() as r: return r |
__exit__ was skipped |
§7.3: return walks to the with's fin, which calls __exit__(None, None, None), then the return successor returns |
| t30 case 6 | for m in xs: / try: raiser() / except StopIteration: note(9); continue / finally: cleanup() |
the back edge's vacuous exception edge left 21 blocks dead | §6.4 and §7.2: the clause's continue goes through fin, the back edge is a plain jump with no exception edge, and raiser()'s StopIteration reaches catch, not stop |
t30 nested_return |
for p in outer: / try: / for q in inner: / try: if check(q): return q / finally: note(12) / finally: cleanup() / return 0 |
neither finally ran, and there was no normal exit |
the inner fin's return successor walks from its own S, reaching the outer cleanup, so note(12) runs, then cleanup(), then the return. Exhausting outer reaches return 0 |
These checks apply to all of them. Module.WF holds. Every block is reachable from the
entry, except handler blocks that no operation names. No finally or __exit__ body is
emitted more than once.
Implemented marks what PyTranslate.translate lowers. It rejects every other construct with
a diagnostic.
| Construct | Section | Status |
|---|---|---|
module body; top-level def with positional, positional-only, keyword-only, *args and **kwargs parameters and constant defaults; prologue; return |
§5.2–§5.5 | specified; implemented |
nested def, lambda, decorators, non-constant defaults |
§5.3 | specified |
| name resolution (all five kinds, mangling, compile-time errors) | §5.1 | specified; implemented (PyScope.analyze, CPython 3.12 model) |
| local names | §5.1 | specified; implemented |
| cell and free names | §5.1 | specified |
global names, global, builtins |
§5.1 | specified; implemented |
import, from … import |
§6.1 | specified; implemented |
relative import, import * |
§6.1 | unsupported (rejected by the scope pass and the translator) |
expression statement, pass, assignment to a name (chained too) |
§6.1 | specified; implemented |
| assignment to an attribute or subscript | §6.1 | specified |
| tuple and starred targets | §6.1 | specified, open question (unpackSeq, Q7) |
| augmented assignment | §6.1 | specified; implemented on names |
| annotated assignment | §6.1 | specified; implemented on names |
del x, assert |
§6.1 | specified |
del o.a, del o[k] |
§6.1 | unsupported |
nonlocal |
§6.6 | specified |
if / elif / else |
§6.2 | specified; implemented |
literals, ..., every binary and unary operator, comparisons (chained too), and, or, x if c else y |
§6.3 | specified; implemented |
conditions (transCond) |
§6.2 | specified; implemented for if, while and x if c else y |
calls, with keywords, * and ** |
§6.3 | specified; implemented |
| displays and unpacking in them | §6.3 | specified; implemented |
| attribute, subscript, slice (inside a tuple too) | §6.3 | specified; implemented |
| f-strings | §6.3 | specified; implemented |
| walrus | §6.3 | specified; implemented outside comprehensions |
| complex literal, t-string | §6.3 | unsupported |
while / else, break, continue |
§6.4–§6.5 | specified; implemented |
for / else |
§6.4 | specified |
| closures | §6.6 | specified |
class, bases, keywords, decorators |
§6.7 | specified |
zero-argument super(), __class__ |
§6.7 | unsupported (Q6); the scope pass sets needsClassCell |
| list, set and dict comprehensions | §6.8 | specified, open question (forEach not declared; its body's result); scoping implemented (inlinedComprehension) |
generator expressions, yield |
§6.8 | unsupported; a generator function is a stub |
raise, raise … from |
§7.1 | specified, open question (toException) |
bare raise |
§7.1 | specified inside except; unsupported elsewhere |
try / except / else |
§7.2 | specified, open question (excMatch) |
finally |
§7.2 | specified, open question (Q2) |
with, several items |
§7.3 | specified, open question (Q5) |
async, await, match, except*, type, PEP 695 |
§6.9 | unsupported; an async def is a stub |