Skip to content

Static Python

 7 min zippy: a PGO-built CPython with every C extension statically folded in, the application zipped onto the tail of the binary, and the interpreter treated as just another dependency.
Published

Feb 2, 2015

I was at Corvisa, a cloud telephony company, and I wanted deployment to be one file. Not a virtualenv plus a system Python plus a pile of compiled wheels that had to agree with each other—one binary you could copy to a host and run. The tool that came out of that was zippy. It targeted CPython 2.7 and its extension-building machinery.

Deployment then meant dropping a file on a server. This was before Kubernetes was everywhere; Mesos and Marathon may have been in use somewhere, but I think zippy predates even that. The binary was schlepped onto a host and run. What made it appealing was that it was easy to install and it always did the right thing.

In an earlier distlib discussion, I had already described the multicall Python/uWSGI binary, printed its builtin-extension list, and demonstrated imports from it. The distlib account follows that earlier work. Zippy added the build machinery described below.

The finished artifact was a single ELF: a profile-guided-optimized CPython with every third-party C extension—psycopg2, pycrypto, uWSGI itself—compiled into the interpreter as builtin modules, and the pure-Python world zip-appended onto the tail of the binary. You invoked the whole build from the app’s zippy subdirectory—the README’s django-cloud invocation, abridged:

python -mzippy.zscript \
    --prefix=/opt/corvisa/zippy-django-cloud \
    --requirements=.. \
    install

Two passes over CPython

The build configures CPython with an empty prefix and builds it with PGO—'--prefix=/' followed by make profile-opt—so the interpreter is optimized against a profiling run and has no baked-in installation path. Static linking starts with a tiny constant. The seed Modules/Setup fragment opens with

*static*

# core modules, *always* static
_codecs _codecsmodule.c
_sre _sre.c
...
zipimport zipimport.c

#HACK: eliminates path oddities before distutils chokes
prefix=         /
exec_prefix=    /
BINDIR=         /bin

*static* means every module line that follows builds into libpython rather than as a shared object. The build then rebuilds CPython a second time with every third-party extension appended to that file—using CPython’s own Setup machinery as the linker driver, rather than anything custom.

Startup, without a supported hook

A prefix-less interpreter reading itself as a zip needs work done at a precise moment after startup, and CPython 2 had no hook for that moment. zippy replaced site.py with its own site module, which then arms sys.settrace with a one-shot tracer that waits for the first __main__ frame, fixes sys.path, and disarms itself—the comment block calls it “HACKZILLA!”. I wrote that one up separately as the settrace ambush.

Another early-boot change kept unpacked builds from recognizing the wrong standard library. CPython 2 normally compiled os.py as its LANDMARK, the file whose presence tells the path search it has found a Python home. zippy replaced that at build time with zippy.<identifier>.json, wrote that marker only into its own lib/python2.7, and compiled the prefix from the executable path. Its site hook then added sys.executable and the library directories derived from it to sys.path. An unpacked tree won when it was present; the zip appended to the executable remained the fallback. A second unpacked build at the conventional path could not donate its stdlib accidentally because its marker carried a different identifier.

Entry points without script files

The binary contains every console script, but ships none of them as files. A small C patch to Py_Main splices a synthetic argument in when the executable’s own name doesn’t start with “python”:

strncpy(new_argv[1], "-m@", 4);

On the Python side, a replaced runpy._run_module_as_main sees @ and translates the executable name through metadata embedded in the zip (read via os.__loader__.get_data, so it works from inside the binary), scanning every distribution’s wrap_console map for a matching package.module:function target. So ln -s python gunicorn is a complete install of the gunicorn command: argv[0] routes through package metadata to the right callable. The hook also taught plain -m to accept module:function syntax directly, and published the resolved entry as sys.export. The in-source comment names its immediate use:

really for xacto, but useful on it’s own

Xacto was the companion tool that turned callables into CLIs.

The repo carries its own toolchain

zippy vendored waf and distlib wholesale, and spliced the vendored copy in before anything could import a system one: sys.modules['waflib'] = get_module('waflib'). It also committed a prebuilt ccache ELF at cache/bin/ccache.x86_64. At configure time the repo’s cache is copied under ~/.cache/zippy, the binary is hardlinked to ccache, and gcc, g++, cpp, c++, and cc—plus their target-triplet-prefixed variants—are all symlinked at it and prepended to PATH. Every compiler invocation in the whole build is silently cached without touching any build system’s configuration. That bootstrap needed no pip or system waf, and its build-tool layer could materialize from the checkout. It did not make the complete build network-free: the resolver still retrieved source distributions, the CPython locator scraped the tag list over HTTP, and the later GitLocator fetched refs.

Python is a package too

In zippy, the interpreter is another requirement. A custom distlib locator scraped http://hg.python.org/cpython/tags?style=raw and turned every CPython tag into a resolvable distribution whose source URL was the hg archive zip, so Python (== 2.7.7, < 3.0) flowed through the same dependency resolver as flask or psycopg2. Alongside it sat a setuptools (< 8) pin, explained by its commit message:

bug in setuptools 8 causing symlink recursion?

A GitLocator later joined the stack, shallow-fetching exactly one ref per git requirement into a sha256-addressed checkout.

PEP 426 was never an adopted standard: after years deferred, it was withdrawn in 2018 in favor of PEP 566’s narrower path. Zippy’s pydist.json work belongs to that draft period. Its public repository names real packages, carries commits flagged “TODO: UPSTREAM”, and links to upstream work. That work led to my entry in distlib’s CONTRIBUTORS file and, from there, in stranger places.

Where it stopped

The final commit was still refining the resolver:

reprocess rdepends before attempting a replace

The test suite still asserts sys.version_info == (2, 7, 6, 'final', 0) while the README shows a 2.7.7 build; the pin was never updated, an exact snapshot of where polish stopped. The README also kept this outdated claim:

Zippy does not currently support git URLs

The Git locator work had already made that obsolete.

The repository makes its target concrete. Its uWSGI patch series exists in version-specific sets, each embedding a setup.cpyext.py that built uWSGI as a CPython extension. The README walks django-cloud into /opt/corvisa/zippy-django-cloud, then shows the resulting interpreter importing corvisacloud.settings.

In a later Python-ideas reply I wrote that zippy “worked well for the time it was used,” that the single binary was easy to send to other developers, and that psycopg2, librabbitmq, sqlite3, and dozens of other libraries had linked into one process. The single-file build made sharing the environment straightforward.