Constructs¶
Sonolus.py functions as a compiler from Python to Sonolus nodes. While most standard Python constructs are supported, there are some limitations compared to standard Python. The following sections outline what Sonolus.py supports and how it differs from standard Python.
Key Differences¶
- Non-num variables must have a single live definition.
- If there are multiple definitions
var = ...for a variable, the compiler must be able to determine that a single one is active whenever the variable is used.
- If there are multiple definitions
- Conditional branches may be eliminated if they are determined to be unreachable
- Functions with non-num return types may not return multiple distinct objects
- Most functions returning a non-num value should have a single return at the end
- Destructuring assignment does not support the
*operator. - Sequence
matchpatterns do not support the*operator. - Mapping
matchpatterns are unsupported. - Match patterns cannot match the literals
TrueorFalsedirectly; use1and0instead. - Imports may not be done within functions
- The
globalandnonlocalkeywords are unsupported. - List literals (
[1, 2, 3]) are unsupported; use a tuple orArrayinstead. - List, set, and dict comprehensions are unsupported; only generator expressions are supported.
- Exception statements (
try,except,finally,raise) andwithstatements are unsupported. - F-strings and slices are unsupported.
- The bitwise operators (
&,|,^,<<,>>,~) are not supported forNum, since the Sonolus runtime has no bitwise operations. They are available only for types that define them, such asInterval. isandis notare only supported againstNone.
Supported Constructs¶
The following constructs are supported in Sonolus.py:
- Expressions:
- Literals:
- Numbers (excluding complex numbers):
0,1,1.0,1e3,0x1,0b1,0o1 - Booleans:
True,False - Strings:
'Hello, World!',"Hello, World!" - Tuples:
(1, 2, 3) - Dicts (keys must be compile-time constants):
{1: 'a', 2: 'b'} - Sets (members must be compile-time constants):
{1, 2, 3}
- Numbers (excluding complex numbers):
- Operators (if supported by the operands):
- Unary:
+,-,not, and~for types implementing it - Binary:
+,-,*,/,//,%,**, and&,|,^,<<,>>for types implementing them - Comparison:
==,!=,>,<,>=,<=,in,not in, andis/is notagainstNone - Logical:
and,or(forNumarguments only) - Ternary:
a if <condition> else b - Attribute:
a.b - Indexing:
a[b] - Call:
f(a, b, c)
- Unary:
- Variables:
a,b,c - Lambda:
lambda a, b: a + b(if not on the same line as another lambda or function definition) - Assignment Expression:
(a := b) - Generator Expression:
(x for x in iterable if condition)
- Literals:
- Statements:
- Simple Statements:
- Assignments:
- Simple assignment:
a = b - Augmented assignment:
a += b - Attribute assignment:
a.b = c - Index assignment:
a[b] = c - Destructuring assignment:
a, b = b, a - Multiple assignment:
a = b = c = 1 - Annotated assignment:
a: int = 1(a bare annotation,a: int, binds nothing, though for a target likea.bora[i]the target expression is still evaluated, as in standard Python)
- Simple assignment:
- Assert:
assert <condition>, <message> - Delete:
del a[b](subscript targets only) - Pass:
pass - Break:
break - Continue:
continue - Return:
return <value> - Yield:
yield <value>,yield from <iterable> - Import:
import <module>,from <module> import <name>(only outside of functions)
- Assignments:
- Compound Statements:
- If:
if <condition>:,elif <condition>:,else: - While:
while <condition>:,else: - For:
for <target> in <iterable>:,else: - Match:
match <value>:,case <pattern>:,case <pattern> if <guard>: - Function Definition:
def <name>(<parameters>): - Class Definition:
class <name>:(only outside of functions)
- If:
- Simple Statements:
Compile Time Evaluation¶
Some expressions can be evaluated at compile time:
- Numeric literals:
1,2.5,True,False, ... - None:
None - Basic arithmetic: for compile-time constant operands:
a + b,a - b,a * b,a / b, ... - Is/Is Not None: for any left-hand operand,
a is None,a is not None - Type checks:
isinstance(a, t)for any value, andissubclass(a, t)where both arguments are compile-time types - Boolean operations:
- Negation:
not a - And
- Both operands are compile-time constants:
a and b - One operand is known to be False:
False and a,a and False
- Both operands are compile-time constants:
- Or
- Both operands are compile-time constants:
a or b - One operand is known to be True:
True or a,a or True
- Both operands are compile-time constants:
- Negation:
- Comparison: for compile-time constant operands:
a == b,a != b,a > b,a < b,a >= b,a <= b, ... - Variables assigned to compile-time constants:
a = 1,b = a + 1, ...
Some values like array sizes must be compile-time constants.
The compiler will eliminate branches known to be unreachable at compile time:
def f(a):
if isinstance(a, Num):
debug_log(a)
else:
debug_log(a.x + a.y)
# This works because `isinstance` is evaluated at compile time and only the first (if) branch is reachable.
# The second (else) branch is eliminated, so we don't get an error that a does not have 'x' and 'y' attributes.
f(123)
Variables¶
Variables can be assigned and used like in vanilla Python.
a = 1
b = 2
c = a + b
Unlike vanilla Python, non-num variables must have a single live definition when used. Nums have no such restriction.
The following are allowed:
v = Vec2(1, 2) # (1)
v = Vec2(3, 4) # (2)
debug_log(v.x + v.y) # 'v' is valid because (2) is the only active definition
v = 1 # (1)
v = Vec2(3, 4) # (2)
debug_log(v.x + v.y) # 'v' is valid because (2) is the only active definition
v = Vec2(1, 2) # (1)
while condition():
v = Vec2(3, 4) # (2)
debug_log(v.x + v.y) # 'v' is valid because (2) is the only active definition
v = Vec2(1, 2) # (1)
if random() < 0.5:
v @= Vec2(3, 4) # Updates 'v' in-place without redefining it
debug_log(v.x + v.y) # 'v' is valid because (1) is the only active definition
The following are not allowed:
v = Vec2(1, 2) # (1)
if random() < 0.5:
v = Vec2(3, 4) # (2)
debug_log(v.x + v.y) # 'v' is invalid because both (1) and (2) are active
v = Vec2(1, 2) # (1)
while condition():
debug_log(v.x + v.y) # 'v' is invalid because (1) and (2) are active
v = Vec2(3, 4) # (2) redefines 'v' for future iterations
Expressions¶
Literals¶
int, float, bool, str, tuple, dict, and set literals are supported. dict keys and set members
must be compile-time constants:
a = 1
b = 1.0
c = True
d = 'Hello, World!'
e = (1, 2, 3)
f = {1: 'a', 2: 'b'}
g = {1, 2, 3}
Operators¶
All standard operators are supported for types implementing them. @= is reserved as the copy-from operator.
a = 1 + 2
b = 3 - 4
c = 5 * 6
d = 7 / 8
e = Vec2(1, 2)
f = e.x + e.y
g = Array(1, 2, 3)
h = g[0] + g[1] + g[2]
(i := 1)
The ternary operator is supported for any type, but if the operands are not Num values, the
two branches must produce the same object, or a type that supports merging such as
Maybe. Otherwise the condition must be a compile-time constant, or this is
considered an error:
# Ok
a = 1 if random() < 0.5 else 2
b = Vec2(1, 2) if b is None else b
# Not ok
c = Vec2(1, 2) if random() < 0.5 else Vec2(3, 4) # Multiple definitions
If the condition is a compile-time constant, then the ternary operator will be evaluated at compile time:
e = Vec2(0, 0) if e is None else e # Ok, evaluated at compile time
Statements¶
Assignment¶
Most assignment types are supported. Destructuring assignment is supported only for tuples, and the *
operator is not supported.
# Ok
a = 1
b += 2
c.x = 3
d[0] = 4
(e, f), g = (1, 2), 3
# Not ok
h, *i = 1, 2, 3 # Not supported
Conditional Statements¶
The standard conditional statements are supported.
if / elif / else¶
if a > 0:
...
elif a < 0:
...
else:
...
When the condition is a compile-time constant, the compiler will remove the unreachable branches:
v = None
if v is None:
v = Vec2(1, 2)
debug_log(v.x + v.y)
v = None
# The 'if' branch is always taken
v = Vec2(1, 2)
debug_log(v.x + v.y)
This is useful for handling optional arguments and supporting multiple argument types:
def f(a: Vec2 | None = None):
if a is None:
a = Vec2(1, 2)
debug_log(a.x + a.y)
def f(a: Vec2 | int):
if isinstance(a, Vec2):
debug_log(a.x + a.y)
else:
debug_log(a)
match / case¶
The match statement is supported for matching values against patterns. All patterns, including subpatterns,
are supported except mapping patterns, sequences with the * operator, and the singleton patterns case True:
and case False: (use case 1: and case 0: instead).
Records have a __match_args__ attribute defined automatically, so they can be used with positional subpatterns.
match x:
case 1:
...
case 2 | 3:
...
case Vec2() as v:
...
case (a, b):
...
case Num(a):
...
case _:
...
As with if statements, the compiler will remove unreachable branches when the value is a compile-time constant:
v = 1
match v:
case Vec2(a, b):
debug_log(a + b)
case Num():
debug_log(v)
case _:
debug_log(-1)
v = 1
# 'case Num()' is always taken
debug_log(v)
Loops¶
while / else¶
While loops are fully supported, including the else clause and the break and continue statements.
while a > 0:
if ...:
break
if ...:
continue
...
else:
...
for / else¶
For loops are supported, including the else clause and the break and continue statements.
Custom iterators must subclass SonolusIterator.
for i in range(10):
if ...:
break
if ...:
continue
...
else:
...
Tuples can be iterated over and result in an unrolled loop. This can be useful for iterating of objects of different, types, but care should be taken since it results in more code being generated compared to a normal loop:
for i in (1, 2, 3):
debug_log(i)
debug_log(1)
debug_log(2)
debug_log(3)
Functions¶
Functions and lambdas are supported, including within other functions:
def f(a, b):
return a + b
def g(a):
return lambda b: f(a, b)
Closures¶
Nested functions and lambdas can read variables from an enclosing function. Unlike default argument values, which are evaluated once when the function is defined, a captured variable is looked up using the enclosing scope's current value each time the closure runs:
def f():
x = 1
def g():
return x
debug_log(g()) # 1
x = 2
debug_log(g()) # 2
Since the global and nonlocal keywords are unsupported, a closure cannot rebind a name from an enclosing
scope, but it can mutate a captured Record in place:
def f():
p = Pair(1, 2)
def increment():
p.first += 1
increment()
debug_log(p.first) # 2
A captured variable is subject to the same single-live-definition rule as any other variable.
Return Values¶
Function returns follow the same rules as variable access. If a function returns a non-num value, it must only
return that value. If the function always returns a num, it may have any number of returns. Similarly, if a function
always returns None (return None or just return), it may have any number of returns.
The Maybe type is also an exception; see the
Maybe documentation for details.
The following are allowed:
def f():
return Vec2(1, 2)
def g(x):
# Only one return is reachable since isinstance is evaluated at compile time
if isinstance(x, Vec2):
return Vec2(x.y, x.x)
else:
return x
def h(x):
# Both returns return the exact same value
x = Vec2(1, 2)
if random() < 0.5:
debug_log(123)
return x
else:
return x
def i(x):
# All return values are nums
if random() < 0.5:
return 1
return 2
The following are not allowed:
def j():
# Either return is reachable and return different values
if random() < 0.5:
return Vec2(1, 2)
return Vec2(3, 4)
def k():
# Both the return and an implicit 'return None' are reachable
if random() < 0.5:
return Vec2(1, 2)
Outside of functions returning None or a num, most functions should have a single return statement at the end.
Generators¶
A function containing yield is a generator function. Calling it returns an iterator that can be used with for
or other functions that accept an iterator:
def gen():
yield 1
yield 2
yield 3
for x in gen():
debug_log(x)
Generators are lazy: code before the first yield does not run until the first value is requested. Yielded
values follow the same single-live-definition rule as function return values, and a generator function's return
statements must not return a value.
Reusing iterators¶
Treating an iterator as single use is recommended: consume it once, and build a fresh one if the values are needed again.
Advancing an iterator that is already being consumed, by nesting two loops over it or mixing next with a for
loop, is not supported. Neither is consuming one a second time after it has
been exhausted. Use copy if a value taken from an iterator needs to outlive the
next advance. This is an area which diverges from normal Python behavior. Otherwise, values obtained previously from
an iterator may unexpectedly change when the iterator is advanced.
Classes¶
Classes are supported at the module level. User defined classes should subclass Record or have a supported
Sonolus.py decorator such as @level_memory.
Methods may have the @staticmethod, @classmethod, or @property decorators.
class MyRecord(Record):
x: int
y: int
def regular_method(self):
...
@staticmethod
def static_method():
...
@classmethod
def class_method(cls):
...
@property
def property(self):
...
Imports¶
Imports are supported at the module level, but not within functions.
assert¶
Assertions are supported. Assertion failures terminate the current callback.
When runtime checks are disabled (the default in production builds), an assertion on a runtime-dependent
condition is skipped and must not be relied upon. An assertion on a condition that is a compile-time constant
known to be false, such as assert False, is always kept and still terminates the callback, so it can be used
to mark unreachable code.
assert a > 0, 'a must be positive'
del¶
del a[b] is supported for types implementing __delitem__, such as
VarArray and ArrayMap.
Deleting a variable (del a) or an attribute (del a.b) is not supported.
pass¶
The pass statement is supported.