| layout | page |
|---|---|
| title | Compiled packaging |
| permalink | /guides/packaging-compiled/ |
| nav_order | 6 |
| parent | Topical Guides |
{% include toc.html %}
There are a variety of ways to package compiled projects. In the past, the only way to do it was to use setuptools/distutils, which required using lots of fragile internals - distutils was intended primarily to compile CPython, and setuptools tried to stay away from changing the compile portions except as required. Now, however, we have several very nice options for compiled packages!
The most exciting developments have been new native build backends:
- scikit-build-core: Builds C/C++/Fortran using CMake.
- meson-python: Builds C/C++/Fortran using Meson.
- maturin: Builds Rust using Cargo. Written entirely in Rust!
- enscons: Builds C/C++ using SCONs. (Aging now, but this was the first native backend!)
{: .note }
You should be familiar with [packing a pure Python project]({% link pages/guides/packaging_compiled.md %}) - the metadata configuration is the same.
There are also classic setuptools plugins:
- scikit-build: Builds C/C++/Fortran using CMake.
- setuptools-rust: Builds Rust using Cargo.
{: .important }
Selecting a backend: If you are using Rust, use maturin. If you are using CUDA, use scikit-build-core. If you are using a classic language (C, C++, Fortran), then you can use either scikit-build-core or meson-python, depending on whether you prefer writing CMake or Meson. Meson is a lot more opinionated; it requires you use version control, it requires a README.md and LICENSE file. It requires your compiler be properly set up. Etc. While CMake can be as elegant as Meson, there are a lot of historical examples of poorly written CMake.
{% rr PY001 %} Packages must have a pyproject.toml file {% rr PP001 %} that
selects the backend:
{% tabs %} {% tab skbc Scikit-build-core %}
[build-system]
requires = ["pybind11", "scikit-build-core>=0.11"]
build-backend = "scikit_build_core.build"{% endtab %} {% tab meson Meson-python %}
[build-system]
requires = ["meson-python>=0.18", "pybind11"]
build-backend = "mesonpy"{% endtab %} {% tab maturin Maturin %}
[build-system]
requires = ["maturin>=1.9,<2"]
build-backend = "maturin"{% endtab %} {% endtabs %}
{% include pyproject.md %}
These tools all read the project table. They also have extra configuration
options in tool.* settings.
{% tabs %} {% tab skbc Scikit-build-core %}
[tool.scikit-build]
minimum-version = "build-system.requires"
build-dir = "build/{wheel_tag}"These options are not required, but can improve your experience.
{% endtab %} {% tab meson Meson-python %}
No tool.meson-python configuration required for this example.
{% endtab %} {% tab maturin Maturin %}
[tool.maturin]
module-name = "package._core"
python-source = "src"
sdist-generator = "git" # default is cargoMaturin assumes you follow Rust's package structure, so we need a little bit of configuration here to follow the convention of the other tools here.
{% endtab %} {% endtabs %}
{% tabs %} {% tab skbc Scikit-build-core %}
Example CMakeLists.txt file (using pybind11, so include pybind11 in
build-system.requires too):
cmake_minimum_required(VERSION 3.15...3.26)
project(${SKBUILD_PROJECT_NAME} LANGUAGES CXX)
set(PYBIND11_FINDPYTHON ON)
find_package(pybind11 CONFIG REQUIRED)
pybind11_add_module(_core MODULE src/main.cpp)
install(TARGETS _core DESTINATION ${SKBUILD_PROJECT_NAME})Scikit-build-core will use your .gitignore to help it avoid adding ignored
files to your distributions; it also has a default ignore for common cache
files, so you can get started without one, but it's recommended.
{% endtab %} {% tab meson Meson-python %}
Example meson.build file (using pybind11, so include pybind11 in
build-system.requires too):
project(
'package',
'cpp',
version: '0.1.0',
license: 'BSD',
meson_version: '>= 1.1.0',
default_options: [
'cpp_std=c++11',
],
)
py = import('python').find_installation(pure: false)
pybind11_dep = dependency('pybind11')
py.extension_module('_core',
'src/main.cpp',
subdir: 'package',
install: true,
dependencies : [pybind11_dep],
)
install_subdir('src/package', install_dir: py.get_install_dir() / 'package', strip_directory: true)Meson requires that your source be tracked by version control. In a real project, you will likely be doing this, but when trying out a build backend you might not think to set up a git repo to build it.
{% endtab %} {% tab maturin Maturin %}
Example Cargo.toml file:
[package]
name = "package"
version = "0.1.0"
edition = "2021"
[lib]
name = "_core"
# "cdylib" is necessary to produce a shared library for Python to import from.
crate-type = ["cdylib"]
[dependencies]
rand = "0.9.2"
[dependencies.pyo3]
version = "0.27.2"
# "extension-module" tells pyo3 we want to build an extension module (skips linking against libpython.so)
# "abi3-py310" tells pyo3 (and maturin) to build using the stable ABI with minimum Python version 3.10
features = ["extension-module", "abi3-py310"]{% endtab %} {% endtabs %}
This example will make a _core extension inside your package; this pattern
allows you to easily provide both Python files and compiled extensions, and
keeping the details of your compiled extension private. You can select whatever
name you wish, though, or even make your compiled extension a top level module.
{% tabs %} {% tab skbc Scikit-build-core %}
Example src/main.cpp file:
#include <pybind11/pybind11.h>
int add(int i, int j) { return i + j; }
namespace py = pybind11;
PYBIND11_MODULE(_core, m) {
m.doc() = R"pbdoc(
Pybind11 example plugin
-----------------------
.. currentmodule:: python_example
.. autosummary::
:toctree: _generate
add
subtract
)pbdoc";
m.def("add", &add, R"pbdoc(
Add two numbers
Some other explanation about the add function.
)pbdoc");
m.def("subtract", [](int i, int j) { return i - j; }, R"pbdoc(
Subtract two numbers
Some other explanation about the subtract function.
)pbdoc");
}{% endtab %} {% tab meson Meson-python %}
Example src/main.cpp file:
#include <pybind11/pybind11.h>
int add(int i, int j) { return i + j; }
namespace py = pybind11;
PYBIND11_MODULE(_core, m) {
m.doc() = R"pbdoc(
Pybind11 example plugin
-----------------------
.. currentmodule:: python_example
.. autosummary::
:toctree: _generate
add
subtract
)pbdoc";
m.def("add", &add, R"pbdoc(
Add two numbers
Some other explanation about the add function.
)pbdoc");
m.def("subtract", [](int i, int j) { return i - j; }, R"pbdoc(
Subtract two numbers
Some other explanation about the subtract function.
)pbdoc");
}{% endtab %} {% tab maturin Maturin %}
Example src/lib.rs file:
use pyo3::prelude::*;
/// A Python module implemented in Rust. The name of this function must match
/// the `lib.name` setting in the `Cargo.toml`, else Python will not be able to
/// import the module.
#[pymodule]
mod _core {
use super::*;
#[pyfunction]
fn add(x: i64, y: i64) -> i64 {
x + y
}
#[pyfunction]
fn subtract(x: i64, y: i64) -> i64 {
x - y
}
#[pymodule_init]
fn pymodule_init(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add("__version__", env!("CARGO_PKG_VERSION"))?;
Ok(())
}
}{% endtab %} {% endtabs %}
The recommendation (followed above) is to have source code in /src, and the
Python package files in /src/<package>. The compiled files also can go in
/src.
Check the documentation for the tools above to see what forms of dynamic versioning the tool supports.
Each tool uses a different mechanism to include or remove files from the SDist, though the defaults are reasonable.
Unlike pure Python, you'll need to build redistributable wheels for each platform and supported Python version if you want to avoid compilation on the user's system using cibuildwheel. See [the CI page on wheels][gha_wheels] for a suggested workflow.
Modern versions of NumPy (1.25+) allow you to target older versions when building, which is highly recommended, and this became required in NumPy 2.0. Now you add:
#define NPY_TARGET_VERSION NPY_1_22_API_VERSION(Where that number is whatever version you support as a minimum) then make sure
you build with NumPy 1.25+ (or 2.0+). Before 1.25, it was necessary to actually
pin the oldest NumPy you supported (the oldest-supported-numpy package is the
easiest method). If you support Python < 3.9, you'll have to use the old method
for those versions.
If using pybind11, you don't need NumPy at build-time in the first place.
{: .important }
Python 3.13.4 is broken on Windows for compiling code - it always reports that it is free-threaded. 3.13.5 was rushed out to fix it.
[gha_wheels]: {% link pages/guides/gha_wheels.md %}
<script src="{% link assets/js/tabs.js %}"></script>