Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Alloconda

Alloconda is Zig-first Python extensions with cross-compiled wheels. It has two pieces:

  • The Zig library, which provides a small wrapper API for CPython.
  • The alloconda CLI, which builds, installs, and packages extensions.

This book is a user guide for building your own extension with alloconda. Examples assume uv and uvx are available for running the CLI.

Project links:

The Zig API reference is generated from src/root.zig and published alongside this book at zig-docs/index.html.

What alloconda is best at

Alloconda is built to cross-compile a full wheel matrix from a single machine. If you care about shipping manylinux, musllinux, macOS, and Windows wheels without multiple build hosts, start with the cross-compilation chapter.

Versions

  • Zig 0.15
  • Python 3.14 (current testing target).

Start with the tutorial to scaffold a minimal project and build your first module.

Tutorial: your first module

This tutorial creates a tiny extension module that exposes hello(name).

Prerequisites

  • Zig 0.15.x.
  • Python 3.14.
  • uv for project init and running the CLI via uvx.
  • Network access for zig fetch (or pass --alloconda-path).

1) Initialize a Python project (uv)

Start by creating a pyproject.toml with uv:

mkdir hello_alloconda
cd hello_alloconda
uv init

uv init writes a pyproject.toml with basic project metadata, including the project name that alloconda init will reuse. alloconda init will also add the build backend stanza automatically.

2) Scaffold the Zig project

From a working directory, run:

uvx alloconda init

If you want to use a local alloconda checkout during development, pass --alloconda-path ../alloconda. Otherwise alloconda init will pin the dependency via zig fetch.

alloconda init writes:

  • build.zig
  • build.zig.zon
  • src/root.zig

The default module name is _<project_name> (so _hello_alloconda here).

3) Verify the Python package

alloconda init creates src/<project_name>/__init__.py so the CLI can locate the package directory. If you prefer a different layout, move the package and set tool.alloconda.package-dir in pyproject.toml.

4) Build and import

uvx alloconda build
uv run python -c "import hello_alloconda; print(hello_alloconda.hello('alloconda'))"

If alloconda build cannot infer your package directory, pass it explicitly:

uvx alloconda build --package-dir src/hello_alloconda

The CLI copies the built extension into the package directory and generates an __init__.py that re-exports the extension module.

If you prefer an editable install, use:

uvx alloconda develop
uv run python -c "import hello_alloconda; print(hello_alloconda.hello('alloconda'))"

5) Edit the module

Open src/root.zig and add new functions or classes using py.method and py.class. The next chapters show the available patterns.

6) Cross-compile your first wheel matrix

Alloconda can build a multi-platform wheel matrix from one machine. Start with a dry run to see the matrix:

uvx alloconda wheel-all --python-version 3.14 --include-musllinux --include-windows --dry-run

Then run the build (and fetch any missing headers automatically):

uvx alloconda wheel-all --python-version 3.14 --include-musllinux --include-windows --fetch

For detailed targeting and configuration, see the cross-compilation chapter.

Project layout

A minimal alloconda project typically looks like this:

hello_alloconda/
├── build.zig
├── build.zig.zon
├── pyproject.toml
└── src/
    ├── root.zig
    └── hello_alloconda/
        └── __init__.py

Notes:

  • build.zig and build.zig.zon are generated by alloconda init.
  • src/root.zig defines pub const MODULE = ... and your Zig API surface.
  • src/hello_alloconda/ is the Python package directory. The CLI copies the compiled extension here and writes __init__.py.
  • pyproject.toml provides Python metadata and build backend configuration.

If you prefer a different package layout, pass --package-dir to the CLI or set tool.alloconda.package-dir in pyproject.toml.

Writing modules

Alloconda exposes a small, Zig-first API for defining Python modules.

The generated API reference is published alongside this book at zig-docs/index.html.

To generate it locally:

zig build docs -Dpython-include=/path/to/python/include -p docs

Module definition

Export a single module definition named MODULE:

const py = @import("alloconda");

pub const MODULE = py.module("_hello_alloconda", "Example module", .{
    .hello = py.method(hello, .{}),
});

fn hello(name: []const u8) []const u8 {
    return name;
}

Method options

py.method always takes an explicit options struct. You can attach docstrings and argument names:

.hello = py.method(hello, .{
    .doc = "Echo back the provided name.",
    .args = &.{ "name" },
}),

If a method is defined on a class and needs self, set .self = true and include self: py.Object as the first parameter.

Classes

Define classes with py.class and attach them via .withTypes:

const Greeter = py.class("Greeter", "A tiny class", .{
    .hello = py.method(hello, .{ .self = true }),
});

