pythonrs
September 6, 2026 · View on GitHub
pythonrs is Python lowered to fusevm (bytecode VM + Cranelift JIT), with a PyHost
object heap. It runs a large, real subset of Python 3 correctly (verified
byte-for-byte against CPython 3.14.6 on the example corpus). This file is the
honest list of what is not yet covered, so nobody mistakes a gap for a bug
fixed. Every line below was re-checked against the default-build binary
(cargo build — default features, so the stdlib-ffi bridge is ON) before being
written.
Implemented (previously listed here as gaps)
-
A
sliceis hashable and richly comparable. CPython made slices hashable in 3.12; here the construction used to raise and the STORE formd[slice(1, 2)] = 1was worse — it was taken for a slice ASSIGNMENT, so the key never reached the dict at all.PKey::Slicenow keys a slice by its three bounds (a DISTINCT variant fromTuple, because a slice and the tuple of its bounds must not share a dict slot),pyhash::slicereproduces CPython's own number, and the four subscript paths (get_item_raw,del_item_raw,subscript_store,subscript_delete) now tell a slice KEY from a slice INDEX by the RECEIVER, as CPython does, so a mapping takes it as a key while a sequence still splices. Slice==/</>compare the bounds tuple, which also fixedslice(1, 2) in [slice(1, 2)](it was False for every pair of distinct slice objects).pyhash::sliceis NOTpyhash::tuple: CPython omits the length-mangling step, verified against CPython 3.14.7 over all 1 728 bound triples drawn fromNone/0/1/-1/±2**63/10**30/'ab'/1.5/inf/(1, 2)/Truewith zero mismatches. -
Builtins bindable by keyword now read their keywords.
pow,math.iscloseanditertools.groupbyaccept by keyword what they also accept positionally, and each read the positional slots alone — so the keyword forms did not raise, they answered with a DEFAULT:pow(2, exp=3)was2,pow(2, 3, mod=5)was8,isclose(a, b, rel_tol=<obj with __float__>)compared at1e-09, andgroupby(xs, key=f)grouped by the raw element while reporting it as the key.pownow binds through CPython's Argument Clinic contract (base/exp/mod, the given-by-name-and-position error, the unexpected-keyword error and the at-most-3 arity), andisclosecoerces both tolerances throughmath_real, the same protocol its positionals use. -
bool's numeric descriptors yield anint.True.realandTrue.conjugate()handed the receiver straight back, so they wereTruewhere CPython gives1(type(True.real)isintandTrue.real is Trueis False)..numeratoralready normalized; the other two now match it. -
A negative
ris aValueErrorfrom the combinatoric itertools.permutations(xs, -1)cast-1tousize, wrapped it past the pool length and yielded nothing;combinations(xs, -1)clamped it to0and yielded the single empty tuple. Both now raise CPython'sValueError: r must be non-negative. -
A module-level builtin reports its bare
__name__and its own__module__.itertools.permutations.__name__was the dotted'itertools.permutations'and__module__was'builtins'; the name split that type objects already got now applies to functions too. -
len,abs,minandmaxcheck their argument count.len(a, b)andabs()read the first slot and ignored the rest, andmin()reported the empty-iterableValueError— the message formin([]), a different mistake. -
Source too deeply nested no longer aborts the process. Five shapes killed the interpreter thread outright —
fatal runtime error: stack overflow, SIGABRT, exit 134, no traceback and nothing forexceptto see:exec('('*10000),'-'*100000+'1','a'+'.b'*100000,'1'+'+1'*200000and'not '*20000+'1'. CPython answers all five with an ordinary catchable exception. The tokenizer now carries CPython'sMAXLEVEL— 200 open brackets, one counter shared by(,[and{, so'([{'*67trips it too — and refuses the 201st withSyntaxError: too many nested parentheses(measured:compile('('*200+'1'+')'*200, …)compiles on 3.14.6 and'('*201does not, for all three bracket kinds). Bracket-free operator chains nest just as deeply, so the parser also carries a tree-depth cap (parser::MAX_TREE_DEPTH, 20 000) reported as CPython's ownMemoryError: Parser stack overflowed - Python source too complex to parse. The cap sits above every depth CPython accepts in those shapes ('1'+'+1'*20000and'a'+'.b'*20000parse there,*100000does not) and below where the 512 MB interpreter stack insrc/main.rsruns out (measured: those shapes survive 25 000 levels and abort by 30 000 on a debug build). See "Partial / simplified semantics" for the two limits that remain. -
A format spec with too many width or precision digits raises instead of panicking.
parse_internal_render_format_specaccumulated digits with a plain*/+on ausize, soformat(1, '1' + '0'*20 + 'd')— and'{:{}d}'.format(1, 10**20), which splices its argument in as spec text — aborted with "attempt to multiply with overflow", which noexceptcan catch. CPython'sget_integerraisesValueError: Too many decimal digits in format string. The accumulator is checked againstPy_ssize_t, notusize, because'9'*19fits one and not the other and CPython rejects it; a precision pastINT_MAXkeeps its ownValueError: precision too big; and a width the allocator refuses isMemoryError(viatry_reserve) rather than an abort, matchingformat(1, '9'*18 + 'd'). -
A repetition too large to allocate is
MemoryError, not an abort.Vec::with_capacityandstr::repeatabort on a failed allocation, so'a' * (2**48)printedmemory allocation of 281474976710656 bytes failedand exited 134. The result length is reserved fallibly now:[1]*(2**48),'a'*(2**62)and(1,)*(2**62)raiseMemoryErroras CPython does, and the bytes path raises CPython's ownOverflowError: repeated bytes are too long. -
An
inttoo large forPy_ssize_t, used as an index, a count or a length.PyHost::as_intanswersNonefor a bignum exactly as it does for a string, so all three failure modes collapsed into one and every site reported the wrong thing — or, worse, read theNoneas "argument omitted" and silently produced a different answer.PyHost::index_fitkeeps "fits" / "too large" / "not an int" apart, and each site reports what CPython reports:- a subscript is
IndexError: cannot fit 'int' into an index-sized integer([1][10**30],'a'[10**20],b'a'[10**20],memoryview(b'ab')[10**30],l[10**30] = 2,del l[10**30]) — it wasTypeError: list indices must be integers or slices, not int.rangeis the exception: it computes in arbitrary precision, sorange(10)[10**30]isIndexError: range object index out of range; - a repetition or a length is
OverflowError: cannot fit 'int' into an index-sized integer([1]*(10**20),b'a'*(10**20),bytes(10**20),bytearray(10**20)); thePy_ssize_tconversion runs before the sign check, sobytes(-10**30)is that too rather thanValueError: negative count; - an Argument Clinic
Py_ssize_tparameter isOverflowError: Python int too large to convert to C ssize_t(ljust/rjust/center/zfill,split/rsplit's maxsplit,replace's count,int.to_bytes's length,'%*d''s width). Each of these previously reverted to its DEFAULT and answered silently:'abc'.ljust(10**20)was'abc','abc'.replace('b','x',10**20)replaced everywhere; - the two C-
intparameters name that width instead —'a\tb'.expandtabs(10**20)and'%.*f' % (10**20, 1.5).
A range longer than
Py_ssize_trefuses to materialize instead of looping forever:list(range(10**25))built a vector nothing could hold, with no panic and no error to interrupt it, where CPython'sPyObject_LengthHintasksrange.__len__first and raisesOverflowError: Python int too large to convert to C ssize_t.len(range(10**25))raised already but named the wrong C type. A bignum range that is SHORT (range(10**30, 10**30+5)) still materializes.A slice bound SATURATES rather than raising, because
_PyEval_SliceIndexpasses a NULL exception type toPyNumber_AsSsize_t. Read as "omitted", every one of these returned the whole sequence; they now match CPython:'abc'[10**30:]is'','abc'[::10**30]is'a','abc'[::-10**30]is'c',[1,2,3][10**30:]is[],range(10)[10**30:]isrange(10, 10). Andchrread the bignum asNoneand then as0, sochr(10**30)printed a NUL where CPython raisesValueError: chr() arg not in range(0x110000); a non-integer argument is now the__index__TypeErrorCPython gives rather than thatValueError. - a subscript is
-
Binary-mode file reads answer
bytes. Every read path decoded UTF-8 unconditionally, sotype(open(p, 'rb').read())wasstrand a file holding a byte that is not valid UTF-8 died withOSError: stream did not contain valid UTF-8— CPython returns the bytes.read/read(n)/readline/readlines/ iteration all answerbyteson a'b'handle now,writerejects the wrong operand type in both directions (TypeError: a bytes-like object is required, not 'str'on a binary handle,TypeError: write() argument must be str, not byteson a text one), andopen()on a DIRECTORY raisesIsADirectoryError: [Errno 21] Is a directoryrather than handing back a handle that only fails at read time. Text mode is unchanged (a multi-byte character still counts as oneread(n)character). -
OSErrorcarrieserrno,strerror,filename,filename2. It was a one-string exception: the whole rendered line sat inargs[0]and none of the four attributes existed, soif e.errno == errno.ENOENT:— the ordinary way to discriminate anOSError— raisedAttributeErrorfrom inside the handler.synth_excnow splits[Errno N] strerror: 'filename'the way CPython'soserror_initsplits its arguments, soopen('/no/such/file')givesargs == (2, 'No such file or directory'),errno == 2,filename == '/no/such/file',filename2 is None. Anyopenfailure other than the three that were hard-coded keeps the OS's own errno and maps it to CPython's subclass. -
NameError.nameandAttributeError.name. Both attributes were absent, soexcept NameError as e: e.nameraised from inside the handler.AttributeError.objis still absent — see below. -
A regex group NUMBER out of range raises.
Match.groupaccepted any integer and read its span vector out of bounds, answeringNone— which is the value CPython reserves for a group that EXISTS and did not participate in the match, so a caller distinguishing the two saw the wrong one.re.match('(a)','a').group(5),.group(-1)and.group(0, 9)areIndexError: no such group, and a group that really did not match is stillNone. -
sys.setrecursionlimitvalidates its argument. The whole call wasOk(Value::Undef), sosys.setrecursionlimit(0)— which CPython refuses — was accepted silently. It reportsValueError: recursion limit must be greater or equal than 1below 1,OverflowError: Python int too large to convert to C intpast a Cint, and the__index__TypeErrorfor a non-integer. The limit itself is still not enforced; see below. -
The
npresentation type.nreached no arm of the renderer at all and fell through to the no-type one, soformat(1234567.891, 'n')printed therepr(1234567.891) where CPython gives1.23457e+06, andformat(True, 'n')printedTruewhere CPython gives1. It now renders asdfor an int-like value andgfor a float —format_float_internalliterally doesif (type == 'n') type = 'g'— and takes its separator, group WIDTHS and decimal point fromlocaleconv(), soformat(1234567, 'n')underde_DEis1.234.567and underhi_INis12,34,567(grouping[3, 2, 0], not a fixed three)._PyUnicode_InsertThousandsGroupingand itsGroupGeneratorare ported for the variable widths and the0-flag interleave.,nand_nare both rejected (nbrings its own separator) and a precision on an intnis rejected as it is ford. -
The
#alternate form on a float conversion.Py_DTSF_ALTkeeps a decimal point even when the precision rounded every fraction digit away:format(1.0, '#.0f')is1.,'%#.0e' % 1.0is1.e+00,format(1.0, '#.0%')is100.%. All of these dropped the point. Relatedlyfmt_gshort-circuited any zero to the string"0", which lost both the sign of-0.0(format(-0.0, 'g')is-0) and the flag (format(0.0, '#g')is0.00000). -
A bignum through a float presentation type.
as_fstops ati64and the fallback was.unwrap_or(0.0), soformat(10**20, 'f')printed0.000000instead of100000000000000000000.000000. It now converts asPyNumber_Floatdoes and raisesOverflowError: int too large to convert to floatpastf64.'%d' % 1e30likewise went through ani64cast that truncated; it is exact now, and'%d' % float('inf')raisesOverflowError: cannot convert float infinity to integerrather than printing9223372036854775807. -
Grouping stops at the digits.
parse_numbercounts the leading run of ASCII digits and everything after it is remainder, so a separator can no longer land inside an exponent or a suffix:format(1, '_.0%')is100%(was1_00%),format(1.5, ',.0')is2e+00(was2e,+00), and a non-finite has ZERO digits soformat(float('inf'), '012,f')is000000000inf(was0,000,000,inf). -
The
0flag keys off the FILL, not the alignment.parse_internal_render_format_spectakes0as the fill whenever no fill char was named — naming an alignment is not enough.format(1, '<08d')is10000000; it used to be1padded with spaces because the explicit<suppressed the flag. -
crejects a sign and the alternate form.format(65, '+c')/format(65, '-c')/format(65, ' c')areValueError: Sign not allowed with integer format specifier 'c'andformat(65, '#c')is the matchingAlternate form (#) …; all four used to succeed.'%c' % (10**20)isOverflowError: %c arg not in range(0x110000)rather than aTypeError— an int too large is a RANGE error, not a type one. -
PYTHONHASHSEEDis honoured for every seed, not just0. See thehash()section below. -
functools.wrapscopies__doc__and__module__across the bridge. Every pyclass answers those two names from its own type (Noneand"builtins"), so normal attribute lookup succeeded and the proxy's__getattr__never fired for them —functools.wraps(f)copiedNoneover the wrapped function's docstring and"builtins"over its module. Both are getset pairs now, delegating to the wrapped callable until something assigns. -
iter()/next()honor the user iterator protocol.iter(x)calledPyHost::make_iterdirectly, which cannot run Python, so a class defining__iter__/__next__wasTypeError: 'Count' object is not iterableandnext()on one wasTypeError: not an iterator— even thoughfor x in Count()worked, because the loop took a different path.iter(x)now runstype(x).__iter__and hands back its result UNCHANGED (so an object whose__iter__returnsselfkeeps its identity and an unbounded iterator is never drained), falls back to the__getitem__sequence protocol when there is no__iter__, and rejects a non-iterator result with CPython'siter() returned non-iterator of type 'int'.next()steps a user__next__outside the host borrow, treatingStopIterationas exhaustion, and names a non-iterator as'N' object is not an iterator. -
Every builtin iterator reports its own CPython type name. All of them answered
iterator— the name CPython reserves for the__getitem__sequence iterator alone. The snapshot cursor now carries anIterKindtag, sotype(iter(x)).__name__islist_iterator/tuple_iterator/str_ascii_iterator/str_iterator/bytes_iterator/bytearray_iterator/set_iterator/memory_iterator/_deque_iterator/dict_keyiterator/dict_valueiterator/dict_itemiterator/range_iterator/longrange_iterator, andreversedsplits intolist_reverseiterator, the threedict_reverse*iterators, and the genericreversed. -
co_flagsmatches the 3.14 compiler.CO_NOFREE(0x40) was set whenever a function had no free variables, sodef f(): passreported 67; 3.14's compiler never sets that bit and reports 3.dis.COMPILER_FLAG_NAMESstill NAMES the bit, which is what made the stale value look right. The three flags that do apply are now derived from__qualname__and the docstring:CO_NESTED(0x10) for any function inside another function's scope,CO_METHOD(0x8000000, new in 3.14) for one defined directly in a class body, andCO_HAS_DOCSTRING(0x4000000) when the body opens with a string. -
Cls[T]requires__class_getitem__. Every user class was treated as parameterizable, soclass Box: passsilently acceptedBox[int]as atypes.GenericAliaswhere CPython raises. A class now parameterizes only when__class_getitem__is in its MRO; a metaclass__getitem__is dispatched as ordinary indexing (it outranks the alias reading); and the rejection names the class itself —type 'Box' is not subscriptable, not'type' object is not subscriptable.tuple[int, ...]also prints the ellipsis in its literal spelling rather than asEllipsis. -
types.UnionTypeistyping.Union. 3.14 merged the PEP 604 type intotyping, sotype(int | str)reports__name__ == 'Union',__module__ == 'typing',repr<class 'typing.Union'>, and messages such as'typing.Union' object is not callable. pythonrs still answered with the pre-3.14builtins.UnionTypespelling. -
import typingworks.typing._SpecialFormdeclares__slots__ = ('_name', '__doc__', '_getitem')with no docstring. pythonrs seeded__doc__into every class namespace unconditionally, so the slot check saw a class variable that CPython's compiler never emits and the import died withValueError: '__doc__' in __slots__ conflicts with class variable, taking the whole module with it. The default is now skipped exactly when the body slots__doc__and has no docstring. -
__debug__is bound. It is a builtin constant, soif __debug__:— the ordinary spelling of a debug-only block — was aNameErrorin every scope. It now resolves everywhere and is False exactly when the interpreter is optimized;-O/-OOare folded intoPYTHONOPTIMIZEso both spellings share one source of truth, with CPython's lax parse (empty is 0, an integer is that integer, any other non-empty value is 1). Assert stripping under-Ois still not implemented — see "Partial / simplified semantics". -
function.__isabstractmethod__raisesAttributeError. The slot belongs tostaticmethod/classmethod/property, not tofunction; answeringFalseon a plain function hid the real shape (abcreads it with agetattr(…, False)default precisely because the attribute is absent).propertygained the slot it was missing. -
A mutable container reached through a bridged CPython object keeps its identity. The marshaller converted an exact CPython
list/dict/setto a native value on every read, which is right for a call RESULT (a fresh object the caller owns; arguments passed IN are already written back bywriteback_mutated_args) and wrong for a reference into a live object. With@dataclass class P: tags: list = field(default_factory=list),p.tags is p.tagswasFalseandp.tags.append(3)mutated a copy that was then discarded. An attribute or item read that yields a mutable container now keeps it behind theForeignhandle (ffi::reference_to_value), so identity holds and the mutation lands on the real object;__setitem__/__delitem__are routed too, and a slice crosses as a realslice(built through theslicebuiltin, so an omitted bound isNonerather than a sentinel int). The rule applies at every depth —d.m['k'].append(2)reaches the inner list by ITEM access on an already-bridged dict. Immutable containers (tuple,frozenset,bytes,str, scalars) still cross by value: nothing can observe the difference and operations on them stay native. -
Private-name mangling. Every
__namewritten inside a class body now compiles as_Class__name(CPython_Py_Mangle), soC().__dict__reads{'_C__x': 1}, theAttributeErrornames_F__missing, and two classes in one hierarchy can each keep a private__xwithout aliasing. The rewrite (src/mangle.rs) runs in the compiler on the parsed AST, not in the parser —ast.parsemust keep showing the name as written, and it reaches the sameparser::parsewithout passing throughcompile. It covers attribute access, plain names,def/classnames, parameters,global/nonlocal,importandexcept ... asbindings, andmatchcaptures; a CALL keyword (f(__k=1)) is not an identifier reference and is left alone, as are__x__and_z. Leading underscores are stripped from the class name (_K->_K__v,__L->_L__v) and the innermost enclosing class wins. Slot names mangle for the descriptor they install while__slots__keeps the tuple as written, so__slots__ = ('__x',)beside a_C__x = 1class variable now raisesValueError: '_C__x' in __slots__ conflicts with class variable. This changes emitted bytecode, socache::SCHEMAwent to 49. -
withchecks the context-manager protocol before entering. The desugar calledctx.__enter__()directly, so a manager carrying only__enter__ran it and the whole body and only failed on the way out withAttributeError: 'E' object has no attribute '__exit__'. CPython'sSETUP_WITHlooks up__exit__FIRST and refuses to enter at all. The entry now routes through a dot-prefixed sentinel (unwriteable in Python source, like the desugar's own.ctxtemporaries) so the check runs before the call, in CPython's order, with CPython's message:TypeError: 'E' object does not support the context manager protocol (missed __exit__ method)— andmissed __enter__ methodfor the other half.async withreports theasynchronous context manager protocolwording against__aexit__/__aenter__. An explicitobj.__enter__()written by the user still raises the ordinaryAttributeError, as CPython does. -
Parenthesized with-items (
with (a as x, b as y):). PEP 617 gave CPython 3.10 a PEG parser that can backtrack over the(-ambiguity, so a longwithheader can be wrapped in parentheses. pythonrs rejected the whole form withSyntaxError: expected ')' but found Name("as")— a hard stop on any modern script. The parenthesized item list is now tried first and wins whenever the group closes immediately before the:, sowith (a, b):is TWO context managers (CPython's reading), whilewith (a, b)[0]:,with (a) as x:,with (x for x in y):andwith ():still parse as one expression. -
divmoddispatches__divmod__/__rdivmod__. It was computed as(a // b, a % b), so a class defining only__divmod__raisedTypeError: unsupported operand type(s) for //, and a class defining all three ran the wrong two.divmodis a binary operator in its own right, and a missing pair now reports CPython'sunsupported operand type(s) for divmod(): 'V' and 'int'. -
dir(obj)honors a user__dir__. The hook was inert:dir()always listed the class/instance dict. CPython callstype(obj).__dir__(obj)and only sorts the result — no dedup (['a', 'a', 'z']stays three entries), and a non-iterable return or unorderable elements raise from that list()+sort(). -
obj.__class__ = Cretypes the instance. The assignment stored a shadowing__class__entry in the instance dict and lefttype(obj)untouched — a silent no-op with no error. It now swaps the class (methods,isinstance, and__class__all follow, the instance dict is kept) when the layouts match, and otherwise raises CPython's message:__class__ must be set to a class, not 'int' objectfor a non-class,__class__ assignment only supported for mutable types or ModuleType subclassesfor a static type on either side,__class__ assignment: 'B' object layout differs from 'A'when the slot layouts disagree.del obj.__class__raisesTypeError: can't delete __class__ attributeinstead of anAttributeError. -
Attribute stores and deletes carry a line and caret.
SETATTR,DELATTRandDELITEMwere emitted with line 0, so every traceback out of a rejectedobj.attr = v(a__slots__rejection, a setter-lessproperty) or a faileddel obj.attr/del obj[k]renderedFile "…", line 0, in <module>with no source line and no carets — naming nothing at all. They now carry the statement's line and the target's span, the same fix the container displays and subscript stores got. -
Binary operator slots are real bound methods on the builtin containers.
{'a': 1}.__ior__({'b': 2}),[1].__add__([2]),{1, 2}.__and__({2}),'a'.__mul__(3),b'a'.__add__(b'b'),(1j).__truediv__(2)— every one of them raisedAttributeError: 'dict' object has no attribute '__ior__', even though the operator SYNTAX (d |= …) worked, because an operator slot is dispatched natively rather than through a per-type descriptor object. Onlyint/float/bool, which carry an explicit dunder table, ever answered one. Each type now exposes exactly the set CPython 3.14 puts on an instance of it (str/bytes/bytearray/list/tuple/dict/set/frozenset/complex), the in-place halves mutate and return the receiver, and an operand of the wrong kind answersNotImplementedfor the set/dict/complex operators exactly as CPython does. The same table drivesdir(), so dispatch and listing still agree in both directions. -
x %= argson abytes/bytearray. The in-place fallback carried thestr %branch but not the PEP 461 one, sob'%d' % 1formatted whilex %= 1on the same receiver raisedunsupported operand type(s) for %: 'bytes' and 'tuple'. -
A numeric
AttributeErrornames its type.(1).__iadd__reportedAttributeError: object has no attribute '__iadd__'with no type; CPython names it ('int' object has no attribute '__iadd__'). -
A value-keyed object NESTED inside a
tuple/frozensetkey. Atuple/frozensetkey is hashed element-wise, so an element with a user__hash__is a key in its own right — but only the TOP-LEVEL object was prepared outside the host borrow, so{(P(1),): 5}raisedTypeError: unhashable type: 'P'from the borrowedto_key, which cannot run user code. The preparation now walks intotuple/frozensetoperands, collapse candidates are collected at every depth (so a nested element merges onto a value-equal one anywhere in the destination), and two equal elements of ONE key collapse onto each other. Afrozensetkey's element keys are recomputed at use, since they were resolved when the frozenset was built and carry heap ids the destination knows nothing about.hash()of such a container drops those ids, sohash((P(1),)) == hash((P(1),))holds as in CPython. Twenty-one distinct shapes were wrong — subscript, assignment,in,get,pop,setdefault, literal dedup,repr, whole-container==,set.add/update, the set algebra over tuple elements, andfrozenset-keyed lookups (which failed withKeyErrorrather thanTypeError). -
Container
==runs the elements' user__eq__.list,tuple,deque, and adict's values compared element-wise INSIDE the host borrow, where a user__eq__cannot run, soP(1) == P(1)was True while(P(1),) == (P(1),),[P(1)] == [P(1)],deque([P(1)]) == deque([P(1)]), and{1: P(1)} == {1: P(1)}were all silently False. Element comparison now runs through the full==dispatch, with CPython'sPyObject_RichCompareBoolidentity shortcut, whenever any element compares through user code; containers of plain values keep the borrowed comparison.tuple.index/tuple.counthad the same gap while theirlistcounterparts did not —(P(1), P(2)).index( P(2))raisedValueError: x not in tuple. -
Cross-container algebra with value-keyed elements. A set/dict operation between two independently built containers whose elements key through user code — a user instance with
__hash__+__eq__, or a CPythonForeignobject (enum member,Decimal,Fraction,datetime, …) — now merges value-equal elements across the two operands.{P(1), P(2)} & {P(2)}is{P(2)}, and|/-/^, the method spellings (union/intersection/difference/symmetric_difference), the in-place forms (|= &= -= ^=,update/intersection_update/difference_update/symmetric_difference_update), the subset orders (< <= > >=,issubset/issuperset/isdisjoint), and==between two whole sets or dicts all agree with CPython. Such a key carries the heap id of the object it collapsed onto (PKey::Instance/PKey::Foreign) and the borrowed ops compare keys structurally, sohost::align_operandre-keys the right operand's elements against the left's throughprepare_key(running__hash__/__eq__, or the bridge's, outside the borrow) before the comparison. Containers with no value-keyed element skip the pass entirely.updateandsymmetric_difference_updateadditionally raisedTypeError: unhashable typeon any user-__hash__element, because they hashed inside the borrow.dict.updatekeys against the DESTINATION for the same reason. It copied the source dict's keys verbatim, so a value-equal key opened a SECOND slot —{P(1): 'a'} | {P(1): 'z'}was right butd.update({P(1): 'z'})andd |= {P(1): 'z'}left a dict holding twoP(1)entries, which CPython cannot produce; its pair-iterable form (d.update([(P(1), 'z')])) hashed under the borrow and raisedunhashable type. Two value-equal keys within oneupdatenow collapse the way a dict literal's do. -
A class may define
__hash__without__eq__. CPython then inheritsobject.__eq__(identity). The key collapse called__eq__directly, so the first hash collision between two such instances raisedAttributeError: 'P' object has no attribute '__eq__'and made the whole dict/set unusable ({P(5): 1, P(5): 2}with__hash__ = v // 2). The collapse now runs the full==dispatch, which also routes a builtin-type subclass through its payload, soclass S(str)with its own__hash__still mergesS('a')withS('a'). -
dict_keys/dict_itemsviews are set-like, as in CPython: they take part in==and in the subset order (d.keys() == {1, 2},d.keys() <= {1, 2},d.items() == {(1, 0)}), not only in& | - ^.==answered False for every view — including all-intkeys — and the ordering operators raised'<=' not supported between instances of 'dict_keys' and 'set'. Adict_valuesview stays non-set-like (two views are never equal). Separately, a key view coerced to a key-set by re-hashing its key OBJECTS, and a value key cannot be hashed under the host borrow — the error was discarded, sod.keys() & {P(2)}silently dropped exactly the value-keyed elements and came back empty. A key view now contributes its dict's own key map. -
A set predicate answers for an iterable it cannot hash.
{1}.issubset( [P(1)])isFalsein CPython, not aTypeError; with no candidate key to collapse onto, the argument's elements still have to be hashed outside the borrow rather than short-circuited into it. -
__slots__validation (CPythontype_new_slots_impl): a slot name also bound in the class body isValueError: 'a' in __slots__ conflicts with class variable; a non-string isTypeError: __slots__ items must be strings, not 'int'; a non-identifier isTypeError: __slots__ must be identifiers; a repeated__dict__/__weakref__isTypeError: <name> slot disallowed: we already got one.__qualname__and__classcell__— names class creation inserts itself — are exempt from the conflict check, and so is__doc__in a body that has no docstring (CPython's compiler emits that store only for a real one, so there is nothing for the slot descriptor to collide with). -
itertools.chain.from_iterableis reachable as an attribute ofchain. -
f.__annotate__(PEP 649): the callable that yields the annotations for a requested format,Noneon an unannotated function. CPython 3.14'sfunctools.singledispatch.registergates on it, so@generic.registeron an annotated implementation now infers the dispatch type. -
CPython-side stdout ordering. pythonrs's
printwrites straight to the fd, while the embedded interpreter'ssys.stdoutis block-buffered on a pipe and is neverPy_Finalized. A pythonrs builtin handed to CPython crosses as the genuine CPython builtin, sofunctools.partial(print, …),ExitStack.callback(print, …)and friends wrote through that stream — their output came out reordered, or was dropped at exit. Both streams are line buffered at bridge init (ffi::line_buffer_std_streams). -
sys.argvreaches the bridged stdlib.argparse— andgetopt,pdb,unittest— run on the embedded interpreter and read ITSsys.argv, a list libpython builds at startup as['']because nothing passes the program's arguments toPy_Initialize. pythonrs's ownsys.argvwas correct all along, which is what made this so quiet:parser.parse_args()raised nothing, printed nothing and returned every option at its default with every positional dropped, so an argument-driven program ran to completion against the wrong inputs. pythonrs's livesys.argvis now mirrored across on every bridged import (host::current_argv→ffi::queue_argv→ffi::apply_pending_argv), the same queue-then-apply shapesys.pathalready used, and it is re-read from thesysmodule rather than fromPyHost::argvso a program that rewritessys.argvbefore importing argparse gets what it set. A rewrite performed after the last bridged import is not mirrored. -
open()honoursencoding=and universal newlines. The builtin parsed onlyfileandmode;encoding,errors,newlineandbufferingwere accepted and dropped. Two silent wrong answers came out of that. A text handle always wrote UTF-8, soopen(p, 'w', encoding='latin-1').write('é')put two bytes on disk where CPython puts one — the program asked for an encoding, got another, and nothing reported it. And no read translated line endings, so a CRLF file came back as'a\r\nb'fromread()and['a\r\n', 'b']fromreadlines()instead of CPython's universal-newline'a\nb'/['a\n', 'b']. The handle now carries aTextEncoding(utf-8, ascii, latin-1, resolved through CPython's own name normalisation) and anewline_translateflag, andopentakes CPython's real positional signature so the arguments land in the right slots. An encoding pythonrs cannot serve isLookupError: unknown encoding: Xat open time rather than UTF-8 bytes at write time, a character the codec cannot represent isUnicodeEncodeErrorwith CPython's wording, and binary mode rejectsencoding=/newline=with CPython's twoValueErrors.read(n)counts characters AFTER translation — a\r\ncosts two bytes and yields one — so the reader tops up until it hasnof them or reaches EOF.errors=is accepted and still ignored: decoding stays lossy. -
A CPython-side file the program never closed keeps its writes.
io.openhands back a CPython stream, which is block-buffered, and the embedded interpreter is neverPy_Finalized — so nothing ran the teardown that flushes it. Every byte written to a handle the program did not close was lost, and the file was left on disk at the zero lengthopentruncated it to: an empty file where a written one should be, with no error anywhere. Refcounting does not cover the dropped-handle case either (io.open(p, 'w').write(s)as a statement, or rebinding the only name), because theForeignside-table holds a strong reference to every CPython object for the process lifetime, so the stream stays alive long past the program's last mention of it. Interpreter shutdown now flushes every writable, still-openio.IOBasefound throughgc.get_objects()and then the standard streams (ffi::flush_open_files, called fromlib.rsbeside theatexitteardown). A flush that raises is skipped: teardown must not replace the program's own outcome. -
A
SystemExitraised on the CPython side sets the exit status. It crosses the bridge as an error string plus aforeign_excrecord, never as aPyObj::Exception, soclassify_top_error— which looked only at the latter — called it an ordinary uncaught exception. Every argparse program was affected at its two most common exits:--helpprinted the help text and then a traceback and exited 1 where CPython exits 0, and a usage error printed CPython'sprog: error: …line and then a traceback and exited 1 where CPython exits 2 — the status a caller actually tests for.unittest.main()and anything else that ends the program from bridged code were wrong the same way.classify_top_errornow also recognises a foreignSystemExitand maps it through the samesystem_exit_outcomehelper pythonrs's ownsys.exituses, so the code, thestr(code)stderr message and the absent traceback all match. The record is matched against the error being classified, so a stale one from an earlier caught bridge call cannot claim an unrelated failure. -
An exception raised by pythonrs code keeps its class for a CPython caller. Every wrapper that hands a pythonrs callable to CPython — a
key=function, ajson.dumpsdefault, aunittesttest method,functools.partial(fn)— mapped a failure toPyRuntimeError, soraise KeyError('k')arrived asRuntimeError: KeyError: 'k'. A caller'sexcept KeyErrordid not fire, andunittest— which decides FAIL vs ERROR purely on the class — logged every failed assertion as an ERROR.ffi::call_errnow rebuilds the CPython exception from the live pythonrs exception object (falling back to parsing the class out of the"Class: message"rendering), the same two stepsbody_erralready used for generator bodies, and is used on the three paths where the failure is the user's:PyrsCallable::__call__,PyrsInstance::__getitem__and thePyrsFilemethods. The live object is only trusted when its class heads the error string, so a staleh.exccannot claim an unrelated failure. A pythonrs-defined exception class that is not a builtin still arrives asRuntimeError. -
Generators /
yield. Adefwhose body containsyieldbuilds a real lazy generator, backed by a stackfulcorosenseicoroutine on the same thread (the thread-localPyHostis shared across suspend/resume via a swapped execution context). Supported:for x in gen(),next(g),list(gen()), theyield-expression value, the full method protocol (.send()/.throw()/.close()/.__next__()), a generatorreturnsurfacing asStopIteration.value, and fullyield fromdelegation (PEP 380): a value.send()-ed into the delegating generator reaches the sub-generator'syieldexpression, a.throw()is forwarded to the sub-generator's.throw(), a.close()(GeneratorExit) forwards to the sub-iterator and runs its try/finally, and the delegate'sreturn(r = yield from sub()) bindssub's return value. Generator expressions(x for x in xs)are lazy (a hidden generator function), not eager. -
Call-site unpacking
f(*args, **kwargs),f(a, *b, c, **d)— flattened at runtime throughBUILD_ARGS/BUILD_KWARGSand theCALL*_EXops. -
Literal spreads
[*a, *b],(*a, b),{*a, *b}, and dict**-spread{**a, "k": 1, **b}(later keys override;Nonestays a valid key). -
match/case(PEP 634): literal, capture, wildcard_, dotted-valueColor.RED, sequence[a, *rest], mapping{"k": v, **rest}, classPoint(x=0)(via__match_args__+ builtin-type self-match), OR-patternsa | b(withasbinding looser than|),asbindings,ifguards, and arbitrary nesting. Singleton patternsNone/True/Falsematch by identity (is), every other literal by==. Compile-timeSyntaxErrors (duplicate capture, duplicate mapping key, repeated class-keyword, OR alternatives binding different names) and the positional-overflowTypeErrormirror CPython. -
Name resolution (LEGB) follows CPython's compile-time scope analysis. A name assigned anywhere in a function body is a local; reading it before it is bound raises
UnboundLocalError(aNameErrorsubclass) rather than falling through to an enclosing/global binding — covering read-before-assign,+=on an unbound name, a conditionally-assigned name, anddel-then-read. A read at module scope stays dynamic (NameError). A class body is not an enclosing scope for its methods/comprehensions: free names there resolve against the enclosing/module scope, never the class namespace (reachable only viaself/ClassName). -
nonlocalrebinds the nearest enclosing FUNCTION scope that binds the name (distinct fromglobal, which targets module scope). Validated at compile time: anonlocalwith no enclosing binding isSyntaxError: no binding for nonlocal '<x>' found, and one at module level isSyntaxError: nonlocal declaration not allowed at module level. -
Function/class introspection:
__name__,__qualname__(the dottedco_qualnamepath —outer.<locals>.inner,C.m,A.B),__module__(__main__), and__defaults__(positional-default tuple, orNone) on functions, bound methods, and classes. -
Augmented assignment (
+= -= *= /= //= %= **= @= &= |= ^= <<= >>=) runs the CPython in-place protocol:x += ytriestype(x).__i<op>__(x, y)first, then falls back tox = x <op> y. A user__iadd__/… that mutates and returnsselfpreserves identity (id(x)unchanged), as do the mutable built-ins (list +=/*=,set |= &= -= ^=,dict |=,bytearray +=/*=); immutables (int/str/tuple/frozenset) rebind a new object. A subscript/attribute target's receiver and index are evaluated exactly once. -
Chained comparisons
a < b < cevaluate each interior operand exactly once and short-circuit (1 < f() < 10callsfonce; a failed earlier link skips the later operands entirely). -
with/async withcall a real__exit__(exc_type, exc_value, tb)with the active exception's type and value on the error path (tbisNone— pythonrs has no traceback objects); a truthy return suppresses the exception, a falsy/Nonereturn re-raises. On the normal path__exit__is called once with(None, None, None).with A, B:nests independently, so an inner manager's suppression hides the exception from the outer one.__enter__'s return value binds to theastarget. A foreign context manager (contextlib.suppress, …) works on the error path too: the pythonrs exception is reconstructed as a real CPython exception for its__exit__, sosuppressmatches it (including by base class).contextlib.redirect_stdout/redirect_stderrandsys.stdout = io.StringIO()retarget pythonrs's ownprint(a native redirect; a CPython one only touches CPython's stream, which print doesn't consult); nesting restores correctly andsys.__stdout__/__stderr__/__stdin__keep the native streams. -
User exception subclasses inherit
BaseException:class E(Exception)instances carryargs(seeded by construction /super().__init__/ direct assignment), stringify to the message (''/str(arg)/repr(tuple)), repr asE(arg, …), and expose.argsand.__class__(the type object);str()uses the message even when a user__repr__exists. An uncaught exception prints CPython'sTraceback (most recent call last):block — header,File "<path>", line N, in <scope>+ source line + CPython 3.11+ fine-grained caret per frame (outermost first), thenErrorType: message. Carets follow CPython's anchor rules:~^~under a binary operator,~~~^^^under a subscript/call's brackets, a plain^^^under a name/attribute, and no caret when the span covers the whole line or when anx = f(...)/return f(...)call raises. A fused name/method call whose callee lookup fails (foo()on an undefined name,obj.missing()) anchors the call brackets rather than the name — the one spot the fused CALL op diverges from CPython's separate LOAD+CALL. Exception chaining renders in full:raise X from Yrecords__cause__and prints the cause's own block followed by "The above exception was the direct cause …"; an exception raised while handling another chains via__context__("During handling of the above exception …");raise X from Nonesets__suppress_context__, hiding the implicit context. Each chained exception's frames are captured (__traceback__) at the point it is caught. -
Did you mean: 'x'?on an uncaughtNameError/AttributeError. A port ofPython/suggestions.c's_Py_CalculateSuggestions— which is what CPython 3.13+ actually runs, and which disagrees withtraceback.py's pure-Python fallback (the fallback seeds its running best withlen(wrong_name), sostsuggests nothing there andsetin the real interpreter). The distance is CPython's modified Levenshtein: moves cost 2, a pure case flip costs 1, common affixes are trimmed, and a row that cannot beat the budget bails out. Candidates are the frame's locals (including the ones held in frame SLOTS, which never reach the environment), then its globals, then the builtins for aNameError;dir(obj)with private names hidden — unless the code asked for a private one, or the receiver is the running method's own instance — for anAttributeError. A bare name that is an attribute of the running method's instance is reported asself.<name>. The hint belongs to the RENDERED traceback, never tostr(e)/e.args, as in CPython. Fuzzed to zero divergences (parity-fuzz --mode suggest --stderr, 8000 cases; the same mode finds 207 in 2000 against the previous build). -
Exception groups and
except*(PEP 654).ExceptionGroup/BaseExceptionGroupare real: the constructor validates its arguments and narrows (BaseExceptionGroupholding onlyExceptions builds anExceptionGroup);.message/.exceptions/.argsread back;strcounts members (g (2 sub-exceptions));ExceptionGroupanswersisinstancefor BOTH its bases.split/subgroup/deriveare ported from CPython'sexceptiongroup_split_recursive/exceptiongroup_subset, so a nested group is rebuilt with its own nesting on both sides and each part inherits the group's traceback and chaining; the matcher may be a class, a tuple of classes, or a predicate.except*runs each clause at most once against what is left of the group, binds it to the matching subgroup, wraps a naked exception in a one-element group, and reassembles what the handlers left behind with_PyExc_PrepReraiseStar's rules — a bare re-raise merges back into the original group's nesting, a freshly raised exception becomes a sibling in a newExceptionGroup('', …). Its three compile-time rules (exceptandexcept*may not be mixed, every clause names a type, nobreak/continue/returnleaves the handler) are enforced. An uncaught group renders CPython's+-+---------------- n ----------------tree — a port oftraceback.py's_ExceptionPrintContext, including themax_group_width(15) /max_group_depth(10) elisions and each member's own chained blocks. Fuzzed to zero divergences (parity-fuzz --mode excgroup, stdout and--stderr). -
Object model:
complex((1+2j)*(3-1j),.real/.imag,abs),frozenset(immutable, hashable, set algebra), metaclasses (class A(metaclass=M),M.__new__/__init__;type(A) is M),propertygetters/setters, custom descriptors (__get__/__set__),super()+ C3 MRO (C.__mro__linearization), and__init_subclass__(PEP 487) (parent hook fires with the new class and class-header keywords). -
Instances are hashable as dict keys / set members via a user
__hash__(with__eq__), so{K(1): 'a'}[K(1)]resolves. -
NotImplemented-driven reflected-op negotiation: a forward dunder that returnsNotImplementedretries the reflected dunder, for both arithmetic (A().__add__→B().__radd__) and comparison (A().__lt__→B().__gt__); when neither resolves, aTypeErroris raised. CPython's two ordering rules hold as well: the RIGHT operand goes first when its type is a proper subclass of the left's and overrides the reflected dunder (A() + C()runsC.__radd__and never reachesA.__add__), and two operands of the SAME type never consult the reflected half for ARITHMETIC —A() + A()whose__add__declines raises even though__radd__exists — while comparison does consult it (B() < B()tries__lt__then__gt__). An augmented assignment that no dunder answers names the augmented operator (`unsupported operand type(s) for=
), and a sequence reports its own concat/repeat refusal (can only concatenate list (not "T") to list,can't multiply sequence by non-int of type 'T'`). -
%s/%r/%adispatch a user instance's__str__/__repr__/ascii(repr)(and recurse into containers holding instances), matching f-strings/.format; the format args' dispatched values are pre-resolved outside the host borrow. -
Nested format specs (f-string AND
str.format)f'{x:{w}.2f}'/f'{3.14159:{5}.{2}f}'/'{:{}}'.format('hi', 10)/'{:>{width}.{prec}f}'.format(v, width=10, prec=2): the{…}inside a spec is evaluated as its own replacement field (sharing the automatic-field counter) and spliced into the final spec before formatting. -
f-string
=debug specifierf'{x=}'/f'{x = }'/f'{x+1=}': the source text up to and including the top-level=(preserving surrounding whitespace) is emitted literally, then the value — defaulting toreprwith neither conversion nor format spec, and honoring a trailing!r/!s/!aconversion or:spec(f'{x=:.2f}',f'{y=!r}'). Byte-verified vs CPython via theconttailfuzz mode. -
str.formatkeyword / index / attribute fields'{name}'.format(name=…),'{0[1]}'.format(seq),'{d[k]}'.format(d=…)(unquoted subscript key → str),'{0.real}'.format(x)(attribute access) — all resolve against the positional args, kwargs, and accessor chain. -
\N{NAME}named-Unicode escapes decode in normal and f-strings. -
File I/O:
open()(text and binary, read/write/append),.read/.readline/.readlines/.write, line iteration, andwith open(...) as f:work in the default build. -
bytes/bytearrayare real heap types with the full sequence + method surface (byte-verified vs CPython via thebytesopsandbytestailfuzz modes, 0 divergences): construction (b'…',bytes([65,66]),bytes(3),bytearray(b'…'),bytes.fromhex/bytearray.fromhex),len, integer indexing (b[0]→int), iteration/list(), slicing (b[1:3],b[::-1]), concat (b1+b2, result type follows the left operand), repeat (b*3), membership (int in bbyte-value, bytes-like substringb'a' in b'abc'), ordering (</==, incl. bytes vs bytearray), andbytesas a hashable dict/set key. Str-parallel methods returning/taking bytes:split/rsplit/join/replace/find/rfind/index/rindex/count/startswith/endswith/strip/lstrip/rstrip/upper/lower/swapcase/title/capitalize/zfill/expandtabs/center/ljust/rjust/splitlines/partition/rpartition/removeprefix/removesuffix/translate/maketrans/decode(acrossutf-8/ascii/latin-1/utf-16/utf-32witherrors=strict/ignore/replace/backslashreplace; the encode-onlynamereplace/xmlcharrefreplaceraiseTypeErroron decode, matching CPython)/hex(incl. thesep/bytes_per_sepgrouping form), the ASCIIisXpredicates (isalpha/isdigit/isalnum/isspace/isupper/islower/istitle/isascii), and PEP 461%-formatting (b'%d-%s' % (1, b'x'),%b/%c/%a/%r, width/precision/flags,%(name)smapping;%b/%sdispatch a user instance's__bytes__).bytearrayitem + slice assignment (ba[0]=65,ba[1:2]=b'xy',ba[::2]=…), deletion (del ba[i],del ba[i:j],del ba[::k]), plusappend/extend/pop/clear.reprmatches CPython quoting (single/ double-quote selection; the bytearray always-escape-'quirk). -
memoryviewover abytes/bytearraybuffer (faithful 1-D unsigned-byte subset, byte-verified vs CPython):memoryview(b'…'),len, integer indexing (incl. negative), contiguous slicing (a sub-view sharing the buffer) and strided slicing (a fresh view), iteration, byte-value membership, equality againstbytes/bytearray/other views,bool,bytes(mv)/list(mv)conversion, andtobytes/hex/tolist. Read-only descriptorsobj,nbytes,format('B'),itemsize,ndim,shape,strides,readonly,contiguous. A view over abytearrayreflects later mutations to the backing buffer and is writable-flagged (readonlyFalse); abytesbacking is read-only.<memory at 0x…>repr. Item assignment THROUGH the view writes into the backingbytearray—mv[i] = b,mv[i:j:k] = <bytes-like>(every step, with CPython's fixed-length "different structures" rule rather than a splice), a sliced view writing at its own offset, and aliased views seeing each other's writes — with each refusal distinguished as CPython distinguishes it (cannot modify read-only memory,memoryview: invalid type/value for format 'B',index out of bounds on dimension 1,a bytes-like object is required,cannot delete memory). Not covered:cast(format reinterpretation), multi-dimensional views, a view over any buffer that is not abytes/bytearray(memoryview(array.array('i', …))raisesTypeError: memoryview: a bytes-like object is required, not 'array'; CPython builds anitemsize 4,format 'i'view), and the export bookkeeping that makes CPython raiseBufferError: Existing exports of data: object cannot be re-sizedwhen abytearrayis resized while a view over it is alive. -
Codecs, escapes, and unicode (byte-verified vs CPython via the
codecfuzz mode, 0 divergences):str.encode(encoding, errors)acrossutf-8/ascii/latin-1/iso-8859-1/utf-16/utf-32(bareutf-16/utf-32emit a little-endian BOM; the-le/-benames don't) with thestrict/ignore/replace/backslashreplace/xmlcharrefreplace/namereplaceerror handlers;bytes.decodefor the same codecs with BOM auto-detection and the decode-side handler set.repr/asciiescape exactly the non-printable code points CPython does (Unicode 16.0 general categories Cc/Cf/Cs/Co/Cn and Zl/Zp/Zs, space excepted), choosing the shortest\xHH/\uHHHH/\UHHHHHHHHform.chr/ordround-trip the full range (lone surrogates rejected — a Ruststrcan't hold them; see gaps).str.isprintable/isascii/isidentifier(incl. the PEP 3131Other_ID_Continue+ ZWNJ/ZWJ chars)/isspace(incl. U+001C..U+001F) match CPython;len/indexing count code points, not bytes. Escape literals —\n \t \r \0, octal\NNN,\xHH,\uHHHH,\UHHHHHHHH,\N{NAME}, rawr"…", and byte-string escapes — decode in the lexer. -
Comprehension scope: list/set/dict comprehensions run in their own function scope, so the loop variable no longer leaks; enclosing variables are still read through the closure (the outermost iterable is evaluated in the enclosing scope, matching CPython).
-
Subclassing builtin types (
class Stack(list),class D(dict),class U(str),class C(int),class F(float),class T(tuple),class S(set)). The instance is a hybrid: it carries the native builtin payload (list storage / int value / …) alongside the class +__dict__, so it inherits ALL builtin behavior — methods (.append/.upper/.keys), operators (+/[]/len), iteration, membership,repr/str, hashing, equality — while supporting user methods, instance attributes, andsuper().__init__(...)/super().__new__(cls, …). One mechanism routes every type (builtin_base_ofdetects the base from the MRO; the payload is unwrapped for operators/coercion and delegated to for methods/protocol dunders). Construction builds the payload from the constructor args (immutable bases at__new__, mutable bases via__init__/super().__init__). Adictsubclass fires__missing__on a key miss;int/floatsubclass arithmetic returns the plain base type (C(5) + 3→int8);isinstanceandtype(x).__name__reflect the subclass. Fuzzed to zero divergences (parity-fuzz --mode subclass). -
math.gamma/lgamma/erf/erfcanswer bit-for-bit, which needed each from the same source CPython takes it from.erf/erfcare the platform's (CPython 3.14 declares themFUNC1A(erf, erf, …));gamma/lgammaare ports ofm_tgamma/m_lgammafromModules/mathmodule.c, which CPython carries itself because the platform's are not accurate enough. The pure-Rustlibmcrate is neither, and disagreed in the last place on 312/1201, 390/1201, 907/1194 and 976/1194 sampled points across[-6, 6]; a straight translation of the Lanczos code still disagreed on 524 and 637 until the multiply-add inlanczos_sumwas contracted the way clang contracts it.lgamma(-inf)isinf— the log of a magnitude — not the domain error pythonrs raised. -
The
itertools/collections/mathcontainer surface that no probe exercised. Found by diffing the namessrc/builtins.rsdispatches against the identifiers the fuzz corpus actually writes: a keyword-only argument, a function nobody called, or a method absent from the note-taker's list is invisible to a curated corpus no matter how many cases run. All of the below are now covered byparity-fuzz --mode containertail(4 000 cases, zero divergences):itertools.accumulate(initial=)was ignored. The seed is yielded before the source is touched, so the result is one longer than the input andaccumulate([], initial=5)is[5]— pythonrs answered[], andaccumulate([1,2,3], operator.mul, initial=10)answered[1, 2, 6]instead of[10, 10, 20, 60].itertools.batcheddid not exist (AttributeError: module 'itertools' has no attribute 'batched'), including itsstrict=form and its two ValueErrors (batched(): incomplete batch,n must be at least one).picklebatches its APPENDS/SETITEMS through it.itertools.count(start, step)coerced both throughas_int, socount(1.5, 0.5)counted0, 1, 2— a silently wrong answer, not an error. Start and step are added with the numeric+now, so floats count in floats and a bignum start stays exact.reprofcount/repeatprinted the generic<itertools.count object at 0x…>; CPython gives both a constructor-style repr reporting LIVE state (count(3)after two pulls,repeat('x', 2)after one).collections.deque.insertdid not exist. It clamps likelist.insert, accepts a negative index, and — unlikeappend— REFUSES on a full bounded deque withIndexError: deque already at its maximum sizerather than evicting from the far end.Counterheld only ints: every count went throughas_int, soCounter(a=1.5)stored0andCounter(a=10**30)stored0. The constructor,update,subtract,total,most_common,elements, the multiset operators and the unary forms all carry counts as values now, added with the numeric+/-.Counter.update/subtractalso dropped their keyword counts entirely —c.update(a=2)was a silent no-op.Counter.__repr__used insertion order; CPython's isf'Counter({dict(self.most_common())!r})'— descending by count, stable, so ties keep insertion order.Counter(a=3, b=-1, c=0, d=0)reprs asCounter({'a': 3, 'c': 0, 'd': 0, 'b': -1}).- Unary
+c/-con a Counter were aTypeError: bad operand type for unary +: 'Counter'. CPython defines them asc - Counter()andCounter() - c, so both drop non-positive counts — the pair that splits a signed tally into its gains and its losses. defaultdict.default_factorydid not exist in either direction. It reads back the factory (orNone) and is writable — assigningNoneturns the defaultdict back into a KeyError-raising dict.OrderedDict.popitem(last=)raisedTypeError: dict.popitem() takes no arguments (1 given), so the ordered form could only pop LIFO.last=Falseis how an OrderedDict is used as a FIFO queue. Its empty-dictKeyErroralso carries'dictionary is empty', notdict's'popitem(): dictionary is empty'.math.prod(start=)was ignored —prod([2,3], start=4)answered6. The start also fixes the RESULT TYPE of an empty iterable:prod([], start=2.5)is2.5, not1.
-
sys.stdlib_module_names, and the NameError hint built on it. The attribute did not exist, and with it missingprint(functools)reported a bareNameError: name 'functools' is not definedwhere CPython adds. Did you forget to import 'functools'?(and, when a near miss also matches, the stacked. Did you mean: 'funtools'? Or did you forget to import 'functools'?). CPython ships the name table as a generated static list; pythonrs COMPUTES it from the three places a stdlib module can actually come from —sys.builtin_module_names, the native-only arms ofimport_module_inner, and the bundledpylib/tree — so the set can never advertise a module the interpreter would fail to import. CPython's own exclusions are ported (Tools/build/generate_stdlib_module_names.py'sIGNOREset, plus the install-only_sysconfigdata_*/sitecustomize/usercustomizenames its generator never sees). Measured: 217 names, every one of them present in CPython 3.14.7's 297 — zero false positives. -
A pythonrs value that crossed into CPython and back came home as a NEW object.
py_to_valuehad no case for the four proxy pyclasses this crate hands out (PyrsCallable,PyrsIterator,PyrsInstance,PyrsFile), so a round trip through any stdlib API that merely stores a value and returns it minted a freshForeignhandle andiswent False. The proxy is unwrapped on the way back now.functools.wrapsneeded a second half: it doessetattr(wrapper, '__name__' / '__doc__' / '__wrapped__', …)and then RETURNS the wrapper, and every one of those assignments landed in the proxy's own__dict__and died with it — the decorated function kept its original__name__and had no__wrapped__at all.PyrsCallable.__setattr__writes through to the wrapped pythonrs callable, which is what CPython's in-place semantics mean. -
int→floatconversion saturated instead of raising. CPython reads anintoperand of a mixed arithmetic expression throughPyLong_AsDouble, which RAISES past thef64range.num_valsaturated toinf, so a wrong NUMBER travelled where an error was due:(2**2000) * 1.0wasinfand(2**2000) // 1.0wasnan. Arithmetic now reads operands throughnum_val_arith. Comparison deliberately keeps saturating — CPython never converts there, and(2**2000) > 1.0must stayTrue.float(2**2000)raises too. -
int / intdivided in the FLOAT domain. Both sides were read asf64and divided, so past thef64range the answer was not merely imprecise but absent:2**2000 / 2**1999came outinf / inf=naninstead of2.0, and a representable quotient was reported as overflow because an OPERAND alone did not fit.bigint_true_dividenow runs CPython'slong_true_divide— the quotient is formed in the integer domain and rounded once, scaled to 55 significant bits with the low bit forced odd so the two-step rounding is exact (one spare bit is not enough: an odd quotient is then itself the tie, which cost an ulp on(10**20) / 3). 4000 randomized bignum divisions agree with CPython bit-for-bit, compared asfloat.hex. -
2.0 ** 10000returnedinf. CPython'sfloat_powreports the C library's ERANGE asOverflowError: (34, 'Result too large'). Only a FINITE pair can overflow into one, sofloat('inf') ** 2staysinf. Relatedly(-1.0) ** float('inf')answered(nan+nanj):fract()of an infinity is NaN, which compares unequal to0.0and sent every infinite exponent down the "negative base to a non-integer power is complex" path. C99 givespow(-1.0, inf) == 1.0. -
range()named itself instead of the offending type.range(1.5)said'range' requires integer arguments; CPython uses the vocabulary every index-taking builtin shares —'float' object cannot be interpreted as an integer. -
Container dunders were granted to every value.
__len__,__getitem__,__setitem__,__delitem__,__iter__,__contains__and__bool__were exposed as bound methods on any builtin, which is observable:hasattr(5, '__len__')was True and(1, 2).__setitem__handed back a bound method for a method a tuple does not have — 38 wrong answers across the builtin types.is_object_dunder_methodnow takes the receiver's type name and gates each on the types CPython gives it to. A container's truth comes from__len__, so containers get no__bool__either; only__str__/__repr__stay universal.dict_valuesloses__contains__to match, andv in d.values()still works by iterating the view — which is exactly why CPython omits the method. CALLING an absent dunder now raises the sameAttributeErrorthat reading it does, instead of letting the native operation answer with its own complaint. -
The "perhaps you missed a comma?"
SyntaxWarningcovered one of its twelve shapes. Only a literal sequence subscripted by a FLOAT warned. Every non-int compile-time index warns now ([1, 2]['a'],[None],[b'x'],[1j],[...], and the list/tuple/dict displays), while anint/boolindex, a slice, adict, and a bare NAME stay silent as in CPython. CALLING a literal —None(),1(2),[1, 2](3)— did not warn at all and now does.evalandexeccompiled their source and DROPPED the warnings entirely; they print them to stderr attributed to<string>, as CPython does. -
The bytecode cache did not invalidate on a REBUILD. The key hashed the source, a hand-bumped
SCHEMA, andCARGO_PKG_VERSION— no term that a rebuild moves. Any build between two releases that changed lowering silently replayed the PREVIOUS build's bytecode out of~/.pythonrs/scripts.rkyv: no error, no wrong answer to chase, just "my fix did not take". Found when a compiler change emitting a newSyntaxWarningappeared to do nothing for every already-cached script on a binary rebuilt seconds earlier; the v49SCHEMAnote records the same class of bug biting once before. The key now also hashes the running executable's size and mtime.
Implemented — async/await/asyncio (native fusevm event loop)
async def/await/asyncio.async def f()returns a real coroutine object (type(f()).__name__ == 'coroutine'; the body does not run on call), backed by the same stackfulcorosenseicoroutine as generators — eachawaitis a suspension point.awaitdrives an awaitable (a coroutine, anasyncio.Future/Task, or an object with__await__), suspending the running coroutine (yielding up to its Task) until it settles, then resuming with the result (or raising its exception). The event loop (crate::async_rt) is a native ready-queue + timer-heap with a virtual clock, single-thread and cooperative like CPython's.asyncio.run/sleep/gather/create_task/ensure_future/wait_for/get_event_loop/get_running_loop/Futureall run on it, verified byte-for-byte vs CPython (coroutine type, orderedgatherresults,create_taskinterleaving,Future.set_result+ await, exception propagation acrossawait, andasyncio.sleeptimer ordering).async for/async with/ async comprehensions.async for x in aitdrives__aiter__/__anext__(stopping onStopAsyncIteration, with correctfor…elsesemantics);async with cmdrivesawait __aenter__/await __aexit__; async comprehensions[x async for x in ag()](and set/dict forms, withiffilters) run the hidden comprehension body as an awaited coroutine — all byte-verified vs CPython.asyncio.wait/as_completed/Event/Lock/Queueare also implemented natively on the same event loop (Event.wait/set/clear,Lock.acquire/releaseasync with lock,Queue.put/get/qsize), byte-verified vs CPython.
- Async generators.
async defcontainingyieldbuilds an async generator (type().__name__ == 'async_generator') with__aiter__/__anext__; each__anext__drives the body to the nextyield(forwarding interveningawaitsuspensions to the loop) and raisesStopAsyncIterationon exhaustion — soasync for x in ag()and[x async for x in ag()]over a real async generator both work (byte-verified). Theawait-vs-yielddistinction rides anawaitingflag on the generator cell. Not yet: task cancellation propagation (Task.cancelsettles the future but does not injectCancelledErrorinto a suspended coroutine); bounded-Queueput back-pressure (put is always accepted);wait'stimeout/return_whenvariants; async-generatorasend/athrow/aclose.
Partial / simplified semantics
-
A bridged exception carries no CPython traceback. An exception that crosses from pythonrs into CPython is rebuilt as a fresh exception object, so its
__traceback__is empty. Two visible consequences, both in code that is not otherwise wrong:logging.exception('…')inside a pythonrsexceptblock logsNoneType: Nonewhere CPython prints the four-line traceback (CPython'ssys.exc_info()on the bridge side has no exception to report), and aunittestfailure report carries theAssertionError: 1 != 2line without theTraceback (most recent call last):block above it. Repro:import logging; logging.basicConfig(); \ntry: 1/0\nexcept ZeroDivisionError: logging.exception('boom'). -
contextlib.redirect_stdoutdoes not capture CPython-side writes. pythonrs tracks the redirect inPyHost::stdout_target, which its ownprintandsys.stdoutreads honour, but the embedded interpreter'ssys.stdoutstill points at the real fd. Output written through the CPython side —functools.partial(print, …), anything a bridged module prints — escapes the buffer and lands on the terminal, in the wrong order relative to the captured text. The same gap applies tounittest's--bufferand any capture built onredirect_stdout. Repro:with contextlib.redirect_stdout(io.StringIO()) as buf: functools.partial(print, 'x')()—xappears on stdout, not inbuf. -
A pythonrs callable or object cannot be used from a worker thread. On the bridged build
import threadingis CPython's, and CPython'sthreading.pyimports the REAL C_thread— not the native_threadthis crate ships — soThread.start()spawns a genuine OS thread rather than running the target inline.PyHostis athread_local, so on that thread the heap is empty and every pythonrs value resolves to nothing: the target comes back as a bareobjectand the thread dies withTypeError: 'object' object is not callable, once per thread, with the program's own result silently missing. Measured:threading.Thread(target=print, args=('x',)).start()works (the target is a CPython builtin) whilethreading.Thread(target=res.append, args=(1,)).start()fails for a pythonrsres, and_thread.get_ident() != threading.get_ident()because two different_threadmodules are live at once. The native_threadarm and its inline-execution semantics therefore only apply to the--no-default-featuresbuild; the header comment insrc/stdlib/pythread.rsdescribes that build, not this one. Closing the gap means putting the native_threadinto the embedded interpreter'ssys.modulesbeforethreadingis imported, which would also make every thread on the default build serialise — a decision about what the default build IS, not a defect to patch. -
A pythonrs instance cannot be pickled.
pickleis CPython's, and a pythonrs object reaches it as the opaquePyrsInstancewrapper, which exposes no__reduce__and no picklable__class__:pickle.dumps(obj)raisesTypeError: cannot pickle 'builtins.PyrsInstance' object. Plain containers of builtins pickle correctly (they cross by value); it is user-defined classes that do not, which also rules outcopy.deepcopythrough the pickle fallback,multiprocessingarguments, and anything that caches objects to disk. -
warnings.catch_warnings(record=True)records nothing.warnings.warnis CPython's C_warnings.warnwhile the recording list the context manager installs is read back across the bridge by value, so the appended entries are not visible to the pythonrs-side name.wstays empty and indexing it raisesIndexErrorwhere CPython reports oneUserWarning. -
dir()on a builtin type omits most of the inherited dunders. Every builtin is missing theobject-level names (__delattr__,__dir__,__format__,__getattribute__,__getstate__,__init_subclass__,__reduce__,__reduce_ex__,__setattr__,__subclasshook__) plus the comparison set and the container dunders it does implement —dir('a')is short by 24 names,dir([1])by 26,dir(5)by 15 (which also lacksdenominator/numerator/imag/real/from_bytes). Attribute ACCESS is unaffected: the names that matter resolve, andhasattragrees with CPython across the container dunders. What this costs isdir()itself and the "Did you mean" hint computed from it, so'a'.__setitem__reports the rightAttributeErrorwithout CPython'sDid you mean: '__getitem__'?clause. -
The depth guards are calibrated for the interpreter's 512 MB stack, not for an embedder's.
src/main.rsruns the interpreter on a thread withstack_size(512 * 1024 * 1024), andparser::MAX_TREE_DEPTHis chosen against that. pythonrs descends roughly fifteen parser frames per nesting level, sopythonrs::eval_strcalled from an ordinary 2 MB thread overflows well below the cap — libtest's worker cannot hold even the 200 bracket levels CPython accepts, which is whydeeply_nested_source_raises_instead_of_overflowing_the_stackspawns a matching thread. Lowering the cap to fit 2 MB would reject source CPython accepts; making the levels cheaper is the real fix. -
The stage that runs out of parser stack is not reproduced. CPython reports
MemoryError: Parser stack overflowed …when its PEG parser is what overflows andRecursionError: Stack overflow (used N kB) during compilationwhen the parse succeeded and the compiler is what overflows —'-'*100000+'1'is the first,'a'+'.b'*100000and'1'+'+1'*200000are the second. pythonrs's cap lives entirely in the parser, so all of them report theMemoryErrorform. Both are catchable, which is the property that was missing; the class split is not. Relatedly, pythonrs is MORE permissive than CPython on two shapes it accepts up to the cap:'lambda: '*5000+'1'and'not '*20000+'1'parse here and areMemoryErrorthere. -
AttributeError.objis absent..nameis bound (see above), but the object the failed lookup ran against is not recoverable from the rendered message thatsynth_excreconstructs the exception from, and fabricating one would be worse than its absence. CPython answers1for(1).nope. -
UnicodeDecodeError/UnicodeEncodeErrorcarry the rendered message, not the five-tuple. CPython'sargsis(encoding, object, start, end, reason)—('utf-8', b'\xff', 0, 1, 'invalid start byte')— with.encoding,.object,.start,.endand.reasonreading back from it; pythonrs hasargs == (<the whole message>,)and none of the five attributes. Unlike theOSErrorcase, this one cannot be fixed by parsing the message: theobjectis the offendingbytes/stritself and the message only shows one byte of it. Closing it means carrying the structured arguments from the codec raise sites insrc/stdlib/codecs.rsthrough to the exception object, and teachingexc_messageto render CPython's text back from them. -
A bridged exception's type has a two-element MRO.
struct.errorandbinascii.Errorreport__module__ == 'builtins'andtype(e).__mro__ == (error, object), where CPython saysstruct.error/binascii.Errorand(error, Exception, BaseException, object)/(Error, ValueError, Exception, BaseException). Catching is unaffected —except struct.error,except binascii.Errorandexcept ValueErrorall match, because handler matching walks the base names captured at raise time rather than the type object — but the traceback's final line readserror:/Error:rather thanstruct.error:/binascii.Error:, and code that reads__mro__or__module__off a caught exception sees the wrong thing. Separately,re.erroris not a class at all here (it answers abuiltin_function_or_method, sore.error.__mro__raises), where CPython 3.14.6 answers a class namedPatternErrorin modulere. -
int(str)has no digit limit andsys.set_int_max_str_digitsis absent. CPython 3.14.6 caps a decimalint()conversion at 4300 digits (int('9'*100000)isValueError: Exceeds the limit (4300 digits) for integer string conversion: value has 100000 digits; use sys.set_int_max_str_digits() to increase the limit) and exposessys.set_int_max_str_digits/get_int_max_str_digitsto change it;int('9'*100000)succeeds here. pythonrs is more permissive, so nothing that works under CPython breaks — but a program relying on the guard does not get it. -
A binary operator's caret anchor stops at the operator. When the right operand is PARENTHESIZED, CPython's anchor runs from the left operand's end to the right operand's own
col_offset, which is INSIDE the parens:1+("a")underlines~^^~~~~and pythonrs underlines~^~~~~~. Unparenthesized operands agree. Only the caret row differs; the message, the line and the span are the same. -
stdoutis never block-buffered, so a merged stream interleaves differently. CPython line-buffersstdouton a TTY and BLOCK-buffers it on a pipe or file, whilestderrstays unbuffered; pythonrs flushesstdouton every write. Redirected output therefore comes out in a different order:import sys print("out1"); sys.stderr.write("err1\n") print("out2"); sys.stderr.write("err2\n") $ python3 prog.py > log 2>&1 -> err1 err2 out1 out2 $ python prog.py > log 2>&1 -> out1 err1 out2 err2The same difference makes pythonrs KEEP output CPython drops:
print("kept", end=""); os._exit(0)printskepthere and nothing under CPython, becauseos._exitskips the flush of a buffer pythonrs does not have.-ucannot be observed on the pythonrs side for the same reason — the streams it would unbuffer are already unbuffered. Neither differential harness can see any of this:dropin_check.shdiscards stderr andparity-fuzzreads the two streams through separate pipes, so the interleaving is never compared. Closing it means owning a realBufWriterforstdoutwith a TTY check, a flush at normal exit and beforeinput(), and matching buffering on the embedded interpreter's side (ffi.rs::line_buffer_std_streamscurrently line-buffers CPython's streams specifically to match the unbuffered behaviour described here). -
A user exception raised out of a wrapped generator loses its identity, not its class.
PyrsIteratornow implements the generator protocol (send/throw/closebeside__iter__/__next__), so@contextlib.contextmanagerdrives a pythonrs generator:__exit__throws the body's exception in, atry/exceptaround theyieldsees it, andStopIterationcomes back out as "handled". An exception the generator body re-raises unchanged, however, crosses as a NEW CPython object of the same class and args rather than the very object__exit__threw in —contextlibbranches onexc is not value, so it re-raises instead of returning False. The message, class and args a caller sees are identical; the traceback is one frame shorter. Closing it means carrying the CPython object's identity through the pythonrs exception value rather than rebuilding from class + args. Reachable fromparity-fuzz --mode ctxmgrand--mode stdlibexc. -
-Oreaches__debug__and nothing else. The flag sets__debug__to False, but the compiler still emits everyassert(so an optimized run keeps checking them) andsys.flags.optimizestill reports 0. Skipping asserts at compile time means threading the level into the bytecode CACHE KEY as well — otherwise a chunk compiled under-Owould be reused without it — so it is deliberately not bolted on to the flag alone. -
__slots__installs no member descriptors. The slot RESTRICTION is enforced (a non-slot attribute is the CPythonAttributeError), but the names are absent from the class:class A: __slots__ = ('x',)thenA.xisAttributeError: type object 'A' has no attribute 'x'where CPython answers<member 'x' of 'A' objects>. Reaching a slotted__doc__through the class reportsNonefor the same reason. -
types.UnionType is type(int | str)is False on the ffi build. In the self-contained buildtypes.pybindstype(int | str)and the identity holds. Understdlib-ffi,types.UnionTypeandtyping.Unionare both CPython's own object (identical to each other) whiletype(int | str)stays native, so the union type has two representations that never compareis, even though the name, module, repr and messages all match. This is the general cross-bridge type-identity boundary, not specific toUnion. -
PEP 649 forward-ref annotations do not raise.
def g(x) -> NotYet: ...theng.__annotations__yields{}; CPython 3.14 evaluates the annotation lazily on that read and raisesNameError: name 'NotYet' is not defined. Class bodies drop the unresolvable entry the same way. -
Vendored
astomits optional-field defaults fromrepr. In the self-contained buildrepr(ast.Constant(1))isConstant(value=1); CPython saysConstant(value=1, kind=None), because every OPTIONAL ASDL field carries a class-levelNonedefault thatreprthen reads._fieldsalready listskind; what is missing is the optional/required split fromPython.asdl. -
compile()is absent —NameError: name 'compile' is not defined. The-c/file paths compile internally, but the builtin that exposes it (and socode-object construction from source,exec(compile(...)), anddis-over-source) is not wired up. -
The context-manager protocol check does not reach the natively shadowed managers.
with <not a context manager>:now raises CPython'sTypeError: 'X' object does not support the context manager protocol (missed __exit__ method)for a user instance and for the core scalars/containers, and refuses to enter (see the Implemented entry). It is skipped for a nativeFile/Lock/redirect_stdoutand for any bridged CPython object, because those dispatch__enter__/__exit__insidecall_method_innerwithout exposing them as attributes — probing them would report a missing method that is in fact there. Awithon such a value that genuinely lacks the protocol still reports the oldAttributeError. A faithful fix needs a "does this native type answer__exit__" predicate that agrees withcall_method_inner's own dispatch table. -
memoryviewis not a context manager.with memoryview(b"ab"):raisesAttributeError: 'memoryview' object has no attribute '__enter__'; CPython'smemoryviewsupportswith(the exit releases the buffer). -
f.__annotate__is afunctools.partial, not afunction. It is callable, answers theVALUE/FORWARDREFformats with the def-time annotations dict, and raises a bareNotImplementedErrorotherwise — buttype(f.__annotate__)and itsreprdiffer from CPython's compiler-generated annotate function. Likewiserepr(itertools.chain.from_iterable)is<built-in function itertools.chain.from_iterable>where CPython prints<built-in method from_iterable of type object at 0x…>; calling it agrees. -
Operator overloading dunders: dispatched, with
NotImplementedreflected fallback (see Implemented). Covered: arithmetic/bitwise (__add__/__sub__/__mul__/__truediv__/__floordiv__/__mod__/__pow__/__matmul__/__and__/__or__/__xor__/__lshift__/__rshift__) with their reflected__r*__, comparisons (__eq__/__ne__/__lt__/__le__/__gt__/__ge__), and__getitem__/__setitem__/__len__/__bool__/__str__/__repr__/__iter__/__next__/__init__/__hash__. Containerrepr/strrecurses so instance elements/keys/values dispatch their own__repr__. The numeric dunders are also exposed as callable bound methods onint/bool/float((5).__index__(),(-3).__abs__(),(7).__floordiv__(2),(1).__add__(2),(2.0).__round__(), reflected__r*__, comparisons,__int__/__float__/__trunc__/__floor__/__ceil__/__invert__/__bool__/__hash__); a binary dunder returns theNotImplementedsingleton for operand types the base type declines (intcombines only withint-likes) — matching CPython, byte-verified.int-only bitwise/shift/__index__/__invert__are absent onfloat, as in CPython. In-place augmented dunders are dispatched too (see Implemented). Subclassing builtin types (class L(list),class C(int), …) is fully covered: inherited methods/operators/iteration,super().__init__,__new__, use as dict/set keys (a payload-hashing subclass keys identically to its base value),dict(subclass)conversion, and augmented assignment preserving the subclass type for mutable bases. -
intis arbitrary precision (bignum) across+ - * ** // %and the bitwise ops& | ^ << >>— verified byte-identical to CPython on10**30-scale values (the earlier i64-cap on///%/bitwise is gone). -
f-string /
str.formatformat spec is complete for the builtin types. Every presentation type (b c d e E f F g G n o s x X %and the omitted one), every flag (fill/align/sign/#/0/width/,/_/.prec), and nested field specs are covered — measured by sweeping 4 800 generated specs against all ofint/bignum/bool/float/-0.0/inf/nan/str(91 206 pairs) underLC_ALLinC,en_US,de_DE,hi_INandfr_FR, byte-identical to CPython 3.14.6 in every one. -
A
slice's repr does not dispatch a bound's__repr__.slice(Idx(), Idx())rendersslice(<__main__.Idx object at 0x…>, …)where CPython rendersslice(Idx(), Idx(), None). The rendering happens insidePyHost::repr_of, which cannot call back into the interpreter while it holds the host borrow — the same constraint%rsolves by pre-resolving the dispatched values outside the borrow, which is what a slice's bounds would need too. -
Lone surrogates in
str:chr(0xD800..0xDFFF)raisesValueErrorwhere CPython returns a surrogate-bearingstr(which then fails only on UTF-8 encode). pythonrs strings are RustString(valid scalar values only), so a lone surrogate is unrepresentable without a surrogate-aware string type; the out-of-range and surrogate paths share CPython'schr() arg not in range(0x110000)message.surrogateescape/surrogatepasshandlers are likewise not reachable for the same reason. -
A traceback stops at the pythonrs frame; frames INSIDE a bridged stdlib module are not listed.
textwrap.shorten('a b c', 4)raises the right exception with the right message and the right caret line, but CPython's rendering also names the fourtextwrap.pyframes between the call site and theraise(shorten→fill→wrap→_wrap_chunks) and pythonrs's does not. The exception object and its type are correct; only the intermediate frames of the CPython-side call stack are missing, because the bridge returns the error without walking the foreign traceback. This is the same boundary theDuring handling of the above exception…chained section sits behind. -
floatreprtie-break: the shortest-round-trip formatter defers to Ruststd's Ryū, which breaks an exact tie between two equally-short 17-digit decimals toward the larger digit, whereas CPython's dtoa rounds half-to-even. This surfaces only on the rare value whose two shortest reprs are equidistant from the true value (e.g.2113325745016023.2prints as…3.3); the underlyingf64bits are identical either way (float.hexagrees). A faithful fix needs a dtoa-style shortest formatter rather than thestdone. -
dir()on a native builtin type/value is the method table, not CPython's full slot listing.dir(list)/dir("a")enumerate the names the type really responds to (so'append' in dir(list)and'upper' in dir(str)are right), plus__class__/__doc__/__init__/__new__/__sizeof__. CPython'sdir(list)is 48 entries because every slot wrapper (__add__,__iadd__,__class_getitem__, …) is a real descriptor on the type; pythonrs dispatches those natively rather than through per-type descriptor objects, so they are not enumerable.dir()of a bridged CPython object (dir(json),dir(datetime.date(...))) IS exact — it delegates to CPython's owndir(). -
__loader__/__builtins__are not bound in module globals.__name__,__file__,__doc__,__package__,__spec__and (for a script)__cached__all match CPython, but the remaining two need real importer and module objects:__loader__is a_frozen_importlibclass and__builtins__is thebuiltinsmodule itself.sorted(globals())therefore differs from CPython by exactly those two names. Relatedly,import builtins; builtins.len is lenisFalse— the bridgedbuiltinsmodule is a distinct CPython object from the native builtin dispatch. -
256+ argument calls /
**-spread dict literals:CallBuiltincarries au8operand count, so an op that must name >255 stack slots at once raisestoo many arguments (>255) for one call. Plain collection literals ([...]/(...)/{...}and f-strings) no longer hit this — the compiler now builds them in ≤255-slot chunks via theEXTEND_LIST/EXTEND_TUPLE/EXTEND_SET/EXTEND_DICT/EXTEND_STRops (mirrors CPython's LIST_EXTEND/DICT_UPDATE/BUILD_STRING). Still overflowing: a call with >255 positional args, a{**a, …}dict literal with >127 entries (the tag-packedMKDICT_EXsite), and the rare >255-slotMKFUNC/class-base/MATCH_CLASSsites. CPython lowers all of these too; the same chunked treatment would extend to the call/spread paths. -
A call with an ATTRIBUTE callee resolves it after its arguments. CPython evaluates the callee first, then the arguments left to right. The bare-name callee now does the same (
aa(bb)blamesaa), butcompile_call(src/compiler.rs) still folds the attribute lookup INTOCALL_METHOD, soobj.m(g())runsg()before resolvingm:log = [] def f(*a): log.append('call'); return 0 def g(): log.append('arg'); return 1 class K: def __getattr__(s, n): log.append('callee'); return f K().m(g()) print(log) CPython: ['callee', 'arg', 'call'] pythonrs: ['arg', 'callee', 'call']Only a callee with a side effect (
__getattr__, a property, a module__getattr__) can tell the difference; a method that exists on the type has none. Fixing it means resolving the attribute to a value before the arguments, which costs aBuiltinobject plus aBoundMethodallocation per call — measured on a debug build, interleaved min-of-7 with the bytecode cache off,xs.append(i)+18.4%,d.get("k")+12.4%, a user method +7.0%. The bare-name change was taken because its measured cost was +0.9% for a user function and +6.0% for a builtin (after interning the builtin type objects); the method path was kept for that 12–18%. Closing it without the regression needs a resolve-first opcode that leavesrecv/nameon the stack when the type answers the name natively and only materializes a callable when the lookup would run user code. -
No "did you mean" suggestions on a
SyntaxError. TheNameErrorandAttributeErrorhints are implemented (see below); CPython also suggests a keyword for someSyntaxErrors, which pythonrs does not. -
dir()on a builtin type is not CPython's full slot listing. Every name it reports is onegetattrresolves (asserted in both directions bybuiltin_dir_lists_only_dispatchable_names/builtin_dispatch_is_fully_listed_by_dir), but the remaining slots (__class_getitem__,__reduce_ex__,__sizeof__,__init_subclass__, …) are dispatched natively rather than through per-type descriptor objects, so they are not enumerable. pythonrs reports 459 of CPython's 781 names across the 13 builtin types (int float bool str bytes bytearray list tuple dict set frozenset complex type), measured by intersectingdir(t)per type against CPython 3.14.6. The BINARY OPERATOR slots are now real bound methods (see "Implemented"), which is what moved the count up. -
collections.dequeimplements none of its operators.deque + deque,deque * nandq += […]all raiseunsupported operand type(s), so its__add__/__iadd__/__mul__/__rmul__/__imul__are deliberately kept out of the bound-method table — exposing them would only move the failure. -
TypeErrormessages for a bad sequence repetition operand. CPython sayscan't multiply sequence by non-int of type 'str'for[1] * 'a'and'str' object cannot be interpreted as an integerfor the__mul__spelling; pythonrs saysunsupported operand type(s) for *: 'list' and 'str'for both. -
An unhashable key is not named at the container-op boundary. CPython 3.12+ wraps the error as
cannot use 'list' as a dict key (unhashable type: 'list')/... as a set element (...); pythonrs reports the bareunhashable type: 'list'. Measured across 16 shapes — dict/set displays,d[k] = v,d[k],get,in,set.add,dict(pairs),set(iter),frozenset(iter),dict.fromkeys,setdefault, and the dict/set comprehensions. Barehash([1])correctly keeps the unwrapped message, and{}.pop([1])raisesKeyErrorin CPython where pythonrs raises theTypeError. The traceback frame, source line and carets around it now match (see "Implemented"); only the message text differs. -
[nan] == [nan]with one sharednanis False. CPython's sequence comparison shortcuts on element IDENTITY before==, so a list holding the samenanobject twice compares equal to itself. pythonrs stores afloatunboxed, so two equal-valued floats are indistinguishable from one object and the shortcut cannot be reproduced. The identity shortcut IS applied to heap objects, which is what makes[P(1)] == [P(1)]and[x] == [x]correct for everything with a heap identity.
Tooling
--build(AOT to a standalone native executable): implemented for the libpython-free build (cargo build --no-default-features). An uncaught exception in the AOT binary renders the same traceback the interpreter does —File/source line + CPython carets — and exits non-zero (the embedded image carries the source, filename, and caret position tables, and the binary recomputes each chunk's serde-skippedop_hashso caret lookups hit).sys.exit(n)returnsn. Two limits: (1) astdlib-ffibuild cannot AOT (its CPython/pyo3 symbols can't be statically linked into a standalone binary — the build fails up front with that instruction); (2) an error from a native fast-path op (int + str, unary-on a bad type) is held in fusevm's private AOT result rather than on the host, so it exits silently instead of printing a traceback — every builtin-dispatched error (index/key/type-via- method/name/division/attribute) renders correctly.--dap(Debug Adapter Protocol): implemented — breakpoints, step in/out/over/continue, stack trace, locals, and program-stdout capture (pipe + dup2 →outputevents). Frame names in the stack use the function name (or<module>), shared with the traceback path. Watch expressions not yet added.--lsp: full corpus — completion (builtins/keywords/methods), position- aware hover, and diagnostics via the real parser. Go-to-def and signature help not yet added.- REPL echoes bare-expression values through
sys.displayhook(CPython "single" mode: printsrepr(value)for non-Nonetop-level results and binds_); multi-line blocks close on a blank line. Passing--replwith piped (non-TTY) stdin runs the same interactive loop over the piped source, the analogue ofpython3 -i < file.
What the parity harnesses cannot report
Every number this project quotes comes out of one of four measuring tools, and each is blind to a definite class of divergence. A gap in this table is not a gap that has been ruled out — it is one no amount of running the tool can surface, so it has to be found by reading code or by writing a new probe.
| Harness | Compares | Structurally cannot report |
|---|---|---|
scripts/dropin_check.sh | stdout bytes + exit code of whole scripts | stderr (discarded); any script the reference exits non-zero on (SKIPped, so the whole nonzero-exit surface); stdin-reading scripts (none supplied); argv shapes other than the one fixed triple; files the script wrote; stdout/stderr INTERLEAVING (separate pipes); timing |
src/bin/parity.rs | stdout of the examples/ corpus | stderr; the corpus scripts' own exit codes (not compared at all); everything the corpus does not happen to do; no frozen replay, so a machine without python3 measures nothing — but it now says so and exits 2 rather than reporting success (see below) |
src/bin/parity_fuzz.rs | stdout bytes + zero/non-zero exit of -c one-liners | the exact exit CODE (only success-ness); stderr unless --stderr, and then only a normalized last line; anything a generator does not emit — no filesystem, no subprocess, no threads, no stdin, no argv, no multi-file import, no __main__ semantics; a case whose oracle output is nondeterministic is reported as a permanent gap rather than rejected |
in-process g() (tests/*.rs) | one global's repr after eval_str | stdout entirely (print is invisible); stderr; the exit code; ordering between statements; and it is not differential at all — it compares against a value a human transcribed from CPython, so it catches a REGRESSION and can never catch a divergence that was wrong from the first commit |
A harness that reports success having measured nothing is worse than no harness,
and src/bin/parity.rs had four ways to do it: no examples/ directory (it
printed a note and returned), an examples/ with no .py files (the loop ran
zero times), no python3 on PATH (every file printed skip and the summary read
0 passed, 0 failed), and — the sharpest — an actual divergence, since fail > 0 still fell off the end of main. All four exited 0, so a caller reading the
status could not tell a clean sweep from a total mismatch. It now exits 1 on a
divergence, 2 on a run it cannot measure, and prints how many scripts it actually
compared. scripts/dropin_check.sh already refused an empty corpus and a missing
reference; the two agree now.
Two axes were pinned to a constant across every one of them, which hid the axis rather than controlling it:
PYTHONHASHSEEDwas frozen at0by both subprocess harnesses, and pythonrs ignored the variable entirely — so the fuzzer could not have detected that any other seed returned the seed-0 value. Closed: the seed is honoured (see thehash()section) andparity-fuzznow sweeps it, pinning the same value on both children per case.LC_ALLwas pinned nowhere, which is the opposite failure — every run measured whatever locale the operator's shell had. That is what letformat(n, 'n')ship with no locale grouping at all: on aC-locale machine it is indistinguishable fromd. Closed:dropin_check.shpinsLC_ALL=Cso a run is reproducible, and the locale-VARYING surface is measured by sweepingLC_ALLover the format-spec corpus againstpython3.
Standard library
The default build ships the stdlib-ffi bridge, so a native fast-path subset
plus the entire CPython stdlib are importable out of the box. A
--no-default-features build serves only the native subset below; every other
module then raises ModuleNotFoundError.
- Native in every build:
math(constants + a common function fast path; in a default build any symbol the native arm lacks —isqrt,trunc,comb,hypot, … — defers to the real CPythonmathover the FFI bridge),sys(argvfrom the process args,exit/getrecursionlimit/setrecursionlimit,maxsize,version/version_inforeporting the emulated CPython3.14.6,platform(darwin/linux),path,modules,executable,stdout/stderr/stdinfile objects), and_thread(the single-threaded primitivesthreadingis built on — native under the bridge too, since a target handed to CPython's_threadwould run on an OS thread whose pythonrs heap, athread_local, is empty).collections's four MUTABLE containers (deque,Counter,defaultdict,OrderedDict) are native in a default build as well: a CPython one would hand its values back through the by-value marshaler, sodd['k'].append(1)would mutate a throwaway copy.namedtupleis not shadowed — its instances are immutable, and CPython's builds the real_tuplegetterfield descriptors (writable__doc__).ChainMap,UserDict,UserList,UserStringandcollections.abcdefer to CPython. The--no-default-featuresbuild instead runs the full vendoredcollections/__init__.pyover the native_collectionsaccelerators.textwrapandstatisticshave native subsets too, but they cover only positional args, so under the FFI bridge (default) they defer to the real CPython modules (full keyword-option surface —textwrap.fill(t, width=…)); the native subsets serve only--no-default-features. - The rest of the stdlib is served by the
stdlib-ffibridge (on by default) — an embedded libpython over pyo3, soimport json/os/random/string/functools/datetime/hashlib/… load the real CPython modules (pure.py+ the C accelerators), not hand-rolled shadows.functools.partial/lru_cache/reduce,json,os+os.path,randomandstringall come from CPython there. A barecargo buildworks as-is against any CPython 3.9–3.14 (abi3-py39; no env pin, and.cargo/config.tomlis gone with theabi3-py313floor that needed it). Only a--no-default-featuresbuild drops the bridge — thereimport functools/import osall raiseModuleNotFoundError. reanditertoolsare NATIVE shadows in BOTH builds — they never reach CPython. This entry previously listed both among the modules the FFI bridge serves, which was wrong in a way that matters: a probe that "passes" against a bridged module proves CPython works, while a probe against these two proves pythonrs's own code works, and only the second kind is evidence about the port.module_ffi_fallbackcovers exactlymath,collections,functoolsandcontextlib;reis not in that list, so a miss on the native namespace is a hardAttributeErrorand never defers. Checking against CPython 3.14.6:hasattr(re, 'Scanner')andhasattr(re, 'RegexFlag')are bothFalsehere andTruethere;hasattr(itertools, 'batched')isFalsehere andTruethere.reis the Rustregex/fancy_regexengines behindsrc/regexpr.rs, and its remaining gaps are listed under "Standard library —re" below.- FFI-boundary integration — crossing the bridge with a pythonrs object.
Working:
class C(enum.Enum)(and other Foreign-base classes) are built by the real metaclass via CPythontypes.new_class, so members/.name/.value, singletonisidentity, IntEnum/Flag, and body-defined methods all behave like CPython; a pythonrs generator marshals into a CPython call as a lazy iterator (itertools.takewhile(pred, gen())over an infinite generator); pythonrs callables carry a__dict__and expose the wrapped function's dunders, so@functools.wrapssucceeds; pythonrs methods stored in a CPython-built class bindself(thePyrsCallabledescriptor). A native pythonrs class also crosses into a CPython call —@dataclassmirrors it overobjectviatypes.new_class(methods asPyrsCallabledescriptors,__annotations__/ class-vars by value), so dataclass installs__init__/__repr__/__eq__/ ordering and the result rebinds the name. Class bodies capture their simple annotations into__annotations__, soCls.__annotations__,@dataclass, andtyping.NamedTupleall see the fields. Function parameter/return annotations are also kept:def f(a: int) -> strbuildsf.__annotations__at def time (evaluated eagerly, keys in source order with"return"last), reachable on a bound method too; a bare builtin type in an annotation (Optional[int]) crosses into CPython as the realinttype, sotypinggenerics build correctly. A pythonrs instance also crosses into a CPython call as aPyrsInstanceproxy (attribute/item access, comparison, hashing, repr route back to the fusevm object), sooperator.attrgetter("x")(obj)/sorted(objs, key=itemgetter(0))work.functools.total_orderingandfunctools.cached_propertyrun natively (the class stays a native pythonrs class):total_orderingderives the missing rich-comparison ops from the one defined ordering method plus__eq__, andcached_propertyis a non-data descriptor that computes on first access and caches into the instance dict (later reads hit the dict; a__slots__instance with no dict raises CPython'sTypeError). Every otherfunctoolsmember (reduce,partial,lru_cache,wraps,cmp_to_key) defers to the real CPython module.int(x)of a foreign value converts via CPython'sint()(anIntEnummember,Fraction, …);isinstance(v, foreign_cls)against a CPython ABC (collections.abc.Sequence, …) marshalsvand lets CPython's structural__instancecheck__decide, and the mirror direction works too — a CPython object behind a handle tested against a NATIVE builtin type (isinstance( namedtuple_instance, tuple)) resolves the type name out of CPython'sbuiltinsand asks CPython, since the handle reports only its own class name and the native structural check has no base chain to walk; and a CPython exception raised over the bridge (e.g.dataclasses.FrozenInstanceError) is caught byexcept Exception. A foreign exception also matches a specific base: its__mro__base names are captured at raise time, soexcept ValueErrorcatches ajson.JSONDecodeErrorandexcept ArithmeticErrorcatchesdecimal.InvalidOperation; the exact foreign type (except json.JSONDecodeError) matches by its CPython__name__. A foreign exception also keeps what its renderedClass: messageline cannot carry: its realargsand any instance attributes outside them are recorded at raise time (host::ForeignExc) and restored on the pythonrs side, soos.environ['missing'].argsis the KEY rather than the key's repr (KeyError. __str__isrepr(args[0]), so re-parsing the rendering doubled the quotes) andexcept json.JSONDecodeError as e: e.linenoreaches the real position. A@dataclassinstance also matches amatchclass pattern (positional via__match_args__/keyword), routed through CPythonisinstance+ bridge attribute reads. Remaining gaps:collections.namedtuplefield types cross asPyrsCallablewrappers, not the CPython type objects, sodataclasses.fields(x)[i].typeon a mirrored class is a proxy — the generated__init__/__repr__/__eq__(which use only field names) are unaffected.- A class with a foreign base cascades to a foreign class, so a zero-arg
super()in one of its methods raisessuper(): no arguments— the pythonrs method runs on the CPython mirror without a native__class__/selfframe. This bitesclass C(abc.ABC)hierarchies (abc.ABCis foreign): nativeabc.ABC/@abstractmethodare not yet recognized, so use a plain base class (a method raisingNotImplementedError) for now. A native-base hierarchy'ssuper()/MRO is unaffected.
Standard library — re
re is native (see the entry above): the Rust regex engine, with
fancy_regex taking the patterns that need look-around or backreferences.
Implemented (previously a silent wrong answer): every reported position is a
CODEPOINT index, not a byte offset. Both engines index a &str by byte, and
every span, pos and slice inside src/builtins.rs's re implementation is a
byte offset — which is what the slicing needs. CPython's re indexes a str by
codepoint. The two agree on ASCII and only on ASCII, so on any other subject
every position was wrong with no error raised:
re.search('b', 'éb').span() was (2, 3) against CPython's (1, 2), and
[m.span() for m in re.finditer(r'.', 'aéb')] was
[(0, 1), (1, 3), (3, 4)] against [(0, 1), (1, 2), (2, 3)] — so
s[m.start():m.end()] did not even reproduce m.group(). The conversion now
happens at each of the five places a position crosses to or from Python and
nowhere else, so the stored spans stay byte offsets and the slicing that reads
them stays correct:
Match.start()/.end()/.span()(re_match_method), for every group and for a named group;Match.pos/.endpos, which were additionally hard-coded to0and to the BYTE length — a match now records the window it was found in;repr(Match), which renders the group-0 span;- the
pos/endposARGUMENTS ofPattern.match/.search/.fullmatch, which arrive as codepoint indices (Python computed them withlen()). Consumed as bytes,re.compile(r'.').search('aéb', 1)sliced into the interior of'é'and reported NO MATCH at all; - the
fullmatchend-of-window comparison, which stays on the byte basis because both sides of it are internal.
The pair regexpr::char_index_of/byte_index_of is the single definition of
that boundary. Regression test: re_positions_count_codepoints_not_bytes in
tests/stdlib.rs, and the regex mode of parity-fuzz, whose subjects mix
1-, 2-, 3- and 4-byte characters in one string so that neither byte == char
nor byte == k*char can carry a wrong implementation.
Still open:
- A
bytessubject is rejected.re.search(rb'b', b'ab')raisesTypeError: expected string; CPython matches and reports BYTE offsets there (span() == (1, 2)on'aéb'.encode()is(3, 4)). A bytes PATTERN compiles (each byte is decoded as latin-1, whichjsonrelies on), but the subject must be astr. Supporting it means carrying a bytes/str flag on the match so the position conversion above is skipped. Match.regs,Match.re,Match.lastgroupandMatch.expand()are missing — all four raiseAttributeError.m.regsis the span tuple (((1, 3), (1, 2), (2, 3))), so it is a position API and would convert the same way.m[i](Match.__getitem__) raisesTypeError;m.group(i)works.finditeris eager. It builds every match and returns a list iterator, sotype(...).__name__islist_iteratorwhere CPython sayscallable_iterator, and a scan over a huge subject materializes all of it.re.Scannerandre.RegexFlagare absent (the flag constants exist as plain ints;re.A/re.I/re.M/re.S/re.Xall resolve).
hash() values: what is reproduced, and what cannot be
hash(x) now returns CPython's own number. The algorithms are ported from the
CPython 3.14.6 C sources in src/pyhash.rs (long_hash, _Py_HashDouble,
complex_hash, Py_HashBuffer/siphash13, tuple_hash, frozenset_hash),
and the cross-bridge container collapse that follows from them works in both
directions:
len({1, Decimal(1)}) # 1 len({0.5, Fraction(1, 2)}) # 1
{1: 'int'} | d[Decimal(1)]='dec' -> {1: 'dec'}
{Decimal(1): 'dec'} | e[1]='int' -> {Decimal('1'): 'int'}
PYTHONHASHSEED is honoured, not ignored. _Py_HashRandomization_Init
(Python/bootstrap_hash.c) is ported: seed 0 zeroes the 24-byte secret, any
other pinned seed expands through lcg_urandom, and an unset variable — or
random — draws per-process entropy exactly as CPython does. hash('abc') is
therefore byte-identical to PYTHONHASHSEED=N python3 for every N in
[0, 4294967295], where before this only N == 0 agreed and every other seed
silently returned the seed-0 value. A seed CPython refuses (0x10, -1,
4294967296, a trailing space) is refused here with CPython's own
Fatal Python error: config_init_hash_seed: … text and exit code 1.
One residue remains, and it is a boundary rather than a gap:
- Address-derived hashes are not reproducible by anyone.
hash(float('nan')),hash(...),hash(NotImplemented)and an instance's default identity hash come fromPyObject_GenericHash, i.e. the object's address. Measured across CPython runs they differ every time even underPYTHONHASHSEED=0, so there is no value to match. pythonrs returns a stable internally-consistent number instead.
An UNSET seed is likewise unmatchable in principle — both interpreters draw
their own entropy — which is a property of asking for unpredictability, not a
divergence. parity-fuzz pins the same seed on both children and sweeps it
across cases rather than freezing it at 0, so the whole seed axis is measured;
it was previously frozen, which made a hash-seed divergence structurally
unreportable.
A __hash__ RESULT is not reduced modulo 2**61-1. CPython's slot_tp_hash
tries PyLong_AsSsize_t first and uses any value that already fits a
Py_hash_t verbatim — __hash__ returning 2**62 hashes to 2**62, not 2 —
falling back to long.__hash__ only on overflow. Reducing unconditionally would
rewrite every large in-range hash a user returns.
Reachable from parity-fuzz --mode hashval, which prints RAW hash values. The
six older hash( sites only compare hash(x) == hash(y), a shape any
self-consistent hash satisfies — which is why a hash that matched CPython for no
type at all went unnoticed.
set iteration order diverges for a set DISPLAY
setobject.c's open-addressing table is ported (host.rs, SetTable) and
reproduces the order for set(iterable), .add() in a loop, and frozenset.
A set literal still diverges:
{1, 2, 3, 10, 20} # CPython {1, 2, 3, 20, 10}, pythonrs {1, 2, 3, 10, 20}
{100,200,300,400,500,600} # CPython [400, 100, 500, 200, 600, 300]
# pythonrs [100, 200, 300, 400, 500, 600]
The cause is a table SIZE difference, not a hash difference. A literal compiles
to BUILD_SET 0 + LOAD_CONST frozenset({...}) + SET_UPDATE, and
set_update_internal's set-to-set path presizes with
set_table_resize(so, (so->used + other->used)*2) — for five elements that is
minused = 10, giving a 16-slot table (mask 15). Inserting the same five
elements one at a time never presizes: the table starts at 8 and grows to 32 on
the fifth insert (mask 31). A 16-slot table reproduces both diverging cases
above exactly, including the LINEAR_PROBES runs that place 500 and 600.
Closing this needs the literal's presize modelled, not a change to hashing.
Open divergences found by round-3 protocol probing
Round 2 probed values -- numbers, strings, containers, exception text. Round 3
probed the PROTOCOLS: generators, context managers, descriptors, class
machinery, argument binding, control flow, operators. Most of that surface
already agreed with CPython 3.14.7 byte for byte. Four bugs came out of it and
are fixed (zip/map over a user iterator class, the __iter__-returned-a-
non-iterator message, the implicit __hash__ = None, __len__ validation plus
PEP 479). The unhashable-key message was the fifth and is fixed too: an
unhashable key now names the role it was playing (cannot use 'X' as a dict key (unhashable type: 'X')), matching CPython at all 17 spellings. Round 4
added the __index__ coercion boundaries and the __slots__ "__dict__"
entry. Four remain open:
-
PEP 695
type X = ...binds the value, not aTypeAliasType.type Alias = list[int]makesAliasbelist[int]itself, soAliasreprs and behaves like the aliased type butAlias.__value__raisesAttributeError, andtype(Alias)istypewhere CPython saysTypeAliasType. The lazy-evaluation semantics (the value is not computed until__value__is read, which is what lets an alias be recursive or refer to a name defined later) are absent with it.type X = ...inside a class or function body, and the generic formtype X[T] = ..., are the same gap. -
operator.index()does not see a native__index__. Theoperatormodule is served by the FFI bridge, so the argument crosses as aPyrsInstanceproxy, and CPython'sPyNumber_Indexasks the PROXY's type for the slot rather than the pythonrs class behind it:operator.index(Idx()) # TypeError: 'builtins.PyrsInstance' object cannot be # interpreted as an integerEvery NATIVE boundary honours it -- subscripting, slice bounds,
range, sequence repetition,bytes(n),chr,bin/hex/oct,int()-- so this is the marshalling layer, not the protocol. Closing it means givingPyrsInstancean__index__that routes back to the pythonrs object, the same way its attribute and comparison slots already do. -
Slot values live in the instance dict. pythonrs stores a
__slots__attribute in the same per-instance dict as any other, and restricts writes by consulting the class's declared slots rather than by having separate storage. Restriction, inheritance and the"__dict__"entry all behave correctly, and a fully-slotted instance correctly has no__dict__at all. What leaks is introspection of a PARTIALLY slotted hierarchy -- a slotted base with an unslotted subclass, where the instance does have a dict:class A: __slots__ = ("a",) class C(A): pass c = C(); c.a = 1; c.z = 2 sorted(vars(c)) # CPython ['z'], pythonrs ['a', 'z']A.__dict__["a"]is likewise absent where CPython has amember_descriptor. Closing it means real slot storage separate from the instance dict, not a filter over the dict:__dict__is handed out by handle so that mutations through it write through, and filtering the handed-out object would break that identity. -
A
SyntaxErrorfromeval/execomits the inner frame. CPython renders the compiled string's own frame under the calling one, with the source line and a caret:File "<string>", line 1 r.nope = 1 ^ SyntaxError: invalid syntaxpythonrs reports the calling frame and then the bare
SyntaxError: invalid syntaxline, so nothing points at WHERE in the evaluated source the problem is.