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 raises probe, 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 SystemExit follows CPython’s own rule instead: an int value is the exit status, a str value 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

  1. int is i64. Overflow aborts the process instead of growing a big int.
  2. // and % by zero raise ZeroDivisionError matching CPython 3.14’s division by zero (or 3.13’s variant on a 3.13 host), rather than aborting the process.
  3. The str model is mid-migration to the CPython code-point model (epic #472; this item is rewritten again when it lands). A str is a UTF-8 byte buffer with a code-point count and an ASCII flag, so len, 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 with PS2005 (no UTF-8 bytes to store). chr() with a literal surrogate stops with PS2005; at run time, chr() with a variable surrogate code point raises ValueError: chr() arg not in range(0x110000) where CPython returns the surrogate character. format widths and precisions count code points, repr follows CPython’s escape rules (repr("café") is 'caf\xe9'), %c and the c spec take a code point, and the six is* predicates plus isascii decide on code points. The is* predicates that need Unicode category tables beyond the case data (isalpha/isalnum on a non-cased code point, isspace beyond ASCII) raise NotImplementedError naming the predicate and the code point. translate, maketrans, istitle, isdecimal, isnumeric, isprintable, iscapitalize do not compile (PS2001). Still Latin-1-bound: unicodedata covers only Latin-1 code points and a normalization that would leave Latin-1 raises ValueError; re and json are separate items (4 and 5).
  4. re is the runtime’s byte-oriented backtracking subset, not CPython’s re. L, T, DEBUG and unknown flag bits raise ValueError, A and U are no-ops, and a pattern that can loop without consuming stops with RuntimeError once its fuel is spent. Five or more groups are refused.
  5. json has no non-ASCII text.
  6. Set and frozenset iteration is insertion order, not CPython’s hash order. Length and membership match.
  7. After for i in range(0), a new loop target reads as 0 instead of raising UnboundLocalError.
  8. A subclass __init__ that never assigns an inherited field leaves it at the zero value of its type instead of raising AttributeError.
  9. raise AppError("no") for a user exception class stores a code and an optional str and does not call __init__.
  10. @classmethod and @staticmethod of the form Class.method(...) do not pass the class object.
  11. Some library failures surface as a different exception type: importlib.resources.files on a zip or missing package raises ValueError rather than ModuleNotFoundError, shutil.copyfile of the same path raises RuntimeError rather than SameFileError, other shutil and os failures (except os.mkdir, os.makedirs and Path.mkdir which raise FileExistsError) are RuntimeError, and type errors on unicodedata calls surface as ValueError.
  12. assert is always evaluated; a compiled binary has no -O.
  13. x is None compiles as == against None. Identical for None because it is a singleton; every other is on a scalar is refused.
  14. ABC, ABCMeta, Protocol, @abstractmethod and @overload compile as plain classes and methods with no CPython runtime semantics.
  15. Calls that run through embedded CPython (subprocess.check_output, socket.gethostname, threading, logging) follow the interpreter the binary links, not the pin.
  16. dotenv compiles 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, an export prefix is allowed, keys are bare (KEY alone binds None), 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 \v and a single-quoted one only \\ and \', and a later binding overwrites an earlier one. No interpolation ($NAME stays literal), no multi-line values, no quoted keys, and the stream, verbose, interpolate, override and encoding arguments are refused. The three differences: a malformed line raises ValueError where python-dotenv skips it with a warning, a missing file parses as the empty document where python-dotenv searches with find_dotenv(), and load_dotenv does not overwrite a variable that is already in the environment (its override=False default) while still returning True for a document with bindings.
  17. yaml.safe_load covers a closed YAML subset, not all of PyYAML: timestamps and sexagesimal numbers (1:30) raise ValueError where PyYAML resolves them to date and int objects, and errors surface as ValueError rather than yaml.YAMLError.
  18. HTTP and HTTPS go through a curl subprocess bridge, so the request is made by the host curl and depends on curl being on PATH at run time; a host without curl cannot 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 is curl’s rather than CPython’s. See HTTP.
  19. generator.send(v) on a generator that has not started ignores v and runs the body from the top, where CPython raises TypeError: can't send non-None value to a just-started generator. None (the priming send) behaves identically in both.
  20. generator.throw(E) marks the frame done and raises E out of the call site. CPython’s “the generator catches it, execution continues” outcome is not reachable through this surface: throw is a separate entry point that never runs the generator’s own except handlers. GeneratorExit semantics do not apply either: close() marks the frame done without running a pending finally, where CPython raises GeneratorExit inside the generator so its finally runs.
  21. yield from <subgenerator> drains the subgenerator and discards its return value (StopIteration.value), where CPython’s yield from expression evaluates to it. Statement-form yield from (the only form that compiles) never reads the value, so no observable program differs. A return <value> in a generator compiles (the value rides in the frame) but no surface reads it back.
  22. The curated pyscriptc-jwt distribution package compiles the HMAC family only: HS256, HS384 and HS512 through jwt.encode and jwt.decode over a dict[str, str] payload. RS/ES algorithms and a non-literal algorithm argument stop the compile where PyJWT signs with a private key, claims beyond str values (exp, aud) are refused, errors surface as ValueError rather than PyJWT’s InvalidTokenError hierarchy, 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).
  23. The curated pyscriptc-orjson distribution package compiles orjson.dumps and orjson.loads over the same C JSON writer and reader as json: compact bytes output, OPT_SORT_KEYS and OPT_INDENT_2, with the json subset’s value shapes. The default= callable, every other OPT_* flag, orjson.Fragment and orjson’s extended types (dataclass, UUID, datetime, numpy) are refused, and errors surface as ValueError rather than orjson.JSONDecodeError.
  24. @contextmanager runs the body’s code after the yield when the with exits cleanly, and re-raises the in-flight exception when the body raises, but the CPython gen.throw path that lets the generator catch the exception is unreachable: the manager’s __exit__ marks the frame done instead of throwing into it, so a try/except inside the generator cannot see the in-flight exception.
  25. A finally that is pending when the generator is abandoned does not run: close() marks the frame done and drops the pending finally, where CPython raises GeneratorExit inside the generator so its finally runs and its cleanup happens. A finally that is reached because the generator runs to completion or raises through the try behaves exactly like CPython’s.
  26. The threading surface that needs a second thread (threading.Thread, threading.Condition, threading.Semaphore, threading.Event, queue.Queue) is native-only: on a wasm32 target the link is refused with a diagnostic naming the target and the runtime symbols it cannot use, because wasi-libc stubs pthread_create to return EAGAIN and its pthread_cond_wait traps. The threading.Lock primitives that need no second thread are unaffected.
  27. The HTTP client is a closed subset of urllib.request: only urlopen and Request compile. urlopen accepts url, data and timeout; headers ride the Request as a dict display with constant str keys and values, so runtime-built header dicts are refused. urlretrieve, openers and proxy handling are refused. The error types are synthetic HTTPError and URLError records that match through the OSError chain, not the full urllib.error class surface. See HTTP.
  28. https:// raises OSError in 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.
  29. socket, ssl, http.client, http.server and selectors do not compile: an import of any of them stops the compile with PS2001 naming the module. CPython provides them. Inbound servers and http.client keep-alive are issues #227-#230 and #237; the event loop underneath (epoll/kqueue) is #177, which is also why asyncio I/O without the compiled run/sleep/gather/create_task/wait_for surface is refused.
  30. urlopen and Request are native-only: on the wasm32 and *-windows-* targets the runtime stub raises URLError (an OSError) at run time with a message naming the target family, because WASI has no sockets and Windows needs winsock. Programs that never call urlopen link unchanged for those targets.
  31. On wasm32-wasi-musl the program is a WASI preview1 command module, and the preview1 surface is smaller than a POSIX host: there is no subprocess (fork/exec has no WASI story, so subprocess calls are refused at link time with a diagnostic naming the target), no dlopen and therefore no native-to-embedded call (zlib.crc32 through 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, and threading.Thread is refused on every target. The filesystem is preopen-only, so the wasmtime invocation must pass --dir or -S dir::/path preopens for the program to see any file; os.getcwd and the tempfile helpers write into whatever is preopened. tempfile.NamedTemporaryFile uses an open(O_CREAT|O_EXCL) port because wasi-libc declares no mkstemp. The emitted entry point is @__main_argc_argv on wasm targets, since wasm-ld resolves a C-style main only through that name. Everything else is unchanged: the smoke lane proves stdout byte-identical to the native binary on the same program.
  32. On wasm32-wasi-p2 the program is a WASI 0.2 component: the same preview1 core module wrapped by wasm-tools component new --adapt with the preview1 adapter pinned from the wasmtime v50.0.0 release (URL, version and sha256 recorded in src/pyscriptc/wasm_adapter.py; a fetched adapter whose digest does not match the pin is refused, never used). The component imports wasi:cli/run@0.2.x instead 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.
  33. 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.islink is always false and os.lstat follows the link instead of reporting it, because Windows has no lstat; path handling is Win32-flavoured, so \ and / are both separators, drive letters (C:\) are absolute, and os.path.join still emits /; urlopen and Request raise URLError at 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.
  34. Only refusals in modules the compiled tree’s import graph reaches stop a compile with dependency source roots (PyPI pure 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.
  35. The __getattr__ hook compiles for unknown-name reads only, and only for one shape: a plain instance method def __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 (PS4204 otherwise). 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 raises RecursionError. Writes (__setattr__) are still refused. Issue #434.
  36. The shortest float spelling sometimes carries an exponent form CPython does not use at that magnitude: 10.0 prints 1e+01 where CPython prints 10.0 (100.0 prints 1e+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.
  37. A round(x, n) call whose constant ndigits is outside 0 to 8 stops the compile (PS2003) where CPython rounds for -8 <= n <= 20. A variable ndigits outside the range raises ValueError: round ndigits is out of the supported range at run time. In range, round(x, n) rounds the exact binary value of x the way CPython’s dtoa path does (round(2.675, 2) is 2.67).
  38. newline= is refused on open, so text-mode reads always apply the default universal-newline translation (\r\n and \r read as \n); a program that needs CPython’s newline="" (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 other open keyword arguments are refused.
  39. 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. Base 0 and out-of-range base constants stop the compile (PS2003), where CPython accepts both (base 0 prefixes off the text).
  40. subprocess.run takes capture_output=, text= and check= as bool literals: check=True raises the check_call stand-in RuntimeError on a non-zero exit where CPython raises CalledProcessError carrying the cmd and returncode. text=True (or an encoding= name) decodes the captured streams through the runtime’s CPython-exact decoder (utf-8/ascii/latin-1 with strict/replace/ignore/backslashreplace), so the record’s stdout/stderr are 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.
  41. 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 like Path.home() / x the 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 unbounded int also runs there (issue #677): a conservative static check finds the growth shape (a *=, <<=, **= in-place update, or the loop-carry assign name = name <op> anything with 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 single name = name ** k assign with a literal exponent of at most 62 and no loop in the body stays native. Values cross one way per call: scalars, str, bytes and lists of scalars by value (a list is copied, so a change made there is not visible to native code), dict and everything else as an opaque handle usable through attribute reads, item reads, further calls and str()/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.
  42. 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_load result, a Path global) plans the whole top level for the embedded interpreter instead of stopping the compile. The binary’s main seeds 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.argv mirrors the process argv, so sys.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).
  43. argparse type=Path options compile to a native Path in the namespace, but the namespace has no None slot, so an absent option cannot be None. A type=Path option without required=True or a str default stops the compile (PS2003), and a default is converted to a Path at parse time. parse_args takes None, a list of str, or an Optional list; the list form skips sys.argv.
  44. 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, so lp = Path(x) followed by lp = find_latest() stops the compile with PS1003, and lp is None on a plain Path stops with PS2004. The typed form lp: Optional[Path] = None compiles. Narrowing through is None and is not None guards combined with and, or, conditional expressions, early exits and loop bodies does compile.
  45. These shapes are refused after the mechanical-delta pass, each as its own PS code: set(d) and list(d) over a dict (PS2002, the set kind takes a list of int or str), sorted(paths) over Path elements (PS2003), incremental hashlib update calls and a hashlib constructor with data (PS2003), Path.open("rb") (PS2001), collections.abc.Sequence annotations (PS1104), class-level constants (PS1204), module-level list-of-dict tables (PS1106, dict values are not heterogeneous), and a module-level Path(__file__).resolve().parents[n] constant (PS1001).
  46. 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, so Path(__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 raise FileNotFoundError where 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.
  47. 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 same readdir order. The walk is computed before the loop body runs, so a body that edits dirnames does not prune the walk where CPython honours the edit; topdown=, onerror= and followlinks= are refused (os.scandir and the other os names outside the fixed rows keep their PS2001). json.load(fp) reads the whole handle and reuses the json.loads annotation path: a missing result annotation stops the compile (PS1001) where CPython returns an untyped value, and a decode failure stays the runtime ValueError. json.dump(obj, fp) serializes through the same json.dumps subset and writes the handle with one call, so the bytes are the dumps bytes (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_counter and time.monotonic read 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.