Skip to content

Architecture

postc turns a typed Python subset into a native shared library by driving one source program through a fixed sequence of stages. The load-bearing decision is in the middle of that sequence: the frontend lowers Python AST into a single backend-agnostic typed SSA IR, and every backend consumes that IR unchanged. This is why postc can be genuinely multi-backend where AST-directed transpilers stall — a backend never re-derives types, control flow, or array addressing from Python syntax; it only walks IR that already has all of that pinned down.

The pipeline

  foo.py  (POST Python source)
 ┌───────────────┐   check_source()          reject anything outside the
 │   checker     │ ─────────────────────►    compilable subset → Violation (PPxxx)
 └───────────────┘
     │  zero violations
 ┌───────────────┐   ast.parse → FunctionLifter
 │   frontend    │   declare()  then  lower()
 │  (lowering)   │ ─────────────────────►    typed SSA IR
 └───────────────┘
     │  compile_program: dependencies first, entry module last
 ╔═══════════════════════════════════╗
 ║   typed SSA IR  —  Module(s)      ║   ONE IR, backend-agnostic
 ╚═══════════════════════════════════╝
     │  get_backend(name)   "c" │ "qbe" │ "llvm" │ plugin
 ┌───────────────┐   emit(module)             IR → backend source (.c / .ssa / .ll)
 │   backend     │   compile_object(src,obj)  source → .o
 └───────────────┘
     │  + runtime_sources (always C)  + pp_<export> ABI wrappers (always C)
   cc -shared -lm   →   foo.so / foo.dylib
Stage Entry point Output
Subset check checker.check_source list[Violation] (empty = valid POST Python)
Lowering compiler.frontend.compile_source / compile_program typed SSA IR Module objects
Backend selection compiler.backends.get_backend a Backend implementation
Emit + assemble Backend.emit / Backend.compile_object backend source, then object files
Link build.build_source / build.build_file one native shared library

1. Subset check

check_source parses the file with ast.parse and walks it, reporting every construct that falls outside the compilable subset as a Violation carrying a PPxxx code and a source location. A syntax error becomes PP000; banned dynamic builtins (eval, exec, getattr, …) and other out-of-subset forms get their own codes. A file with zero violations is a valid POST Python translation unit. The build drivers refuse to proceed if any violation is reported, so nothing past this gate has to defend against un-typeable Python.

2. Frontend lowering (AST → typed SSA IR)

The frontend re-runs the checker, parses to AST, then lifts each def into an IR Function (or a UFunc when it carries a @vectorize / @guvectorize layout signature). Lifting is deliberately two-phase, driven by FunctionLifter:

  • declare() builds the typed function shell from the signature — parameter dtypes, array-ness, shapes, and core-dimension parameters.
  • lower() fills in the body only after every function in the module (and its imported POST dependencies) has been declared.

That ordering is what lets a call resolve against forward references and against functions imported from other POST modules, using the callee's declared signature as the source of truth for the result type — never a guess from syntax. Type inference (infer_function) runs over the body, and statements/expressions lower into basic blocks of typed SSA Values: range loops become explicit condition/body/step/after blocks, if/while become CondBranch, subscripts become stride-scaled ArrayLoad/ArrayStore. Anything valid-but-not-yet-lowered is reported as PP900 rather than silently dropped.

compile_source produces one Module from text. compile_program resolves POST imports (narrowly — the entry file's source root plus explicit search_paths, never site-packages), compiles each dependency once depth-first, diagnoses import cycles as PP500, and returns the modules dependency-ordered with the entry module last. It also enforces that public function names are unique across the linked program (PP501), since every unit's publics become extern C symbols in one artifact.

3. The IR: one representation, every backend

The IR is a typed, SSA-inspired three-address form: a Module (one translation unit) holds Function/UFunc definitions, each a list of BasicBlocks ending in a Return/Branch/CondBranch terminator. Every Value carries its DType, plus shape, layout, and array/output flags. Types, control flow, array addressing, and cross-module linkage are all resolved here, once.

This is the architectural thesis. Because the IR is complete and backend-neutral, a backend is a pure IR→text lowering with no Python knowledge. Adding a backend costs one emit walk, not a second frontend — and a differential test oracle can hold every backend to the same semantics precisely because they consume identical IR.

Why AST-directed compilers stall here

When each backend lowers straight from Python AST, every backend re-implements type inference, promotion, and array indexing — so the second backend is nearly as expensive as the first, and they drift. Concentrating that work in one typed IR makes the N-th backend cheap and keeps them in agreement.

4. Backend registry and selection

Backends live in a name-keyed registry (compiler.backends). The built-ins — CBackend ("c"), QBEBackend ("qbe"), and LLVMBackend ("llvm") — register on import; third parties ship a backend as a separate package via the postc.backends entry-point group (a broken plugin is skipped with a warning, never breaking import postc).

from postc.compiler.backends import get_backend, available_backends

backend = get_backend("qbe")          # UnknownBackendError if not registered
available_backends()                  # names whose external tools are present

Every backend satisfies the Backend protocol: a name, a source_ext (".c", ".ssa", …), a Capabilities descriptor (complex, ext_module, float16), and three methods — emit(module) -> str, compile_object(source, obj, tc), and runtime_sources(modules). The driver reads capabilities before building; for example, build_file(..., ext_module=True) is rejected up front unless the chosen backend reports ext_module.

The driver (build.py) ties it together. build_source compiles a single translation unit from text; build_file compiles an entry file and every POST module it imports. Both funnel into _link_modules, which, for the selected backend be:

  1. writes be.emit(module) for each IR module to a file with suffix be.source_ext, then calls be.compile_object(src, obj, tc) to produce an object file;
  2. compiles each be.runtime_sources(modules) unit — backend runtime helpers are always C (e.g. QBE's integer-** helper);
  3. compiles the pp_<export> stable-ABI wrapper translation unit, also always C;
  4. links every object into one shared library with cc -shared -lm (a -bundle with dynamic lookup on macOS for CPython extension modules).

Each POST module becomes its own object file, so cross-module calls resolve to the compiled POST functions at link time rather than to a same-named libm symbol. The result is a .so/.dylib you load with ctypes.CDLL, or — with ext_module=True — an importable CPython extension whose public ufuncs register as real numpy.ufuncs. The Toolchain dataclass carries the compiler command, the qbe command, and the composed cflags, so the same driver serves every backend.

The C backend's export wrappers and the CPython-extension glue are intentionally always C: they are the ABI boundary, shared by every backend, and don't belong in a backend's per-target lowering. See the backend protocol for the full contract and writing a backend to add your own.