docs/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 send its own class instances in (wrapped in a
ClassInstance policy wrapper) and namedtuple values; those are a separate
mechanism whose method calls and lazy attribute lookups dispatch back to the
host, routed by the wrapper's uuid (see test_cases/dataclass__basic.py
and "Host class instances" below).
Listed 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.
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.__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__/__index__ 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, leaving 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. A native stack overflow
would abort the process, which is fatal for the in-process/wasm API sharing
the host process, so this native re-entry is capped independently at a much
lower, fixed depth, raising a catchable RecursionError once exceeded. So
infinite __repr__ recursion 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 render may raise
RecursionError in Monty. The same cap applies to synchronous callback
evaluation such as map(), filter(), sorted()/list.sort(key=...),
min()/max(key=...), and exotic __init__ recursion (see the "Recursion"
section of resource_limits.md).[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)A sandbox-defined class instance crosses out structurally: the host
receives a read-only MontyClassProxy with .name, .is_dataclass, .id
and .attributes (the instance __dict__, converted; the JS package spells
these .name / .isDataclass / .id / .attributes). The host cannot call
methods on it — the methods are defined only inside the sandbox, and the proxy holds
no live object. The instance and its class carry worker-generated uuids (stored
on the heap objects, so stable across crossings and dump/restore). Passing the
proxy back into the sandbox (as an input or an external-function result) hands
over the original object — back is foo and isinstance(back, Foo) hold —
with these divergences:
attributes are not applied on the way back: editing them
host-side does not change the sandbox object (only a still-live sandbox
object is resolved, and it keeps its own state).RuntimeError: invalid input type: sandbox instance of 'Foo' (id ...) no longer exists rather than materializing a
host-backed copy.attributes, not the host's original object.from pydantic_monty import Monty
with Monty() as pool, pool.checkout() as session:
result = session.feed_run(
'class A:\n def __init__(self):\n self.x = 1\nA()'
)
# result is MontyClassProxy(name='A', attributes={'x': 1})
A sandbox-defined class object (A itself) still has no structural host
representation and converts to its type text (e.g. "<class 'A'>"). A user
__repr__ is NOT consulted when an instance crosses the boundary — the host
gets the structured proxy, not the repr string.
ClassInstance wrapper)Host objects enter the sandbox only when explicitly wrapped in the host
package's ClassInstance policy wrapper (passing a bare dataclass or class
instance as an input raises MontyConversionError in Python, TypeError in
JS). Inside the sandbox they are proxies
whose eager attrs were copied at send time; everything else routes back to the
host by the wrapper's id uuid (never id() or any other address-derived
value). Divergences from real CPython objects:
type(x) returns a lightweight stand-in for the real class, since the
class itself stays on the host. The sandbox keeps one such object per host
class id: type(a) is type(b) holds for instances of one class,
x.__class__ returns it, and a ClassType passed as a value with the same
id resolves to it too (type(p) is Point); equality and hashing go by
class id alone. It names the real class (type(x).__name__ is 'Point',
repr is <class 'Point'> — without CPython's module qualification like
<class 'mymod.Point'>), and error messages name the real class too
(unhashable type: 'Point', 'Point' object is not subscriptable) — always
bare, so where CPython's message is module-qualified
('mymod.Point' object does not support the context manager protocol (missed __exit__ method))
Monty says 'Point'. But it is not the class: calling it suspends
a __call__ request to the host, which only succeeds when the host granted
init on a ClassType wrapper (see below); and — like Monty class objects
generally — it exposes __name__ plus any eager class attrs the host sent
(__module__, __qualname__, __doc__, __mro__, __bases__, ... raise
AttributeError, and so does __class__, which CPython answers with
type). Returned to the host, it resolves back to the real class object
when the class is registered in the session.isinstance(x, Point) matches by exact class id only: the host never
sends bases, so an instance of a subclass is not an instance of Point in
the sandbox, and issubclass does not exist.repr() shows all eager attrs in order (Point(x=1, y=2)). After
sandbox code sets a new attribute, that attribute appears in the repr too —
CPython's dataclass repr shows declared fields only.obj.attr, getattr() and
hasattr() — but not from inside a synchronous nested call the
interpreter makes itself (a __repr__, __eq__ or sort key invoked from
Rust): there the lookup cannot suspend, so the attribute reads as absent
(hasattr → False, getattr raises/returns the default). Underscore-
prefixed names never consult the host (dunder probes stay local).@property, a JS getter, the
wrapper's convert_value), and only a host AttributeError reads as
"absent". Any other host exception is raised inside the sandbox where the
read happened, and hasattr() / getattr(obj, name, default) do not
swallow it (CPython treats a raising property the same way). A value the
wire cannot carry raises TypeError: Cannot convert X to Monty value ...
in the sandbox, where CPython would return it.setattr does not affect the host object.allowed_methods='all' exposes only functions defined on the class
(its MRO is searched): a nested class, a callable stored as an attribute,
or any other non-function class attribute raises AttributeError when
called, where CPython would call it. An explicit set of names calls
whatever getattr returns. In JS, 'all' requires a function found on a
prototype below Object.prototype / Function.prototype, so toString(),
hasOwnProperty(), call(), bind() and the like are absent; and
constructor, __proto__, prototype, arguments and caller are
refused under every policy, an explicit list included.allowed_methods, m = x.greeting raises AttributeError and
hasattr(x, 'greeting') is False — only the call x.greeting(...)
reaches the host. If the name is also in lazy_attrs, the read crosses as
a host function proxy that is resolved by name through external_lookup
when called, not bound to the instance.__eq__ are not consulted. Host instances are
always unhashable — matching CPython's rule for a class defining
__eq__ without __hash__ — so a frozen dataclass that hashes in
CPython raises TypeError: unhashable type: '...' in the sandbox.setattr succeeds on the sandbox copy
of any host instance (the host object is never touched).dataclasses.fields() / asdict() do not work on host instances;
dataclasses.is_dataclass(x) returns the flag the host sent.a is b is False.module.qualname in
pydantic_monty.class_instance.type_id_cache, JS by class object — so
instances of the same host class compare equal by type across sessions in
one process. In a fresh process the ids differ unless pinned explicitly
(ClassType(..., id=...), or pre-seeding the cache) — required when
restoring a dump there. Because the Python key is the name, two distinct
class objects sharing a module.qualname (a class redefined in a notebook
cell, or built by a factory function) get the same default id, and sending
both into one session raises ValueError rather than silently aliasing
them — give one an explicit id. JS validates an explicit id as a
canonical uuid and stores it lowercased, so wrapper.id may differ in case
from the value passed. Sandbox-defined uuids live in the heap and survive
dump/restore.__bases__
raises AttributeError.init=True construction and each
convert_value wrap adds an entry to the host-side instance store that
max_memory does not count; re-sending a wrapper with the same id
overwrites its entry rather than adding one. See the class-instance store
note in pool-architecture.md.ClassType wrapper)A host may pass a bare class into the sandbox with the ClassType policy
wrapper — ClassInstance's sibling, applied to the class object itself:
eager_attrs sends class constants with the type, lazy_attrs serves them
on demand, and allowed_methods exposes classmethods/staticmethods (calls
and lazy lookups route to the host by the class uuid, exactly like instance
routing). With init=True (pydantic_monty.ClassType(Point, init=True); JS
new ClassType(Point, { init: true })) sandbox code can also call the
class; the construction crosses as a __call__ method call, runs
host-side, and the constructed instance crosses back wrapped with the
wrapper's instance_* policies (instance_eager_attrs, instance_lazy_attrs,
instance_allowed_methods; JS instanceEagerAttrs, ...). init is purely
host-side policy — it never crosses the wire, and the wrapper checks it on
every construction request. Divergences:
AttributeError: type object 'Point' has no attribute 'x'). Like
instance attrs, only Type.attr syntax consults the host, underscore
names stay local, and lazy class lookups are not cached.allowed_methods on a ClassType exposes classmethods and
staticmethods only, under 'all' and an explicit set alike: calling an
instance method through the class (Person.greet(other)) raises
AttributeError: type object 'Person' has no attribute 'greet', where
CPython would pass other as self. In JS, 'all' exposes the class's
own static functions, none inherited from a base class.init absent or false, calling the class raises
TypeError: cannot instantiate host class 'Point' (CPython would
construct). That includes type(x)() on a plain ClassInstance: sending
an instance registers a default ClassType for its class, with init
false.RuntimeError, lazy class attrs raise
AttributeError, and a host class crossing back to the host (as a value,
or as type(x)) is a read-only MontyClassTypeProxy (name, id,
is_dataclass, attributes) rather than the original class; passing the
proxy back in re-enters as the same sandbox type object. In JS an
unregistered class comes back as a plain {__monty_type__: 'Type', ...}
marker; a registered one resolves to the class object.__proto__
keyword is dropped from that bag.is_dataclass flag.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__,
__hash__, and __index__: __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.__index__ is dispatched for indexing, but not for arithmetic
operators. A class defining it works as a subscript read (seq[obj]), as
a slice bound (seq[obj:], slice(obj)), and as an integer argument
(range(obj), 'x'.center(obj), s.find(sub, obj)). It is not
consulted by sequence repetition, so 'ab' * obj and [0] * obj raise
TypeError: unsupported operand type(s) for * where CPython repeats — each
numeric operator carries its own coercion, which does not route through the
shared index path.__index__. lst[obj] = x raises
TypeError: list indices must be integers or slices, not Foo where CPython
coerces and assigns; only the read side takes the index path.slice() stores coerced bounds, not the objects passed. CPython's
slice() keeps its arguments untouched and only calls __index__ when the
slice is used, so slice(obj).start is obj; Monty coerces during
construction, so it is the resulting int. A bound whose __index__ raises
therefore raises at slice(...) rather than at use, and one that is neither
None, an int, nor __index__-able is rejected up front instead of on
first use.i64. Because bounds are coerced at
construction (above), one beyond i64 is clamped to i64::MIN/i64::MAX
rather than kept exact: slice(10**30).stop is 9223372036854775807, where
CPython reports 10**30. Slicing with such a bound still matches CPython —
it clamps to the sequence either way, so [1, 2, 3][10**30:] is [] — the
divergence is only visible by reading the attribute back. This applies to
bounds written as literals and to those returned by __index__ alike. Plain
indexing is unaffected: [1, 2, 3][10**30] raises IndexError as CPython
does.__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. The error is
raised at the offending call inside the callee, so a try/except there
catches it like any other exception. 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. (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__. object.__setattr__ exists (see below) and, since
there are no hooks to skip, differs from a plain obj.x = v only on a
@dataclass(frozen=True) instance, which it writes to and obj.x = v
refuses — the same escape hatch CPython's generated __init__ uses. On a
class object it does not write at all, where Foo.x = v sets a class member.__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, since annotation
expressions are captured as source text (stringized) and never evaluated, so
the walrus never binds; CPython raises SyntaxError. This 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).objectThe name resolves, but it is a carrier for object.__setattr__ rather than a
type: Monty has no inheritance, so there is no base class for it to be.
isinstance(x, object) is True for every value, as in CPython.
object() cannot be constructed — raises TypeError: cannot create 'object' instances, where CPython returns a
featureless instance.class Foo(object): is still rejected, like any base list (see above),
so the idiom carries no more weight than class Foo:.__setattr__ and __name__ resolve. Every other member CPython's
object carries — __doc__, __init__, __eq__, __getattribute__,
__class__, __mro__, __bases__, __qualname__, __module__,
__dict__ — raises AttributeError, with Monty's generic 'type' object has no attribute 'x' where CPython says
type object 'object' has no attribute 'x'.object.__setattr__ accepts only instances of sandbox-defined classes.
Anything else raises CPython's
AttributeError: '<type>' object has no attribute '<name>' and no __dict__ for setting new attributes — including
a class object, where CPython instead raises TypeError: can't apply this __setattr__ to type object.<built-in function object.__setattr__>, where CPython says
<slot wrapper '__setattr__' of 'object' objects>.FrozenInstanceErrorRaised when assigning to a field of a dataclass declared in the sandbox with
@dataclass(frozen=True) (see dataclasses.md); host-supplied instances
are never frozen in the sandbox (see "Host class instances" above). Subclass
of AttributeError, so except AttributeError: catches it, as in CPython's
dataclasses module. A plain class is never frozen, and
object.__setattr__ writes past the check either way.