The Python subset
pyscriptc compiles the typed subset of Python 3.14.
Code inside the subset becomes a native binary with no interpreter inside.
Code outside it either stops the build with a PSxxxx code that names the exact line, or runs through embedded CPython (fallback mode) when you enable it.
The compiler never guesses.
Check whether your script is inside the subset before compiling:
pyscriptc coverage script.py
coverage needs no C toolchain and reports, per line, what would stop the build.
pyscriptc explain PSxxxx looks up any code it prints and shows the smallest rewrite that clears it.
How to write code that compiles
Annotate every variable and function parameter.
The annotations are the input the compiler works from: a type-checker preflight (pyrefly) checks them first, then the compiler uses them to build the binary.
def sum_squares(xs: list[int]) -> int:
total: int = 0
for x in xs:
total = total + x * x
return total
def find(xs: list[str], t: str) -> str | None:
for x in xs:
if x == t:
return x
return None
def main() -> None:
counts: dict[str, int] = {"a": 1, "b": 2}
best: int | None = None
for v in counts.values():
if best is None or v > best:
best = v
print(sum_squares([1, 2, 3]), best, find(["x"], "x"))
The rules that matter most:
- Assign to annotated names.
counts: dict[str, int] = {}compiles;counts = {}stops withPS1001, and every later read of the name stops withPS2002because of it. - Narrow optionals with
is None.
best: int | None = Nonefollowed byif best is None or v > best:compiles, including inside loops. - Return
T | Noneinstead of a sentinel value, then narrow at the call site. - Type function values with
Callable:c: Callable[[int], int] = lambda v: v + 1compiles and calls throughcare direct native calls. - Write generic helpers in the PEP 484 shape:
T = TypeVar("T")at module level, thendef first(xs: list[T]) -> T.
The helper is specialized per call-site type, including over your own classes. - Use
if TYPE_CHECKING:for imports that only feed annotations (for examplecollections.abcshapes or third-party types).
The branch body is skipped by the compiler and the checker still checks it. - Prefer
from __future__ import annotationsfor self-referencing classes, so annotations resolve without forward-reference refusals. - Read schema-like data into annotated locals:
d: dict[str, int] = json.loads(s), then pull typed fields out ofd.
typing and typing_extensions are annotation-only modules.
Importing them is free, but a typing name used as a value (cast(...), get_type_hints(...), NewType(...)) stops the build with PS2002.
There is no typing module object in the binary.
Annotations and idioms
| Write | What happens |
|---|---|
list[int], dict[str, int], set[int], tuple[int, str], tuple[int, ...] |
compiles; the annotation fixes the element layout |
T | None, Optional[T] |
compiles as a tagged optional; is None narrowing drives the branches |
A | B (neither arm None), Union[A, B, None] |
unsupported (PS1101 / PS1102) |
Final, Final[int], ClassVar[T] |
the inner annotation compiles; the checker enforces no reassignment |
Annotated[int, "..."] |
the first argument compiles, the rest is dropped |
Callable[[int], int] |
compiles over functions and lambdas; bare Callable is unsupported (PS1104) |
Iterator[int], Iterable[int], Generator[...], Sequence[int] |
compiles (Sequence[T] reads as list[T]); spell the names under if TYPE_CHECKING: from collections.abc |
TypeVar("T") generic functions |
compiles, specialized per call site |
PEP 695 def f[T], bound or constrained TypeVar, ParamSpec |
unsupported (PS1106 and friends) |
Protocol, ABC, @abstractmethod, @overload |
the annotation is checked, then dropped; no runtime enforcement (divergence 14) |
NamedTuple, TypedDict, NewType, Literal[...], Self |
unsupported; use a @dataclass, a plain dict[K, V], an alias, a plain str, or the class name |
Any, object as an annotation |
unsupported (PS1104) |
async def and await |
compiles |
dataclasses.dataclass with no options |
compiles as a native record; frozen=True and other decorators are unsupported (PS1201) |
cast(t, v), get_type_hints, __annotations__ reads |
unsupported (PS2002); write the type on an annotated local instead |
isinstance(v, list[int]) |
unsupported (PS2001); runtime checks take scalar and class names only |
quoted forward references ("Node") |
unsupported (PS1105); define the class first and write the name unquoted |
Constructs with runtime meaning beyond their annotation (pydantic models, attrs validators, anything that reads annotations at run time) are not typing failures: run them through embedded CPython (fallback mode), see Libraries and fallback mode.
For background on the typing system itself, the upstream references are the best source and pyscriptc does not re-explain them:
- The typing module and Type hints cheat sheet from the mypy docs
- PEP 484 (type hints), PEP 526 (variable annotations), PEP 544 (protocols), PEP 570 (positional-only parameters), PEP 585 (builtin generics), PEP 604 (
X | Yunions), PEP 647 (type guards), PEP 695 (type parameter syntax)
What stays unsupported
- Runtime reflection:
inspect.signature,getattrdispatch,**kwargsforwarding, annotation reads. - Typing names used as values:
cast,NewType,get_type_hints,Annotatedoutside an annotation. - Dynamic object model features:
issubclass,type(v),vars,__import__. - Anything not covered above stops the build with a named
PSxxxxcode, never a guess.
The full divergence list is in Divergences from CPython.
Where the live status lives
Per-construct, per-builtin, per-module and per-library support status is tracked on the compat site, fed by the nightly CI workflow, so it cannot go stale the way a page would.
This page states the rules and the known gaps only.
Scripts as a target
pyscriptc aims at real CLI and automation scripts, and is measured against third-party scripts vendored from public repositories (benchmarks/real_scripts/).
A script that stops with a PSxxxx code is treated as a signal about the compiler, so corpus scripts are never edited to make them compile.
Common failure modes on real scripts:
- An assignment without an annotation (
files = os.listdir(d)) stops withPS1001, and every later read of the name stops withPS2002; the first one is the cause and the rest follow from it. - A keyword argument to a stdlib call (
shutil.copy(src, dst, follow_symlinks=False)) stops withPS2003. - A module outside the closed import table (
platform,calendar,zipfile,tarfile,sqlite3) stops withPS2002on the import.
Run the module through embedded CPython (fallback mode) instead, see Libraries and fallback mode.
Related commands:
pyscriptc assess script.py # what compiles, what does not, and why
pyscriptc imports script.py # how each imported module is classified
pyscriptc verify script.py --call main # run the binary and CPython side by side and diff the output