Skip to main content
Pre-release

This release is a pre-release and may not be stable for production use.

PPY — Pretty Python

PyPI Python CI License

A statically analyzable language in Python's syntax. A .ppy file is valid Python: it runs under plain CPython with no compiler involved. The compiler adds static checking, an optimized Python backend, and an LLVM native backend — and all three must produce the same answer.

PPY is source-compatible with Python's syntax and ecosystem, not with every dynamic Python behavior: exec/eval, monkey-patching, dynamic namespace mutation, and unrestricted runtime reflection are deliberately restricted — or isolated behind an explicit ppy.dynamic boundary — in exchange for analysis, optimization, and native compilation that can be trusted. Running existing Python is a migration feature (ppy migrate), not the definition of the language.

Install

Add PPY to your project with uv (Python 3.12+):

uv add "ppy-lang[llvm]"

or with pip: pip install "ppy-lang[llvm]". The distribution is ppy-lang and what it installs is ppy, so your code writes import ppy.

Releases are alphas — the language and the diagnostics are in use and tested, and neither is promised to stay put — so pin an exact version: uv add "ppy-lang[llvm]==0.1.0a1". For the development tip instead:

uv add "ppy-lang[llvm] @ git+https://github.com/franknoh/PPy.git"

The base package is the compiler and the runtime; extras enable the rest, so you install only what you use:

extra enables
llvm the native backend (llvmlite)
numpy / pydantic / uvicorn the matching plugin
torch / jax the matching plugin, CPU builds by default — point at a CUDA index in your own project if you want one

Everything degrades cleanly: a missing library only disables its plugin, and uv run ppy doctor reports what was found. For the fastest native call boundary, have the CPython headers installed (python3-dev); without them PPY says so once and uses the slower boundary.

One file, four ways

# collatz.ppy
import ppy


@ppy.pure
@ppy.opt(3)
def longest(limit: int) -> int:
    best: int = 0
    for start in range(1, limit):
        n: int = start
        steps: int = 0
        while n != 1:
            n = n // 2 if n % 2 == 0 else 3 * n + 1
            steps += 1
        best = max(best, steps)
    return best


print(longest(ppy.input[int]()))

ppy.input[T]() reads the next value the way T says to read it, straight into memory rather than through a Python object per field. Piping the limit in, echo 300000 | before each of these:

uv run python    collatz.ppy           # 1  plain CPython, no compiler   1170.2 ±  9.1 ms
uv run ppy       collatz.ppy           # 2  optimized Python backend     1167.9 ± 13.1 ms
uv run ppy run   collatz.ppy           # 3  LLVM, JIT every run            45.4 ±  1.3 ms  (+ ~1200 ms JIT)
uv run ppy build collatz.ppy -o dist   # 4  LLVM, built once (~800 ms)...
./dist/collatz                         #    ...then the native binary      33.6 ±  1.1 ms
                                       #    ...--host-cpu, not portable    31.8 ±  0.9 ms

gcc -O3 collatz.c && ./a.out           # reference: the same loop in C     45.0 ±  0.6 ms  (+ ~110 ms gcc)

Numbers are the kernel's wall time on one machine — mean ± standard deviation over ten runs, each a fresh process; the parenthesized figure is what that row spends turning source into machine code, and how often. Ways 3 and 4 are one compilation path, ahead of time or not — the binary is ppy run in a compiled coat, machine code taken from the library built next to it. They differ in one default: ppy run keeps Python-integer semantics (overflow is guarded and falls back to arbitrary precision — that is the 45.4 ms, level with C with the guards in), while ppy build produces a wrap-semantics artifact like every native compiler — that is the 33.6 ms, past C. run --unsafe and build --safe flip either one; bounds checks stay in both. The built binary is compiled software: it starts an embedded interpreter and imports ppy_runtime — about 35 ms before the program begins — and keeps working with the compiler uninstalled. ppy build --standalone removes even that, for a program whose reachable graph is entirely native. (Python's floor semantics help too: n // 2 lowers to one arithmetic shift exactly, where C's truncating division needs a sign fixup.)

