Building from Source
Build the Python bindings and the C++ unit-test tree, with or without MPI.
monoprop has one supported from-source build workflow:
- the Python bindings — the nanobind extension behind
import monoprop, built with scikit-build-core and driven byuv(orpip); - the C++ unit tests — built within the same
uv(orpip) invocation.
MPI is off by default in every build path; you enable it explicitly. The mechanism differs by build:
| Build | Enable MPI with |
|---|---|
Python bindings and C++ tests (scikit-build / uv / pip) | monoprop_ENABLE_MPI=ON in the environment, or the monoprop.mpi=ON config-setting |
The prebuilt wheels published to PyPI (pip install monoprop) already include an MPI
backend for the MPICH family and Open MPI, so a from-source build is only needed for another MPI
implementation or for the C++ library; see Parallelism.
Prerequisites
- a C++23-compliant compiler; on Linux the minimum supported versions are GCC 14 and Clang 18
- CMake and Ninja
- Python 3.11 to 3.15 and the
uvpackage manager (for the bindings) - an MPI implementation such as Open MPI (only for MPI builds)
hwloc(version 2.9+) andpkg-config(required so CMake can locatehwloc)
The repository ships a DevContainer with all of the above pre-configured; opening the folder in VS Code and rebuilding the container is the quickest route to a working environment.
Building with Nix
The repository is also a Nix flake:
Flake outputs support x86_64 Linux, aarch64 Linux, and aarch64 macOS. The
nixos-unstable input no longer supports x86_64 macOS.
| Command | What it gives you |
|---|---|
nix develop | a shell with every prerequisite above, plus uv, just and Node.js |
nix build .#monoprop | the Python package, built without MPI |
nix build .#monoprop-mpi | the same package with MPI enabled |
nix build .#monoprop-py313 | the package for a specific interpreter: py312, py313 or py314, with a -mpi suffix for the MPI build |
nix run | a Python interpreter with monoprop importable |
The unsuffixed packages, nix run and nix develop use Python 3.14, the
interpreter nixpkgs builds and caches; older interpreters are compiled locally.
Inside nix develop the uv sync and just workflows below apply unchanged. The
shell sets two variables that only matter on NixOS: UV_PYTHON_PREFERENCE=only-system,
because uv's managed interpreters expect a loader NixOS does not provide, and
LD_LIBRARY_PATH, so that manylinux wheels can resolve libstdc++.
The packaged build deviates from the uv build in three places, all in
nix/monoprop.nix:
- the C++ unit tests are disabled, because they resolve msgpack-cxx through a git fetch that the build sandbox denies — build them from the dev shell instead;
monoprop_ENABLE_ARCH_FLAGSis off, since a store path may be substituted onto a machine other than the one that built it; pass.override { enableArchFlags = true; }for a native build;- the version comes from the tracked root
VERSIONfile, because setuptools-scm cannot read git metadata inside the sandbox; update it to the stable version when preparing a release. The release workflow rejects tags that do not match it.
Using monoprop downstream with Nix
Use the exported overlay when monoprop must share a downstream project's Python
and native dependency package set. Following the downstream nixpkgs input avoids
introducing a second Python, Boost, hwloc, or MPI closure:
{
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
monoprop = {
url = "github:Algorithmiq/monoprop";
inputs.nixpkgs.follows = "nixpkgs";
};
};
outputs = { nixpkgs, monoprop, ... }:
let
system = "x86_64-linux";
pkgs = import nixpkgs {
inherit system;
overlays = [ monoprop.overlays.default ];
};
python = pkgs.python313.withPackages (ps: [
ps.monoprop
ps.numpy
]);
in {
devShells.${system}.default = pkgs.mkShell {
packages = [ python ];
};
};
}The overlay adds monoprop, monoprop-mpi and nanobind-backend to every Python
package set through pythonPackagesExtensions, so ps.monoprop works for any
interpreter in withPackages. On Python 3.12 it also skips mpi4py's test suite,
because the binary cache has no mpi4py for that interpreter and its tests add
minutes to every build. CI evaluates the packages for Python 3.12, 3.13 and 3.14
but builds only Python 3.12, the interpreter the binary cache covers least. The
top-level pkgs.monoprop and pkgs.monoprop-mpi are the Python 3.14 builds.
For consumers that follow their own nixpkgs input, its nanobind_3 version must
match the nanobind pin in pyproject.toml (currently 3.1.0). For consumers that
do not need to compose package sets, the pre-built flake outputs remain available
as monoprop.packages.${system}.monoprop, monoprop.packages.${system}.monoprop-mpi
and the per-interpreter monoprop-py3XX[-mpi] variants. Consume these entrypoints from the Git
repository flake; flake.nix, flake.lock, and nix/ are deliberately excluded
from the Python source distribution.
Building the Python bindings
uv creates a virtual environment, installs the Python dependencies, and
compiles the nanobind extension in editable mode. Re-run the sync command whenever
the dependency graph or the C++ sources change.
Build options
The build is configured through a few package options, declared in
[tool.scikit-build.config-setting] in pyproject.toml. Each one can be set
as a PEP 517 config-setting or through an environment variable:
| Config-setting | Environment variable | Values | Effect |
|---|---|---|---|
monoprop.mpi | monoprop_ENABLE_MPI | bool (default off) | MPI parallelization; also adds mpi4py to the build requirements and appends -mpi to the build tree |
monoprop.sanitizer | monoprop_SANITIZER | none (default), asan-ubsan, tsan | sanitizer profile; also selects the build type (AsanUbsan, Tsan) and thus the build tree |
monoprop.cxx-unit-tests | monoprop_ENABLE_CXX_UNIT_TESTS | bool (default on) | build the C++ unit test suite |
monoprop.arch-flags | monoprop_ENABLE_ARCH_FLAGS | bool (default on, forced off for Debug and sanitizer builds) | architecture-specific compiler flags |
monoprop.mpi-test-procs | monoprop_MPI_TEST_PROCS | ;-separated rank counts | rank counts the C++ MPI tests are registered for |
monoprop.mpi-abis | monoprop_MPI_ABIS | ;-separated <abi>=<prefix> | build one MPI variant per entry from an MPI installed under <prefix> instead of the MPI CMake finds (release wheels; needs monoprop.mpi) |
Booleans accept ON/OFF, true/false, 1/0 and similar. These two
commands are equivalent:
monoprop_ENABLE_MPI=ON uv sync --all-extras -v
uv sync --all-extras -v --config-settings-package="monoprop:monoprop.mpi=ON"In the uv workspace, prefer --config-settings-package="monoprop:..." over
--config-settings, so the setting does not reach the sibling distributions in
packages/. With pip, pass -C monoprop.mpi=ON. A misspelled option is
rejected with a suggestion.
The environment variable takes precedence over the config-setting. A stale
monoprop_ENABLE_MPI=OFF in your shell silently overrides
monoprop.mpi=ON.
uv rebuilds monoprop whenever an option changes, by either route: config-settings
are part of its build cache key, and [tool.uv] cache-keys in pyproject.toml
adds the environment variables above, along with SKBUILD_BUILD_DIR,
SKBUILD_CMAKE_BUILD_TYPE and SKBUILD_CMAKE_DEFINE. Switching variants
therefore needs no --reinstall-package or --no-cache. Any other CMake cache
variable can still be passed as cmake.define.<NAME>=... or through
SKBUILD_CMAKE_DEFINE.
Without MPI (default)
uv sync --all-extras -vThis produces a single-process build with no MPI dependency.
With MPI
Turn on the monoprop.mpi build option, for example through
the environment:
monoprop_ENABLE_MPI=ON uv sync --all-extras -vIt works the same with pip when installing from a checkout:
pip install . -C monoprop.mpi=ONThat one option does three things. It sets the monoprop_ENABLE_MPI CMake
option, and, through the [[tool.scikit-build.overrides]] block in
pyproject.toml, it adds mpi4py to the build requirements and moves the build
to its own tree, build/editable/Release-mpi.
An MPI build compiles the core twice: the serial _core, and
_core_mpi.<abi> for the MPI ABI family of the MPI CMake finds (openmpi or mpich; see
Parallelism and distribution). Expect about twice the build time of a
serial build.
mpi4py is a build-time input — the bindings need its headers to hand an MPI
communicator across the nanobind boundary — but only for MPI builds, so it is
injected per-build through PEP 517's get_requires_for_build_wheel rather than
sitting in build-system.requires where every build would pay for it.
The separate tree means switching between the serial and MPI variants rebuilds incrementally instead of reconfiguring a shared tree from scratch. It is a directory suffix rather than a build type, so the build keeps CMake's Release flags.
Setting the CMake option directly (cmake.define.monoprop_ENABLE_MPI=ON or
SKBUILD_CMAKE_DEFINE) bypasses that override, so mpi4py is not provisioned
and the build fails at configure time with a message telling you as much. Use
monoprop.mpi instead.
Release wheels
The published wheels contain one MPI variant per ABI family, built against MPI installations
under fixed prefixes rather than the MPI CMake would find. To reproduce one locally, install
mpich and openmpi from PyPI with pip install --target, then:
CIBUILDWHEEL=1 monoprop_ENABLE_MPI=ON \
monoprop_MPI_ABIS="mpich=$PWD/mpi/mpich;openmpi=$PWD/mpi/openmpi" \
uv build --wheelCIBUILDWHEEL=1 is what cibuildwheel exports; it keeps the build machine's library directories
out of the RPATHs.
Verify the install
uv run python -c "import monoprop as mp; print(mp.__version__)"Running the bindings
A serial run is just a normal Python invocation:
uv run python your_script.pyFor a multi-rank run, launch the same script under mpiexec (requires an MPI
build) and pass comm=MPI.COMM_WORLD to the simulator. Without comm, every rank runs its own serial propagation:
mpiexec -n 8 uv run python your_script.pySee Parallelism and distribution for the communicator options and the operator-partitioning controls.
Building the C++ unit tests
The supported C++ workflow reuses the build tree produced by uv sync. Do not
run cmake --preset ... to configure this project directly: the top-level CMake
configuration expects scikit-build-core to provide Python, nanobind, and related
cache variables. Instead, first create the tree with uv sync, then invoke ctest
directly to run the C++ unit tests.
Release tree
uv sync --all-extras -v
ctest --test-dir build/editable/ReleaseThis uses the scikit-build-core Release tree at build/editable/Release and
runs bin/monoprop_unit_tests.x there, along with bin/monoprop_link_export_probe.x — a
link-time check that the installed shared monoprop library exports every detail/** free
function reachable from the public template chain. In an MPI build the same probe also runs
against every monoprop_mpi_<abi> library.
monoprop::monoprop is always serial: a serial target never acquires MPI compile definitions or
link dependencies, even if the consuming project has an MPI::MPI_CXX target. MPI consumers
request the MPI component, which picks the libmonoprop variant for the MPI ABI family of the MPI
CMake finds for the consuming project:
find_package(monoprop CONFIG REQUIRED COMPONENTS MPI)
target_link_libraries(my_target PRIVATE monoprop::monoprop_mpi)Configuration fails, naming both, when the installation was built without that family;
monoprop_MPI_ABIS lists the families it ships. Projects that linked monoprop::monoprop from an
MPI build before must switch to the MPI component.
just test-find-package goes one step further out: it builds
cpp/tests/find_package_smoke — a standalone project that consumes the installed
package through find_package(monoprop CONFIG) — and runs it. That is the only leg that
exercises monopropConfig.cmake, so it is what catches a missing find_dependency or a
usage requirement the package fails to export. It compiles the same source as the in-tree
probe, so the two cannot drift. With an MPI installation it also builds the
consumer through the MPI component, runs it under mpiexec -n 2, and checks that a package
lacking the consumer's MPI ABI fails at configure time.
Debug tree
uv sync --all-extras -v --config-settings=cmake.build-type=Debug
ctest --test-dir build/editable/DebugSanitizer trees
The QA workflow builds two sanitizer profiles, selected with the
monoprop.sanitizer build option (none, asan-ubsan, or
tsan). Each profile has its own build type, AsanUbsan or Tsan, so it
builds into its own tree next to build/editable/Release (with an -mpi
suffix for an MPI build). Both require Linux and
either GCC or Clang; CI uses the default system GCC, which is what builds the
released wheels.
Export monoprop_SANITIZER rather than setting it for the build command alone:
the just test recipes read it too, to find the tree.
Sanitizer trees intentionally skip the _core.pyi typing stubs — generating them
means importing the instrumented extension, which is not worth arranging for a
tree that is never packaged.
ASan + LSan + UBSan
export monoprop_SANITIZER=asan-ubsan
just build --group workspace-testThe C++ test binary is fully instrumented, so every check applies to it, including leak detection:
just test-cpp-asanThe Python tests are a different situation: an instrumented _core is loaded
into an ordinary CPython, so the ASan runtime must be preloaded, and the checks
that assume the whole process is instrumented have to be switched off.
just test-py-asanBoth preloads are required. Without libasan.so, the first import monoprop
aborts with ASan runtime does not come first in initial library list. Without
libstdc++.so.6, ASan initialises before libstdc++ is loaded — python is not
linked against it — so ASan's __cxa_throw interceptor never resolves the real
symbol, and the first C++ exception thrown out of the engine dies with
CHECK failed: real___cxa_throw != 0. monoprop reports validation errors as C++
exceptions, so that affects a large part of the suite.
Leak detection is off here because LSan cannot get a clean baseline from an uninstrumented interpreter — the C++ leg above is what covers leaks.
Sanitizer reports are hidden by pytest
pytest replaces the stderr file descriptor, so a sanitizer report printed from
native code is swallowed and the run looks like a bare exit code 1 with no
diagnostic. just test-py-asan therefore sets log_path, routing the reports to
sanitizer-log.*; read them with:
just sanitizer-reportsThe QA workflow runs that recipe on failure.
ThreadSanitizer
export monoprop_SANITIZER=tsan
just build --group workspace-test
just test-cpp-tsanTSan is scoped to the concurrency surface — the partition and ShmComm tests —
which is where its reports are worth the runtime cost.
There is no ThreadSanitizer equivalent of the ASan Python leg. TSan does not
support dlopen of instrumented libraries, so loading an instrumented _core
into a stock interpreter crashes during TSan initialisation; exercising the
Python API under TSan would require a TSan-instrumented CPython.
Related workflows
- Use
just code-coveragefor the coverage build. - Use
just test-cppandjust test-cpp-mpifor the two C++ legs, orctest --test-dir build/editable/Release-mpi -L mpi-2(or-L mpi-4) to filter further. - Use
just build [uv sync args…]when you want the build CI performs. It adds--all-extrasand, for a serial build, drops thempiextra — importingmpi4py.MPIwithout an MPI runtime raises.monoprop_ENABLE_MPIdrives all of it, somonoprop_ENABLE_MPI=ON just build --group workspace-testis the MPI build and a barejust buildis the serial one. The recipes read the environment variables, not config-settings, so select variants that way here. - The platform packages the build needs are listed in
tools/packages/, one per line:apt.txt/brew.txt, plusapt-mpi.txt/brew-mpi.txtfor an MPI build..github/actions/setupand.devcontainer/Dockerfileboth install from those files, so there is one list per platform to keep current.
See also
- Getting Started — installing a prebuilt release from PyPI.
- Parallelism and distribution — running across MPI ranks and shared-memory threads.
- Testing — the full Python and C++ test workflow.
- How to Contribute — contributor workflow and documentation checks.