Divergences from CPython
pyscriptc targets CPython 3.14.x. Parsing, probes and the compatibility oracle follow 3.14.
Compiled programs match CPython 3.14 on stdout for everything that compiles, except the numbered divergences below.
The list is load-bearing: anything not listed here is claimed identical, so a difference you find that is not listed is a bug.
Constructs pyscriptc cannot compile are not divergences.
They stop the compile with a PSxxxx code, and every code is in Diagnostics.
Parity contract
stdout: byte-exact for every compiled call. The corpus scorer compares whitespace-stripped stdout; the nightly strict corpus A/B lane (
scripts/strict_corpus_ab.py) compares untrimmed bytes over the visible corpus and reports every difference it finds.stderr: for a
raisesprobe, only the exception name must appear. Full traceback text is not matched.exit code: an uncaught exception prints its name to stderr and exits 1, as CPython does. An uncaught
SystemExitfollows CPython’s own rule instead: anintvalue is the exit status, astrvalue is written to stderr and the process exits 1, and no value exits 0, with no class name or traceback text printed.raise SystemExit(<int>) from <cause>does not compile (PS2003).
The tool host and the target
The compiler runs on CPython 3.11 through 3.15 (requires-python = ">=3.11"); the program it emits claims CPython 3.14 (src/pyscriptc/target.json). The two are separate, and the split is the contract:
- The source grammar is the host’s. The compiler parses user source with the host interpreter’s
ast, so a source the host cannot parse is aSyntaxErrorbefore pyscriptc sees it. Syntax newer than the host (PEP 646def f[*Ts], for example) needs a host that knows it; a 3.11 host refuses such a source where a 3.14 host lowers it. - The emitted semantics are the target’s. The error wording (
division by zero), the bakedsys.versionconstant, thesys.version_infobranch folds, the typeshed version gate, the dependency resolution’s Python minor and the runtime pack’s stdlib tree all readtarget.json, not the host. A 3.11 host and a 3.14 host emit the same program for the same source; the canonical IR of the whole probe corpus is byte-identical across 3.11, 3.13, 3.14 and 3.15 hosts once thecpythonstamp line is stripped (the stamp names the lowering interpreter by design, and the typed hash serializer (pyscriptc/tirhash.py) never emits it, so the IR hash gate is unaffected). - The island mirrors the host it links. A program that embeds CPython runs the interpreter the binary carries, and
sys.executableis the binary’s own path (divergence 41 and 42 describe the embedded interpreter’s version). - The verify oracle still needs a 3.14 interpreter. The PS3001 refusal names
uv python install 3.14.
Two constants are deliberately not target-pinned, because they describe the machine rather than the program: sys.executable (an environment path, which the IR hash serializer classifies as incidental) and the host’s own sys.platform.
Numbered divergences
Every entry has a row and a probe in divergence-ledger.json, regenerated by scripts/gen_divergence_ledger.py; a test replays each row’s recorded bytes, so a divergence that is fixed or that moves is a reviewed change.
intisi64. Overflow aborts the process instead of growing a big int.//and%by zero raiseZeroDivisionErrormatching CPython 3.14’sdivision by zero(or 3.13’s variant on a 3.13 host), rather than aborting the process.The
strmodel is mid-migration to the CPython code-point model (epic #472; this item is rewritten again when it lands). Astris a UTF-8 byte buffer with a code-point count and an ASCII flag, solen, indexing, slicing and the find/count/justify family count code points, the case family applies the full Unicode 16.0.0 mappings, and text output (stdout, text-mode files, stdin reads) is UTF-8 bytes as CPython writes it. A literal may hold any scalar code point; a lone surrogate stops the compile withPS2005(no UTF-8 bytes to store).chr()with a literal surrogate stops withPS2005; at run time,chr()with a variable surrogate code point raisesValueError: chr() arg not in range(0x110000)where CPython returns the surrogate character.formatwidths and precisions count code points,reprfollows CPython’s escape rules (repr("café")is'caf\xe9'),%cand thecspec take a code point, and the six is* predicates plusisasciidecide on code points. The is* predicates that need Unicode category tables beyond the case data (isalpha/isalnumon a non-cased code point,isspacebeyond ASCII) raiseNotImplementedErrornaming the predicate and the code point.translate,maketrans,istitle,isdecimal,isnumeric,isprintable,iscapitalizedo not compile (PS2001). Still Latin-1-bound:unicodedatacovers only Latin-1 code points and a normalization that would leave Latin-1 raisesValueError;reandjsonare separate items (4 and 5).reis the runtime’s byte-oriented backtracking subset, not CPython’sre.L,T,DEBUGand unknown flag bits raiseValueError,AandUare no-ops, and a pattern that can loop without consuming stops withRuntimeErroronce its fuel is spent. Five or more groups are refused.jsonhas no non-ASCII text.Set and
frozensetiteration is insertion order, not CPython’s hash order. Length and membership match.After
for i in range(0), a new loop target reads as0instead of raisingUnboundLocalError.A subclass
__init__that never assigns an inherited field leaves it at the zero value of its type instead of raisingAttributeError.raise AppError("no")for a user exception class stores a code and an optionalstrand does not call__init__.@classmethodand@staticmethodof the formClass.method(...)do not pass the class object.Some library failures surface as a different exception type:
importlib.resources.fileson a zip or missing package raisesValueErrorrather thanModuleNotFoundError,shutil.copyfileof the same path raisesRuntimeErrorrather thanSameFileError, othershutilandosfailures (exceptos.mkdir,os.makedirsandPath.mkdirwhich raiseFileExistsError) areRuntimeError, and type errors onunicodedatacalls surface asValueError.assertis always evaluated; a compiled binary has no-O.x is Nonecompiles as==againstNone. Identical forNonebecause it is a singleton; every otherison a scalar is refused.ABC,ABCMeta,Protocol,@abstractmethodand@overloadcompile as plain classes and methods with no CPython runtime semantics.Calls that run through embedded CPython (
subprocess.check_output,socket.gethostname,threading,logging) follow the interpreter the binary links, not the pin.dotenvcompiles a closed subset of python-dotenv’s parsing rules, and it differs from the library in three places. The subset: a UTF-8 BOM is stripped, blank lines and whole-line comments are skipped, anexportprefix is allowed, keys are bare (KEYalone bindsNone), an unquoted value runs to the end of the line with the#comment cut and the rest right-stripped, a double-quoted value decodes\" \\' \\ \a \b \f \n \r \t \vand a single-quoted one only\\and\', and a later binding overwrites an earlier one. No interpolation ($NAMEstays literal), no multi-line values, no quoted keys, and thestream,verbose,interpolate,overrideandencodingarguments are refused. The three differences: a malformed line raisesValueErrorwhere python-dotenv skips it with a warning, a missing file parses as the empty document where python-dotenv searches withfind_dotenv(), andload_dotenvdoes not overwrite a variable that is already in the environment (itsoverride=Falsedefault) while still returningTruefor a document with bindings.yaml.safe_loadcovers a closed YAML subset, not all of PyYAML: timestamps and sexagesimal numbers (1:30) raiseValueErrorwhere PyYAML resolves them todateandintobjects, and errors surface asValueErrorrather thanyaml.YAMLError.Plaintext
http://is native: the compiled program makes the request itself, with no subprocess and no hostcurl.https://rides the system libcurl, linked at build time as a shared library (issue #235): a program whose emitted IR reaches anhttps://URL gains libcurl’s flags (resolved throughcurl-config --libsorpkg-config libcurl) on its link line, so the libcurl development package is a build dependency of such a program and the compile fails with a diagnostic naming the package to install when neither tool finds it (apt install libcurl-devon Debian/Ubuntu,brew install curlon macOS,dnf install libcurl-develon Fedora).PSC_CURL_DYNAMIC=1opts out of the build-time link; thehttps://request then raisesOSErrorat run time where CPython makes the request. Peer and hostname verification stay on in every shape; there is no insecure fallback. Anhttp://-only program carries no libcurl dependency. See HTTP.generator.send(v)on a generator that has not started ignoresvand runs the body from the top, where CPython raisesTypeError: can't send non-None value to a just-started generator.None(the primingsend) behaves identically in both.generator.throw(E)marks the frame done and raisesEout of the call site. CPython’s “the generator catches it, execution continues” outcome is not reachable through this surface:throwis a separate entry point that never runs the generator’s ownexcepthandlers.GeneratorExitsemantics do not apply either:close()marks the frame done without running a pendingfinally, where CPython raisesGeneratorExitinside the generator so itsfinallyruns.yield from <subgenerator>drains the subgenerator and discards its return value (StopIteration.value), where CPython’syield fromexpression evaluates to it. Statement-formyield from(the only form that compiles) never reads the value, so no observable program differs. Areturn <value>in a generator compiles (the value rides in the frame) but no surface reads it back.The curated
pyscriptc-jwtdistribution package compiles the HMAC family only:HS256,HS384andHS512throughjwt.encodeandjwt.decodeover adict[str, str]payload. RS/ES algorithms and a non-literal algorithm argument stop the compile where PyJWT signs with a private key, claims beyondstrvalues (exp,aud) are refused, errors surface asValueErrorrather than PyJWT’sInvalidTokenErrorhierarchy, and a multi-claim payload is emitted with the claims sorted by key where PyJWT emits insertion order (decode does not depend on the order; single-claim tokens are byte-identical to PyJWT’s).The curated
pyscriptc-orjsondistribution package compilesorjson.dumpsandorjson.loadsover the same C JSON writer and reader asjson: compactbytesoutput,OPT_SORT_KEYSandOPT_INDENT_2, with thejsonsubset’s value shapes. Thedefault=callable, every otherOPT_*flag,orjson.Fragmentand orjson’s extended types (dataclass,UUID,datetime,numpy) are refused, and errors surface asValueErrorrather thanorjson.JSONDecodeError.@contextmanagerruns the body’s code after theyieldwhen thewithexits cleanly, and re-raises the in-flight exception when the body raises, but the CPythongen.throwpath that lets the generator catch the exception is unreachable: the manager’s__exit__marks the frame done instead of throwing into it, so atry/exceptinside the generator cannot see the in-flight exception.A
finallythat is pending when the generator is abandoned does not run:close()marks the frame done and drops the pendingfinally, where CPython raisesGeneratorExitinside the generator so itsfinallyruns and its cleanup happens. Afinallythat is reached because the generator runs to completion or raises through thetrybehaves exactly like CPython’s.The threading surface that needs a second thread (
threading.Thread,threading.Condition,threading.Semaphore,threading.Event,queue.Queue) is native-only: on awasm32target the link is refused with a diagnostic naming the target and the runtime symbols it cannot use, because wasi-libc stubspthread_createto returnEAGAINand itspthread_cond_waittraps. Thethreading.Lockprimitives that need no second thread are unaffected.The HTTP client is a closed subset of
urllib.request: onlyurlopenandRequestcompile.urlopenacceptsurl,dataandtimeout; headers ride theRequestas a dict display with constantstrkeys and values, so runtime-built header dicts are refused.urlretrieve, openers and proxy handling are refused. The error types are syntheticHTTPErrorandURLErrorrecords that match through theOSErrorchain, not the fullurllib.errorclass surface. See HTTP.https://raisesOSErrorin the compiled program: the native client speaks plaintext HTTP/1.1 only, because TLS (mbedTLS through the FFI manifest) has not landed. CPython raises nothing and makes the request. Issue #235.socket,ssl,http.clientandhttp.serverdo not compile: an import of any of them stops the compile withPS2001naming the module. CPython provides them. Inbound servers andhttp.clientkeep-alive are issues #227-#230 and #237; the event loop underneath (epoll/kqueue) is #177, which is also whyasyncioI/O without the compiledrun/sleep/gather/create_task/wait_forsurface is refused.selectorscompiles as a closed subset over that loop (src/pyscriptc/runtime/ps_select.c, theDefaultSelectorrecords over the #177 poller):DefaultSelector,SelectorKey,EVENT_READ/EVENT_WRITE, andregister/unregister/modify/select/get_key/close. The subset takes a bare int fd where CPython takes afileobj- the typedsockethandle is #229, and until it lands the fd-carrying object has no type to hand over, sosel.register(sock, events)fences withPS1003naming #229. Two further shapes differ from CPython:select()returnslist[SelectorKey], not CPython’slist[tuple[SelectorKey, int]]pairs, andget_keyof an unregistered fd returnsNone, where CPython raisesKeyError.SelectorKeycarriesfdandevents; CPython’sfileobjanddataattributes stay outside the subset for the same #229 reason. When #229 lands,registerreads the fd off the handle and the runtime records do not change. The scope decision is recorded in issue #485.urlopenandRequestare native-only: on thewasm32and*-windows-*targets the runtime stub raisesURLError(anOSError) at run time with a message naming the target family, because WASI has no sockets and Windows needs winsock. Programs that never callurlopenlink unchanged for those targets.On
wasm32-wasi-muslthe program is a WASI preview1 command module, and the preview1 surface is smaller than a POSIX host: there is no subprocess (fork/exechas no WASI story, sosubprocesscalls are refused at link time with a diagnostic naming the target), nodlopenand therefore no native-to-embedded call (zlib.crc32through that path is refused the same way), no symbolic links (os.symlinkandos.readlinkare refused at link time with the same target-naming diagnostic), and no fallback mode (already refused for every cross target). The threading primitives that compile everywhere (Lock,RLock,get_ident,active_count,current_thread().name) work on the one thread a WASI program has; wasi-libc’s pthread stubs never block, andthreading.Threadis refused on every target. The filesystem is preopen-only, so the wasmtime invocation must pass--diror-S dir::/pathpreopens for the program to see any file;os.getcwdand the tempfile helpers write into whatever is preopened.tempfile.NamedTemporaryFileandtempfile.mkstempuse anopen(O_CREAT|O_EXCL)port (ps_mkstemp) because wasi-libc declares nomkstemp; the file lands in whatever preopen holds the temp directory. The emitted entry point is@__main_argc_argvon wasm targets, since wasm-ld resolves a C-stylemainonly through that name. Everything else is unchanged: the smoke lane proves stdout byte-identical to the native binary on the same program.On
wasm32-wasi-p2the program is a WASI 0.2 component: the same preview1 core module wrapped bywasm-tools component new --adaptwith the preview1 adapter pinned from the wasmtime v50.0.0 release (URL, version and sha256 recorded insrc/pyscriptc/wasm_adapter.py; a fetched adapter whose digest does not match the pin is refused, never used). The component importswasi:cli/run@0.2.xinstead of the raw preview1 functions, and every constraint of divergence 31 applies unchanged. There is no thread world to spawn into on 0.2; threading arrives with the WASI 0.3 coop-threads lane, which is a separate deliverable. Generators compile through the wrap unchanged.On a
*-windows-*target the program is a Win32 PE binary built by cross-compilation, and the Win32 surface is smaller than a POSIX host in ways the runtime cannot paper over:os.path.islinkis always false andos.lstatfollows the link instead of reporting it, because Windows has nolstat; path handling is Win32-flavoured, so\and/are both separators, drive letters (C:\) are absolute, andos.path.joinstill emits/;os.symlinkmakes file links only andos.readlinkraisesRuntimeErrorat run time, because Windows has no CRTreadlink;urlopenandRequestraiseURLErrorat run time because the HTTP client needs BSD sockets and Windows needs winsock. The remaining Windows semantics (CRLF stdout,input(), signals, threading) are unverified rather than claimed: the emitted binaries are never executed anywhere, in CI or otherwise. Windows is experimental cross-compilation only; native Windows compiles are not supported yet.Only refusals in modules the compiled tree’s import graph reaches stop a compile with dependency source roots (
PyPIpure wheels, issue #424). In a program the graph starts at the entry module; in a library package it starts at every module, because any of them can be the consumer’s import target. The rule rests on CPython’s import-time side effect rule: a module body runs once, at the import that reaches it, and never otherwise, so a dependency module the tree never imports never runs and a refusal inside it cannot change what the compiled program does. Such a module is pruned (named on stderr as a note, not compiled) or kept in the export table without compiling when an unreached program module imports it. A reached dependency refusal stops the compile with its code and the distribution that ships it. The reach walk is static and name-based, so a dynamic import (importlib.import_module,__import__, both refused) never extends it. The program’s own modules keep the whole-tree rule: a refusal in one of them always stops the compile. CPython runs every module whose import appears anywhere in the executed call graph, so a program whose entry reaches that module through a dynamic import behaves differently: CPython refuses at run time or succeeds through the import, where the compile succeeded without the module’s code.The
__getattr__hook compiles for unknown-name reads only, and only for one shape: a plain instance methoddef __getattr__(self, name: str) -> T. The read compiles as a direct call of the first MRO definer with the attribute name as a compile-time constant, and the hook’s declared return type must cover the read site exactly (PS4204otherwise). Everything else in the dynamic attribute protocol stays refused at the definition site:__setattr__,__getattribute__,__get__,__set__(PS4201),__delattr__(PS4202),__del__(PS4203), and a__getattr__in any other shape (a decorator, a different parameter list). Where CPython dispatches a missing attribute through__getattr__on any shape, or an attribute write or deletion through the protocol, the compile stops instead. An unguarded read of a name the hook itself reads recurses to no limit in the binary where CPython raisesRecursionError. Writes (__setattr__) are still refused. Issue #434.The shortest float spelling sometimes carries an exponent form CPython does not use at that magnitude:
10.0prints1e+01where CPython prints10.0(100.0prints1e+02). The shortest round-trip search accepts%g’s exponent spelling before CPython’s fixed-notation preference kicks in, so the value is exact and the bytes differ for whole floats whose shortest spelling picks an exponent. Values whose shortest spelling is fixed-notation (0.5,16.1,0.1) are byte-identical.A
round(x, n)call whose constantndigitsis outside 0 to 8 stops the compile (PS2003) where CPython rounds for-8 <= n <= 20. A variablendigitsoutside the range raisesValueError: round ndigits is out of the supported rangeat run time. In range,round(x, n)rounds the exact binary value ofxthe way CPython’s dtoa path does (round(2.675, 2)is2.67).newline=is refused onopen, so text-mode reads always apply the default universal-newline translation (\r\nand\rread as\n); a program that needs CPython’snewline=""(no translation) or a specific newline spelling cannot compile the call.encoding=compiles only as a str constant from{utf-8, ascii, latin-1}and their firm/short spellings, and it rides the same read path as the no-encoding call, so a file whose content is not representable under the subset’s byte read differs from CPython’s decode there. CPython accepts any encoding object and honors it. A third positional (buffering) and the otheropenkeyword arguments are refused.int(text, base)compiles for bases 2 to 36 and matches CPython’s read and messages (the leading prefix sits behind the sign, base names check like CPython’s, the surrounding-whitespace strip takes the Unicode space set, and the underscore digit separator follows CPython’s rule: one_between two digits, never at an end, never doubled), with the i64 ceiling of divergence 1. Base0and out-of-range base constants stop the compile (PS2003), where CPython accepts both (base 0 prefixes off the text). One message detail stays open: an invalid literal in thebytesform is reported with thestrrepr (invalid literal for int() with base 10: '1x') where CPython writes thebytesrepr (b'1x'), because the runtime reads both forms through one buffer ABI that does not carry the str/bytes distinction.subprocess.runtakescapture_output=,text=andcheck=as bool literals:check=Trueraises thecheck_callstand-inRuntimeErroron a non-zero exit where CPython raisesCalledProcessErrorcarrying the cmd and returncode.text=True(or anencoding=name) decodes the captured streams through the runtime’s CPython-exact decoder (utf-8/ascii/latin-1withstrict/replace/ignore/backslashreplace), so the record’sstdout/stderrare str like CPython’s text-mode record; the default bytes path is unchanged.cwd=compiles as a str, Path or None;input=,shell=and the remaining popen keyword arguments are refused.Fallback mode (
compile --fallback=cpython, alias--island) routes a function pyscriptc cannot compile to embedded CPython: that function’s source segment plus the module’s top-level imports and module-global initializers are exec’d once inside the binary (so a global initializer runs again in the embedded interpreter; for a deterministic initializer likePath.home() / xthe value matches, and a change to a global made there is never visible to native code), and every call to it crosses into the embedded interpreter. Under fallback mode an annotated function that accumulates an unboundedintalso runs there (issue #677): a conservative static check finds the growth shape (a*=,<<=,**=in-place update, or the loop-carry assignname = name <op> anythingwith a multiplying operator) and plans that function for the embedded interpreter instead of letting the native i64 overflow of divergence 1 abort it, so an annotated program keeps CPython’s arbitrary-precision result where the same function compiled natively would not. A singlename = name ** kassign with a literal exponent of at most 62 and no loop in the body stays native. Values cross one way per call: scalars,str,bytesand lists of scalars by value (a list is copied, so a change made there is not visible to native code),dictand everything else as an opaque handle usable through attribute reads, item reads, further calls andstr()/f-string formatting, never by native operators. No object identity survives a crossing. A function running in the embedded interpreter that calls a compiled sibling stops the compile with a diagnostic naming both functions, where CPython would run the call. A class definition that pyscriptc cannot compile (@attrs.define,@dataclasses.dataclass(frozen=True), any decorator the native dataclass form does not take, class keywords, a class-body statement) or whose method bodies cannot compile is routed the same way (issue #628): the class leaves the native class table, the embedded interpreter defines it, and every compiled function that names it is routed there too. A native class method that still uses that class reaches it through the handle path: an instance is a call on the class name, a class-level attribute read goes through the handle path, and an attribute write from compiled code stops the compile because values cross one way. Functions and classes routed there run under the host CPython (3.14.x), not the compiled runtime.With fallback mode on, the module top level is all-or-nothing: the synthesized entry body is probed like a function, and a refused probe (a module-level statement the subset does not take, an unannotated
yaml.safe_loadresult, aPathglobal) plans the whole top level for the embedded interpreter instead of stopping the compile. The binary’smainseeds the embedded interpreter with the module’s own source text, exec’d once with__name__ = "__main__"(the script’s main guard fires there exactly once) and__file__set to the compiled source path;sys.argvmirrors the process argv, sosys.argv[0]is the binary path where CPython shows the script path.raise SystemExit(status)out of the body delivers the process status (an int becomes the exit code, a str prints to stderr and exits 1), and any other uncaught exception surfaces as its own type and message with exit 1, matching CPython’s shape but not its traceback bytes. Annotated scalar globals the top level binds (LABEL: str = os.environ.get(...)) unbox once at init into native shadow slots, so typed native functions read the embedded interpreter’s initial value; a later rewrite of the global there is never visible to native code, and a native write to a global owned there stops the compile naming the global. Unannotated top-level globals stay in the embedded interpreter only. The native entry never runs when the top level is routed there: every function rides the seed, and it runs under the host CPython (3.14.x).argparsetype=Pathoptions compile to a nativePathin the namespace, but the namespace has no None slot, so an absent option cannot be None. Atype=Pathoption withoutrequired=Trueor a str default stops the compile (PS2003), and a default is converted to aPathat parse time.parse_argstakesNone, a list of str, or an Optional list; the list form skipssys.argv.A local that is first bound to a value and later assigned
None(or an Optional call result) in a branch is not widened to Optional. The first binding fixes the type, solp = Path(x)followed bylp = find_latest()stops the compile withPS1003, andlp is Noneon a plainPathstops withPS2004. The typed formlp: Optional[Path] = Nonecompiles. Narrowing throughis Noneandis not Noneguards combined withand,or, conditional expressions, early exits and loop bodies does compile.These shapes are refused after the mechanical-delta pass, each as its own PS code:
set(d)andlist(d)over a dict (PS2002, the set kind takes a list of int or str),sorted(paths)overPathelements (PS2003), incrementalhashlibupdatecalls and ahashlibconstructor with data (PS2003),Path.open("rb")(PS2001),collections.abc.Sequenceannotations (PS1104), class-body values the compiler cannot compute (PS1204; aname = <constant>orname: T = <constant>class attribute compiles and reads through the class and its instances, and so does a value the compiler computes — a call to a lowered function, a container literal, a comprehension, a name bound at module scope — stored in a runtime slot keyedC.<name>and initialized at module init, where CPython computes it in the class body; a value with no slot form, a tuple or a float or a multi-target assign, still fences), module-level list-of-dict tables (PS1106, dict values are not heterogeneous), and a module-levelPath(__file__).resolve().parents[n]constant (PS1001).Under fallback mode for a whole program, the compile stages the program directory’s non-Python files beside the binary (
<binary dir>/pyscriptc-data/<program name>/, the exact compile-time bytes) and rebases the module origins and__file__of the embedded interpreter’s finder onto that tree, soPath(__file__).parent / "data.json"reads the staged copy after the output is relocated and the source tree is gone. Lookups that leave the program directory (Path(__file__).parent.parent / "x", absolute paths) are not staged and still resolve against the compile-time path: with the source tree absent they raiseFileNotFoundErrorwhere CPython reads the real file. A single-file fallback compile stages nothing and keeps the compile-time__file__(the same restriction divergence 42 documents). Issue #665.os.walk(top)is a materialized list of the(dirpath, dirnames, filenames)triples in CPython’s topdown pre-order, so the visit order and the rows match a CPython walk over the same tree read in the samereaddirorder. The walk is computed before the loop body runs, so a body that editsdirnamesdoes not prune the walk where CPython honours the edit;topdown=,onerror=andfollowlinks=are refused (os.scandirand the otherosnames outside the fixed rows keep theirPS2001).json.load(fp)reads the whole handle and reuses thejson.loadsannotation path: a missing result annotation stops the compile (PS1001) where CPython returns an untyped value, and a decode failure stays the runtimeValueError.json.dump(obj, fp)serializes through the samejson.dumpssubset and writes the handle with one call, so the bytes are thedumpsbytes (no trailing newline); the closed dumps keyword set (indent,sort_keys,ensure_ascii=True) rides along and any other keyword stops the compile (PS2003).time.perf_counterandtime.monotonicread the monotonic clock as a float.A
--embed-python=bundleoutput carries a trimmed runtime: the standard library travels as the safe-trimmed zip (test, idlelib, tkinter, turtledemo, ensurepip,pydoc_dataand__phello__are out), the pack interpreter’s ownsite-packagestooling (pip) is not bundled, and the unversionedlibpython3.X.soname of a bundled Linux runtime is a symlink to the versioned library, not a second copy. A program that imports a trimmed tree stops the compile closed (PS4301) where CPython imports it; a bundled program that callspipat runtime gets the sameModuleNotFoundErrora CPython interpreter without pip would. Issues #709 and #885.The program island carries a precompiled code cache next to the embedded sources (issue #782): the compiler marshals every program unit’s code object at compile time, zlib-compresses the entries into one blob, and the seed’s loader unmarshals a module on its first import instead of compiling it (a 523 ms to 333 ms
--versionon yt-dlp). The cache is guarded, not trusted: each entry names the compiling interpreter’s.pycmagic number and the source’s sha256, the loader re-checks both at run time against the interpreter that runs the binary and the embedded text, and any mismatch (a binary moved to a different CPython build, a corrupted blob) falls back to compiling the source, so behavior never differs - only start time. A cached program answers CPython byte for byte; traceback frames name the sameco_filenameeither way. Source:program_island_sourceand_module_code_entryinsrc/pyscriptc/boxed.py,ps_island_codecacheinsrc/pyscriptc/runtime/ps_island.c, andtests/test_island_code_cache.py.A module-scope container bound without an annotation (
xs = [...],d = {...},s = {...}) in a script is a compile-time constant: every read re-builds the literal, so the name has no object identity. Mutating it through a method call (xs.sort(),d.update(),s.add()), an item or slice store (xs[i] = v), or a copy-on-alias bind (ys = xs,x = y = xs) stops the compile withPS2002naming the annotation fix: annotating the name (xs: list[int] = ...) gives it a global slot and CPython identity. Two paths still read copies where CPython aliases: passing the name to a function hands the callee a fresh container, so a callee that mutates its parameter leaves the module value unchanged, andys = xs[i]binds a copy of the element, so a mutation throughysis not shared.x += ysandx *= non a list desugar to a fresh-concat rebind, not CPython’s in-place__iadd__/__imul__. The rebound name’s own value is right, but a second name bound to the same list keeps the old object where CPython mutates the shared one.itertools.tee(xs)andtee(xs, 2)return a pair of eagerlistcopies of the materialized source (restlower/_itertools.py), so later mutation of one copy does not affect the other andnextis not defined on them; CPython returns two lazy iterators that share an underlying buffer.itertools.batched(xs, n)yields lists of elements (a short last list when the length is not a multiple ofn) rather than tuples, because a leftover of one element cannot be a one-field tuple in the IR. Issue #490.A class decorator other than
@dataclassis erased: the class lowers undecorated (fields, methods, constructor and every read of it unchanged) and the decorator call never runs. CPython applies the decorator to the class object after the body executes, so side effects the decorator has (a registry write, a flag, a returned replacement class) are missing in the binary. Every decorator spelling other than the single@dataclassform takes the erase; the compile prints anote: PS3099 ...line on stderr and still succeeds, and the binary runs with the class’s own value semantics. Under--fallback=cpythonthe class keeps the decorator: it runs through embedded CPython with the wrap applied. Issue #732.A method decorator or module-function decorator outside the closed set that does not synthesize behavior is erased: the function lowers undecorated and the decorator call never runs. CPython replaces the attribute (or the module name) with whatever the decorator returns, so side effects the decorator has (a registry write, a flag, a wrapper with different call semantics) are missing in the binary. A decorator stack that carries a behavior-synthesizing member (
functools.lru_cache,functools.cache,functools.wraps,functools.singledispatchmethod) never takes the erase: the stack would lower undecorated and the synthesis would be lost with it, so it keeps its fence (PS1201for a method,PS2006for a module function). The closed set (property,staticmethod,classmethod,contextmanager, the erased typing names) keeps its existing lowering. The compile prints anote: PS3099 ...line per erased decorator and still succeeds. Under--fallback=cpythonthe decorated definition keeps the decorator: it runs through embedded CPython with the wrap applied. Issue #732.The
uuidsubset coversuuid4(),uuid1(),uuid5(namespace, name)andUUID(text).uuid4anduuid1draw their random bytes from the host entropy source and set the version and variant bits the way CPython does, so the shape, the version nibble and the variant bits match but the values differ from CPython’s by construction.uuid5hashes the namespace bytes and the name bytes with SHA-1 exactly as CPython does, souuid5andUUIDare byte-identical to CPython’s.UUIDis not lowered as a class: the call returns the canonical text, sostr(UUID(text))reads the way CPython’s does, and the.hexattribute of a lowered uuid call is the canonical text without the dashes (what CPython’sUUID.hexholds). The.int,.urnand.bytesforms are refused (PS2001), anduuid.UUIDas a parameter or return annotation is refused (PS1106) because the lowered value is text, not a class instance.The
binasciisubset coversunhexlify(text)andhexlify(data). Both are byte-identical to CPython’s for ASCII hex, including the error cases: an odd-length hex string and a non-hex character raiseValueError, which is what CPython’sbinascii.Errorreads as at anexcept (ValueError, ...)handler (binascii.Erroritself is aValueErrorsubclass).hexlifytakes bytes and returns bytes, as CPython’s does; astrargument is refused at compile time (PS1003) where CPython raisesTypeError. Every otherbinasciiname is refused (PS2001).The
shlexsubset coverssplit(text),quote(word)andjoin(words).splitruns CPython’s POSIX-mode lexer, so the words match CPython’s byte for byte for the quoting, escaping and comment rules; thecommentsflag must be a constant bool (a variable is refused withPS1003), where CPython reads it at run time. An unterminated quote and a trailing backslash raiseValueError, as CPython’s do.quoteleaves a word alone when it holds only the characters CPython’s safe set holds and wraps it in single quotes otherwise, spelling an embedded single quote'"'"';joinspaces the quoted words. Theshlexclass itself, theposixflag and thepunctuation_charsmode are refused (PS2001).The
secretssubset coverstoken_hex(n),token_bytes(n)andrandbelow(n). The bytes come from the host entropy source (/dev/urandom), the source CPython’sSystemRandomreads throughos.urandom, so the shapes match CPython’s but the values differ by construction.token_hexreturns the lowercase hexadecimal text ofnrandom bytes (two digits per byte),token_bytesreturns the bytes, andrandbelowreturns an int in[0, n)drawn the way CPython’sSystemRandom._randbelowdraws it:n.bit_length()bits at a time, redrawn while the draw is out of range.nis a constant int (a variable is refused withPS1003) and defaults to CPython’s 32 bytes when omitted;randbelow(0)raisesValueError, as CPython’s does. Every othersecretsname (choice,randbits,token_urlsafe,compare_digest,SystemRandom) is refused (PS2001).The island plan is conservative past the module that forced it: once any import in a module is routed to embedded CPython, the module’s whole top level plans there (divergence 42) and every function whose body names an island-planned import or an unannotated value reaching one is planned there too, transitively through calls. On a real Flask-plus-LLM backend this compounds: 41% of its definitions pass the per-definition check in isolation, but the plane map routes 100% of them to the island because untyped imports at the module top level demote everything below them (the
assess --jsonplaneMap.demotedBynames the function that pulled each demotion). No compiled code runs unless the forced imports are moved behind a typed boundary or routed out of the compiled tree. The program still builds and runs there: the island is the route for code the subset cannot lower, not a failure, and it costs interpreter speed on the island plane plus a marshalling cost on every native-island crossing (Libraries and fallback mode).math.factorial(n)follows divergence 1:intisi64, so the factorial past20!raisesOverflowErrorwhere CPython grows a big int. Every other integer helper (gcd,lcm) stays in range the way CPython’s does for the same inputs; the domain errors (factorial(-1)isValueError) match CPython exactly.Float text output is shortest-round-trip without CPython’s fixed-exponent rule: a value CPython prints as
180.0prints as1.8e+02here. Values CPython also spells in exponent form byte-match, and every%.17g-style numeric comparison is unaffected; only exactprinttext near CPython’s exponent crossover can differ.struct.calcsizeandstruct.unpack_fromlower only with a string-literal format that opens with an explicit byte-order prefix (<,>,!or=);calcsizefolds to an int constant at compile time andunpack_from(fmt, buffer, offset=0)takes abytesbuffer and anintoffset (negative offsets count from the end, as in CPython). A computed format (PS2001), the native@default and an unprefixed format (PS2001, native alignment is not lowered),pack_into,iter_unpack,Structanderrorstay refused. Likestruct.unpack,unpack_fromreturns alist, not a tuple, and a buffer too short for the format raisesValueError(struct.unpack_from buffer is too small) where CPython raisesstruct.error. Issue #489.The
randomsubset runs one module-level CPython-compatible Mersenne Twister:random.seed(int)seeds it withinit_by_arrayover the int’s little-endian 32-bit limbs, and every draw consumes the stream the way CPython 3.14’srandom.pydoes, so a seeded program prints the same numbers as CPython (the differential probeprobes/differential/random_surface.pybyte-compares them).seed()with no argument is nondeterministic, as in CPython, andseedtakes an int only (noversion=, no str, bytes or float seeds). The subset coversrandom,randint,randrange,uniform,getrandbits,choice,shuffle,randbytes(n),sample(population, k),choices(population, k=n)andchoices(population, weights=w, k=n), and the drawsgauss,normalvariate,lognormvariate,expovariate,triangular,gammavariateandbetavariate.populationis a list (a str, tuple or range is refused withPS1003),weightsis alist[int]orlist[float], andchoicestakesweightsandkas keywords. Integer weights are accumulated in 64 bits and compared as floats, where CPython compares exact ints, so cumulative weights beyond 2**53 can pick a different index.lognormvariatereturnsinfon overflow where CPython raisesOverflowError.samplehas nocounts=,choiceshas nocum_weights=, and theRandomandSystemRandomclasses plusvonmisesvariate,paretovariate,weibullvariate,binomialvariate,getstateandsetstateare refused (PS2001).The
pyscriptc_arrowFFI adapter (--ffi packaging/adapters/pyscriptc-arrow/ffi.toml, issue #1209) carries the primitive fixed-width Arrow dtypes only: int32, int64, float32, float64, bool and utf8.array_getandarray_sumwiden every numeric element to double, so an int64 value above 2**53 loses integer precision where CPython-level int arithmetic stays exact, and an int32 value above the f64 mantissa reads rounded. The Arrow IPC wire form is a one-column record batch namedvwhose top level is a struct (the wire format requires it), so a stream PyArrow produced with a multi-column or differently named schema refuses withValueErrorinstead of projecting a silent subset;stream_readreturnslist[dict[str, str]]whose values are the element’s own text (ints decimal, floats%g, boolsTrue/False), not typed values. Every other Arrow type (nested types, dictionaries, unions, timestamps, extension metadata, decimal) is the honest fence: the thunk refuses the dtype word withValueErrornaming the six supported constants, never a silent cast. The adapter has no__arrow_c_array__/__arrow_c_stream__capsule protocol in-process: the exchange with a live Arrow implementation goes over the wire bytes (export_array/import_stream), and a consumer under CPython opens them withpyarrow.ipc.open_stream.The
statisticssubset (mean,fmean,median,median_low,median_high,mode,variance,pvariance,stdev,pstdev,quantileswith the default exclusive method andnin{2, 4, 8}) takes alist[int]orlist[float]; every otherstatisticsname is refused (PS2001) and other container shapes are refused (PS1003). The functions never mutate the caller’s list. Two seams come from CPython’s exact-rational implementation (statisticsconverts every number to aFractionand converts the result to float at the end) against the i64/double runtime:stdevandpstdevdiffer from CPython by one ulp on a minority of datasets (CPython’s correctly-rounded square root needsmath.isqrtof a big integer), and the mean/median/variance family overlist[int]returnsfloatwhere CPython returnsint(the mean of an exactly divisible int dataset, the median of an odd-length one, a variance whose exact fraction is integral): the value is equal, therepris not, soprint(statistics.mean([1, 2, 3]))prints2.0where CPython prints2. Overlist[float]the mean/median/variance family folds in double arithmetic, so a minority of datasets round differently from the exact-rational path.modetakeslist[int]only (adict[int, int]count; float keys are outside the subset,PS1003), breaks ties the way CPython does (the first key to reach the top count), andquantilestakesnas a constant int. See divergence 61 for the float spelling rule that can also move aprintof a whole float.csv.DictReaderreturns a materializedlist[dict[str, str | None]]where CPython returns a lazy iterator: the rows are all parsed before the loop body runs, so a loop body that mutates the source lines does not see the edit, and the reader cannot be resumed afterlist()or a secondfor(CPython’s iterator is spent the same way, so a freshDictReadercall is the fix either way). Two row-shape differences ride along: a record longer than the header raisesValueError: dict reader has more fields than fieldnamesat run time where CPython stores the extra fields under theNonerestkey (aNonekey and alist[str]value are outside the row dict’s type), andrestkey=/restval=are refused (PS2003) where CPython re-keys or refills the row. Blank records are skipped like CPython skips them. The writer matches CPython’s excel dialect byte for byte, including the\r\nterminator, the minimal quoting and the empty-text restval for a missing field. Issue #488.
Where each entry comes from
Every entry names the runtime file or compiler module that produces the difference, so a report can point at the code.
The live support state across modules and language features is on the compat site, and the code that stops a compile is in Diagnostics.