The same kernel through the neighbors, same machine and methodology:

compiler kernel integer semantics
PPY ppy build --host-cpu 31.8 ± 0.9 ms 64-bit, wraps on overflow (this machine's instruction set)
PPY ppy build binary 33.6 ± 1.1 ms 64-bit, wraps on overflow (--safe to keep Python ints)
Codon -release 35.9 ± 1.9 ms 64-bit, wraps on overflow
Numba @njit 36.5 ± 1.0 ms 64-bit, wraps on overflow
C (gcc -O3) 45.0 ± 0.6 ms 64-bit, wraps on overflow
PPY ppy run 45.4 ± 1.3 ms Python ints: guarded, falls back to arbitrary precision
PyPy 3.11 56.9 ± 2.5 ms Python ints
mypyc 78.4 ± 1.4 ms Python ints
Cython (cdef long long) 81.4 ± 3.0 ms 64-bit, wraps on overflow
Nuitka 776.9 ± 11.6 ms Python ints, no type specialization
CPython 3.14 1170.2 ± 9.1 ms Python ints

--host-cpu is the opt-in that compiles for the machine doing the build instead of the portable baseline — 33.6 ms to 31.8 ms here, and about 20% on a matmul kernel where the vectorizer has something to work with. It is off by default because an artifact is meant to be shipped and host code faults on an older CPU; JIT code under ppy run always targets the host, which is free because it never leaves the machine. Giving C the same option changes nothing on this kernel (gcc -O3 -march=native: 45.5 ± 1.2 ms), so the row above it is not winning on a flag C was denied.

Same compiler, run and build: the 12 ms between them is the price of Python's integers, and it is a per-command default rather than a language decision — wrap semantics where a native artifact is expected, full Python semantics where a Python program is. Ports are the straightforward one for each tool: @njit, a cdef long long .pyx, an annotated module for mypyc, codon build -release, nuitka --module; Numba 0.67, Cython 3.3, mypy 2.3.1, Nuitka on CPython 3.13, Codon 0.19.6, PyPy 3.11.15 (warm).

Turning ordinary Python into it

uv run ppy convert src/ --in-place    # strict: the output must be valid strict PPY
uv run ppy migrate src/ --in-place    # permissive: rewrite toward it, report the rest

convert is the compiler-facing command: it refuses to produce anything ppy check would reject, and says why. migrate is for normal existing Python — it first rewrites dynamic-but-static patterns (setattr with a constant name, globals()["X"] = ..., constant importlib.import_module), then staticizes, and classifies whatever remains (--report migration.json, --diff).

Given untyped Python:

import math

LIMIT = 3.0


def clamp(value):
    return min(value, LIMIT)


def spread(samples):
    total = 0.0
    for sample in samples:
        total += clamp(sample)
    return math.sqrt(total / len(samples))


print(spread([1.0, 2.0, 9.0]), spread((4.0, 5.0)))

ppy convert writes:

import math
from collections.abc import Sequence
from typing import Final

import ppy

LIMIT: Final[float] = 3.0


@ppy.pure
def clamp(value: float) -> float:
    return min(value, LIMIT)


@ppy.pure
def spread(samples: Sequence[float]) -> float:
    total: float = 0.0
    for sample in samples:
        total += clamp(sample)
    return math.sqrt(total / len(samples))


print(spread([1.0, 2.0, 9.0]), spread((4.0, 5.0)))

Types come from the whole call graph, not one file. samples is Sequence rather than list because the body only reads it and one call site passes a tuple; LIMIT is Final because nothing rebinds it; @ppy.pure is attached only where the checker proved it. Nothing is renamed and no function is split — those are design decisions, not mechanical ones.

Works with

Each library is a plugin: the compiler learns that library's types and effects, takes a faster path where it can prove one is equivalent, and falls back to the ordinary Python call everywhere else. A guard that fails is a fallback, never a different answer.

NumPy — elementwise expressions fuse into a single loop with no temporaries; dot, matmul, inner, vdot, and tensordot route to the linear-algebra path. Contiguity and shape are guarded at runtime, not assumed. Reduction order is preserved unless @ppy.fastmath permits reassociation, so a sum stays bit-identical to NumPy's.

PyTorch — a function whose body is entirely curated tensor operations (55 of them) compiles into one C++ region calling ATen directly, removing a Python round trip per operator. Every call still goes through the dispatcher, so autograd, device selection, and backend keys are unchanged; a tensor subclass or a __torch_function__ override trips the guard and the Python body runs. CUDA is used when it is there.

JAX / Flax — a @jax.jit function whose inputs carry ppy.Shape and ppy.DType can be exported to StableHLO at build time, so the trace is not repeated at startup; shapes may be symbolic, so one artifact serves every batch size (export runs project code and is off until the project opts in). The same plugin models Flax and optax — layers, activations, Module.init/ apply through the external MRO, optimizers — so a Flax training loop checks under strict mode as-is.

Pydantic — models are typed, constructor and output shapes are kept distinct, and field constraints become refinements the checker can use.

Uvicorn / FastAPI — the ASGI application is resolved statically instead of re-imported by module string per worker, and the reloader is told to watch .ppy. FastAPI rides the same plugin: its surface is modeled so strict mode checks a FastAPI service as-is, while route handlers keep the exact signatures FastAPI reads at import.

The plugin's exact version is part of every cache key, so an artifact built against one build of a library is never reused against another. ppy doctor prints what it found.

Docs

Development

git clone https://github.com/franknoh/PPy.git
cd PPy
uv sync              # the compiler core, LLVM, NumPy, pydantic, and the linters
./scripts/check.sh   # the one gate; CI runs exactly this

Plugin runtimes are separate groups (uv sync --group torch|jax|uvicorn|all), and everything else a contributor needs — the gate, the invariants a change has to keep, how examples and measurements are kept current — is in CONTRIBUTING.md.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

ppy_lang-0.1.0a1.tar.gz (404.6 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

ppy_lang-0.1.0a1-py3-none-any.whl (340.4 kB view details)

Uploaded Python 3

File details

Details for the file ppy_lang-0.1.0a1.tar.gz.

File metadata

  • Download URL: ppy_lang-0.1.0a1.tar.gz
  • Upload date:
  • Size: 404.6 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for ppy_lang-0.1.0a1.tar.gz
Algorithm Hash digest
SHA256 cc47d3c4b214472c8f7c8f16473225784e383acd8f24488e60d9a17101c4a43b
MD5 fd5ec4b5815670f91106f10b1a6c3704
BLAKE2b-256 918db60ae2bbafbc7a99ab5fde09bd7644459d5dde0ef0e00d9703f06c95ea68

See more details on using hashes here.

Provenance

The following attestation bundles were made for ppy_lang-0.1.0a1.tar.gz:

Publisher: release.yml on franknoh/PPy

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file ppy_lang-0.1.0a1-py3-none-any.whl.

File metadata

  • Download URL: ppy_lang-0.1.0a1-py3-none-any.whl
  • Upload date:
  • Size: 340.4 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for ppy_lang-0.1.0a1-py3-none-any.whl
Algorithm Hash digest
SHA256 1fb5f9bbb7536e35327624a0f556c350af9df5b06df00d02cfabe89de47a534c
MD5 f647295f7c6be63a770ec4d103470cec
BLAKE2b-256 586af0fc1ac4232e21cedb7e2be97272eb0e801520ae9e68d7ec906de03d16e1

See more details on using hashes here.

Provenance

The following attestation bundles were made for ppy_lang-0.1.0a1-py3-none-any.whl:

Publisher: release.yml on franknoh/PPy

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Release history Release notifications | RSS feed

This release

0.1.0a1 This release

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page