Classes
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,
__call__, __getitem__/__setitem__, unary +/-, and binary numeric operators
(including @, divmod() and pow()) with their reflected forms,
obj.__class__, Foo.__name__, Foo.__doc__/obj.__doc__,
Foo.__annotations__ (evaluated lazily, see typing.md),
type(obj)/isinstance(obj, Foo), and the 3-arg
type() constructor. The __enter__/__exit__ divergences are in
with.md.
The 3-arg type() form creates classes at runtime with CPython’s validation
order and error wording, but with these divergences:
basesmust be the empty tuple(). Any non-empty bases tuple, even(object,), raisesTypeError: type() bases are not supported, the runtime counterpart of the parse-timeclass Foo(Bar)rejection.- Keywords are always rejected. CPython forwards extra keywords to
__init_subclass__; Monty has no__init_subclass__, but the error message matches whatobject.__init_subclass__produces (A.__init_subclass__() takes no keyword arguments). - Only
__doc__is synthesized into the namespace when absent (asNone, matching CPython). CPython also sets__module__,__qualname__,__dict__,__weakref__, etc.; those attributes raiseAttributeErrorin Monty, as for compiled classes. - Non-string namespace keys raise
TypeError(non-string key (int) in the namespace of class 'A'). CPython accepts them with only aRuntimeWarning; Monty has no warnings machinery, so it raises rather than silently accepting.
- Default
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 saysFoo.__init__() missing ....type(obj)returns the class object (so identity works), but its ownrepris<class 'Foo'>with the bare name; CPython qualifies it.- The class object is not itself a
typeinstance. The bare nametyperesolves to the builtintypefunction, not a type object, sotype(Foo) is typeisFalse(CPython:True) andisinstance(Foo, type)raisesTypeError: isinstance() arg 2 must be a type, a tuple of types, or a union(CPython:True). There is no metaclass. - Bound methods report
function, notmethod.type(obj.method)is<class 'function'>where CPython says<class 'method'>; Monty has no dedicatedmethodtype. - Ordering comparisons on instances raise, but a user
__lt__/__gt__/… is not dispatched.a < bon instances of a class with no comparison dunders raisesTypeError: '<' 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.- Only a plain-function
__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 (noselfprepended unless it is a plain function) and CPython’sNone-return contract is enforced, but it runs to completion synchronously, so it cannot yield to the host, and an external-function__init__raisesNotImplementedErrorrather 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 atryaround the comparison.- Arithmetic, conversion, rounding and subscript dunders cannot suspend.
__neg__,__pos__,__abs__,__invert__,__int__,__float__,__round__,__floor__,__ceil__,__trunc__, binary numeric dunders (including reflected forms),__getitem__and__setitem__run synchronously. An external or OS call raisesNotImplementedErrorrather than yielding to the host.__call__uses the ordinary suspendable call path. - Ordering dunders are still not dispatched; see the entry above.
Instances are always truthy (no
__bool__/__len__dispatch). - Bound methods compare and hash by identity: each
obj.methodaccess creates a fresh object, soobj.method == obj.methodisFalseand two accesses hash differently. CPython compares/hashes bound methods by(instance, func), making separate accesses equal. - Bound-method
repris the bare<bound method>; CPython renders<bound method Foo.m of <__main__.Foo object at 0x..>>. - Assigning
Foo.__name__stores an ordinary class member. Unlike CPython, wheretype.__name__is a metaclass descriptor whose setter renames the class, it does not rename the class, soFoo.__name__reads andrepr(Foo)keep the original name while instances see the member. - Assigning
obj.__class__stores an ordinary instance attribute rather than reassigning the object’s class.obj.__class__ = Xthen reads backX, buttype(obj)andisinstancestill report the original class, leaving an internally inconsistent object. CPython either reassigns the class (for a compatible class) or raisesTypeError: __class__ must be set to a class, not '...' object. - Recursive/deep
__repr__/__str__raisesRecursionErrorearlier than CPython. A__repr__(or__str__) that reprsself, 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 native process (the caller for direct Rust use, otherwise the worker) or trap a WASM worker. This native re-entry is capped independently at a much lower, fixed depth, raising a catchableRecursionErroronce exceeded. So infinite__repr__recursion raisesRecursionError(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 raiseRecursionErrorin Monty. The same cap applies to synchronous callback evaluation such asmap(),filter(),sorted()/list.sort(key=...),min()/max(key=...), arithmetic and subscript dunders, and exotic__init__recursion. A chain of callable instances stored as class-level__call__members also has this cap; ordinary Python-function__call__recursion uses the normal frame limit (see the “Recursion” section of resource_limits.md). - Comprehensions in the class body can see class variables, because Monty
inlines comprehensions into the enclosing scope. In CPython a comprehension
has its own scope that skips the class scope, so only the leftmost iterable
is evaluated in class scope and the body cannot see class variables
(
[n + offset for n in nums]referencing a class variableoffsetraisesNameErrorin CPython but succeeds in Monty). - Same-name collision is rejected, not resolved. When an enclosing-function
local and a class variable share a name and a method captures the enclosing
one, CPython keeps the two distinct (a class-dict entry vs. a closure cell).
Monty maps one name to a single slot and so cannot represent both; it raises
NotImplementedErrorat 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.
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:
- The proxy’s
attributesare 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). - A proxy whose sandbox object has since been freed raises
RuntimeError: invalid input type: sandbox instance of 'Foo' (id ...) no longer existsrather than materializing a host-backed copy. - A proxy of a host-sent instance (produced after a restore) has no
sandbox object to resolve to: passing it back re-enters as a host-backed
copy built from its
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.
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 aClassTypepassed 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 grantediniton aClassTypewrapper (see below); and — like Monty class objects generally — it exposes__name__plus any eager class attrs the host sent (__module__,__qualname__,__doc__,__mro__,__bases__, … raiseAttributeError, and so does__class__, which CPython answers withtype). 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 ofPointin the sandbox, andissubclassdoes 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.- Lazy attribute lookups consult the host for
obj.attr,getattr()andhasattr()— 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,getattrraises/returns the default). Underscore- prefixed names never consult the host (dunder probes stay local). - Lazy attribute reads run host code (a
@property, a JS getter, the wrapper’sconvert_value), and only a hostAttributeErrorreads as “absent”. Any other host exception is raised inside the sandbox where the read happened, andhasattr()/getattr(obj, name, default)do not swallow it (CPython treats a raising property the same way). A value the wire cannot carry raisesTypeError: Cannot convert X to Monty value ...in the sandbox, where CPython would return it. - Lazy lookups are not cached: every access is a fresh host round trip,
and host-side mutations between accesses are visible. Eager attrs are a
snapshot — host-side mutations after send are NOT visible, and sandbox
setattrdoes 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 raisesAttributeErrorwhen called, where CPython would call it. An explicit set of names calls whatevergetattrreturns. In JS,'all'requires a function found on a prototype belowObject.prototype/Function.prototype, sotoString(),hasOwnProperty(),call(),bind()and the like are absent; andconstructor,__proto__,prototype,argumentsandcallerare refused under every policy, an explicit list included.- Instances are not callable:
x()raisesTypeError: 'Point' object is not callableunder every wrapper policy, even if the class defines__call__. Only a host driving the protocol directly can answer the suspended__call__method call. - A method read as a value is not a bound method: with the name only in
allowed_methods,m = x.greetingraisesAttributeErrorandhasattr(x, 'greeting')isFalse— only the callx.greeting(...)reaches the host. If the name is also inlazy_attrs, the read crosses as a host function proxy that is resolved by name throughexternal_lookupwhen called, not bound to the instance. - Equality uses the eager attrs only (same class + equal attrs);
methods like a custom
__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 raisesTypeError: unhashable type: '...'in the sandbox. - Frozen dataclasses are not frozen in the sandbox: there is no frozen
policy on the wire, so in-sandbox
setattrsucceeds 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.- Returning a host-sent instance gives the host the original object
(identity preserved), discarding any sandbox-side attr mutations.
The same wrapper sent twice in one message (two inputs of a feed, two arguments of a call, or nested twice in
one value) is one sandbox object, so
a is bholds. Each separate feed or call allocates its own proxy, soa is bacross feeds isFalseeven though the two are equal (same class uuid + attrs). - Instance ids are per wrapper; class ids are per process (host
classes) — Python keys class ids by
module.qualnameinpydantic_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 amodule.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 raisesValueErrorrather than silently aliasing them — give one an explicitid. JS validates an explicitidas a canonical uuid and stores it lowercased, sowrapper.idmay differ in case from the value passed. Sandbox-defined uuids live in the heap and survive dump/restore. - Inheritance is not modelled: a host class’s bases are not sent, so
base-class attributes and methods are not consulted, and
__bases__raisesAttributeError. - The host keeps every wrapper it sends until the session ends: each
wrapper sent (nested ones included), each
init=Trueconstruction and eachconvert_valuewrap adds an entry to the host-side instance store thatmax_memorydoes not count; re-sending a wrapper with the same id overwrites its entry rather than adding one, with the last wrapper’s policy winning. Wrappers registered before a conversion failure remain retained too. There is no entry cap; bound the objects exposed or recycle long-lived sessions.
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:
- Missing/denied class attributes raise CPython’s type-object wording
(
AttributeError: type object 'Point' has no attribute 'x'). Like instance attrs, onlyType.attrsyntax consults the host, underscore names stay local, and lazy class lookups are not cached. allowed_methodson aClassTypeexposes classmethods and staticmethods only, under'all'and an explicit set alike: calling an instance method through the class (Person.greet(other)) raisesAttributeError: type object 'Person' has no attribute 'greet', where CPython would passotherasself. In JS,'all'exposes the class’s own static functions, none inherited from a base class.- With
initabsent or false, calling the class raisesTypeError: cannot instantiate host class 'Point'(CPython would construct). That includestype(x)()on a plainClassInstance: sending an instance registers a defaultClassTypefor its class, withinitfalse. - Constructor exceptions propagate into the sandbox like external-function errors.
- After a session restore the class registration is gone: construction and
classmethod calls raise
RuntimeError, lazy class attrs raiseAttributeError, and a host class crossing back to the host (as a value, or astype(x)) is a read-onlyMontyClassTypeProxy(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. - JS constructors have no keyword arguments; kwargs arrive as a trailing
options-bag argument, as with wrapped method calls, and a
__proto__keyword is dropped from that bag. - Eager class attrs are a snapshot at send time, and (like eager instance
attrs) host-side mutations after send are not visible. They are re-sent on
every crossing of the class and of each of its instances (the cost scales
with the number of eager class attrs): a non-empty set replaces the
sandbox copy, an empty set leaves it alone, so re-sending cannot clear it;
a re-send also overwrites the class name and
is_dataclassflag. - Dumps written before the shared-type-object layout (dump format version 8) are rejected on load.
class Foo(Bar): ...— no inheritance, no MRO, nosuper()(rejected at parse time: “class inheritance and metaclasses”; the runtime equivalenttype('Foo', (Bar,), {})raisesTypeError, see above).- Metaclasses,
__init_subclass__,__set_name__, and any other metaclass-driven namespace customization. __slots__, descriptors (__get__/__set__/__delete__).- Abstract base classes (
abc.ABC,@abstractmethod). - Method decorators —
@classmethod,@staticmethod,@property, and any decorator on adefinside a class body (rejected at parse time). Decorators on classes and on non-method functions are supported. - Classes are barely introspectable:
__dict__,__bases__anddir()are all unavailable (cls.__name__andcls.__annotations__work, see typing.md). A class decorator can therefore discover fields and nothing else. - Tracebacks from decorator application point at the whole
classstatement (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. - Dunder protocols not dispatched for user-defined instances include
__new__,__ne__, ordering comparisons,__bool__,__len__,__complex__,__format__, and all in-place arithmetic dunders (__iadd__, etc.). Augmented assignment falls back to binary arithmetic rather than calling an in-place dunder, except@=, which is rejected at compile time.__ne__is always the negation of__eq__, so a custom__ne__is ignored. - Returning
boolfrom__int__or__index__does not emit aDeprecationWarning. - Subscript assignment does not dispatch
__index__.lst[obj] = xraisesTypeError: list indices must be integers or slices, not Foowhere CPython coerces and assigns; only the read side takes the index path. slice()stores coerced bounds, not the objects passed. CPython’sslice()keeps its arguments untouched and only calls__index__when the slice is used, soslice(obj).startisobj; Monty coerces during construction, so it is the resultingint. A bound whose__index__raises therefore raises atslice(...)rather than at use, and one that is neitherNone, anint, nor__index__-able is rejected up front instead of on first use.- Slice bounds are stored saturated to
i64. Because bounds are coerced at construction (above), one beyondi64is clamped toi64::MIN/i64::MAXrather than kept exact:slice(10**30).stopis9223372036854775807, where CPython reports10**30. Slicing with such a bound still matches CPython — it clamps to the sequence either way, so1, 2, 3is[]— 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, 3raisesIndexErroras CPython does. __contains__cannot suspend on an external or OS call; it raisesNotImplementedErrorinside the method.__iter__and__next__have the same restriction infor, comprehensions, collection constructors, and native iterator adaptors, but can suspend through directiter(),next(), andfunctools.reduce()calls. See iter.md for other iteration restrictions. Two related protocols are still not dispatched:- the legacy
__getitem__-only fallback: CPython iterates a class defining__getitem__but not__iter__from index 0 untilIndexError, while Monty reports it as not iterable. (monty -tacceptsiter(obj)for such a class, so this fails only at runtime, see iter.md.) __reversed__, soreversed(obj)on any user instance raisesTypeError: '{cls}' object is not reversible. That matches CPython for a class defining neither__reversed__nor__len__+__getitem__, and diverges for one that does.
- the legacy
__next__is looked up on the class only, never the instance__dict__, and aStopIterationraised 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.- A
__contains__returning a user instance is alwaysTrue. The result is coerced by Monty’s truthiness, which reports every instance as truthy (see above), where CPython’sPyObject_IsTrueconsults the returned object’s__bool__/__len__. Every other return type coerces as CPython does. - Attribute-access hooks are never dispatched:
__getattr__,__getattribute__,__setattr__,__delattr__, and__del__. A missing attribute always raises the defaultAttributeErroreven when the class defines__getattr__, and attribute writes always go straight to the instance__dict__. The one exception is the__setattr__that@dataclass(frozen=True)generates, which assignment honours (see dataclasses.md).object.__setattr__exists (see below) and, since there are no hooks to skip, differs from a plainobj.x = vonly on a@dataclass(frozen=True)instance, which it writes to andobj.x = vrefuses — the same escape hatch CPython’s generated__init__uses. On a class object it does not write at all, whereFoo.x = vsets a class member. - Introspection attributes other than
__name__,__doc__,__annotations__andobj.__class__:Foo.__dict__,obj.__dict__,Foo.__bases__,Foo.__mro__,Foo.__qualname__,Foo.__module__,Foo.__annotate__of a compiled class (atype()namespace may bind its own; see typing.md), and explicitobj.__repr__()/obj.__str__()calls when the class defines none, all raiseAttributeError. - Class-body statements other than a
def, a simplename [: T] = <expr>variable assignment,pass,..., or a docstring, e.g.if/for/whilein the class body, or tuple/multiple assignment targets (rejected at parse time). - Assignment expressions (
:=) 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)givesC.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 aSyntaxErrorwith different wording). del obj.attr(thedelstatement is unsupported generally).
The 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 — raisesTypeError: 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 thanclass Foo:.- Only
__setattr__and__name__resolve. Every other member CPython’sobjectcarries —__doc__,__init__,__eq__,__getattribute__,__class__,__mro__,__bases__,__qualname__,__module__,__dict__— raisesAttributeError, with Monty’s generic'type' object has no attribute 'x'where CPython saystype object 'object' has no attribute 'x'. object.__setattr__accepts only instances of sandbox-defined classes. Anything else raises CPython’sAttributeError: '<type>' object has no attribute '<name>' and no __dict__ for setting new attributes— including a class object, where CPython instead raisesTypeError: can't apply this __setattr__ to type object.- It reprs as
<built-in function object.__setattr__>, where CPython says<slot wrapper '__setattr__' of 'object' objects>.
Raised when assigning to a field of a dataclass declared in the sandbox with
@dataclass(frozen=True), or by calling its generated __delattr__ directly
(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.