PyVelox compiles a subset of Python source to VeloxVM bytecode that runs on the same VM as the Scheme and Cyclus front-ends. It targets IoT scripting, not CPython compatibility.
./pyvelox-compile hello.py # writes hello.vm
./pyvelox-compile -o program.vm src.py
./pyvelox-compile --dump fibonacci.py # inspect bytecodeFrom the repository root, ./compile.sh basic/hello and
./run.sh basic/hello drive the whole build and execute the result.
Programmatic access:
from pyvelox import compile_file, compile_string
compile_file("program.py", "output.vm")
bytecode = compile_string('print("Hello, World!")')When something is rejected at compile time you get a
PyveloxCompileError with path:line:col location and the offending
source line; the CLI prints the same and exits non-zero.
| Construct | Status | Notes |
|---|---|---|
int, bool, str, None |
Yes | None is encoded as False; they're indistinguishable at runtime. |
float |
Partial | Truncated to int unless the VM is built with VM_ENABLE_REALS. |
list |
Yes | Cons cells; mutating methods rebind the variable rather than updating in place. |
dict |
Yes | Association lists, O(n) lookup. |
tuple |
Yes | Stored as a list; the immutable distinction isn't enforced. |
set, frozenset |
No | No set type. |
bytes (b'...', bytes(...)) |
Yes | Backed by R7RS bytevector storage (buffer-flagged vector). bytes(b'...') shares storage instead of copying — observable only via vector-set!, which user Python can't reach. bytes(True) / bytes(False) is refused as ambiguous. |
bytearray |
No | The VM has no separate mutable-buffer type; refused at compile time. |
if/elif/else, ternary x if cond else y |
Yes | |
for, while |
Yes | |
break, continue |
Yes | Implemented via VM exception sentinels. |
def, lambda, return |
Yes | Closures and free-variable capture supported. |
Default arguments def f(x=10) |
Partial | Defaults must be literal constants (int, bool, str, None). On a module function they are resolved by call-site padding, so passing f as a value (e.g. map(f, xs)) doesn't carry the defaults. A method is reached through the runtime lookup, where no call site can pad, so its defaults ride on its own closure instead and hold on every path (obj.m(), Class.m(obj), super().m(), a splatted call). Lambdas with defaults are refused. |
*args (receive and forward) |
Yes | def f(*args) and lambda *args: lower to bind_function_rest; trailing actuals arrive as a list bound to args. f(*xs) and f(prefix, *xs) at call sites lower to (apply f arg-list). Combining *args with default arguments is refused, as are: multiple * arguments at one call site, positionals after *, and * arguments to built-ins or method calls. |
**kwargs, keyword-only, positional-only |
No | Refused at compile time. |
nonlocal, global |
Yes | Recognised by the scope analyser. |
try / except / raise |
Yes | Multiple except clauses with type filters: except SomeClass as e: matches if _pyvelox_isinstance(e, SomeClass). except: and except Exception: are catch-all aliases that absorb both the pyinstance and the legacy #(py-exception ...) shape. The class named in a typed clause must be defined earlier in the module; tuple filters (except (A, B):) aren't yet supported, and a catch-all clause that shadows later typed clauses is refused so unreachable code doesn't sneak in. raise X(args...) lowers as instance construction when X is a defined class (or Exception itself, which is auto-injected); otherwise as a tagged 3-vector. Handlers read e.type, e.args, str(e), f"{e}" the same way regardless of which shape was raised. raise e on a bound name re-raises the caught object unchanged. |
with (context managers) |
No | |
class |
Partial | class Foo: def __init__/methods and class Bar(Foo): ... lower to tagged class vectors. Instance construction Foo(args) runs __init__; classes without __init__ (in their own body or any ancestor) still construct. self.x = v / self.x / obj.method(args) work. super().method(args) walks from the enclosing class's parent. isinstance(obj, Cls) walks the class chain. Refused: multiple inheritance, forward-reference base classes, @property and other method decorators (@classmethod and @staticmethod are supported -- see the section below), class-level attributes, nested classes, dunder operator overloading (__add__ etc.), __getattr__ / __setattr__ / descriptors, metaclasses, bare super() standalone, super(Class, self) 2-arg form, isinstance with a tuple second arg. Method names that collide with _METHOD_HANDLERS (get, append, upper, etc.) are decided on the receiver's type at run time -- a class instance takes the user's method, anything else the built-in -- provided the receiver is a simple name. With any other receiver shape (f().append(x), self.xs.append(x)) the built-in handler still wins, since the two readings would otherwise have to re-evaluate the receiver. |
Comparison ops (<, <=, >, >=) |
Yes | Numeric only. |
==, != |
Yes | Type-aware deep equality (equalp). True == 1 is False because the VM keeps booleans and ints as distinct types. |
is, is not |
No | Refused at compile time. |
| Arithmetic / bitwise / boolean ops | Yes | / produces a rational, not a float. |
Augmented assignment (+=, -=, ...) |
Yes | Simple variable targets only. |
Multiple targets (a = b = 5) |
No | |
Tuple unpacking (a, b = pair, for x, y in ...) |
Yes | Simple names only. |
Slicing seq[start:stop] |
Yes | Lists, strings, regular vectors, and bytes. Negative indices supported. Bytes slicing returns a fresh bytes object. |
Slicing with step [::2] |
No | |
Plain negative indexing lst[-1] |
Buggy | Compiles but crashes at runtime. Use lst[len(lst)-1] for now. |
Subscript assignment lst[0] = v |
Buggy | Emits dict-style code; produces wrong list contents. Rebuild the list instead. |
Subscript assignment d['k'] = v |
Yes | Simple variable targets only. |
List comprehension [expr for x in xs] |
Partial | Single generator plus filters. Multiple generators refused. |
| Dict / set comprehensions | No | |
Generators (yield) |
No | |
f-strings f"x={x}" |
Yes | Lowers to string-append over str(). Format specs (:.2f) and conversions (!r, !s, !a) are refused. |
| Decorators | Partial | Bare @dataclass on a class, plus @classmethod and @staticmethod on methods (see @dataclass / class sections). Other class- and function-level decorators are still refused. |
import lib |
Yes | Loads a port-specific VM library by canonical name. import math is a compiler-level no-op so math.isqrt(...) works without needing the VM to define a math library. |
import x as y, from x import y |
No |
Status values: Yes supported, Partial supported with documented
gaps, No refused at compile time, Buggy currently silently
miscompiles (will be tightened to a refusal).
| Function | Status | Notes |
|---|---|---|
print |
Yes | Adds a trailing newline; sep/end kwargs ignored. |
len |
Yes | |
range |
Yes | 1–3 arguments, variable bounds. Small literal range(N) constant-folds; everything else uses a _pyvelox_range helper emitted into the program prologue. Step 0 would loop forever (Python raises). |
int, str, abs, min, max, sum |
Yes | int and str insert a small runtime type-dispatch for non-literal arguments. |
bytes |
Yes | bytes(), bytes(N) (N zero-filled), bytes([b1, b2, …]), bytes(b'...'). Variable arg routes through a numberp/bufferp runtime dispatch. |
bytearray |
No | Refused at compile time. |
isinstance |
Yes | Single class only -- isinstance(x, (A, B)) with a tuple is refused. Walks the class chain via eqp against the target. |
enumerate, zip, reversed, list |
Yes | |
map, filter, reduce, any, all |
Yes | Eager — return lists, not iterators. |
sorted |
No | Refused; sort manually until a VM-level sort is added. |
Method calls (only on simple variable receivers; expressions like
[1, 2].append(3) are refused):
| Receiver | Methods |
|---|---|
str |
upper, lower, casefold, split, join, startswith, endswith, strip, replace |
list |
append, extend, pop (no-arg), remove, reverse, count, index, insert |
dict |
keys, values, items, get |
Classes are tagged 4-vectors:
#(pyclass "Name" parent method-alist)
where parent is another class object or #f for a root class,
and method-alist is ((symbol-name . closure) ...). Instances
are tagged 3-vectors:
#(pyinstance class-ref slot-alist)
slot-alist starts empty and grows by cons-prepending new pairs as
self.x = v writes go through; existing slots are mutated via
set-cdr!. Both alists are looked up via assoc, so attribute
access is O(N) in the slot/method count -- fine for typical class
sizes.
_pyvelox_lookup_method walks the class chain through the parent
slot, so an inherited method is resolved without copying it into
each subclass's method-alist. super().method(args) reaches the
helper with the enclosing class's parent slot directly, so the walk
starts above the current class. isinstance(obj, Cls) is backed by
a similar walk against the parent chain.
Instance construction does not use that walk when it doesn't have
to. Classes are module-scope and their method sets are fixed once
the class definition finishes, so the compiler resolves which
__init__ a Foo(args) site runs and emits one of three shapes:
the bare instance vector, when no __init__ exists anywhere in the
chain and no arguments were passed; a call to
_pyvelox_make_instance_direct, which takes the resolved closure as
an argument; or _pyvelox_make_instance, which performs the runtime
lookup. The last is the fallback for the cases static resolution
can't cover -- a constructor call inside the class's own body (the
closure doesn't exist yet at that point), a @classmethod /
@staticmethod __init__ (the class stores a wrapper rather than
the closure itself), and cls(...) dispatched through
_pyvelox_invoke.
The direct shapes need no guard: the guard in _pyvelox_make_instance
exists only to turn "the lookup found no __init__" into "return the
instance unchanged", which is a compile-time answer on the resolved
paths.
The supporting runtime helpers (_pyvelox_make_instance,
_pyvelox_make_instance_direct, _pyvelox_lookup_method,
_pyvelox_get_attr, _pyvelox_set_attr, _pyvelox_isinstance,
_pyvelox_class_extends) are emitted lazily into the program
prologue when the first class definition or attribute access is
compiled.
@dataclass on a class with name: type annotated fields
synthesises an __init__(self, f1, f2, ...) that stores each
parameter into the matching slot via _pyvelox_set_attr.
Methods can coexist with fields. If the user provides their own
__init__, synthesis is skipped (matching CPython).
@dataclass
class Point:
x: int
y: int
def magnitude_sq(self):
return self.x * self.x + self.y * self.yPoint(3, 4) constructs an instance whose x and y slots are
populated from the constructor args; Vec.magnitude_sq(),
isinstance(p, Point), subclassing Point, etc. all behave like
they would on any class.
Field defaults work for trailing fields (CPython requires defaulted fields to follow non-defaulted ones, and pyvelox enforces the same ordering):
@dataclass
class Config:
host: str
port: int = 8080
debug: bool = FalseDefaults must be literal constants (int, str, bool, None),
matching the existing default-arg restriction on plain functions.
The synthesised __init__ is a fixed-arity closure taking every
field, and the construction site fills in the defaults for fields it
doesn't supply -- the same call-site padding a directly-named call to
a defaulted function gets.
When any field has a default, the class additionally carries a
variadic (self, *_args) wrapper that fills the missing tail in via
argc dispatch and forwards to that closure. It is what the method
alist holds, so callers that can't pad at compile time still get the
declared defaults: Foo(*args), cls(...) through
_pyvelox_invoke, and super().__init__(...).
A user-written __init__ that declares defaults is arranged the same
way, since it is a method like any other: the class carries the
padding wrapper, and construction sites that can resolve it
statically call the fixed-arity closure behind it directly.
Because the padding happens while compiling the construction, both
arity mistakes are compile-time errors there: passing more arguments
than the __init__ accepts, and leaving a parameter that has no
default unsupplied.
What's not supported: parameterised forms (@dataclass(eq=False, frozen=True, ...)), the field() config helper for
default-factory / kw-only behaviour, non-literal defaults like
x: list = [] or x: int = compute(). Type annotations
themselves are stored in the AST but otherwise ignored at
runtime, matching CPython.
A class body that has annotations but no @dataclass decorator
is rejected (CPython would silently treat them as class-level
attributes); the error message points at the missing decorator.
class MyError(Exception): pass (and arbitrarily nested
subclasses) work. The compiler auto-injects an Exception base
class into the program prologue the first time a class extends it
or raise Exception(...) is used. Exception's synthesised
__init__(self, *args) stores self.args = args and reads the
actual class name from the instance's class slot into self.type,
so subclasses without their own __init__ get the standard shape
for free, and subclasses that override __init__ can call
super().__init__(msg) to retain it.
raise X(args) on a defined class lowers as ordinary instance
construction, taking whichever of the three shapes above applies;
the resulting pyinstance is what the handler sees. Handler-side
e.args, e.type, str(e), and
f"{e}" work the same way they do for the legacy
py-exception tagged vector -- both shapes share the
_pyvelox_get_attr and _pyvelox_str runtime helpers.
isinstance(e, MyError) walks the class chain, so the usual
"check the kind" pattern works inside an except handler. Typed
exception handlers (except MyError as e:) are also supported
directly: each clause's type filter lowers to an
_pyvelox_isinstance check, and clauses chain via nested ifs that
fall through to a re-raise when nothing matches. Tuple filters
(except (A, B):) aren't yet supported; expand them into separate
clauses or use an except Exception: plus if isinstance(e, ...)
inside the body.
@classmethod and @staticmethod are recognised on methods. At
class-def time each method is wrapped (or not, for plain instance
methods) so all three kinds share the universal (recv args...)
calling convention -- the wrapper drops the receiver for
staticmethods, or substitutes the class object (via
_pyvelox_class_of) for classmethods. Both Class.method(args)
and obj.method(args) reach the right entry. cls(args) inside a
classmethod body lowers through _pyvelox_invoke, which dispatches
at runtime to either _pyvelox_make_instance (when cls is a
class) or a plain apply, so the standard alternative-constructor
pattern works for subclasses too.
What's deferred to later steps: multiple inheritance + MRO,
@classmethod / @staticmethod / @property, dunder operator
overloading (__add__ etc.), __getattr__/__setattr__ and the
descriptor protocol, metaclasses.
- Exceptions carry their constructor args.
raise ValueError("oops")is observable inside the handler ase.args == ["oops"],e.type == "ValueError",str(e) == "oops". Multi-arg exceptions returnargs[0]fromstr(e)-- a deviation from CPython, which formats the args tuple's repr.print(e)still prints the bare vector representation; wrap it asprint(str(e))orprint(e.args[0])to get the message.e.messagedoesn't exist (matching CPython 3, which removed it). - Lists are cons cells; method calls that look mutating actually rebind the variable to a new list. Aliases of the old list keep seeing the old contents.
- Dictionaries are association lists; lookup is linear. Fine for small configurations, not for hot paths.
- The
/operator produces a rational, not a float. Use//for integer division. - Embedded builds cap per-expression bytecode at 255 bytes; keep individual statements modest.
int(x),str(x), and f-string interpolation insert a runtime type-dispatch whenxisn't a literal. Cheap, but worth pulling out of hot loops.
Some module-style names are recognised by the compiler directly
rather than going through the VM's library loader, so they work
without needing a port library. The matching import statement
compiles to a no-op.
| Module | Members |
|---|---|
math |
isqrt(n) -- integer square root via Newton's method. Negative n raises ValueError. |
A local variable named math (or any other pseudo-module name)
shadows the syntactic dispatch -- if you write math = SomeObj(),
math.isqrt(n) falls back to regular instance-method dispatch on
that local. Other math.* names than the ones listed above are
refused at compile time.
tests/python-tests/— runnable test suite (compiler unit tests plus end-to-end programs).languages/python/pyvelox/translator.py— the AST-to-bytecode walker if you need to understand exact emission.