Divergences from CPython
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 today compares whitespace-stripped stdout; tightening that to unstripped bytes is planned work.
- 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).
Numbered divergences
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.- HTTP and HTTPS go through a
curlsubprocess bridge, so the request is made by the hostcurland depends oncurlbeing onPATHat run time; a host withoutcurlcannot make the call. The bridge needs the libpython-linked shape, holds the whole body in memory with no streaming, and its TLS, proxy and redirect behavior iscurl’s rather than CPython’s. 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.client,http.serverandselectorsdo 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.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), 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.NamedTemporaryFileuses anopen(O_CREAT|O_EXCL)port because wasi-libc declares nomkstemp. 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/;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), with the i64 ceiling of divergence 1 and the same no-underscore subset the base-10 form has:int("1_000", 16)raises where CPython returns 1000. Base0and out-of-range base constants stop the compile (PS2003), where CPython accepts both (base 0 prefixes off the text).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-level constants (PS1204), 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.
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.