limitations/classes.md
Sandboxed Python code in Monty can define simple classes. A class
statement with instance methods, __init__, __eq__, __repr__/__str__,
and class variables works. The class body has a real scope (like CPython's
class-body code object), so class variables may be arbitrary expressions
and may reference earlier class variables:
class Foo:
count = 0
def __init__(self, a: int) -> None:
self.a = a
def bar(self) -> int:
return self.a * 2
def __repr__(self) -> str:
return f'Foo(a={self.a})'
See test_cases/class__basic.py and test_cases/class__repr.py.
The host can also construct dataclass and namedtuple values (using the
MontyObject API) and pass them in; those are a separate mechanism whose
methods dispatch back to the host (see test_cases/dataclass__basic.py).
Per the limitations/ convention this file documents only divergences from
CPython; the supported surface is summarized here just to bound what the
divergences below apply to. Working, CPython-matching features: instance
methods, __init__ (full parameter shapes), instance and class attribute
get/set (including setattr(Foo, ...) and function-attributes-become-methods),
bound methods, class variables (arbitrary expressions, evaluated in a real
suspendable class-body scope), class decorators (@deco class Foo),
__repr__/__str__/__enter__/__exit__/__eq__/__hash__ dispatch,
obj.__class__, Foo.__name__, Foo.__doc__/obj.__doc__,
Foo.__annotations__ (ordered; values stringized and provisional — see
typing.md), type(obj)/isinstance(obj, Foo), and the 3-arg
type() constructor. The
__enter__/__exit__ divergences are in with.md. Everything else
below is where Monty differs from or does not implement CPython behaviour.
type(name, bases, dict)The 3-arg type() form creates classes at runtime with CPython's validation
order and error wording, but with these divergences:
bases must be the empty tuple (). Any non-empty bases tuple — even
(object,) — raises TypeError: type() bases are not supported (the
runtime counterpart of the parse-time class Foo(Bar) rejection; no
inheritance).__init_subclass__; Monty has no __init_subclass__, but the error
message matches what object.__init_subclass__ produces
(A.__init_subclass__() takes no keyword arguments).__doc__ is synthesized into the namespace when absent (as None,
matching CPython). CPython also sets __module__, __qualname__,
__dict__, __weakref__, etc. — those attributes raise AttributeError
in Monty, as for compiled classes.TypeError
(non-string key (int) in the namespace of class 'A'). CPython accepts
them with only a RuntimeWarning; Monty has no warnings machinery, so it
raises rather than silently accepting.repr (no user __repr__) is <Foo object at 0x..> using the
bare class name, where CPython uses the qualified name
<module.Foo object at 0x..>.__init__/method argument-count errors name the method without the
class qualifier — e.g. __init__() missing 1 required positional argument: 'y', where CPython says Foo.__init__() missing ....type(obj) returns the class object (so identity works), but its own
repr is <class 'Foo'> with the bare name (CPython qualifies it).type instance. The bare name type
resolves to the builtin type function, not a type object, so
type(Foo) is type is False (CPython: True) and isinstance(Foo, type)
raises TypeError: isinstance() arg 2 must be a type, a tuple of types, or a union (CPython: True). There is no metaclass.function, not method. type(obj.method) is
<class 'function'> where CPython says <class 'method'> — Monty has no
dedicated method type.__lt__/__gt__/… is
not dispatched. a < b on instances of a class with no comparison dunders
raises TypeError: '<' not supported between instances of 'Foo' and 'Foo'
(matching CPython). A class that defines __lt__ etc. still raises — those
dunders are not dispatched (see the not-dispatched dunder list below).__repr__/__str__ cannot suspend: they are run to completion
synchronously, so a __repr__/__str__ that calls an external/OS function
raises rather than yielding to the host. __init__ and regular methods
can suspend on external/OS calls.__init__ can suspend. When __init__ is bound to
something else (a builtin, another class, a bound method, ...), it is called
with CPython's descriptor-binding semantics (no self prepended unless it is
a plain function) and CPython's None-return contract is enforced — but it
runs to completion synchronously, so it cannot yield to the host, and an
external-function __init__ raises NotImplementedError rather than
suspending.__eq__/__hash__ cannot suspend: like __repr__/__str__ they run to
completion synchronously, so one that calls an external/OS function raises
rather than yielding to the host. An exception raised by __eq__ terminates
the run instead of being catchable by a try around the comparison.__bool__/__len__ dispatch).obj.method access
creates a fresh object, so obj.method == obj.method is False and two
accesses hash differently. CPython compares/hashes bound methods by
(instance, func), making separate accesses equal.repr is the bare <bound method>; CPython renders
<bound method Foo.m of <__main__.Foo object at 0x..>>.Foo.__name__ stores an ordinary class member: unlike CPython
(where type.__name__ is a metaclass descriptor whose setter renames the
class), it does not rename the class, so Foo.__name__ reads and repr(Foo)
keep the original name while instances see the member.obj.__class__ stores an ordinary instance attribute rather
than reassigning the object's class. obj.__class__ = X then reads back X,
but type(obj) and isinstance still report the original class — an
internally inconsistent object. CPython either reassigns the class (for a
compatible class) or raises TypeError: __class__ must be set to a class, not '...' object.__repr__/__str__ raises RecursionError earlier than
CPython. A __repr__ (or __str__) that reprs self, or a deep chain of
instances whose reprs nest (e.g. a long linked list), re-enters the
interpreter on the native Rust call stack once per nesting level (unlike
ordinary Python-level recursion, which lives on a heap-allocated frame stack
and is bounded at 1000 by the normal recursion limit). To avoid a native
stack overflow (which would abort the process — fatal for the
in-process/wasm API, which shares the host process), this native re-entry is
capped independently at a much lower, fixed depth, raising a catchable
RecursionError once exceeded. The practical effect: infinite __repr__
recursion now raises RecursionError (matching CPython's outcome, though
not its exact depth), but a deep-but-finite chain that CPython's default
1000-frame limit would still successfully render may raise RecursionError
in Monty where CPython succeeds — a deliberate divergence traded for avoiding
native stack overflow. The same cap also applies to synchronous callback
evaluation such as map(), filter(), sorted()/list.sort(key=...),
min()/max(key=...), and exotic __init__ recursion (see
limitations/resource_limits.md's "Recursion" section).[n + offset for n in nums] referencing a class variable offset raises
NameError in CPython but succeeds in Monty).NotImplementedError at compile time ("class member 'x' that shadows a
captured variable of the same name from an enclosing scope") rather than
miscompiling. Distinct names work fine.pydantic_monty / @pydantic/monty)TODO: change dataclasses to class and use that.
A user-defined class object or instance has no faithful host representation.
When one is returned to a host caller as a run's result value, it is converted
to its repr() string, not a proxy or a value that preserves attributes:
result = session.feed_run('class A:\n x = 1\nA()')
# result is the str '<A object at 0x..>', NOT an object with `.x`
A (the class) and A() (an instance) both surface as their repr text (e.g.
"<class 'A'>" and "<A object at 0x..>"), so the host cannot read
attributes, call methods, or reconstruct the object. This is unlike the values
Monty does round-trip structurally (numbers, str/bytes, list/tuple/dict/set,
datetimes, and host-supplied dataclasses/namedtuples, which dispatch back to the
host). To return class data to the host, convert it inside the sandbox first —
e.g. return a dict of the fields.
class Foo(Bar): ... — no inheritance, no MRO, no super() (rejected at
parse time: "class inheritance and metaclasses"; the runtime equivalent
type('Foo', (Bar,), {}) raises TypeError, see above).__init_subclass__, __set_name__, and any other
metaclass-driven namespace customization.__slots__, descriptors (__get__ / __set__ / __delete__).abc.ABC, @abstractmethod).@classmethod, @staticmethod, @property, and any
decorator on a def inside a class body (rejected at parse time). Decorators
on classes and on non-method functions are supported.__dict__, __bases__ and dir()
are all unavailable (cls.__name__ and cls.__annotations__ work — the
latter with stringized values, see typing.md). A class decorator
can therefore discover fields and nothing else.class statement
(a span from the first decorator through the body, with the body elided as
...<N lines>...), where CPython pins the individual decorator that raised.
Every decorator in a stack reports that same location; only the callee frame
identifies which one raised.__init__, __repr__, __str__,
__enter__, __exit__, __iter__, __next__, __contains__, __eq__,
and __hash__: __new__, __call__, __getitem__, __setitem__,
__add__, __ne__, __bool__, etc. are not dispatched for user-defined
instances. __ne__ is always the negation of __eq__, as CPython derives it
by default, so a custom __ne__ is ignored.__iter__ / __next__ / __contains__ are dispatched, but like
__repr__/__str__ they run synchronously, so one that calls an external or
OS function cannot suspend and raises NotImplementedError. Two related
protocols are still not dispatched, so a class relying on either is not
iterable:
__getitem__-only fallback — CPython iterates a class defining
__getitem__ but not __iter__ from index 0 until IndexError, while
Monty reports it as not iterable. (Note monty -t accepts iter(obj) for
such a class, so this fails only at runtime — see iter.md.)__reversed__ — so reversed(obj) on any user instance raises
TypeError: '{cls}' object is not reversible. That matches CPython for a
class defining neither __reversed__ nor __len__ + __getitem__, and
diverges for one that does.__next__ is looked up on the class only, never the instance __dict__, and
a StopIteration raised anywhere inside it ends the iteration — including one
that propagates out of a nested call, where CPython's PEP 479 protections
apply only to generators, which Monty does not have.__contains__ returning a user instance is always True. The result is
coerced by Monty's truthiness, which reports every instance as truthy (see
above), where CPython's PyObject_IsTrue consults the returned object's
__bool__/__len__. Every other return type coerces as CPython does.__getattr__,
__getattribute__, __setattr__, __delattr__, and __del__. A missing
attribute always raises the default AttributeError even when the class
defines __getattr__, and attribute writes always go straight to the
instance __dict__.__name__, __doc__, __annotations__
and obj.__class__: Foo.__dict__, obj.__dict__, Foo.__bases__,
Foo.__mro__, Foo.__qualname__, Foo.__module__, and explicit
obj.__repr__() / obj.__str__() calls when the class defines none — all
raise AttributeError.def, a simple name [: T] = <expr>
variable assignment, pass, ..., or a docstring — e.g. if/for/while
in the class body, or tuple/multiple assignment targets (rejected at parse
time).:=) that bind in the class-body scope — in
class-variable values, method parameter defaults, and lambda parameter
defaults (rejected at parse time). In CPython the walrus target becomes a
class member (class C: x = (y := 5) gives C.y); Monty's class-namespace
assembly only records directly-assigned names, so the syntax is reserved
rather than silently dropping the binding. A walrus inside a lambda body
(f = lambda: (z := 1)) binds in the lambda's own scope and works. A walrus
in a comprehension in the class body is also rejected (CPython rejects that
too, but as a SyntaxError with different wording). A walrus in an
annotation (x: (y := int) = 5) runs in Monty — annotation expressions are
captured as source text (stringized) and never evaluated, so the walrus never
binds — where CPython raises SyntaxError. This one follows from annotations
never being evaluated, so it would change if they ever are (see
typing.md).del obj.attr (the del statement is unsupported generally).FrozenInstanceErrorRaised when assigning to a field of a frozen host-supplied dataclass.
Subclass of AttributeError — except AttributeError: catches it, as in
CPython's dataclasses module. (User-defined classes in the sandbox are
never frozen.)