pub const MODULE = py.module("_hello_alloconda", "Example module", .{})
    .withTypes(.{ .Greeter = Greeter });

fn hello(self: py.Object, name: []const u8) []const u8 {
    _ = self;
    return name;
}

Wrapper types

Prefer the alloconda wrapper types (py.Object, py.List, py.Dict, py.Tuple, py.Bytes) instead of raw CPython calls. Use py.ffi.c only when the wrappers do not expose what you need.

Working with Python types

Alloconda wraps common Python container types so you can stay in Zig without reaching for the raw C API.

Available wrappers include:

  • py.Object for owned and borrowed objects.
  • py.Bytes for byte strings.
  • py.List, py.Dict, and py.Tuple for containers.
  • py.DictIter / py.DictEntry for iterating dictionaries.

Use these wrappers to construct values, inspect contents, and manage ownership. If you need lower-level access, py.ffi.c exposes the CPython API.

Error handling

Alloconda translates Zig errors into Python exceptions and provides helpers for setting exceptions explicitly.

Error unions

If a method returns error!T or error!void, alloconda catches errors and raises RuntimeError with the Zig error name (unless you have already set an exception).

Raising exceptions manually

Use the helpers in alloconda when you need explicit control:

  • py.raise(.TypeError, "message")
  • py.raiseError(err, &.{ .{ .err = MyError, .kind = .ValueError } })

py.raiseError lets you map specific Zig errors to Python exception kinds.

CLI guide

The alloconda CLI builds, installs, and packages your extension module. This guide assumes you run it via uvx.

Running via uvx

Use uvx to run the CLI:

uvx alloconda build
uvx alloconda wheel
uvx alloconda wheel-all --python-version 3.14 --include-musllinux

Scaffold

uvx alloconda init --name hello_alloconda

alloconda init creates build.zig, build.zig.zon, src/root.zig, and a src/<project_name>/__init__.py package directory. If a pyproject.toml exists, it adds the build-system block automatically. Pass --alloconda-path to use a local alloconda checkout instead of fetching.

Build

uvx alloconda build

Builds the Zig project, detects the PyInit_* symbol, and copies the extension into the package directory. Use --package-dir if the CLI cannot infer it.

Develop

uvx alloconda develop

Performs an editable install via pip install -e . (or uv pip if available).

Wheels

uvx alloconda wheel
uvx alloconda wheel-all

wheel builds a single wheel for the current platform. wheel-all builds a matrix across Python versions and platforms. Cross-compilation is a first-class feature: you can target manylinux/musllinux, macOS, and Windows from one host.

Inspect

uvx alloconda inspect --lib zig-out/lib/libhello_alloconda.dylib
uvx alloconda inspect --wheel dist/hello_alloconda-0.1.0-*.whl --verify

Inspect a built library or wheel and print derived metadata.

Python headers for cross builds

uvx alloconda python fetch --version 3.14 --manylinux 2_28 --arch x86_64

This caches python-build-standalone headers for cross compilation.

Cross-compilation guide

See the dedicated cross-compilation chapter for the recommended workflow and flag combinations.

Configuration (tool.alloconda)

You can set defaults in pyproject.toml:

[tool.alloconda]
module-name = "_hello_alloconda"
package-dir = "src/hello_alloconda"
python-version = "3.14"

Any CLI flag can override these defaults.

Packaging

Alloconda provides a PEP 517 build backend so standard tooling can build wheels for your extension.

Build backend setup

Add this to pyproject.toml:

[build-system]
requires = ["alloconda"]
build-backend = "alloconda.build_backend"

Building wheels

You can either use the alloconda CLI via uvx:

uvx alloconda wheel

Or use standard PEP 517 tooling, such as uv build --package your-project. The build backend reads pyproject.toml metadata and tool.alloconda settings.

For cross-compilation and multi-target builds, prefer the CLI (wheel / wheel-all) so you can control the target matrix directly.

Publishing

Alloconda does not ship a publish command. Use twine to upload built wheels.

Cross-compiling wheels

Alloconda is designed to build wheels for multiple targets from a single machine. This is a core feature: you can produce a full matrix without juggling separate build hosts.

How it works (high level)

  • Zig handles cross compilation for the extension module.
  • alloconda can fetch python-build-standalone headers for target platforms.
  • alloconda wheel-all coordinates a build matrix across Python versions and platforms.

Fetch target headers

For cross builds, fetch headers for the target Python version and platform:

uvx alloconda python fetch --version 3.14 --manylinux 2_28 --arch x86_64

The headers are cached locally and reused across builds.

You can also let wheel-all fetch missing headers with --fetch.

Build a single cross-target wheel

uvx alloconda wheel \
  --python-version 3.14 \
  --manylinux 2_28 \
  --arch x86_64

