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
allocondaCLI, 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:
- Docs: https://alloconda.withmatt.com
- Repo: https://github.com/mattrobenolt/alloconda
- Zig API: zig-docs/index.html
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.
uvfor project init and running the CLI viauvx.- 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.zigbuild.zig.zonsrc/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.zigandbuild.zig.zonare generated byalloconda init.src/root.zigdefinespub 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.tomlprovides 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.Objectfor owned and borrowed objects.py.Bytesfor byte strings.py.List,py.Dict, andpy.Tuplefor containers.py.DictIter/py.DictEntryfor 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-allcoordinates 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-initto skip generation--force-initto 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.tomllives)
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.