# conan-py-build *A build backend to create Python wheels with C/C++ extensions using [Conan](https://conan.io).* --- ## Install ```bash pip install conan-py-build ``` ## Getting started Minimal project: a Python package with a C++ extension that uses a Conan dependency. !!! note The walkthrough below uses CMake, but the backend is build-system agnostic — it works with anything Conan can drive. For a Meson version of the same project, see [basic-meson-pybind11](https://github.com/conan-io/conan-py-build/tree/main/examples/basic-meson-pybind11). ### Project layout ```text mypackage/ ├── pyproject.toml ├── conanfile.py ├── CMakeLists.txt └── src/ ├── mymodule.cpp └── mypackage/ └── __init__.py ``` ### `pyproject.toml` ```toml [build-system] requires = ["conan-py-build"] build-backend = "conan_py_build.build" [project] name = "mypackage" version = "0.1.0" ``` ### `conanfile.py` ```python from conan import ConanFile from conan.tools.cmake import CMake, cmake_layout class MyPackageConan(ConanFile): settings = "os", "compiler", "build_type", "arch" generators = "CMakeToolchain", "CMakeDeps" def layout(self): cmake_layout(self) def requirements(self): self.requires("fmt/12.1.0") def build(self): cmake = CMake(self) cmake.configure() cmake.build() def package(self): cmake = CMake(self) cmake.install() ``` ### `CMakeLists.txt` The backend takes everything that Conan's `package()` step stages and copies it into the wheel. For the extension to be importable, **the compiled module must land under a directory that matches your Python package name** — that is how the `.so` / `.pyd` ends up next to `__init__.py`. With CMake, that means the `DESTINATION` in `install()` must match the package name: ```cmake cmake_minimum_required(VERSION 3.15) project(mypackage LANGUAGES CXX) find_package(Python3 REQUIRED COMPONENTS Interpreter Development.Module) find_package(fmt REQUIRED) Python3_add_library(_core MODULE src/mymodule.cpp) target_link_libraries(_core PRIVATE fmt::fmt) install(TARGETS _core DESTINATION mypackage) ``` The resulting wheel layout: ```text mypackage/ ├── __init__.py ← from src/mypackage/ └── _core.so ← from install(TARGETS _core DESTINATION mypackage) ``` If `DESTINATION` doesn't match (e.g. `lib` instead of `mypackage`), the extension lands outside the package and `import mypackage._core` will fail. ### `src/mymodule.cpp` This example uses the Python C API directly to keep it dependency-free. For real projects, [pybind11](https://pybind11.readthedocs.io/) and [nanobind](https://nanobind.readthedocs.io/) are more ergonomic — see [Examples](examples.md). ```cpp #include #include #include static PyObject* greet(PyObject* self, PyObject* args) { const char* name; if (!PyArg_ParseTuple(args, "s", &name)) return NULL; std::string msg = fmt::format("Hello, {}!", name); return PyUnicode_FromString(msg.c_str()); } static PyMethodDef methods[] = { {"greet", greet, METH_VARARGS, "Greet someone."}, {NULL, NULL, 0, NULL}, }; static struct PyModuleDef module = { PyModuleDef_HEAD_INIT, "mypackage._core", NULL, -1, methods, }; PyMODINIT_FUNC PyInit__core(void) { return PyModule_Create(&module); } ``` ### `src/mypackage/__init__.py` ```python from mypackage._core import greet ``` ### Build and test ```bash pip wheel . -w dist/ -vvv pip install dist/mypackage-0.1.0-*.whl python -c "from mypackage import greet; greet('world')" ``` --- ## What's next? | Goal | Page | |------|------| | All `--config-settings` and `pyproject.toml` options | **[Configuration](configuration.md)** | | pybind11, nanobind, cibuildwheel | **[Examples](examples.md)** | | Contributing, tests, docs | **[Development](development.md)** | --- # Configuration ## Config settings (`-C`) Passed per-invocation via `pip wheel … -C =`: | Option | Description | Default | |--------|-------------|---------| | `host-profile` | Conan profile for host context | `default` | | `build-profile` | Conan profile for build context | `default` | | `build-dir` | Persistent build directory | temp dir | ## `pyproject.toml` All project-level options live under `[tool.conan-py-build]`: | Option | TOML section | Description | Default | |--------|--------------|-------------|---------| | `conanfile-path` | `[tool.conan-py-build]` | Path to the Conan recipe, relative to project root | `"."` | | `extra-profile` | `[tool.conan-py-build]` | Extra Conan profile composed on top of the active one | (none) | | `extra-arguments` | `[tool.conan-py-build]` | Extra Conan CLI flags appended to `conan build` and `conan export-pkg` | `[]` | | `clean-after-wheel` | `[tool.conan-py-build]` | Remove the exported Conan reference from the cache after building the wheel | `true` | | `version.file` | `[tool.conan-py-build.version]` | Python file with `__version__` | (none) | | `version.provider` | `[tool.conan-py-build.version]` | `"setuptools_scm"` for version from git tags | (none) | | `packages` | `[tool.conan-py-build.wheel]` | Paths to Python packages in the wheel | `["src/"]` | | `exclude` | `[tool.conan-py-build.wheel]` | Glob patterns to drop files from the wheel | `[]` | | `include` / `exclude` | `[tool.conan-py-build.sdist]` | Glob patterns to add/remove from the sdist | `[]` / `[]` | Variants for extra profiles: `extra-profile-host`, `extra-profile-build`, `extra-profile-all`. Paths relative to project root. For one-off Conan overrides without shipping a separate profile file, use `extra-arguments` (see *Extra Conan arguments* below). Default `packages` is `src/` (hyphens → underscores). ## Dynamic version Set `dynamic = ["version"]` in `[project]` and pick **one** source: ```toml [tool.conan-py-build.version] file = "src/mypackage/__init__.py" ``` ```toml [tool.conan-py-build.version] provider = "setuptools_scm" ``` `version.file` and `version.provider` are mutually exclusive. For setuptools-scm options see [`[tool.setuptools_scm]`](https://setuptools-scm.readthedocs.io/). ## Profiles The wheel is built against the interpreter running the build (your venv, or the one cibuildwheel provides); pick the Python version by running the build with it. A custom `host-profile` is optional. The default profile works for plain native builds. Pass one when you need extra settings (the bundled macOS profile, for example, pins the deployment target and platform tag): ```bash pip wheel . --no-build-isolation \ -C host-profile=examples/profiles/macos.jinja \ -C build-dir=./build \ -w dist/ ``` An `extra-profile` in `pyproject.toml` is applied **on top** of the active profile — useful for enforcing e.g. `compiler.cppstd=17`: ```toml [tool.conan-py-build] extra-profile = "cpp17.profile" ``` ### Wheel tags (`WHEEL_PYVER`, `WHEEL_ABI`, `WHEEL_ARCH`) By default the backend reads the wheel filename tags (interpreter, ABI, platform) from the running Python interpreter. When you cross-compile or otherwise need to force the tags, set these three variables in the profile's `[buildenv]` to override them: | Variable | Wheel tag | Example | |----------|-----------|---------| | `WHEEL_PYVER` | Interpreter | `cp312`, `py3` | | `WHEEL_ABI` | ABI | `cp312`, `abi3`, `none` | | `WHEEL_ARCH` | Platform | `manylinux_2_28_x86_64`, `macosx_11_0_arm64`, `win_amd64` | Each variable independently overrides its auto-detected value. Auto-detection always runs from the current interpreter, so for a normal native build you do **not** need to set any of them. You only override the tag that the build interpreter cannot report correctly. For example, building an x86_64 wheel on an arm64 macOS runner: the interpreter is arm64, so only its platform tag is wrong and needs pinning (the interpreter/ABI tags are still detected correctly): ``` [settings] arch=x86_64 [buildenv] WHEEL_ARCH=macosx_11_0_x86_64 ``` The resulting wheel filename will be, for example, `mypackage-0.1.0-cp312-cp312-macosx_11_0_x86_64.whl`. A working macOS profile lives under [`examples/profiles/`](https://github.com/conan-io/conan-py-build/tree/main/examples/profiles). ## Conan home The backend uses Conan's default home (`~/.conan2`, or `CONAN_HOME` / `.conanrc`). Set `CONAN_PY_BUILD_PROFILE_AUTODETECT=1` to autodetect the profile instead of requiring `default`. ## Extra Conan arguments CLI flags appended to `conan build` and `conan export-pkg`. Symmetric with `extra-profile`, with higher precedence — CLI flags win against any profile entry. Bump `compiler.cppstd` to match a transitive dep (e.g. `gdal/3.12.1` requires C++17, MSVC defaults to 14): ```toml [tool.conan-py-build] extra-arguments = ["-s=compiler.cppstd=17"] ``` Disable an optional dep feature and pin parallelism: ```toml [tool.conan-py-build] extra-arguments = [ "-o=gdal/*:with_arrow=False", "-c=tools.build:jobs=4", ] ``` For values with embedded double quotes (dict / list `[conf]` literals), use TOML literal strings (`'...'`): ```toml extra-arguments = [ '-c=tools.build:cflags+=["-O2", "-fPIC"]', '-c=tools.cmake.cmaketoolchain:extra_variables={"FOO": "bar"}', ] ``` Any Conan CLI flag works: `-s` / `-o` / `-c` (host), `-s:b` / `-o:b` / `-c:b` (build context), `--build=...`, `--lockfile=...`. Pair-form (`["-s", "compiler.cppstd=17"]`) is also accepted. ## Entry points (PEP 621) `[project.scripts]`, `[project.gui-scripts]` and `[project.entry-points.*]` from `pyproject.toml` are written to `.dist-info/entry_points.txt` in the wheel, per the PyPA [entry points specification](https://packaging.python.org/en/latest/specifications/entry-points/). Installers create the corresponding console/GUI wrappers at install time; runtime tools like `importlib.metadata.entry_points()` read them from this file. ```toml [project.scripts] mycli = "mypackage.cli:main" [project.gui-scripts] mygui = "mypackage.gui:run" [project.entry-points."myplugin.hooks"] on_event = "mypackage.hooks:on_event" ``` The file is only written when at least one entry point is declared. ## License files (PEP 639) Set `license-files` in `[project]` (e.g. `["LICENSE"]`) to include license files in the wheel `.dist-info/licenses/` and sdist PKG-INFO. ## Shared libraries When your extension links to Conan-provided shared libraries, the backend: 1. Deploys them to a `.conan-libs/` directory in the project source root. 2. Patches the extension RPATH so that repair tools can discover and bundle those libraries. > **Important:** when Conan shared libraries are deployed, the wheel returned by > `build_wheel()` is **an intermediate artifact**. It contains an absolute RPATH > pointing to the `.conan-libs/` build directory but does not bundle the > libraries themselves. Installing this wheel locally may appear to work as long > as `.conan-libs/` still exists on disk, but will fail with `ImportError` on > any other machine or after the build directory is removed. Always run a repair > step before installing or distributing. Run the appropriate repair tool after building: - **Linux** — [`auditwheel repair`](https://github.com/pypa/auditwheel) - **macOS** — [`delocate-wheel`](https://github.com/matthew-brett/delocate) - **Windows** — [`delvewheel repair`](https://github.com/adang1345/delvewheel) [`cibuildwheel`](https://cibuildwheel.pypa.io/) runs the right tool automatically on Linux and macOS. On Windows, add this to your `pyproject.toml`: ```toml [tool.cibuildwheel.windows] before-all = "pip install delvewheel" repair-wheel-command = 'delvewheel repair --add-path "{project}/build/.conan-libs" -w "{dest_dir}" "{wheel}"' ``` The `.conan-libs/` directory is a build artifact and can be deleted after the repair step completes. Static-only builds are unaffected: when no Conan shared libraries are deployed the backend is a no-op and the wheel is self-contained. ### System libraries and ABI risk Each repair tool excludes libraries it considers part of the host system and does not bundle them into the wheel: - **auditwheel (Linux)** uses an explicit [allowlist of named libraries](https://github.com/pypa/auditwheel/blob/main/src/auditwheel/policy/manylinux-policy.json) guaranteed on manylinux systems (e.g. `libz.so.1`, `libstdc++.so.6`, `libm.so.6`). - **delocate (macOS)** [excludes any library](https://github.com/matthew-brett/delocate/blob/master/delocate/libsana.py#L36-L37) whose install name starts with `/usr/lib/` or `/System/Library/` (e.g. `/usr/lib/libz.dylib`). - **delvewheel (Windows)** maintains a [list of known Windows DLLs](https://github.com/adang1345/delvewheel/blob/master/delvewheel/_dll_list.py) (e.g. `kernel32.dll`, `user32.dll`) that are assumed present on all supported Windows versions. When an excluded library is found in `.conan-libs/`, the repair tool skips it and the wheel loads it from the target system at runtime instead. This means that if you build against a Conan-provided version of a system library (e.g. `zlib/1.3.2` with `shared=True`), the wheel will silently use the system zlib at runtime. If the system version is older and lacks a symbol your code depends on, the import will fail with `undefined symbol`, a bug that passes repair tools undetected. If your code uses symbols that exist in the Conan-provided version but not in the system version on your target machines, the import will fail at runtime despite the wheel passing the repair step. Linking that library statically removes the dependency on the system version. You can inspect which symbols your code requires with `nm -D` (Linux/macOS) or `dumpbin /exports` (Windows). ## Wheel exclude Use `wheel.exclude` to drop files from the wheel that live inside a package directory but are only needed at build time (e.g. C/C++ binding sources co-located with the Python package): ```toml [tool.conan-py-build.wheel] packages = ["src/mypkg"] exclude = ["binding/*.cpp", "binding/*.h"] ``` Patterns are relative to each package root and support standard glob syntax (`*`, `?`, `**`). ## Sdist defaults Included: `pyproject.toml`, `conanfile.py`, your build system's top-level file (`CMakeLists.txt`, `meson.build`), `cmake/`, `src/`, `include/`, README, LICENSE. Excluded: `__pycache__`, `*.pyc`, `.git`, `build`, `dist`. Patterns in `exclude` (and `include`) are matched against each file in two ways: - Against the **full relative path** from the project root — so `src/binding/*.h` or `*.pyc` (which matches `src/foo.pyc`) work as expected. - Against **each path component individually** — so a bare name like `__pycache__` or `.git` excludes that directory wherever it appears in the tree, without needing a `**/__pycache__` pattern. ```toml [tool.conan-py-build.sdist] include = ["docs/"] exclude = [".github", "tests"] ``` --- # Examples Complete working projects under [`examples/`](https://github.com/conan-io/conan-py-build/tree/main/examples): | Example | What it shows | |---------|---------------| | [basic](https://github.com/conan-io/conan-py-build/tree/main/examples/basic) | `fmt` extension, recipe via `conanfile-path` | | [basic-pybind11](https://github.com/conan-io/conan-py-build/tree/main/examples/basic-pybind11) | pybind11 + `fmt`, dynamic version, custom `wheel.packages`, PEP 639 | | [basic-meson-pybind11](https://github.com/conan-io/conan-py-build/tree/main/examples/basic-meson-pybind11) | pybind11 + `fmt` built with Meson | | [basic-nanobind](https://github.com/conan-io/conan-py-build/tree/main/examples/basic-nanobind) | nanobind + `fmt`, `extra-profile` for C++17 | | [external-sources](https://github.com/conan-io/conan-py-build/tree/main/examples/external-sources) | pybind11, C++ dependency fetched in `source()` | | [basic-cython](https://github.com/conan-io/conan-py-build/tree/main/examples/basic-cython) | Cython typed loop + `fmt` C++ dep; shows `cdef extern` and `python -m cython` integration | | [cibw-example](https://github.com/conan-io/conan-py-build/tree/main/examples/cibw-example) | pybind11 + [cibuildwheel](https://cibuildwheel.pypa.io/), shared lib dep chain (libxslt→libxml2, zlib static) | --- # Development ## Editable install ```bash pip install -e . # from repo root cd examples/basic pip wheel . --no-build-isolation -w dist/ ``` ## Tests ```bash pip install -e ".[dev]" pytest tests/ -v ``` ## Documentation ```bash pip install -e ".[docs]" mkdocs serve ``` The site is built from `main` on every push. Changes that don't depend on a specific release can target `main` directly. For documentation tied to an upcoming release, open the PR against a `docs/` branch, it will be merged into `main` after the release ships to keep the site in sync with what users can install.