Examples for other targets:

# macOS arm64
uvx alloconda wheel --python-version 3.14 --platform-tag macosx_14_0_arm64 --arch arm64

# Windows x86_64
uvx alloconda wheel --python-version 3.14 --platform-tag win_amd64 --arch x86_64

When --python-version is set, alloconda uses cached headers to select the right extension suffix. If you are cross-building without cached headers, pass --ext-suffix explicitly.

Build a full matrix

By default, wheel-all targets macOS (arm64 + x86_64) and manylinux 2_28 (x86_64 + aarch64). You can extend this matrix or override it entirely.

uvx alloconda wheel-all --python-version 3.14 --include-musllinux --include-windows

Use --dry-run to inspect the matrix before building, and --fetch to download headers automatically:

uvx alloconda wheel-all --python-version 3.14 --include-musllinux --dry-run
uvx alloconda wheel-all --python-version 3.14 --include-musllinux --fetch

To override the target list explicitly, use --target (repeatable):

uvx alloconda wheel-all --python-version 3.14 \\
  --target macosx_14_0_arm64 \\
  --target manylinux_2_28_x86_64

Configure defaults in tool.alloconda

Set project defaults in pyproject.toml so you don’t have to repeat flags:

[tool.alloconda]
python-version = ["3.14"]
targets = [
  "macosx_14_0_arm64",
  "macosx_11_0_x86_64",
  "manylinux_2_28_x86_64",
  "manylinux_2_28_aarch64",
]

Patterns & tips

Module + class pattern

Attach classes to a module via .withTypes:

const py = @import("alloconda");

const Greeter = py.class("Greeter", "A tiny class", .{
    .hello = py.method(hello, .{ .self = true }),
});

pub const MODULE = py.module("_example", "Example module", .{})
    .withTypes(.{ .Greeter = Greeter });

fn hello(self: py.Object, name: []const u8) []const u8 {
    _ = self;
    return name;
}

Explicit method options

Zig 0.15 requires an explicit options struct:

.add = py.method(add, .{}),

Override module name detection

If symbol detection picks the wrong PyInit_*, pass the module explicitly:

uvx alloconda build --module _my_module

Control __init__.py

The CLI generates a re-exporting __init__.py by default. You can opt out or overwrite:

  • --no-init to skip generation
  • --force-init to overwrite an existing file

FAQ

Why does alloconda init run zig fetch?

The scaffold adds alloconda as a Zig dependency. By default it fetches git+https://github.com/mattrobenolt/alloconda, which gives you a pinned hash in build.zig.zon. Use --alloconda-path if you want a local checkout instead.

Why does alloconda build ask for a package directory?

The CLI needs a Python package directory to copy the extension into and to write __init__.py. If it cannot infer one, pass --package-dir or set tool.alloconda.package-dir in pyproject.toml.

How do I rename the extension module?

Set the module name in src/root.zig and pass --module to the CLI if you need to override symbol detection. You can also set tool.alloconda.module-name.

Contributing

This section covers alloconda development and repository maintenance. The repo is intentionally opinionated: we use Nix, direnv, and the Justfile.

Repo: https://github.com/mattrobenolt/alloconda

Tooling

Recommended setup:

  • Nix (flake-enabled).
  • direnv.

direnv loads the Nix dev shell automatically. From the repo root:

direnv allow

If you prefer to avoid direnv:

nix develop

The dev shell provides Zig 0.15, Python 3.14, uv, mdBook, wrangler, and other tools used in this repo.

Workspace setup

Install dependencies for all workspace members:

just sync

This runs uv sync --all-packages --all-groups --all-extras under the hood.

Running tests and lint

Run the example module tests:

just zigadd
just zigzon

Lint the repo:

just lint

Type-check the CLI:

cd python/alloconda && ty check

Example modules

The repo includes example extension modules:

  • python/zigadd: a minimal extension with tests and type stubs.
  • python/zigzon: a ZON codec example with tests and type stubs.

Docs site

Docs are built with mdBook. The output directory is configured as book/.

Zig API docs are generated via the build system and copied into docs/zig-docs. just docs runs both Zig docgen and mdBook. If Python headers are not detected automatically, set ALLOCONDA_PYTHON_INCLUDE.

just docs

For local previews:

just docs-serve

Cloudflare Pages (Git integration) build settings:

  • Build command: bash scripts/cloudflare/build-docs.sh
  • Build output directory: book
  • Root directory: repo root (where book.toml lives)

The build script downloads mdBook and runs the build. You can override the version by setting MD_BOOK_VERSION in the Pages build environment.

Roadmap

The short-term roadmap lives in PLAN.md at the repo root.