Skip to content

Pcons User Guide v0.30.0.dev6 (ad98890, 2026-10-10)

Pcons is a general-purpose software build system. It constructs a dependency graph of all your sources and targets and commands to build the targets, and generates Ninja build files (or Makefiles or XCode projects). Its configuration language is python, and the tool itself is written in python. It combines some of the best ideas from SCons and CMake: Python as the configuration language, environments with toolchains and tools, and a fast generator architecture with proper dependency tracking.

Why Pcons?

Key Features

  • Python is the language: No custom DSL to learn. Your pcons-build.py is real Python with full IDE support, debugging, and all the power of the Python ecosystem.
  • Fast builds with Ninja: Pcons generates Ninja files and lets Ninja handle the actual compilation. This means fast, parallel builds with minimal overhead.
  • Automatic dependency tracking: Pcons tracks dependencies between source files, object files, and outputs, rebuilding only what's necessary.
  • Transitive requirements: Like CMake's "usage requirements," include directories and link flags automatically propagate through your dependency tree.
  • Tool-agnostic core: The core knows nothing about C++ or any language. All language support comes through Tools and Toolchains, making it extensible.
  • Wide language, compiler and OS support: Pcons supports clang, MSVC, gcc, C++ Modules, Fortran, CUDA, WebAssembly, LaTeX, and more, all mixed together. Every commit is tested on Mac, Windows and Linux (various flavors).
  • Carefully built and tested: We use highest standards for code quality, with strong test coverage for all code and extensive CI testing.
  • Debuggable: Every command line and compiler flag is traceable using pcons explain, and pcons has full python debugger and IDE support including typings.
  • Extensible: It's easy to add your own toolchains, tools, builders and more, the same way the shipped tools work.
  • Works with uv: Designed for modern Python workflows with uv as the recommended package manager.

Comparison with Other Build Systems

Feature Pcons Make CMake SCons
Configuration language Python Makefile CMake DSL Python
Build executor Ninja Make Make/Ninja SCons
Learning curve Low (if you know Python) Medium High Medium
IDE integration Yes (compile_commands.json) Limited Yes Yes
Dependency tracking Automatic Manual Automatic Automatic
Transitive dependencies Yes No Yes Limited

Quick Start

Installing Pcons

Using uv

uv is a fast modern python package and project manager. Install it from here. Highly recommended, and it's a simple quick install.

You can run pcons directly from PyPI with uvx (no installation required):

uvx pcons ...

Or add it to your project:

uv add pcons
pcons ...

Or install globally:

uv tool install pcons
pcons ...

With pipx or python

pcons is on PyPI, so if you have pipx, just pipx install pcons. With plain python, you can install pcons globally using python -mpip install pcons or use a venv if desired.

Your First Build: Hello World

Let's build a simple "Hello World" program.

1. Create the source file (hello.cpp):

#include <iostream>

int main() {
    std::cout << "Hello from pcons!" << std::endl;
    return 0;
}

2. Create the build script (pcons-build.py):

from pcons import Project

# Create project with build directory
project = Project("hello", build_dir="build")

# Create an environment with the system default C/C++ toolchain
env = project.Environment(toolchain="c")

# Create a program target
hello = project.Program("hello", env)
hello.add_sources(["hello.cpp"])

# Set this as the default target
project.Default(hello)

3. Generate and build:

# Using uvx (recommended)
uvx pcons

# Or if pcons is installed as a tool
pcons

This runs your pcons-build.py to generate build/build.ninja, then invokes Ninja to compile your program. If you don't have ninja installed, pcons will try to invoke it via uvx ninja.

Tip

You can swap in a ninja-compatible runner like n2 (a Rust rewrite of Ninja) with pcons --ninja=n2 or by setting NINJA=n2 in the environment to get more advanced rebuild checking. For content-hash rebuilds, use env.use_compiler_cache() (see the Compiler Caching section below).

4. Run your program:

./build/hello
# Output: Hello from pcons!

Understanding the Commands

Pcons provides several commands:

pcons                    # Generate build files AND build (default)
pcons --watch            # ... and keep rebuilding as files change
pcons generate           # Only generate build.ninja
pcons build              # Only run ninja (assumes build.ninja exists)
pcons clean              # Clean build artifacts
pcons clean --all        # Remove entire build directory
pcons info               # Show pcons-build.py documentation
pcons explain            # Show all targets, commands used to build them, and where each flag comes from
pcons run                # Run a user-defined command
pcons test               # Build and run the defined tests
pcons completion         # Set up tab-completion for your shell
pcons cache              # Manage the per-build-dir cache
pcons init               # Create pcons-build.py (adopts existing C/C++ sources,
                         # or scaffolds a hello-world starter in an empty dir)

See pcons --help for more details.


Supported Languages and Toolchains

Pcons ships with built-in support for several languages and toolchains. The core is completely tool-agnostic — all language support comes from toolchain modules that register themselves at import time.

Registered Toolchains

The following toolchains are auto-detected. Select one by name — toolchain="c" auto-detects a C/C++ toolchain, a specific name like "gcc" requires that toolchain, and a list is a preference order:

env = project.Environment(toolchain="c")  # auto-detect C/C++
env = project.Environment(toolchain="msvc")  # require MSVC
env = project.Environment(toolchain=["gcc", "llvm"])  # first available wins

IDE autocompletion for these names comes from the generated KnownToolchain type; any registered name (including user-registered toolchains) also works. For programmatic control, the underlying finder functions like find_c_toolchain(prefer=[...]) remain available and return Toolchain objects.

Toolchain Finder Platforms Description
Gcc find_c_toolchain() Linux, macOS, Windows GNU Compiler Collection (gcc/g++)
Llvm find_c_toolchain() Linux, macOS, Windows LLVM/Clang compiler
Msvc find_c_toolchain() Windows Microsoft Visual C/C++ compiler
ClangCl find_c_toolchain() Windows Clang with MSVC-compatible flags
Cuda find_cuda_toolchain() Linux, Windows NVIDIA CUDA compiler (nvcc)
Emscripten find_emscripten_toolchain() Linux, macOS Emscripten C/C++ to WebAssembly + JS (browser/Node.js)
Wasi find_wasi_toolchain() Linux, macOS WASI SDK for standalone WebAssembly (.wasm)
Cython find_cython_toolchain() Linux, macOS, Windows Cython transpiler (.pyx to Python extension)
Gfortran find_fortran_toolchain() Linux, macOS GNU Fortran compiler (gfortran)
Latex find_latex_toolchain() Any LaTeX document compilation via latexmk
Qt find_qt_toolchain() Linux, macOS, Windows Qt code generation tools (moc, uic, rcc)
Swift find_swift_toolchain() macOS, Linux, Windows Swift compiler (whole-module compilation, swiftc links)

Default C/C++ search order:

  • Windows: clang-cl → msvc → llvm → gcc
  • Linux / macOS: llvm → gcc

Selecting compilers with environment variables

Pcons honors the conventional tool-selection environment variables, same as make/autoconf/CMake/Meson — no build-script changes needed:

$ CXX=g++-15 pcons          # build with a specific compiler
$ CC=clang-19 CXX=clang++-19 pcons
$ CXX=/opt/llvm/bin/clang++ pcons
Variable Selects Toolchains
CC C compiler (and the link driver) gcc, llvm, msvc, clang-cl
CXX C++ compiler gcc, llvm, msvc, clang-cl
FC Fortran compiler gfortran
AR archiver gcc, llvm, swift
SWIFTC Swift compiler swift
CUDACXX CUDA compiler (nvcc) cuda
RC resource compiler msvc, clang-cl

Following the universal convention, these are authoritative, not hints: a set variable selects that command; a value that can't be found is an error. Specifically, the rules are as follows:

  • With toolchain="c" (auto-detect), $CXX/$CC steer detection to the named compiler's family — CXX=g++-15 selects the gcc toolchain even where clang would normally win. Classification checks --version, so macOS's g++-that-is-really-Apple-clang is identified correctly.
  • An explicitly requested toolchain that contradicts the variable (CXX=g++-15 with toolchain="msvc") is an error.
  • Values a compiler-id can't classify (e.g. wrapper scripts) are used as-is on whichever toolchain is selected.
  • On the gcc toolchain, $CXX or $CC also picks the archiver: the gcc-ar next to that driver, keeping its prefix and version (CXX=g++-15 archives with gcc-ar-15). gcc-ar gives ar that GCC's LTO plugin, so -flto static libraries work whatever binutils has installed. A cross preset's tool_cmds drivers do the same, and $AR overrides both. A script that sets env.cxx.cmd itself should set env.ar.cmd too.
  • Explicit script assignments (env.cxx.cmd = ...) and cross-preset tool_cmds still win over the environment; SDK-owned toolchains (emscripten, wasi) ignore these variables entirely, like CMake's Visual Studio generators.
  • env.explain() or pcons explain attribute the result (cxx.cmd <- $CXX), so a forgotten export CXX in a shell profile will show up there.

CFLAGS/CXXFLAGS/LDFLAGS are not read — in pcons, flag policy belongs to the build script (variants, presets), not the ambient environment.

Swift is available as toolchain="swift" (requires Xcode on macOS, or a swift.org toolchain on Linux and Windows). Swift's compilation unit is the module, not the file: each pcons target compiles as one Swift module in a single whole-module swiftc invocation, so files in a target see each other without imports. Importing another target's module is an ordinary dependency — the library's .swiftmodule search path propagates as a usage requirement:

env = project.Environment(toolchain="swift")

geometry = project.StaticLibrary("Geometry", env, sources=["lib/geometry.swift"])
app = project.Program("shapes", env, sources=["src/main.swift"])  # import Geometry
app.link_private(geometry)

The module name is the target name (sanitized to a Swift identifier); library targets compile with -parse-as-library. Variants map to -Onone -g / -O, and werror maps to -warnings-as-errors. compile_commands.json entries are emitted per source file with the whole-module command, the convention sourcekit-lsp expects. See examples/46_swift_hello and examples/47_swift_library.

Swift / C / C++ interop works in both directions. Swift imports a C library through a module.modulemap shipped in the library's include dir (the propagated include path serves both the modulemap and headers). For C++, enable interop mode and header emission:

env = project.Environment(toolchain="swift")
env.add_toolchain("c")  # C/C++ compilers for mixed targets
env.swiftc.set_cxx_interop("c++17")  # Swift <-> C++ interop mode
env.swiftc.interop_header = True  # libraries emit <Module>-Swift.h

analyzer = project.StaticLibrary("Analyzer", env, sources=["analyzer.swift"])
app = project.Program(
    "demo", env, sources=["src/main.cpp"]
)  # #include "Analyzer-Swift.h"
app.link_private(analyzer)

The generated header lands next to the .swiftmodule in the propagated include dir, and consumers' C++ compiles automatically wait for it. When a C header is imported in C++-interop mode it is parsed as C++, so it needs the usual extern "C" guards. Consuming the generated header from C++ is currently reliable on macOS; on Linux it depends on the Swift version and C++ standard library in use. Mixed links are handled automatically: swiftc drives the link when Swift is involved (bringing the Swift runtime), and a C/C++-driven link of Swift objects gets the runtime path injected via swiftc -print-target-info. See examples/48_swift_cxx_interop.

A module.modulemap can be generated instead of hand-written — clang_module_map(project, "CStats", ["include/cstats.h"]) (from pcons.toolchains.swift) writes one into the build tree and returns its directory for public.include_dirs. For distributable libraries, env.swiftc.library_evolution = True builds with -enable-library-evolution and emits a .swiftinterface next to the .swiftmodule.

The Swift language mode is env.swiftc.language_mode, passed to every compile as -swift-version. It defaults to "5", which is swiftc's own default on every version; set it to "6" for Swift 6 language mode and its strict concurrency checking. It is always passed because Swift 6 refuses to emit a module interface unless the mode is stated explicitly.

And Swift participates in cross presets: two lines target iOS (see examples/49_swift_ios):

env = project.Environment(toolchain="swift")
env.apply_cross_preset(ios(arch="arm64", min_version="15.0"))

Fortran (gfortran) is available as toolchain="fortran". It supports all standard Fortran source extensions and uses Ninja dyndep to resolve MODULE / USE dependencies at build time (requires Ninja ≥ 1.10):

env = project.Environment(toolchain="fortran")
project.Program("hello", env, sources=["src/main.f90", "src/greetings.f90"])

Mixed C++/Fortran builds use env.add_toolchain(). Runtime libraries are injected automatically in both directions:

# Fortran primary: gfortran links, -lc++ / -lstdc++ injected for C++ objects
env = project.Environment(toolchain="fortran")
env.add_toolchain("c")

# C++ primary: g++/clang++ links, -lgfortran injected for Fortran objects
env = project.Environment(toolchain="c++")
env.add_toolchain("fortran")

project.Program("hello", env, sources=["src/main.f90", "src/helper.cpp"])

CUDA is designed to work alongside a C/C++ toolchain — CUDA handles .cu compilation while the host toolchain handles linking:

env = project.Environment(toolchain="c++")
env.add_toolchain("cuda")

Emscripten requires the Emscripten SDK. Set the EMSDK environment variable, or install to ~/emsdk or /opt/emsdk.

WASI requires the WASI SDK. Set WASI_SDK_PATH, or install to /opt/wasi-sdk or ~/.local/share/wasi-sdk (also available via Homebrew).

LaTeX is available as a contrib toolchain using latexmk. It handles multi-pass compilation, BibTeX/Biber bibliography processing, makeindex, cross-references, and automatic dependency tracking (including \input'd files and .bib sources):

from pcons.contrib.latex import find_latex_toolchain

env = project.Environment(toolchain=find_latex_toolchain())
env.latex.Pdf(build_dir / "paper.pdf", src_dir / "paper.tex")

# Optional: change engine or add flags
env.latex.engine = "xelatex"
env.latex.flags.append("-shell-escape")

Rust is supported as interop, not as a native toolchain: pcons does not compile .rs files itself and there is no Rust toolchain to detect or configure. Instead project.CargoBuild() drives cargo build as a black-box sub-build (cargo owns the Rust compile and its intra-Rust incremental logic) and wraps the resulting library so C/C++ consumers can .link() it like any other dependency. Cross-compilation, Rust dialect, and similar settings are configured on the cargo side, not through pcons's environment:

rust_core = project.CargoBuild(
    "rust_core",
    env,
    manifest="rust/Cargo.toml",
    crate_type="staticlib",  # or "cdylib", "bin"
    profile="release",
)
app = project.Program("app", env, sources=["src/main.cpp"])
app.link(rust_core)  # -L/-l propagate automatically

Pass generate_header="rust/cbindgen.toml" to also run cbindgen and emit a C header from the Rust sources — pcons wires the header as an implicit dep of consumer compile steps, so the header exists before any #include is processed. See examples/43_rust_cxx_hybrid/ (hand-written FFI header) and examples/44_rust_cxx_cbindgen/ (cbindgen-generated header) for end-to-end examples. Other foreign build tools can be wired up the same way using env.Command(restat=True).

Builder Types

All builders are accessible as methods on Project:

Builder Type Platforms Description
project.Appx() Installer Windows Create a Windows AppX package (legacy MSIX format)
project.CargoBuild() Cargo All Build a Rust crate via cargo and expose it as a library
project.Command() Command All Create a custom command target
project.ComponentPkg() Installer macOS Create a macOS component package using pkgbuild
project.Dmg() Installer macOS Create a macOS .dmg disk image
project.FlatBundle() Installer All Create a flat directory bundle (cross-platform)
project.HeaderOnlyLibrary() Interface All Create a header-only (interface) library target
project.Install() Interface All Install files to a destination directory
project.InstallAs() Interface All Install a file to a specific destination path
project.InstallDir() Interface All Install a directory tree to a destination
project.MacosBundle() Installer macOS Create a macOS .bundle or .plugin structure
project.MetalLibrary() Metal Library macOS Compile .metal shaders and link them into a .metallib
project.Msix() Installer Windows Create a Windows MSIX package
project.ObjectLibrary() Object All Create an object library target (compiles but doesn't link)
project.OverlayDir() Interface All Merge several source trees into one directory, later sources winning
project.Pkg() Installer macOS Create a macOS product archive (.pkg) installer
project.Program() Program All Create a program (executable) target
project.QtDeploy() Command All Bundle Qt runtime with an app (macdeployqt/windeployqt)
project.QtProgram() Program All Qt program with automoc/autouic/autorcc
project.QtQmlModule() Object All QML module: QML files + QML_ELEMENT types, one call
project.QtResources() Object All Qt resources from a file list (no .qrc XML)
project.QtSharedLibrary() Shared Library All Qt shared library with automoc/autouic/autorcc
project.QtStaticLibrary() Static Library All Qt static library with automoc/autouic/autorcc
project.QtTranslations() Object All Compile .ts catalogs with lrelease and embed the .qm files
project.SharedLibrary() Shared Library All Create a shared library target
project.StaticLibrary() Static Library All Create a static library target
project.Tarfile() Archive All Create a tar archive from source files/directories
project.Test() Test All Declare a test to be run by pcons test (or ninja test)
project.Zipfile() Archive All Create a zip archive from source files/directories

Custom Toolchains

You can register your own toolchain to support additional languages or compilers:

from pcons.toolchains import toolchain_registry

toolchain_registry.register(
    MyToolchain,
    aliases=["my-toolchain"],
    check_command="my-compiler",
    tool_classes=[MyCompiler, MyLinker],
    category="c",
    platforms=["linux", "darwin", "win32"],
    description="My custom compiler",
)

Supported Source File Types

Pcons toolchains support various source file types beyond standard C/C++. This table is generated from the registered toolchains, so it lists every source type pcons handles:

Extension Description Toolchains
.c C source GCC, LLVM, MSVC, Clang-CL, Emscripten, WASI
.cpp, .cxx, .cc, .c++, .C C++ source GCC, LLVM, MSVC, Clang-CL, Emscripten, WASI
.cppm, .ixx, .cxxm, .c++m C++20 module interface unit GCC, LLVM, MSVC
.m Objective-C GCC, LLVM
.mm Objective-C++ GCC, LLVM
.s Assembly (preprocessed) GCC, LLVM
.S Assembly (needs C preprocessor) GCC, LLVM
.asm MASM assembly MSVC, Clang-CL
.rc Windows resource MSVC, Clang-CL
.cu CUDA source CUDA
.f, .for, .ftn Fortran, fixed form gfortran
.f90, .f95, .f03, .f08, .f18 Fortran, free form gfortran
.F, .F90 Fortran, needs C preprocessor gfortran
.swift Swift source Swift
.pyx Cython source Cython
.metal Metal shader (macOS) LLVM
.ui Qt Designer form Qt
.qrc Qt resource collection Qt
.ts Qt translation source Qt
.tex LaTeX document LaTeX

A toolchain is listed only for the sources it owns. The compiler drivers accept more than that — gfortran will happily compile C — but pcons routes each source to the toolchain whose tool is built for it.

Metal shaders (macOS)

project.MetalLibrary is the whole pipeline — each .metal source compiles to an .air, and the .air files link into the single .metallib an application loads at runtime:

shaders = project.MetalLibrary(
    "effects", env, sources=["src/blur.metal", "src/warp.metal"]
)
project.Default(shaders)

It returns a Target, so the library can be a default target, an alias member, or something to Install, exactly like a program or a shared library. The output is named verbatim (effects.metallib) — no lib prefix, since shaders are looked up by name at runtime.

env.metal.Object and env.metal.Library drive the two steps separately and return nodes, like every tool-namespace builder. Use those only when an intermediate .air is wanted for its own sake. See examples/62_metal_library.

C++20 modules

Modules just work: name the sources, link the targets, build.

env = project.Environment(toolchain="llvm")
env.cxx.set_standard("c++20")

mod = project.StaticLibrary("m", env, sources=["src/mod.cppm"])
app = project.Program("app", env, sources=["src/main.cpp"])  # says `import m;`
app.link(mod)

When a target has at least one source whose extension is in {.cppm, .ixx, .cxxm, .c++m}, pcons attaches a scanner to it. Each translation unit gets its own build-time scan edge (clang-scan-deps, cl /scanDependencies, or GCC's own preprocess-only pass, all emitting P1689R5), and each target gets one collate edge that turns those reports into the target's Ninja dyndep file and writes each compile a modmap carrying the flags that depend on what the scan found: -x c++-module and -fmodule-file= for clang, mapper lines for GCC, /interface or /internalPartition and /reference for MSVC. Partition units that live in .cpp files (interface partitions like export module M:P; or internal partitions like module M:P;) are recognized there, from the scan.

Note

  • A cross-target import needs the target dependency. A scope resolves a module name against its own units and against the exports of the targets it depends on, so app must link() (or depends()) the target that compiles the interface. The dependency carries the exports; the content decides the compile order.

Scanned builds use dyndep, so they need ninja ≥ 1.11 — pcons writes that floor into build.ninja — and only the ninja generator can express them. The Makefile and Xcode generators refuse such a project with a clear error.

If your project has no sources with one of those extensions but still uses C++ modules — e.g. fmtlib's src/fmt.cc (primary interface in .cc), or a target whose only module use is import std; — opt in explicitly:

env = project.Environment(toolchain="msvc")
env.cxx.modules = True
env.cxx.flags.extend(["/std:c++latest", "/EHsc"])
project.Program("hello", env, sources=["main.cpp"])  # main.cpp does `import std;`

env.cxx.modules has three states. The default, None, is auto: an extension-tagged module source opts its environment in. True also scans module units written in .cpp/.cc, as above. False disables scanning for that environment outright — it beats the extension opt-in, and warns if a module interface is then going to compile as plain C++. env.cxx.scan_deps overrides which scanner executable is used, for a clang-scan-deps that isn't on PATH.

import std; and import std.compat; are entirely dynamic. Configure describes the standard library's module edges but wires nothing to them; the first translation unit whose scan reports the import is what pulls the BMI into the dyndep file and the resulting object into the link. A project that never imports std builds and links nothing extra. It needs a standard library that ships the module source: libc++ with libc++.modules.json (LLVM ≥ 18; on macOS that means Homebrew LLVM, not Apple Clang), GCC 15+ with libstdc++-15-dev, or MSVC's %VCToolsInstallDir%/modules/std.ixx. If none is there, the error arrives when a file actually imports std, naming what was missing.

Compiled module interfaces (BMIs — .gcm / .pcm / .ifc) are only consumable by translation units built with matching BMI-sensitive flags (C++ dialect, ABI options, stdlib feature macros). pcons keys each BMI by a hash of those flags and stores it under <build_dir>/cxx_modules/<target>/<hash>/, so targets that compile a module interface with compatible flags share one BMI, while targets using an incompatible dialect (say -std=c++23 vs -std=c++26) transparently get their own. See examples/39_bmi_compat.

A module source the build generates itself needs nothing special: its .cppm suffix is a static fact read off the declared path, and the scan edge waits for the generator like any other consumer of the file. See examples/71_cxx_modules_codegen and examples/72_cxx_modules_codegen_interface.

These are handled automatically when you add sources to a target:

# C/C++ sources
app.add_sources(["main.cpp", "util.c"])

# Windows resources (icons, dialogs, version info)
app.add_sources(["app.rc"])

# Assembly
lib.add_sources(["fast_math.S"])  # Uses C preprocessor
lib.add_sources(["startup.s"])  # Raw assembly

Windows: MSVC Without Visual Studio (msvcup)

On Windows, find_c_toolchain() normally discovers the MSVC compiler from an installed Visual Studio. If you don't want to install all of Visual Studio with C++ workloads and Windows SDKs — or if you need a reproducible, locked compiler version — you can use msvcup to download just the MSVC compiler and Windows SDK directly from Microsoft's CDN.

The pcons.contrib.windows.msvcup module wraps the msvcup tool. Call ensure_msvc() at the top of your build script, before find_c_toolchain():

import sys
from pcons import Project

if sys.platform == "win32":
    from pcons.contrib.windows.msvcup import ensure_msvc

    ensure_msvc("14.44.17.14", "10.0.22621.7")

project = Project("hello", build_dir="build")
env = project.Environment(toolchain="c")
project.Program("hello", env, sources=["hello.c"])

On the first run, ensure_msvc():

  1. Downloads msvcup.exe from GitHub releases (auto-detects x64 vs arm64)
  2. Runs msvcup install to download the specified MSVC and SDK versions
  3. Runs msvcup autoenv to create wrapper executables (cl.exe, link.exe, etc.)
  4. Prepends the autoenv directory to PATH

Subsequent runs are fast — msvcup detects the toolchain is already installed and skips the download. Everything installs to C:\msvcup.

On non-Windows platforms, ensure_msvc() is a no-op (returns immediately).

Version Pinning

The MSVC version (e.g., "14.44.17.14") and SDK version (e.g., "10.0.22621.7") are explicit — every developer and CI machine gets the exact same compiler. To find available versions, run:

msvcup list

Lock Files

By default, ensure_msvc() writes a lock file to C:\msvcup\msvcup.lock for reproducible installs. You can specify a project-local lock file:

ensure_msvc("14.44.17.14", "10.0.22621.7", lock_file="msvcup.lock")

Cross-Compilation

The target CPU is auto-detected from the host architecture (x64 on x86_64 machines, arm64 on ARM64). For cross-compilation, specify it explicitly:

ensure_msvc("14.44.17.14", "10.0.22621.7", target_cpu="arm64")

CI Usage

msvcup is particularly useful in CI environments where you want reproducible builds without depending on whatever Visual Studio version happens to be pre-installed on the runner. See examples/21_msvcup_hello/ for a complete working example.


Core Concepts

Understanding these core concepts will help you write effective pcons build scripts.

Build Script Lifecycle

Every pcons build script (pcons-build.py) follows these phases:

  1. Configure - Set up toolchains, environments, and build options
  2. Describe - Create targets and define their sources/dependencies
  3. Resolve - Reify all paths and dependency arcs, and propagate and resolve dependencies
  4. Generate - Write build files

Your script only describes the build — the resolve and generate steps run automatically once pcons has run it. Ninja is the default generator; select another with pcons -G make (or the PCONS_GENERATOR/GENERATOR environment variables).

Tip

pcons is the program, so the script's __name__ is __pcons__ rather than the usual __main__. A subdirectory script pulled in by add_subdirectory() gets the same name, so a guard works in all pcons build scripts:

if __name__ == "__pcons__":
    main()

For finer control over the phases, you can resolve explicitly:

# ... define targets ...

# Resolve all dependencies now (generation does this automatically if needed)
project.resolve()

Every target has to exist by then. A target created after resolve() is refused: nothing would resolve it, so it would be missing from the build files.

Project

A Project is the top-level container for your build. It holds all environments, targets, and nodes.

from pcons import Project

project = Project("myproject", build_dir="build")

The project's root directory defaults to the directory containing the build script, and relative paths in the script (sources, include dirs, build_dir) are resolved against it — pcons runs the script from there no matter where it was invoked. An explicit root_dir= argument overrides the default.

The project provides factory methods for creating targets, like these:

  • project.Program() - Create an executable
  • project.StaticLibrary() - Create a static library (.a/.lib)
  • project.SharedLibrary() - Create a shared library (.so/.dylib/.dll)
  • project.HeaderOnlyLibrary() - Create a header-only library

Tip

It is possible to have multiple Projects in a single script, for some advanced uses; see Multiple projects in one script.

Environment

An Environment holds configuration for building: compiler settings, flags, include directories, and more. You can have multiple environments (e.g., for different platforms or variants).

# Create environment with an auto-detected C/C++ toolchain
env = project.Environment(toolchain="c")

# Configure compiler flags
env.cc.flags.extend(["-Wall", "-Wextra"])
env.cxx.flags.extend(["-std=c++17"])

# Add include directories
env.cxx.includes.append("include")

# Add preprocessor defines
env.cxx.defines.append("VERSION=1")
env.cxx.defines.append(("NAME", "value"))  # ("NAME", "value") means NAME=value

Each environment has namespaced tool configurations: - env.cc - C compiler settings - env.cxx - C++ compiler settings - env.link - Linker settings

Environments can be cloned and then the clone modified, to set up multiple variants; see Environment Cloning.

Path Conventions

Pcons uses consistent path conventions throughout:

  • Source paths (inputs): Relative to the project root directory
  • Target paths (outputs): Relative to the build directory
  • Install destinations: Relative to the install prefix (PCONS_INSTALL_PREFIX, default <project-root>/dist) — see Installing Files
  • Absolute paths: Pass through unchanged

This means you don't need to prefix output paths with build_dir (and you typically shouldn't, though pcons will detect that and strip the build_dir prefix):

# Good: output paths are relative to build_dir
project.Tarfile(env, output="packages/release.tar.gz", ...)

# Install destinations are relative to the install prefix (default: dist/)
project.Install("lib", [mylib])              # -> <root>/dist/lib/
project.InstallDir(".", src_dir / "assets")  # -> <root>/dist/assets/

# Not needed: build_dir prefix is implicit
# project.Tarfile(env, output=build_dir / "packages/release.tar.gz", ...)  # Unnecessary

Tip

If you really want a path named (from the top dir) build_dir / build_dir / foo.obj, specify it twice like that and the first one will be stripped, leaving the second one intact.

Toolchain

A Toolchain is a coordinated set of tools (compiler, linker, archiver) that work together. Pcons automatically detects available C/C++ toolchains.

# Auto-detect the best available C/C++ toolchain by name.
# Uses platform-appropriate defaults:
#   Windows: clang-cl, msvc, llvm, gcc
#   Unix/Mac: llvm, gcc
env = project.Environment(toolchain="c")

# Or give a preference order, or require a specific toolchain
env = project.Environment(toolchain=["gcc", "llvm"])
env = project.Environment(toolchain="msvc")

# For programmatic selection, finder functions return Toolchain objects
from pcons import find_c_toolchain

toolchain = find_c_toolchain(prefer=["gcc", "llvm"])

Available toolchains include at least these, depending on your system: - LLVM (Clang) - Default on macOS and Linux; uses GCC-style flags - Clang-CL - Clang with MSVC-compatible flags for Windows - GCC - Common on Linux - MSVC - Visual Studio on Windows

Target

A Target represents something to build: a program, library, or other output. Targets have:

  • Sources: Input files to compile
  • Dependencies: Other targets this links against or requires
  • Usage Requirements: Include dirs, defines, and flags
# Create a program target
app = project.Program("myapp", env)
app.add_sources(["main.cpp", "util.cpp"])

# Create a library target
# Adding "include" as a public include_dir will cause
# the app's build to get the proper include flags to
# find this lib's headers.
lib = project.StaticLibrary("mylib", env)
lib.add_sources(["lib.cpp"])
lib.public.include_dirs.append(Path("include"))

# Link the program against the library
app.link_private(lib)

Target Types

Method Output Use Case
Program() Executable Applications, tools
StaticLibrary() .a / .lib Code reuse, no runtime dependency
SharedLibrary() .so / .dylib / .dll Plugins, shared code
HeaderOnlyLibrary(name, env) None Template libraries

Note that, like CMake, targets have public and private libs and include_dirs. Public includes and libs are propagated to other targets that use this one.

Node

A Node is the low-level representation of a file in the dependency graph. You should rarely need to create them manually, though you'll see them when debugging.

Builder

A Builder defines how to create output files from inputs. Builders are provided by tools within a toolchain. You typically don't create builders directly; instead, use the high-level target API.

Behind the scenes, when you call project.Program(), pcons uses: - The Object builder to compile .cpp files to .o files - The Program builder to link .o files into an executable

Dependency Graph

Pcons builds a dependency graph of all files and their relationships:

hello.cpp  →  hello.o  →  hello (program)
             ↑
math.cpp  →  math.o  ─┘

When you run pcons build, Ninja uses this graph to: 1. Check timestamps on all files 2. Rebuild only files whose dependencies changed 3. Execute builds in parallel where possible

A dependency you declare yourself with target.depends() joins this graph before every step of the target; how each step holds it is described under Custom Commands.

What Gets Built: Build Tiers, Default() and Aliases

Every target is in one of three build tiers, which decide when it gets built:

build_tier built by
"default" plain pcons, ninja or make, with no targets named
"all" pcons all / ninja all / make all
"manual" asking for it only

Asking for a target means pcons build <name> when the script named it (see Named and anonymous targets), or its output path, or an alias. Any of those builds it whatever its tier.

Whatever a target's tier, anything it depends on gets built along with it, as usual. (So just to be clear, marking a target as manual but having a program depend on it will still build that target whenever the program is built.)

Each builder puts its targets in a sensible tier. Programs, libraries, Commands, LaTeX documents, bundles, and custom builders' outputs are default, so a plain pcons builds everything the project makes. Installs, overlays, archives and installers are all: they operate on the things you built, and you don't usually want them running on every build. Test targets, Qt's lupdate and QtDeploy are manual: run them by name (pcons test, ninja lupdate), which works because each declares an alias. Where a target sits in the source tree makes no difference to any of this, apart from Default()'s reach and a subdirectory included as a dependency, both below.

To change a target's tier, just set it:

zipfile.build_tier = "default"   # add the zip to the default build
bench.build_tier = "all"         # `ninja all` or `ninja build/bench`, not every build
lupdate.build_tier = "manual"    # it rewrites sources: only when asked for

Default() is rarely needed now. When you do call it, it resets the default tier to exactly the targets you name (several calls add to that set), so the script has complete control of the default build:

project.Default(app)          # only app, whatever else the project can make
project.Default(lib, tools)   # these too

A Default() call reaches the products of its own script's directory and below. In the top-level script that's the whole project. In a subdirectory's script it trims that subdirectory's products and leaves the rest of the tree alone, so a library's script can't knock your app out of the default build.

Naming a step this way (project.Default(installed)) puts it in the default build. Most of the time it's simpler to set build_tier on the one or two targets you want to move, and leave the rest alone.

None of this depends on the order your script does things in: the tiers are worked out once, at generate time, from every build_tier, every Default() call and every builder's placement. Naming a target in Default() and also setting its build_tier to "all" or "manual" is an error, and pcons tells you which two lines disagree.

To see every target's tier and why it's there:

pcons explain    # includes a "build tiers" section: one list per tier
pcons -v         # logs the same lines while generating

build_by_default

The older boolean attribute introduced in 0.23.0 will still work for one release: True means "default", False means "manual" (which is what False did: out of the default build and out of all). Use build_tier instead; it can also say "all".

Aliases create named phony targets for convenient building:

# Create an alias - builds with "ninja install"
project.Alias("install", installed_lib, installed_headers)

# Create an alias for tests (note: for standard tests, pcons already creates a `test` alias)
project.Alias("run-test", test_runner)

# Now you can run:
#   ninja install    # Build and install
#   ninja test       # Build and run tests

Aliases are Ninja or Makefile phony targets: they produce no file of their own, they depend on other targets, and their name is one the build tool knows.

A target's own name is not, but pcons build <name> works anyway: pcons translates the name into the output paths before the build tool sees them.

pcons build myapp       # the target named myapp, wherever it writes
pcons build myapp@mcu   # one environment's copy of it
ninja install           # the install alias, a phony ninja knows
ninja myapp.exe         # myapp on Windows, by output path

Run ninja or make yourself and you're back to what they know: output paths and phonies. ninja myapp works only where the name and the output path coincide, which a build_prefix, an output_name or a Windows suffix each break. Make an alias when you want a name that works everywhere.

Named and anonymous targets

Name a target when you need to refer to it by name, from another script above all. Otherwise leave the name out and keep the Target the builder returned.

Named targets. Program, StaticLibrary, SharedLibrary, ObjectLibrary, HeaderOnlyLibrary, MetalLibrary, CargoBuild for a library crate, find_package and the Qt program and library builders always take a name, because it's also the base name of the file they write. Command, PyBuilder calls, Install, InstallAs, InstallDir, OverlayDir, Tarfile, Zipfile, android_apk(), sign_apk() and create_universal_binary() take one as an optional keyword, name=. Either way the name is the target's identity, unique within its project and environment for its kind of target — one name may belong to a program and a library at once, since foo and libfoo.so are different files — and it does three jobs:

  • It names what the build writes, or rather the base of it: the toolchain adds the prefix and suffix. Program("myapp") writes myapp, or myapp.exe on Windows; SharedLibrary("net") writes libnet.so, libnet.dylib or net.dll. That is why a script names the target rather than the file: the filename differs on every platform. output_name, output_prefix and output_suffix override the parts; output_filename replaces the whole name.
  • It names the build subdir for that target. A program's objects go in obj.<name>/; every other compiled kind adds its own word, so a static library's objects go in obj.<name>.static/, a shared library's in obj.<name>.shared/, an object library's in obj.<name>.object/ and a MetalLibrary's in obj.<name>.metal/. A Qt target's generated sources sit beside its objects, in qt.<name>/ or qt.<name>.shared/, and a Cargo crate's tree in cargo/<name>/.
  • It's how everything refers to the target. get_target(), Default("myapp"), pcons explain myapp and pcons build myapp all take it, as do myapp@env and sub::myapp@env, and it's how a script reaches a target another subdirectory declared. Where one name does belong to two kinds, no spelling picks one out, so all of those refuse it and say why: keep the Target the builder returned, or give one an alias.

The first two jobs belong to the builders that derive a path from the name. A name= on Command, Install or an archive changes no path, since the call already says what to write; it's there for the third.

Anonymous targets. A builder called without a name makes one, as do Test and the steps built on Command that pass no name through: a CargoBuild bin crate, the Qt deploy steps, the installers. Pcons labels each by what it builds: a command or an archive by its first output's path as the build tool writes it, an install by its flattened destination, a Test by the test's own name. A label is there to be read, in pcons info --targets, pcons explain, the build-tiers report and error messages; labels may repeat, and no lookup answers to one.

header = env.Command(target="gen/config.h", command="...")   # labelled `gen/config.h`
source = env.Command(target="gen/config.c", command="...")   # labelled `gen/config.c`

project.get_target("gen/config.h")   # KeyError: a label is not a name

Keep the Target the call returned, which is what Default(), depends() and Install() want anyway. To build one from the command line, give its output path, or make an alias:

project.Alias("config", header, source)   # now `ninja config` means something

Calling Alias() multiple times with the same alias name adds targets to that alias, and you can have Aliases that contain (depend on) other Aliases.

depends() takes an alias too, and it means every target and file the alias groups, nested aliases included. It's read when the project resolves, so a member some later Alias() call adds counts as well.

Pcons defines a few aliases automatically: all (every target except the manual tier), and when the project has Test() targets, test (build and run them) and test-build (just build them). With the Makefile generator there's also clean; ninja has ninja -t clean built in. Some helpers add their own, like lupdate and deploy from the Qt tools. There's no built-in install alias; make one with Alias("install", ...) as above.

Writing your own builder? Importantly, a builder that puts the target's name into any path has to take that name from the user, the way Program does. A label derived by pcons may not be unique, and two targets building into one directory would collide.

Multi-Platform Builds

Handle platform differences in your build script using normal python:

import sys

env = project.Environment(toolchain="c")

# Add platform-specific flags
if sys.platform == "darwin":
    env.link.flags.append("-framework CoreFoundation")
elif sys.platform == "linux":
    env.link.libs.extend(["pthread", "dl"])
elif sys.platform == "win32":
    env.cxx.defines.append("WIN32")

# Add toolchain-specific warning flags
# clang-cl and msvc use MSVC-style flags (/W4)
# gcc and llvm use GCC-style flags (-Wall)
if env.toolchain.name in ("msvc", "clang-cl"):
    env.cxx.flags.append("/W4")
else:
    env.cxx.flags.extend(["-Wall", "-Wextra"])

Subdirectories and Composable Libraries

add_subdirectory() runs another directory's pcons-build.py as part of the current build, and every name assigned at module scope in that script comes back as an attribute:

libfoo = add_subdirectory("libfoo")
app.link(libfoo.libfoo)

The point of this is that a library can be built either way — on its own during development, and pulled into a larger tree when something depends on it. Write the script the natural way and it works in both:

project = Project("libfoo")

if project.is_top_level:
    env = project.Environment(toolchain="c")
else:
    env = project.default_environment  # the enclosing build's toolchain

config = configure_file("config.h.in", project.build_dir / "config.h", vars)
lib = project.StaticLibrary("foo", env, sources=["src/foo.c"])
lib.public.include_dirs.append(project.build_dir)

project.root_dir and project.build_dir always mean this project's source directory and this project's build output, wherever it sits. Built directly, build_dir is build/; embedded one level down, it is build/libfoo/. The same holds several levels deep, and sibling subdirectories stay in separate build directories.

Every relative path a script writes is read from the script's own directory: sources=, depends=, include directories, project.node(), configure_file(), cwd=, a Path in a command, and $SRCDIR. project.build_dir and env.build_dir are absolute, so a path built on them names one file wherever it goes: project.build_dir / "config.h" works as a source, a target, a command argument, or a file on disk. env.build_dir includes the environment's build_prefix, where a relative target= lands; project.build_dir doesn't. The one exception to the rule is a PathToken, whose relative path is read from the top of the tree; give it an absolute one.

Notes:

  • The subdirectory must live under the top-level project. Pointing add_subdirectory() at a sibling checkout elsewhere on disk is an error.
  • add_subdirectory() is the only way to nest: a second bare Project() call is an independent sibling. See Multiple projects in one script.
  • Only the environment needs the is_top_level branch, because a standalone build has no parent to take a toolchain from. default_environment searches enclosing projects, so a library nested several levels down still finds it.

Someone else's project is included the same way, with the widest tier its targets may take:

fmt = add_subdirectory("third_party/fmt", build_tier="manual")
app.link(fmt.fmt)

Nothing the included tree declares goes into a wider tier than that, whatever its own script chose, including its own Default(). With "manual" its targets build only when something needs them (the app above links fmt, so it builds) or when you ask for them, like CMake's EXCLUDE_FROM_ALL; with "all", ninja all builds them too. A Default() in your script can still name any of them. See examples/93_vendored_subdirectory.

A subdirectory script imports Python modules sitting next to it, the way a root build script does:

# libfoo/pcons-build.py
import sources          # libfoo/sources.py

Its own directory comes first, so a module there shadows a same-named one beside the root script while the inclusion runs. Editing an imported module re-runs pcons, like editing the build script itself. Modules imported by an included script are private to that inclusion; a directory included twice imports its own copy each time.

See examples/13_subdirs for a worked example, including a library nested two levels down that declares its own generator with relative paths.

One subdirectory, once per environment

add_subdirectory(..., env=...) sets the environment the included tree builds with. That becomes the default environment for the duration of the call, so the script above needs no change: its default_environment answers with the environment the caller passed. Include the same directory twice to build it twice:

host = project.Environment(toolchain="c", name="host")
host.build_prefix = "host"

mcu = project.Environment(toolchain="gcc", name="mcu")
mcu.build_prefix = "mcu"

add_subdirectory("libfoo", env=host)   # build/host/libfoo/libfoo.a
add_subdirectory("libfoo", env=mcu)    # build/mcu/libfoo/libfoo.a

project.get_target("foo@host")

Reaching a target another script declared

There are three ways across the boundary, one per direction.

add_subdirectory()'s return value is the way up, for what the parent wants from what the child made; that's the libfoo.libfoo above.

imports= is the way down, for what a directory needs from its parent or from a sibling:

# the parent
icons = add_subdirectory("icons")
add_subdirectory("app", imports={"icons": icons.icon_header})
# app/pcons-build.py
app.depends(project.imports["icons"])

Anything travels that way: targets, environments, paths, functions, plain values. Each inclusion has its own mapping, so a grandchild sees only what its own parent passed, and a directory included once per environment can get different objects each time. Built standalone, a script gets an empty mapping, so it reads project.imports.get("icons") and does without. examples/13_subdirs hands a library down this way to the sibling that links it.

The third way is by name, and it goes either direction: the declaring script names the target, and any script in the build looks it up.

# libfoo/pcons-build.py
stamp = env.Command(target="stamp.txt", command="...", name="stamp")
# the parent, or a sibling subdirectory
app.depends(project.get_target("libfoo::stamp@host"))

Prefer the objects: the return value when the parent wants the child's target, imports= when the child wants the parent's or a sibling's. Prefer the name when threading objects around is awkward, such as a loop over environments that declares a target per pass, where every pass writes the same name= and the environment tells the copies apart. examples/75_multi_env does that, with parity::stamp@host and parity::stamp@strict.

Configuring what you include

add_subdirectory(..., vars={...}) sets the build variables the included tree reads with get_var, for the duration of the call:

add_subdirectory("libfoo", env=vendor_env, vars={
    "LIBFOO_TESTS": False,
    "LIBFOO_PYTHON": False,
})

Values are python, not command-line strings: False, 2, Path("/opt"). They shadow the command line. To let the user override one, say so explicitly:

vars={"LIBFOO_PYTHON": get_var("LIBFOO_PYTHON", False)}

Names not overridden keep whatever the command line or parent gave them.

This replaces setting os.environ before the call, which is not recommended.

Some rules and notes for setting variables like this:

  • Both environments must be named and must differ in build_prefix. The names are what tell the two foo targets apart, and the prefixes are what keep their output files apart.
  • A nested add_subdirectory() inherits the parent's variables. Everything under the call builds in that environment, however deep, unless an inner call passes its own env=, which wins for its own subtree only.
  • It overrides every other source, including the environments the enclosing project registered.
  • A script that makes its own environment is not overridden. That environment is local to the script.

See examples/75_multi_env for a worked example: parity/ is described once and built for both environments, with each environment's flags.

Multiple projects in one script

Some builds contain more than one separate project: firmware plus the host tools that flash it, an application plus its installer, two configurations of one source tree, or a monorepo with several independent sub-projects. Each Project() created outside add_subdirectory() is an independent top-level project, with its own build directory, environments, node namespace, defaults and build files:

device = Project("device")                      # the default build dir
denv = device.Environment(toolchain="c")
denv.cc.defines.append("DEVICE_BUILD")
device.Program("app", denv, sources=["src/main.c"])

host = Project("host", build_dir=f"{device.build_dir}-host")  # its own build dir
henv = host.Environment(toolchain="c")
host.Program("app", henv, sources=["src/main.c"])

One pcons run generates and builds both, in script order. The rules:

  • Each project owns a build directory. The -B/PCONS_BUILD_DIR default gets assigned to the first project; every later sibling must pass build_dir=, and two projects claiming the same directory is an error.
  • Targets belong to the project that made them. device.Program(...) binds to device. Bare Target() and Environment() calls bind to the most recently created project, so in a multi-project script, create things through the project or an env.
  • Names can be qualified to avoid collisions. pcons app is ambiguous above; pcons device::app builds one project's target. pcons with no targets builds every project; pcons -B build-host build selects just that project's build dir and targets.
  • Aliases group across projects. An alias is a user-level grouping, so one name declared in several places means all of them: pcons docs builds every project's docs alias, and within a tree, declarations in subprojects merge into one group (ninja docs builds them all). A name that is an alias in one project and a plain target in another must be qualified.
  • Directory-scoped commands act on one directory. clean and test never run the build script, so they see only the -B directory (default: the first project's); scope them with pcons -B build-host test. --graph/--mermaid files are written per project: the first under the requested name, each sibling with its name suffixed (deps.dot, deps-host.dot).
  • Subdirectories anchor explicitly. device.add_subdirectory("lib") (or add_subdirectory("lib", project=device)) parents into that project. Both siblings may embed the same directory: each inclusion re-runs the script in its project's tree, so the library compiles per project, with that project's flags.
  • The root compile_commands.json symlink points at the first project's database; the others stay in their build directories.
  • One Ninja/Makefile per project: Each project keeps its own build.ninja or Makefile, so to build manually, you'd need individual ninja -C calls.

Sometimes multiple projects are the best way to separate concerns, and sometimes when there's a bit more coupling, multiple environments are best. Use whichever makes most sense for your build (and you can use both!)

See examples/66_multi_project for a worked multi-project example.


Building Projects Step by Step

Let's walk through a few progressively more complex examples.

Hello World - Single File Program

The simplest possible project: one source file, one output.

File structure:

project/
├── pcons-build.py
└── hello.c

hello.c:

#include <stdio.h>

int main(void) {
    printf("Hello from pcons!\n");
    return 0;
}

pcons-build.py:

from pcons import Project

# Setup
project = Project("hello", build_dir="build")
env = project.Environment(toolchain="c")

# Create program
hello = project.Program("hello", env)
hello.add_sources(["hello.c"])
hello.private.compile_flags.extend(["-Wall", "-Wextra"])

project.Default(hello)

Build and run:

uvx pcons
./build/hello
# Output: Hello from pcons!

Multiple Source Files

A program with multiple source files and a header.

File structure:

project/
├── pcons-build.py
├── include/
│   └── math_ops.h
└── src/
    ├── main.c
    └── math_ops.c

include/math_ops.h:

#ifndef MATH_OPS_H
#define MATH_OPS_H

int add(int a, int b);
int multiply(int a, int b);

#endif

src/math_ops.c:

#include "math_ops.h"

int add(int a, int b) {
    return a + b;
}

int multiply(int a, int b) {
    return a * b;
}

src/main.c:

#include <stdio.h>
#include "math_ops.h"

int main(void) {
    int a = 5, b = 3;
    printf("add(%d, %d) = %d\n", a, b, add(a, b));
    printf("multiply(%d, %d) = %d\n", a, b, multiply(a, b));
    return 0;
}

pcons-build.py:

from pcons import Project

project = Project("calculator", build_dir="build")
env = project.Environment(toolchain="c")

calculator = project.Program("calculator", env)
calculator.add_sources(["src/main.c", "src/math_ops.c"])

calculator.private.include_dirs.append("include")
calculator.private.compile_flags.extend(["-Wall", "-Wextra"])

project.Default(calculator)

Static Library

Create a reusable static library and link it to a program.

File structure:

project/
├── pcons-build.py
├── include/
│   └── math_utils.h
└── src/
    ├── main.c
    └── math_utils.c

pcons-build.py:

from pcons import Project

project = Project("myproject", build_dir="build")
env = project.Environment(toolchain="c")

# Create static library
libmath = project.StaticLibrary("math", env)
libmath.add_sources(["src/math_utils.c"])

# Public includes propagate to consumers
libmath.public.include_dirs.append("include")

# Public link libs (e.g., math library on Linux).
# link("m") adds a raw -l library (placed after objects on the link line);
# use link_flags for other linker flags (placed before objects).
libmath.link("m")

# Create program that uses the library
app = project.Program("myapp", env)
app.add_sources(["src/main.c"])
app.link_private(libmath)  # Gets libmath's public includes automatically!

project.Default(app)

Key points: - public.include_dirs propagates to targets that link against this library - app.link_private(libmath) adds libmath as a dependency and applies its public requirements. Use link_private() to keep the dependency local (as here, since app is the final program) or link() to re-export it to consumers of this target.

link() / link_private() vs. the link_libs lists

target.link(...) and target.link_private(...) are the recommended high-level forms. They are exactly equivalent to appending to target.public.link_libs and target.private.link_libs respectively — those lists remain fully supported as the low-level form, and accept the same Target objects and library-name strings.

Static Libraries That Need Each Other

Two static libraries may link each other:

lexer.link(parser)
parser.link(lexer)

That is a dependency cycle, and for static libraries it is allowed. Neither has to be built before the other (compiling one needs only the other's headers), and the linker gets both archives in the form it wants: wrapped in -Wl,--start-group ... -Wl,--end-group for GNU ld and lld, which search an archive once, and as they are for Apple's ld and MSVC's link, which rescan. Object libraries and header-only libraries may be in such a cycle too. A program or shared library may not, since it would have to be built before its own dependency; a cycle through one is an error that says which target is the problem. See examples/85_static_lib_cycle.

System Include Directories

Vendored third-party headers are a special case: you want them found, but you don't want their warnings, and you certainly don't want -Werror failing your build on code you can't change. Every compiler has a second kind of include path for this — -isystem on GCC/Clang, /external:I on MSVC (pcons adds /external:W0 alongside it), -imsvc on clang-cl. In pcons it's system_includes, on the tool or as a usage requirement:

# On the environment
env.cxx.system_includes.append("vendor/ae-sdk")

# Or as a usage requirement, so consumers inherit the headers
# without inheriting the warnings
sdk = project.HeaderOnlyLibrary("ae_sdk")
sdk.public.system_include_dirs.append("vendor/ae-sdk")
app.link(sdk)

Everything that works for includes works here: transitive propagation, deduplication, and path relativization in the generated build files. See examples/58_system_includes.

An external package takes the same treatment through a system= argument, which moves its include dirs across without any list surgery:

doctest = project.find_package("doctest", system=True)
nanobind = ImportedTarget.from_package(description, system=True)
env.use(description, system=True)

system= is off by default, deliberately: -isystem on a directory the compiler already searches — which is what a system or pkg-config prefix usually is — reorders the include search and can break the standard library. Best to use it on prefixes owned by a package manager or a fetched source tree. Packages fetched by pcons-fetch are already recorded that way, and opt out per package with system = false in deps.toml.

A package that uses -isystem in its pkg-config Cflags needs also works: both the pkg-config and Conan finders read it into system_include_dirs, so even MSVC properly gets /external:I.

To systemize a target someone else created, make_includes_system() moves its include dirs in place:

vendored.public.make_includes_system()

Shared/Dynamic Library

Create a shared library (.so on Linux, .dylib on macOS, .dll on Windows).

pcons-build.py:

from pcons import Project

project = Project("myproject", build_dir="build")
env = project.Environment(toolchain="c")

# Create shared library
libplugin = project.SharedLibrary("plugin", env)
libplugin.add_sources(["src/plugin.c"])
libplugin.public.include_dirs.append("include")

# Optional: name the file outright, prefix and suffix included
libplugin.output_filename = "myplugin.ofx"  # instead of libplugin.so

# Output naming defaults (output_name replaces the base name below;
# output_filename replaces the whole thing):
#   SharedLibrary "foo":
#     Linux:   libfoo.so
#     macOS:   libfoo.dylib
#     Windows: foo.dll
#   StaticLibrary "foo":
#     Linux/macOS: libfoo.a
#     Windows:     foo.lib
#   Program "foo":
#     Linux/macOS: foo
#     Windows:     foo.exe

# Create program that uses the library
app = project.Program("host", env)
app.add_sources(["src/main.c"])
app.link_private(libplugin)

project.Default(app, libplugin)

Project with Subdirectories

Organize a larger project with separate directories.

File structure:

project/
├── pcons-build.py
├── include/
│   ├── math_utils.h
│   └── physics.h
└── src/
    ├── main.c
    ├── math_utils.c
    └── physics.c

pcons-build.py:

This single top-level build script builds the whole system:

from pcons import Project

project = Project("simulator", build_dir="build")
env = project.Environment(toolchain="c")

# Library: libmath - low-level math utilities
libmath = project.StaticLibrary("math", env)
libmath.add_sources(["src/math_utils.c"])
libmath.public.include_dirs.append("include")
libmath.link("m")  # Link math library

# Library: libphysics - depends on libmath
libphysics = project.StaticLibrary("physics", env)
libphysics.add_sources(["src/physics.c"])
libphysics.link(libmath)  # Re-exports libmath's includes to consumers

# Program: simulator - main application
simulator = project.Program("simulator", env)
simulator.add_sources(["src/main.c"])
simulator.link_private(libphysics)  # Gets BOTH physics and math includes!

# Set defaults
project.Default(simulator)

For larger projects, you may want to create a build script for each lib in its own dir and use add_subdirectory(). See Subdirectories and Composable Libraries.

Debug and Release Variants

Use set_variant() to switch between debug and release builds.

pcons-build.py:

from pcons import Project, get_variant

# Get variant from command line: pcons --variant=debug
# Defaults to "release"
variant = get_variant("release")

# Build into per-variant dir
project = Project("myapp", build_dir=f"build/{variant}")
env = project.Environment(toolchain="c")

# Apply variant settings
# debug: -O0 -g
# release: -O2 -DNDEBUG
# release-fastest: -O3 -DNDEBUG (/O2 /Ob3 on MSVC)
env.set_variant(variant)

# Add extra flags
env.cc.flags.append("-Wall")

app = project.Program("myapp", env)
app.add_sources(["main.c"])

project.Default(app)

print(f"Variant: {variant}")

The variants are debug, release, release-fastest, relwithdebinfo and minsizerel; each toolchain realizes them in its own flags. release-fastest is the compiler's highest optimization level that doesn't change results, so it never turns on fast-math; add that yourself if you want it.

Usage:

# Release build (default)
uvx pcons
./build/release/myapp

# Debug build
uvx pcons --variant=debug
./build/debug/myapp

To see what a variant (or a preset, or anything else) did to the build, pcons explain myapp prints each target's concrete commands and where every flag and include directory came from. For the example above, in release mode:

## Explanation of Targets and Environments: /home/user/myapp
Commands are shown as the build runs them, from the build directory (build/release).

=== myapp  (program)  [env #1]  pcons-build.py:19
  * build/release/obj.myapp/main.c.o  <-  main.c
      /usr/bin/clang -O2 -Wall -DNDEBUG -MD -MF obj.myapp/main.c.o.d -c -o obj.myapp/main.c.o ../../main.c
  * build/release/myapp  <-  build/release/obj.myapp/main.c.o
      /usr/bin/clang -o myapp obj.myapp/main.c.o
  requirements:
    defines:
      NDEBUG  <- env.cc (base)

Environment #1  (toolchain: llvm)  pcons-build.py:9
  cc.flags:
    -O2     <- release (variant)
    -Wall   <- (manual)
  cc.defines:
    NDEBUG  <- release (variant)
  cxx.flags:
    -O2     <- release (variant)
  cxx.defines:
    NDEBUG  <- release (variant)

Warning

Notice that this example adds -Wall to env.cc.flags. cc and cxx are distinct in pcons, so if you want both you have to add it to env.cxx.flags as well, or just use the "warnings" preset; see below.

Semantic Presets

In addition to build variants (debug/release), pcons provides presets for common development workflows. Presets are orthogonal to variants — you can combine them freely.

# Apply warning flags (all warnings; add "werror" to make them errors)
env.apply_preset("warnings")

# Promote warnings to errors (compose with "warnings")
env.apply_preset("werror")

# Apply address/undefined behavior sanitizers
env.apply_preset("sanitize")

# Enable profiling
env.apply_preset("profile")

# Enable link-time optimization
env.apply_preset("lto")

# Enable security hardening flags
env.apply_preset("hardened")

Presets are toolchain-specific — each toolchain produces the appropriate flags:

Preset Unix (GCC/LLVM) MSVC
warnings -Wall -Wextra -Wpedantic /W4
werror -Werror /WX
sanitize -fsanitize=address,undefined -fno-omit-frame-pointer /fsanitize=address
profile -pg -g (compile+link) /PROFILE (linker)
lto -flto (compile+link) /GL (compile) + /LTCG (link)
hardened -fstack-protector-strong -D_FORTIFY_SOURCE=2 -fPIE + -pie -Wl,-z,relro,-z,now /GS /guard:cf + /DYNAMICBASE /NXCOMPAT /guard:cf

Combine presets with variants for a complete configuration:

env.set_variant("release")
env.apply_preset("warnings")
env.apply_preset("lto")

Variants act like a knob: calling set_variant() again replaces the previous variant's flags rather than piling on top of them, so env.set_variant("release") followed by env.set_variant("debug") switches cleanly. To build both variants side by side, clone the environment (see Environment Cloning).

Where Did This Flag Come From? (pcons explain)

Once variants, presets, and manual edits combine in a complex build, it can be unclear which setting produced a given flag. pcons explain (optionally with target names) prints each target's concrete commands and attributes every flag, define, and command override to the variant, preset, or environment that contributed it; anything set directly (or a toolchain default) is labelled (manual). It also shows the build-script line where each target and environment was created. Given:

from pcons import Project

project = Project("myapp", build_dir="build")
env = project.Environment(toolchain="c")
env.set_variant("release")
env.apply_preset("warnings")
env.cc.flags.append("-fno-strict-aliasing")

app = project.Program("myapp", env, sources=["main.c"])

pcons explain prints:

## Explanation of Targets and Environments: /home/user/myapp
Commands are shown as the build runs them, from the build directory (build).

=== myapp  (program)  [env #1]  pcons-build.py:9
  * build/obj.myapp/main.c.o  <-  main.c
      /usr/bin/clang -O2 -Wall -Wextra -Wpedantic -fno-strict-aliasing -DNDEBUG -MD -MF obj.myapp/main.c.o.d -c -o obj.myapp/main.c.o ../main.c
  * build/myapp  <-  build/obj.myapp/main.c.o
      /usr/bin/clang -o myapp obj.myapp/main.c.o
  requirements:
    defines:
      NDEBUG  <- env.cc (base)

Environment #1  (toolchain: llvm)  pcons-build.py:4
  cc.flags:
    -O2                   <- release (variant)
    -Wall                 <- warnings (feature)
    -Wextra               <- warnings (feature)
    -Wpedantic            <- warnings (feature)
    -fno-strict-aliasing  <- (manual)
  cc.defines:
    NDEBUG                <- release (variant)
  cxx.flags:
    -O2                   <- release (variant)
    -Wall                 <- warnings (feature)
    -Wextra               <- warnings (feature)
    -Wpedantic            <- warnings (feature)
  cxx.defines:
    NDEBUG                <- release (variant)

Note

The same attribution is available inside a build script as a string: env.explain(), or env.cc.explain() for one tool. Cross presets and SDK wiring show up the same way (e.g. cc.cmd <- wasi-sdk), so explain is the first tool to reach for when a build uses a flag or a compiler you didn't expect.


Working with Environments

An Environment carries the toolchain, flags, and tools a target is built with.

Environment Cloning

Create variant environments by cloning:

# Base environment
env = project.Environment(toolchain="c")

# Clone for profiling - gets a COPY of all settings
profile_env = env.clone()
profile_env.cxx.flags.extend(["-pg", "-fno-omit-frame-pointer"])

# Build both variants
app_release = project.Program("app", env)
app_profile = project.Program("app_profile", profile_env)

Key points about environments:

  • Each project.Environment() call creates a fresh environment with toolchain defaults
  • env.clone() creates a deep copy - changes to the clone don't affect the original. A clone has no name; env.clone(name="host") gives it one. Two environments need names, and different build_prefix settings, before they can hold targets with the same name
  • Environments don't share state - there's no "base" environment that accumulates
  • You can clone at any point and re-tune the clone: set_variant() (and other exclusive presets) replace the previous setting, so debug_env = release_env.clone(); debug_env.set_variant("debug") works
  • If you see duplicate flags, check if you're accidentally adding flags multiple times in your script or use pcons explain

Temporary Environment Overrides

env.override() yields a temporary clone of the environment; the original is untouched. You can then modify that clone — it's an ordinary Environment, so a flag list is an ordinary Python list and every operation is just Python:

with env.override() as tuned:
    tuned.cxx.flags.append("-O1")  # add
    tuned.cxx.flags.remove("-Werror")  # remove one
    tuned.cxx.flags = [
        f
        for f in tuned.cxx.flags  # remove by pattern
        if not f.startswith("-W")
    ]
    tuned.cxx.flags.insert(0, "-fno-strict-aliasing")  # order matters
    tuned.cxx.flags = ["-O1"]  # replace outright

    project.Library("mylib", tuned, sources=["lib.cpp"])

Keyword arguments are a shorthand that assigns, so they are for scalars:

with env.override(variant="debug", cc__cmd="clang") as temp_env:
    project.Program("app_debug", temp_env, sources=["main.cpp"])

Tool settings use tool__attr notation because Python keywords can't contain a dot.

override() is clone() plus a scope, and the block isn't required — it just saves the assignment and shows where the modified environment applies. When the modified environment outlives one stretch of the script, keep a clone instead:

careful = env.clone()
careful.cc.flags.remove("-O2")
careful.cc.flags.append("-O1")

lib.add_sources(["cuda-support.cxx"], env=careful)
lib.add_sources(["other-touchy.cxx"], env=careful)

Keyword arguments don't take lists

env.override(cxx__flags=["-O1"]) raises. It can only mean "assign", but at a call site it looks like it ought to mean "add -O1" — and assigning would silently discard all existing flags. Since which of add / remove / reorder / replace you meant can't be inferred, you have to say it in the block:

with env.override() as tuned:
    tuned.cxx.flags.append("-O1")  # or .remove(...), or = [...]

The error message calls out the flags the call would have dropped and shows each form.

Per-File Flags

To compile one file in a target differently, pass the override environment along with the source.

lib = project.StaticLibrary("core", env, sources=common_sources)

with env.override() as careful:
    careful.cxx.flags.append("-O1")  # this file miscompiles at -O2
    lib.add_sources(["cuda-support.cxx"], env=careful)

The file stays part of the target, so it keeps the target's include dirs, defines, and everything inherited from its dependencies — only the environment layer changes.

env.cc.Object() (see examples/17_object_sources) is another way to apply unique flags: compiling a standalone object that several targets can link directly. It sits outside any target, so no target's usage requirements apply to it, and it can use its own or any environment. Its first argument is the object's path; a relative one is placed in the environment's build directory (under its build_prefix, if any), so env.cc.Object("helper.o", "src/helper.c") and the example's build_dir / "helper.o" land in the same place.

Multiple Toolchains

Pcons supports combining multiple toolchains in a single environment. This is useful for projects that mix languages, such as C++ with CUDA, or C++ with Cython.

Adding Additional Toolchains

Use env.add_toolchain() to add extra toolchains to an environment:

from pcons import Project

project = Project("gpu_app", build_dir="build")

# Create environment with C/C++ toolchain
env = project.Environment(toolchain="c++")

# Add CUDA toolchain for .cu files
env.add_toolchain("cuda")

# Now this target can have both .cpp and .cu sources
app = project.Program("gpu_app", env)
app.add_sources(
    [
        "main.cpp",  # Compiled with C++ compiler
        "kernel.cu",  # Compiled with CUDA nvcc
    ]
)

How Source Routing Works

When a target has sources with different file extensions, pcons routes each source to the appropriate compiler:

  • .c files → C compiler from primary toolchain
  • .cpp, .cxx, .cc files → C++ compiler from primary toolchain
  • .cu files → CUDA compiler from CUDA toolchain (if added)

The primary toolchain (passed to project.Environment()) has precedence. If multiple toolchains claim to handle the same file type, the primary toolchain wins.

Variant Support with Multiple Toolchains

When you call env.set_variant(), the variant is applied to all toolchains:

env = project.Environment(toolchain="c++")
env.add_toolchain("cuda")

# This applies "debug" settings to both C++ AND CUDA compilers
env.set_variant("debug")
# C++ gets: -O0 -g
# CUDA gets: -G -g (device debugging)

Available Toolchain Finders

Toolchain name strings resolve through finder functions, which are also available directly for programmatic use:

Function Description
find_c_toolchain() Find C/C++ toolchain (LLVM, GCC, MSVC, etc.)
find_cuda_toolchain() Find CUDA toolchain (returns None if nvcc not found)

One Library, Several Environments

A firmware build needs two toolchains at once: the cross compiler that makes the image, and the host compiler for the generators and tests that run on the build machine. Some code belongs to both.

Each environment owns where its targets are built:

mcu = project.Environment(toolchain="gcc", name="mcu")
mcu.build_prefix = "mcu"        # everything this environment writes
mcu.archive_directory = "lib"   # its static libraries, below that

host = project.Environment(toolchain="gcc", name="host")
host.build_prefix = "host"
host.archive_directory = "lib"
Setting What it moves
env.build_prefix Everything: link outputs, object files, env.Command() targets
env.runtime_directory Programs
env.library_directory Shared libraries
env.archive_directory Static libraries, and Windows import libraries

All four are relative to the build directory, and empty by default: a project that sets none of them sees no path change. The toolchain still decides the lib prefix and the .a / .lib suffix, which is why these are directories and output_prefix is not (that one replaces the toolchain's filename prefix).

build_prefix sits under the build directory and above the add_subdirectory offset. With env.build_dir = "build/rel", a build_prefix of mcu gives build/rel/mcu, and a sub-project keeps its own offset inside that slice, at build/rel/mcu/sub. Setting env.build_dir names the whole directory, so a sub-project that does it drops its own offset, and its targets keep theirs.

Everything the environment writes follows both settings, objects and artifacts alike. env.build_dir is the one place that decides where an environment builds.

Because the two environments write in different directories, a target name can be repeated, and building one library for both is a plain Python function:

def common_lib(env):
    lib = project.StaticLibrary("common", env, sources=["src/common.c"])
    lib.public.include_dirs.append(project.root_dir / "src")
    return lib


common = common_lib(mcu)        # build/mcu/lib/libcommon.a
common_host = common_lib(host)  # build/host/lib/libcommon.a

Two targets may share a name when both environments are named and the names differ, or when the two are different kinds of target: a Program and a SharedLibrary called smudge write smudge and libsmudge.so. One name for one kind in one environment is still refused, and the error says so. Name both environments but give neither a build_prefix and the artifacts stay apart while the objects would not; pcons refuses that collision too, naming both targets. This is about names: two anonymous targets may always share a label (see Named and anonymous targets).

Naming one of them: name@env

@ selects the environment, :: selects the project, and @ binds tighter, so sub::common@mcu is target common of sub-project sub, built in mcu. It works wherever a target can be named, in the script and on the command line alike:

project.get_target("common@mcu")
project.Default("app@host", "app@strict")
app.link(project.get_target("common@mcu"))

A link() string is always a raw link token, so link("m") means -lm and a target is linked by looking it up first.

$ pcons build common@mcu
$ pcons explain common@mcu

A plain common matches two targets here, so pcons stops and prints both qualified names rather than picking one.

See examples/75_multi_env for the smallest complete case, and examples/74_bare_metal for the cross-compiled one.

Compiler Cache

Speed up rebuilds by wrapping compile commands with ccache or sccache:

# Auto-detect: tries sccache first, then ccache
env.use_compiler_cache()

# Explicit choice
env.use_compiler_cache("ccache")
env.use_compiler_cache("sccache")

This sets the cache as the launcher on the cc and cxx tools (see below). Only compile commands are affected — the linker and archiver have nothing to cache. If the requested tool isn't in PATH, a warning is logged and no changes are made.

Notes: - On MSVC (cl.exe), only sccache works. If you request ccache with an MSVC toolchain, pcons warns and does nothing. - Calling use_compiler_cache() twice is a no-op, and it leaves any launcher you set yourself in place.

Command Launchers

A launcher runs in front of the command an edge would otherwise run: ccache ahead of the compiler, valgrind ahead of a test. Set it on a tool namespace and it follows that tool:

env.cc.launcher = ["ccache"]
env.cc.launcher = ["ccache", "time"]  # prepended in order (so first becomes the outermost)

Like every command in pcons, a launcher is a list of tokens rather than a string, so a program whose path contains a space stays one argument.

A launcher can also belong to a single command rather than to a tool, so you could use that for a wrapper for a single expensive step:

env.Command(
    target="model.stl",
    source="model.py",
    command="python $SOURCE --out $TARGET",
    launcher=["valgrind", "-q"], # launcher is just a list of tokens, so args are OK too
)

Both compose, outermost first: a launcher on the tool runs outside the one on the command.

Notes:

  • Launcher tokens are passed through as written. They are a program and its arguments, and/or multiple programs, not paths in the dependency graph, so pcons does not rewrite them for the directory the build runs in. Use absolute paths (project.root_dir / "tools" / "wrap.py" if they're not on $PATH).
  • compile_commands.json reports the compiler itself, without launchers, so clangd and other tools see the real compile.
  • Environment variables for one command are a launcher under the hood: env_vars= on Command() renders as env NAME=VALUE (or a pcons helper on Windows) innermost in the chain. See Custom Commands below.

See examples/63_command_launcher for two stacked launchers wrapping every C compile, and a third belonging to one command.

Running clang-tidy With Every Compile

env.use_clang_tidy() runs clang-tidy on each C and C++ source, with that compile's own flags, and then compiles it. The compile runs either way, so the object is still produced and the diagnostic repeats on the next build rather than turning into a missing file downstream. What fails the build is clang-tidy's own exit status: a warning doesn't fail it, an error does, and --warnings-as-errors=* turns the warnings into failures too.

env.use_clang_tidy()                              # clang-tidy from PATH
env.use_clang_tidy("/opt/llvm/bin/clang-tidy")    # a specific one
env.use_clang_tidy(args=["--warnings-as-errors=*"])

Put the checks in a .clang-tidy file next to the sources; clang-tidy finds it by itself. args is for the rest: --use-color, --export-fixes=..., --warnings-as-errors=....

Under the hood this is a launcher, python -m pcons.tools.co_compile, and it composes with use_compiler_cache() in either order: the cache keeps wrapping the compiler, and the analysis never sees it. If clang-tidy isn't installed, a warning is logged and the compiles are left alone. compile_commands.json still reports the compiler itself.

See examples/76_clang_tidy.


Working with External Dependencies

Finding Packages with project.find_package()

The simplest way to use an external package is project.find_package(). It searches for the package using available finders (pkg-config, system paths) and returns an ImportedTarget that you can link against or apply to an environment.

from pcons import Project

project = Project("myapp", build_dir="build")
env = project.Environment(toolchain="c")

# Find packages (raises PackageNotFoundError if not found)
zlib = project.find_package("zlib")
openssl = project.find_package("openssl", version=">=3.0")

# Find with components
boost = project.find_package("boost", components=["filesystem", "system"])

# Optional dependency — returns None if not found
optional = project.find_package("optional-dep", required=False)

# Third-party headers as system headers (-isystem): found the same way,
# but their warnings never reach your -Werror. See "System Include Directories".
doctest = project.find_package("doctest", system=True)

# Use as a dependency (public requirements auto-propagate)
app = project.Program("myapp", env, sources=["main.cpp"])
app.link_private(zlib)

# Or apply directly to an environment
env.use(openssl)

By default, find_package() tries PkgConfigFinder first, then SystemFinder. You can prepend custom finders:

from pcons.packages.finders import ConanFinder

# Add a Conan finder — it will be tried first
project.add_package_finder(ConanFinder(config, conanfile="conanfile.txt"))

# Now find_package() tries: Conan → PkgConfig → System
fmt = project.find_package("fmt")

Precedence: first finder to return a result wins. add_package_finder() call prepends, so the most recently added finder is consulted first, then the defaults (PkgConfig, then System). A finder added after a package was already looked up still applies to every later lookup. A finder that comes up empty (wrong version, tool missing the package) falls through to the next, and the winning source is recorded on the package as a found_by property (e.g. "pkg-config", "rez+pkg-config", "system"). A finder whose tool isn't installed is skipped with a warning. Run with --debug configure (see debug logging) to see which finder answered (or passed on) each package.

Results are cached per (environment, name, version, components) — including negative results, so repeated find_package(..., required=False) probes don't re-run the finder chain and its subprocesses; a later required=True call for the same failed key raises from the cache.

One search per environment

A cross build and a host build must not share an answer: the host's headers are the wrong ones for the cross compiler, and using them fails much later as a missing header. So each environment searches its own chain and holds its own result.

mcu = project.Environment(toolchain="gcc", name="mcu")
mcu.apply_cross_preset(linux_cross(triple="aarch64-linux-gnu", sysroot="/opt/aarch64"))
host = project.Environment(toolchain="gcc", name="host")

zlib_mcu = project.find_package("zlib", env=mcu)    # searches /opt/aarch64
zlib_host = project.find_package("zlib", env=host)  # searches this machine

An environment given a cross preset with a sysroot searches that sysroot and nothing else, through PKG_CONFIG_SYSROOT_DIR and PKG_CONFIG_LIBDIR for pkg-config and the sysroot's usr/include and usr/lib for the system finder. One with a cross preset and no sysroot searches nothing and raises PackageNotFoundError, which is the honest answer: pcons does not know where that target's packages are. Add a finder that does:

project.add_package_finder(ConanFinder(config, profile="mcu"), env=mcu)

An env= finder is used in that environment only, and is tried before the project-wide ones. Without env=, a finder is used everywhere, which is what a single-environment project wants.

Omitting env= on find_package() uses the environment a target created without one would get, so a single-environment project needs none of this and behaves as before. Environments are told apart by their name, so scoping needs a named environment.

See Using Conan Packages below for more details about using Conan with pcons.

Header-Only and Manual Packages

Some libraries (especially header-only ones) don't have .pc files and can't be found by find_package(). Create an ImportedTarget manually using PackageDescription:

from pcons import ImportedTarget, PackageDescription

# Header-only library with no .pc file
httplib = ImportedTarget.from_package(
    PackageDescription(
        name="cpp-httplib",
        include_dirs=["/opt/homebrew/include"],
        defines=["CPPHTTPLIB_OPENSSL_SUPPORT"],
    )
)

If the manual package depends on another package, link() it to wire up transitive dependencies — you don't need to copy public requirements manually:

openssl = project.find_package("openssl")

httplib = # ... see above
httplib.link(openssl)  # openssl requirements propagate to anything linking httplib

# Now any target that links httplib automatically gets openssl too
app = project.Program("myapp", env, sources=["main.cpp"])
app.link_private(httplib)  # gets httplib AND openssl includes, libs, flags

Using pkg-config

The PkgConfigFinder uses the system's pkg-config to find packages.

from pcons.packages.finders import PkgConfigFinder

# Create finder
finder = PkgConfigFinder()

if finder.is_available():
    # Find a package
    zlib = finder.find("zlib", version=">=1.2")

    if zlib:
        print(f"Found zlib {zlib.version}")
        print(f"Includes: {zlib.include_dirs}")
        print(f"Libraries: {zlib.libraries}")

        # Apply to environment
        env.use(zlib)

Using Conan Packages

The ConanFinder integrates with Conan 2.x for package management.

conanfile.txt:

[requires]
fmt/10.1.1

[generators]
PkgConfigDeps

pcons-build.py:

from pcons import Project, get_variant
from pcons.configure.config import Configure
from pcons.packages.finders import ConanFinder

variant = get_variant("release")

# Configure and find toolchain
config = Configure(build_dir="build")
toolchain = find_c_toolchain()

# Set up Conan
conan = ConanFinder(
    config,
    conanfile="conanfile.txt",
    output_folder="build/conan",
)

# Create project and environment
project = Project("conan_example", build_dir="build")
env = project.Environment(toolchain=toolchain)
env.set_variant(variant)
env.cxx.flags.append("-std=c++17")

# Sync Conan profile with toolchain settings. build_type follows the
# environment's variant; cppstd can be set explicitly, or inferred from
# env.cxx.flags.
conan.sync_profile(toolchain, env=env)

# Install packages (cached, only runs when needed)
packages = conan.install()

# Get the fmt package
fmt_pkg = packages.get("fmt")
if not fmt_pkg:
    raise RuntimeError("fmt package not found")

# Apply package settings with env.use()
env.use(fmt_pkg)

# Build program
hello = project.Program("hello_fmt", env)
hello.add_sources([project_dir / "src" / "main.cpp"])

project.Default(hello)

sync_profile() Reference

conan.sync_profile() generates a Conan profile from pcons settings:

conan.sync_profile(
    toolchain,  # Detects compiler, version, OS, arch
    env=env,  # build_type from env.variant; cppstd from env.cxx.flags
    build_type="Release",  # Overrides the variant: Release, Debug, RelWithDebInfo, MinSizeRel
    cppstd="23",  # Explicit C++ standard (overrides env inference)
)

build_type follows the environment's variant when you leave it out: debug is Debug, release and release-fastest are Release, relwithdebinfo and minsizerel their Conan names. With one environment per variant, one ConanFinder per variant (each with its own output_folder) gives each its matching packages.

The cppstd parameter sets compiler.cppstd in the Conan profile, which many packages require. If omitted, it's inferred from env.cxx.flags (e.g., -std=c++23 becomes compiler.cppstd=23). You can also use the lower-level conan.set_profile_setting("compiler.cppstd", "23") before calling sync_profile().

A conf entry set with conan.set_profile_conf(), such as tools.build:cxxflags, is not part of Conan's package id: changing it reuses the binary Conan already built unless you tell Conan the conf matters:

conan.set_profile_conf("tools.build:cxxflags", '["-march=native"]')
conan.set_profile_conf("tools.info.package_id:confs", '["tools.build:cxxflags"]')

The env.use() Helper

The env.use() method is the simplest way to apply package settings:

# Apply all settings from a package
env.use(pkg)

# This automatically:
# - Adds include_dirs to cxx.includes
# - Adds defines to cxx.defines
# - Adds library_dirs to link.libdirs
# - Adds libraries to link.libs
# - Adds link_flags to link.flags

# Same, but the include dirs land on cxx.system_includes (-isystem), so the
# package's headers produce no warnings. The package itself is unchanged.
env.use(pkg, system=True)

If your project uses rez, see Integrations → Rez for native rez-resolve support — RezFinder plugs into find_package() and rez_environment(env) injects every resolved package's flags.

macOS Framework Linking

On macOS, link against system frameworks using env.Framework():

import sys

if sys.platform == "darwin":
    # Link a single framework
    env.Framework("CoreFoundation")

    # Link multiple frameworks
    env.Framework("Foundation", "Metal", "QuartzCore")

    # Add framework search paths for non-system frameworks
    env.link.frameworkdirs.append("/Library/Frameworks")
    env.Framework("SomeThirdParty")

This adds the appropriate -framework and -F flags to the linker command. Framework linking is only available on macOS with GCC or LLVM toolchains.

env.Framework() puts the flags on the environment, so every target built with it links the framework. To scope a framework to one library and have it propagate only to targets that link that library, use the target-level usage requirement instead: lib.public.frameworks.append("CoreFoundation") (and lib.public.framework_dirs for search paths). See examples/89_macos_frameworks for a worked example.

For more complex scenarios where you need framework flags in compile commands (e.g., for headers), you can also access the raw flags:

# Manual approach (usually not needed)
env.link.flags.extend(["-framework", "Metal"])
env.link.flags.extend(["-F", "/path/to/frameworks"])

Paths in Linker Flags (PathToken)

Sometimes you need to embed a file path inside a linker flag, such as -Wl,-force_load,<path> (macOS whole-archive linking) or -Wl,--version-script=<path>. Plain strings don't work here because the path needs to be relativized correctly for the generator (Ninja runs from the build directory, so paths must be relative to it).

Use PathToken to embed paths in flags:

from pcons import PathToken, Project

project = Project("myapp")
env = project.Environment(toolchain="c")

lib = project.StaticLibrary("mylib", env)
lib.add_sources(["src/mylib.c"])

prog = project.Program("myapp", env)
prog.add_sources(["src/main.c"])
prog.link_private(lib)

# Force-load all symbols from the static library (macOS)
prog.private.link_flags.append(
    PathToken(prefix="-Wl,-force_load,", path="libmylib.a", path_type="build")
)

PathToken takes three key arguments: - prefix: The flag text before the path (e.g., "-Wl,-force_load,", "-Wl,--version-script=") - path: The file path - path_type: How the path should be interpreted: - "build" — relative to the build directory (for build outputs like libraries) - "project" — relative to the project root (for source tree files) - "absolute" — used as-is

See examples/33_path_in_flags for a complete working example.


Custom Build Steps

Not every build step is a compile or a link. env.Command() runs an arbitrary command as part of the graph, post-build commands attach to a target that was just built, and a custom builder packages either one up for reuse.

Custom Commands with env.Command()

Use env.Command() to run arbitrary shell commands as build steps. This is useful for code generators, asset processing, or any tool that doesn't fit the standard compile/link model.

# Generate a header from a template
env.Command(
    "config.h",  # Target file(s)
    ["config.h.in", "version.txt"],  # Source file(s)
    "python generate_config.py $SOURCES > $TARGET",
)

# Run a code generator with multiple outputs
env.Command(
    ["parser.c", "parser.h"],  # Multiple targets
    "grammar.y",  # Single source
    "bison -d -o ${TARGETS[0]} $SOURCE",
)

# Command with no source dependencies
env.Command(
    "timestamp.txt",
    None,  # No sources
    "date > $TARGET",
)

Variable substitution:

Variable Description
$SOURCE, $SOURCES All source files (space-separated) — the two spellings mean the same thing; use ${SOURCES[0]} for the first one
$TARGET, $TARGETS All target files (space-separated)
${SOURCES[n]} Indexed source access (0-based)
${TARGETS[n]} Indexed target access (0-based)
${SOURCES[n:m]} A range of sources — either end may be omitted
${TARGETS[n:m]} A range of targets
$TOOL The program tool= names, spelled so the shell will run it
$SRCDIR The source directory of the script declaring the command (the project root, at the top level)
$$ Literal $ (escaped)

Anything else inside ${...} is an error. An unrecognized form would otherwise reach build.ninja as a shell-escaped literal and run as nonsense.

Sources keep the order you wrote them in. ${SOURCES[0]} is the first source declared, whether or not it's another target's output:

# ${SOURCES[0]} is the template; ${SOURCES[1:]} is however many .def files there are
env.Command(
    target=gen_dir / "entries.c",
    source=[template, *def_files],
    command="python $SRCDIR/tools/fill.py ${SOURCES[0]} $TARGET ${SOURCES[1:]}",
)

A substitution can be part of an argument rather than all of it — the text around it comes along:

command = "$TOOL --out=$TARGET -i$SOURCES"

Run a program the build made with tool=. The program that runs a command is not one of its inputs, so it has its own argument, and pcons writes it the way the shell running the build executes it: ./collate for a POSIX shell, which looks a bare name up on $PATH rather than in the build directory, and a backslashed path for cmd.exe, which reads a / as a switch. Don't write ./${SOURCES[0]} by hand; it gets one of those wrong. See examples/59_codegen_sources.

env.Command(
    target=gen_dir / "entries.c",
    tool=collate_tool,
    source=def_files,
    command="$TOOL $TARGET $SOURCES",
)

tool= becomes an implicit dependency, never a source, so $SOURCES keeps meaning what the command consumes and the indices you already wrote keep their files. A Target is a program this build produces, from any environment — a host tool over a cross-compiled artifact is the case it exists for. A str or a Path is a program that already exists: an absolute path to an installed tool, or a bare name looked up on $PATH. Neither of those adds a dependency, since there is nothing to build.

$TOOL and tool= go together: one without the other is an error.

A list-form command can name its program directly. In the list form a token may be a Target or a FileNode rather than a string. It expands to that output's path and becomes an implicit dependency, without joining $SOURCES:

env.Command(
    target=gen_dir / "entries.c",
    source=def_files,
    command=[collate_tool, "$TARGET", "$SOURCES"],
)

A Path in the list names a file the same way (read from the script's directory when relative, like sources=) but adds no dependency, since it may be a directory the command only writes to. Pass the Path itself, not str() of it, and pcons writes it from wherever the command runs.

First in the list, that token is the program, spelled to run just as $TOOL is. Anywhere else it is an argument and stays a plain path, which is what a wrapper reading the file wants. Where a command gives both, index 0 is what runs and $TOOL is one more path on the line. A Target that builds several files is ambiguous and raises: name the one that is meant, parser.output_nodes[0].

Text attached to a form that expands to several paths repeats on each of them, which is what such a flag always means: -i${SOURCES[1:]} becomes -ione.def -itwo.def, not one -i welded to the first path.

An attached substitution does not change any other substitution in the command. For a command with multiple outputs, -o $TARGET -Map=${TARGET}.map still passes every output after -o, then emits one -Map= argument per output. A compile or link has one output, so the same form in a flag names it: app.private.link_flags += ["-Wl,-Map=${TARGET}.map"] puts the map file next to the program. One substitution per argument: ${SOURCE} and ${TARGET} in the same token is an error.

A slice is the right tool when the input count is a property of the project rather than of the rule — adding a .def file above changes nothing in the build script. See examples/59_codegen_sources, which also shows why a glob needs project.add_configure_dependency() on the directory it read.

Use $SRCDIR to reference files in the source tree that aren't listed as sources. Since the build runs from the build directory, relative paths to source-tree files won't resolve correctly without this:

# Run a source-tree script that isn't a build dependency
env.Command(
    target="generated.h",
    source="schema.json",
    command="python $SRCDIR/tools/codegen.py $SOURCE -o $TARGET",
)

Each of the three kinds of path has its own base. sources= and depends= are relative to the directory of the script that declares the command; in a subdirectory reached through add_subdirectory that is the subdirectory, not the project root. target= is relative to the build directory of that same script — build/<subdir>/, where its programs and libraries also build, under the environment's build_prefix — so target=env.build_dir / "out.txt" and target="out.txt" name the same file. The command's own tokens are the third kind, and worth stating plainly: a relative string inside a command is looked for under the build directory, where the command runs, so "tools/gen.pl" will not be found. Write $SRCDIR/tools/gen.pl, pass a Path in the list form (project.current_dir / "tools/gen.pl", which pcons writes from wherever the command runs), or move the whole command with cwd= below. A build-directory path a tool takes as an argument, an output directory say, is the same: pass env.build_dir / "stage" as a Path.

To use a generated file as a source, pass the target. sources= names files in the source tree, so a generated file passed the way its target= was — sources=["gen/parser.c"] — would erroneously look in the source tree. Using the proper build path works fine: sources=[project.build_dir / "gen/parser.c"]. That will also correctly add the dependency. Passing the target is even better; no path to keep in sync:

parser = env.Command(
    target="gen/parser.c",
    source="grammar.y",
    command="bison -o $TARGET $SOURCE",
)
project.Program("app", env, sources=["src/main.c", parser])

A target with several outputs hands over all of them — a .c/.h pair, say. Just slice to get the one(s) you want: parser.output_nodes[0].

Pcons warns when a command token names a path under the build directory (-Wl,build/libfoo.dylib): the command runs in the build directory, so that path resolves to build/build/.... Pass a Path built on env.build_dir instead. Set PCONS_WARN_BUILD_DIR_PATHS=0 on the occasion the path really is right as written.

A file named when the build resolves. target= and source= also take a callable, and so does a token of a list-form command. pcons calls it when the edge resolves, after the edge's dependencies have, so it can read what they settled. That's how a command names its output after a program, whose file name is the toolchain's business: firmware here, firmware.exe on Windows.

firmware = project.Program("firmware", env, sources=["src/main.c"])
env.Command(
    target=lambda: f"{firmware.output_nodes[0].path.stem}.hex",
    source=[firmware],
    command=["objcopy", "-O", "ihex", "$SOURCE", "$TARGET"],
)

A callable returns what the script could have written in its place: files for target= and source=, and text, a Path or a node for a token, where a node becomes a dependency as it does above. What it reads must have resolved by then: a target in source= or depends= has, and so has one the source= callable returns. The edge needs no name; it's labelled by its output once it has one. env.PyBuilder()'s emitter= is the same idea, packaged with a builder.

Don't quote tokens yourself. pcons keeps a command as a list of tokens and quotes each one for the shell it is writing for, so command=f'"{tool}" $SOURCE' reaches the program with the quotes still attached and it reports that no such file exists. Write it bare; a token that must contain a space goes in the list form, which isn't split on whitespace. pcons raises on a token that starts with a quote — a trailing one is ordinary, since -DNAME="value" wants its quotes delivered. When the quotes really are meant, say so with Verbatim:

from pcons import Verbatim

env.Command(
    target="counts.txt",
    source="log.txt",
    command=["awk", Verbatim("'{print $1}'"), "$SOURCE", ">", "$TARGET"],
)

Running somewhere else: cwd=

Build tools run from the build directory, and pcons writes every path in a command relative to it. Some tools can't live with that — they open an input by a path relative to the source root, or write beside their inputs. cwd= moves the command, and moves its paths with it: $SOURCE, $TARGET and $SRCDIR all come out relative to the directory you named, so nothing else in the rule changes. A relative cwd is read from the script's directory, as sources= is.

# The tool finds its input at "data/items.txt" -- relative to the source root
env.Command(
    target=gen_dir / "items.c",
    source=[make_items],
    depends=["data/items.txt"],
    command="$SOURCE $TARGET",
    cwd=project.root_dir,
)

Paths stay relative wherever they can, so build.ninja remains as relocatable as it was; only a directory no relative path can reach forces an absolute one. Makefiles already write their source paths absolutely, so a moved command there is absolute throughout.

Don't write the cd into the command yourself. It looks equivalent but isn't: pcons wraps your command with steps of its own — post_build() commands, and the write_if_different stash below — that run in the build directory and name their files relative to it. A one-way cd strands them. cwd= changes back. See examples/60_command_cwd.

Extra dependencies with depends=: Files listed in depends= trigger a rebuild when they change, but don't appear in $SOURCE/$SOURCES. Use this for scripts, config files, or other build-time inputs:

# Rebuild when the codegen script or its config changes
env.Command(
    target="generated.h",
    source="schema.json",
    command="python $SRCDIR/tools/codegen.py $SOURCE -o $TARGET",
    depends=["tools/codegen.py", "tools/codegen.cfg"],
)

You can also add dependencies to any target after creation using target.depends():

app = project.Program("app", ["main.c"])
app.depends("app.ld")  # Relink when the linker script changes
app.depends(gen)       # Build the generator first; don't link it

What depends() means. If anything in your target needs something built first, whether the target's own output or one of its objects, say target.depends(thing). The thing can be a target or a file. Pcons builds it (if needed) before any step of your target, then each step of that builder then decides for itself whether a change to it means rerunning: a step that discovers its dependencies as it runs (like a compile, through its depfile, that records all included file paths) reruns only when what it discovered changes, so a generated header it never included doesn't recompile it, and neither does a linker script; a step that doesn't do any discovery (the link step, an env.Command without depfile=) reruns whenever the dependency changes. A target given to depends() also passes its public usage requirements on, as if it were link()ed, but it is not passed to the linker command line.

A command discovers its dependencies with depfile=. If the tool writes a make-style depfile, name it and ninja rebuilds against whatever the file lists. depfile=".d" means the tool writes <target>.d. A tool that names the file itself gets its path instead, written like target=:

env.Command(
    target="gen/schema.h",
    source="schema.json",
    command="python $SRCDIR/tools/codegen.py $SOURCE -o $TARGET",
    depfile=".d",  # codegen.py writes gen/schema.h.d
)
env.Command(
    target=artifact,
    command=["cargo", "build", ...],
    depfile=artifact.with_name("libfoo.d"),  # cargo's own name for it
)

Either way the command has one target, and ninja deletes the file once it has read it.

A step that discovers its dependencies can still miss one: a response file, a sanitizer ignore-list, anything the tool reads but never writes to its depfile. For those, say target.depends(thing, on_change=True) and every step reruns when it changes, whether or not the step's own record mentions it. The opposite, on_change=False, says the thing only has to exist first and never reruns a step by itself.

A file a command names in a flag is a dependency of that command without any depends() at all, when pcons knows the file: write_file() and configure_file() register what they write, and the build knows what it produces. So PathToken(prefix="-Wl,--version-script=", path=exports) in link_flags relinks when the export list changes. Include and library directories are never files, so they're unaffected.

You may pin a dependency to one step if you want, by building that step yourself: obj = env.cc.Object(...) then obj.depends(thing). For an ordinary compile-and-link target that doesn't buy you anything, because the compile already discovers what extra files it read.

The dependency edge is propagated forward: a library whose public headers are generated says lib.depends(gen) once, and every target that links lib compiles after the generator too. See examples/83_generated_public_headers. If you write a builder of your own, see Custom Builders for your half of the rule.

Use $$ for a literal dollar sign. pcons delivers it to the command verbatim: it is quoted and escaped so that neither ninja, nor make, nor the shell gets to interpret it. That is what tools that have their own use for a dollar need — the ELF dynamic linker, awk, sed:

# Set rpath to $ORIGIN for portable shared libraries
env.link.flags.append("-Wl,-rpath,$$ORIGIN")

# The program, not the shell, sees the dollar
env.Command(
    target="rev.txt",
    source="in.txt",
    command="stamper --keyword=$$Revision$$ --out=$TARGET $SOURCE",
)

So $$HOME is not a shell variable reference — it is the five characters $HOME. Build scripts are Python, so read environment variables there, at configure time, where the value is visible to pcons and recorded in the build files:

import os

env.Command(
    target="output.txt",
    source="input.txt",
    command=f"pack --home={os.environ['HOME']} $SOURCE $TARGET",
)

The command runs during the build phase, and Ninja tracks dependencies so the command only re-runs when sources change.

Multiple commands: Chain commands with shell operators:

# Run multiple steps with && (stops on first failure)
env.Command(
    target="output.txt",
    source="input.txt",
    command="step1 $SOURCE -o temp.txt && step2 temp.txt -o $TARGET",
)

Generators that rewrite everything: write_if_different=True

Ninja's restat skips downstream work when a command's output didn't actually change — but only if the generator leaves unchanged files alone, and most generators rewrite every output on every run. write_if_different=True fixes that without the generator's cooperation: pcons stashes the outputs, runs the command, and restores any output that came back byte-identical, timestamp included. It implies restat=True.

env.Command(
    target=[gen_dir / f"S_{name}.c" for name in names],
    source=[manifest],
    command=f"{python} $SRCDIR/tools/gen.py $SOURCE",
    write_if_different=True,  # one changed input != recompile everything
)

Without it, adding one entry to a 280-plugin manifest recompiles all 280. With it, only the new one. See examples/57_staged_generation.

The two halves of that stash have to run in the same directory, so a command that changes directory and doesn't change back fails the build with an explanation, rather than restoring nothing and exiting 0. Use cwd= (above) instead of a bare cd.

Passing only some sources: ${SOURCES[n]}

$SOURCE and $SOURCES contain the whole list of the target's sources. Sometimes a command already knows the paths of some of its sources and you don't want to pass them all to the command line. In this example below, organizer.py knows about gridfinity.py and that shouldn't be passed on the command line, but it should rerun when gridfinity.py changes, so just use [0] to slice it:

env.Command(
    target="organizer.stl",
    source=["organizer.py", "gridfinity.py"],  # both must trigger a rebuild
    command=[python, "${SOURCES[0]}", "--out", "."],  # only the first is run; presumably the command knows about gridfinity.py through other means
)

With plain $SOURCES, that command would be python organizer.py gridfinity.py --out ., so the shared module would be passed as an extra argument.

Note: pcons warns when you write the singular form and the command has more than one source. Use the plural $SOURCES when consuming them all is the intent — cat $SOURCES > $TARGET for example.

Tip

Instead of passing and slicing implicit sources like this, you can also set up a separate dependency: target.depends('gridfinity.py').

Environment variables for one command: env_vars=

A tool that reads its configuration from the environment — a signing server URL, a license key — needs that variable set for its own command and no other:

env.Command(
    target="firmware.signed",
    source="firmware.bin",
    command="sign-tool $SOURCE -o $TARGET",
    env_vars={"SIGNING_URL": "https://signer.example.com"},
)

The variables are written into the generated build file, in front of the one command they belong to: env NAME=VALUE on POSIX, a small pcons helper on Windows (which has no env). So they survive a direct ninja or make run, and no other command sees them. Setting os.environ in the build script would do neither — it reaches every command, and only while pcons itself runs. See examples/73_command_env.

Exporting Only Some Symbols

Some shared libs, plugins for instance, should export only certain symbols. -fvisibility=hidden can be insufficient in some cases. The .set_option("exported_symbols", [...]) method on a shared library or executable target allows users to specify a list of symbol names to be exported, in an OS-independent way: a symbol list on macOS, a version script on Linux, a .def file with MSVC and clang-cl.

plugin = project.SharedLibrary("myplugin", env, sources=["plugin.cpp"])
plugin.set_option("exported_symbols", ["OfxGetPlugin", "OfxGetNumberOfPlugins"])

The names are the C names; pcons adds the Darwin underscore. macOS and Linux accept * patterns ("Spark*"), MSVC doesn't support patterns so it will complain if passed one. The list is written under the target's build directory and the link depends on it, so changing the list relinks.

If you already have an OS-specific linker def or symbol list file, just pass it as a source to the builder: a .def in the target's sources list on MSVC and clang-cl (it becomes /DEF:), and a version script or symbol list as a PathToken in link_flags on Linux and macOS, which the link then depends on.

Python Functions as Build Steps: env.PyBuilder()

env.Command() runs a program. env.PyBuilder() turns a Python function you wrote in the build script into a builder, the way env.Program is a builder. Calling it makes one build edge, and calling it again makes another:

@env.PyBuilder()
def report(targets, sources, title):
    from pathlib import Path

    lines = [title]
    for name in sources:
        text = Path(name).read_text(encoding="utf-8")
        lines.append(f"{Path(name).name}: {len(text.split())}")
    Path(targets[0]).write_text("\n".join(lines) + "\n", encoding="utf-8")


first = report(
    target=project.build_dir / "report.txt",
    source=[src / "a.txt", src / "b.txt"],
    title="word counts",
)
second = report(
    target=project.build_dir / "report2.txt",
    source=[src / "c.txt", src / "d.txt"],
    title="word counts, second set",
)

project.Default(first, second)

report is a builder; first and second are the Targets its calls returned. The whole of examples/90_python_builder is that, plus a second environment.

Underneath, a PyBuilder edge is an ordinary command edge: it takes restat= and worker= the way env.Command() does, and pcons explain shows it the same way, as a (command) edge with its command line, sources, environment and call site.

The function does not run while the build is described. pcons writes its source once to a generated module under the environment's build directory, build/pybuilder/report.py, writes each call's arguments to a pickle beside it, named after the target it builds, build/pybuilder/report.txt.args.pkl, and each call emits an ordinary edge that runs the module. So the work happens when ninja decides it is needed, in parallel with every other edge, and not again until an input changes. It is a build step, not a configure step. Both files are written when pcons resolves the build, not by the call, so a script that only describes the build writes nothing. The runner that loads them is copied once per build directory, to build/pybuilder/pcons-runner/pcons-runner.py, a directory of its own, so build.ninja names no file inside the pcons installation.

The function is called as fn(targets, sources, **kwargs). Both path lists are spelled as the build tool sees them, so they open as written. Its return value is reserved and must be None.

What the function sees. targets, sources, and the call's own keywords, and nothing else. No context object, no implicit handle on the build script, the environment or the project: the closure ban and the script-global ban below are exactly what keeps a function from reaching past its own parameters. A file the function opens without naming it in source= is not a declared input of the edge.

When the keywords are read. When the build resolves, as the environment is: not at the call, and not at build time. The call still checks them at once, pickling them with a placeholder for anything resolve fills in, so a bad one is blamed on the line that passed it. Most keywords are plain data, pickled as written, dollar signs and all. Two kinds of value aren't:

  • A Target arrives as the list of paths of the files it builds, and a node, project.node("data.csv"), as its path. Both open as written from where the function runs, as sources do. The edge depends on them, so a change to one reruns the function.
  • A Subst("$cc.flags") arrives as env.subst_list() expands it in the edge's environment: ["-O2", "-Wall"], the flags the build settled on rather than the ones in force at the call.

Either one expands where it sits: in a list, a dictionary value, or an attribute of your own object. A set element or a dictionary key can't hold a list, so a Target or Subst there is refused. So is anything else of pcons's, because unpickling it at build time would import pcons: Subst("$cc.flags") works, env.cc.flags itself doesn't. The environment, the project and an alias are refused too, for the reason given below.

The decoration says how the function runs, the call says what to build. No option sits at both levels, except depends=: on the decoration it is a dependency of every edge the builder makes, on the call it is a dependency of that edge alone. Two edges that must otherwise run differently are two decorations.

on env.PyBuilder() on the call
python=, worker=, cwd=, launcher= target=, source=, name=
env_vars=, restat=, write_if_different= the function's own arguments, as plain keywords
depends=, emitter=, discovers= depends=

write_if_different=True is worth knowing here, because a Python function usually rewrites its output every run. See the env.Command() section above.

Discovered inputs. A function may read files its call never named: the entries of a manifest, a template's includes. Declare the builder discovers=True and the function returns {"inputs": [...]}, the files it read, and the build tool reruns the edge when one of them changes. A relative path is read from the build directory, where the function runs, so a path built from one of its sources is right as it is. pcons writes the depfile: that's why depfile= and deps_style= aren't options here. The edge may have one target, which the depfile is named after, and cwd= can't be combined with it.

@env.PyBuilder(discovers=True)
def bundle(targets, sources):
    from pathlib import Path

    manifest = Path(sources[0])
    listed = [manifest.parent / name for name in manifest.read_text().split()]
    Path(targets[0]).write_text("".join(path.read_text() for path in listed))
    return {"inputs": listed}


bundle(target="bundle.txt", source=["manifest.txt"])

A builder that names its own targets: emitter=. project.Program works out a program's file name from what it was given; a PyBuilder does the same with an emitter. pcons calls it when the edge resolves, after the edge's dependencies have, so it may read what they settled:

firmware = project.Program("firmware", env, sources=[src / "main.c"])


def named_after_the_program(targets, sources, env, **kwargs):
    program = sources[0].output_nodes[0].path
    return [*targets, f"{program.stem}.hex"], sources


@env.PyBuilder(emitter=named_after_the_program)
def to_hex(targets, sources):
    from pathlib import Path

    Path(targets[0]).write_bytes(Path(sources[0]).read_bytes()[:16].hex().encode())


dump = to_hex(source=[firmware])

It's called as emitter(targets, sources, env, **kwargs) and returns the pair (targets, sources) the edge really has. The lists are what the call passed, either possibly empty, so an emitter may add to them or replace them; the targets source= holds are still Targets, resolved by now. The keywords are the call's own, the ones the function gets, so an emitter that names a file after one declares that parameter and takes **kwargs for the rest. With an emitter the call may leave out target=, and needs no name=: the edge is labelled by its output, and its argument pickle is named after it too, build/pybuilder/firmware.hex.args.pkl. examples/92_python_builder_emitter is this, built end to end.

The emitter is an ordinary function of the build script, unlike the decorated one: it runs in this process while pcons resolves, so it may use anything around it. A target it reads has to be one the call passed in source=, or one the edge otherwise depends on; nothing else is resolved in time.

Discovered outputs. An emitter decides names from what the build description knows at resolve. An output whose name or count only a built file decides is out of its reach. Staged Generation still gets there, no new mechanism needed: a first call whose only declared target is a small manifest, and a second call, made from inside a project.when_generated() block once ninja has built that manifest and re-run pcons, whose targets come from what it says. examples/57_staged_generation is the worked example. It uses env.Command() for both calls, and a PyBuilder() call plays the same role there.

Edge names and labels. name= on the call is optional and does what it does on env.Command(): it makes the edge a named target, which get_target(), Default() and pcons build all answer to. Without one pcons labels the edge by its first target's path, so reports read the way the build file does, and two edges may wear one label without colliding:

one = report(target="out/report.txt", source=[src / "a.txt"], title="one")
two = report(target="tmp/report.txt", source=[src / "b.txt"], title="two")

A label is only there to be read (see Named and anonymous targets). Build an unnamed edge by its output path, or give it an alias with project.Alias().

Reserved parameter names. target, source, name and depends are refused as parameters of the function, because the call spends them on the edge. Rename them; the error says which ones and what the call does with them.

Three rules follow from the function travelling alone.

  1. It imports what it needs inside its own body. The generated module holds the function and nothing else, so a name this script imported does not exist there. pcons refuses a body that reads one, naming it, rather than letting the build fail later with NameError. The function runs with the sys.path its script had when it was decorated, so it imports what the script itself could, a module beside the script included. python= names another interpreter instead, and the function then runs with that interpreter's own path. Editing such a module does not re-run the edge on its own: it is a dependency of the function, not of one call, so it belongs on the decoration or on .depends(), both below, rather than repeated in every call's own depends=.
  2. It reads nothing from around it. A function that closes over a variable of an enclosing function is refused for the same reason. Take the value as a parameter and pass it at the call, where it travels in the pickle, so every value there must be picklable. Some that pickle are refused anyway: an environment, a project, an alias and a tool namespace all belong to the build description, which does not exist when the function runs. From an environment, read the values you want here and pass those, Subst("$cc.flags") rather than env.cc. A target or a node is fine: it arrives as its files' paths, as described above.
  3. The call returns the Target. first above is a target, like everything else a pcons builder returns. It goes to project.Default(), to project.Install(), or into another target's source=.

A lambda, a functools.partial, a method and a builtin are all refused: only a plain def written out in a build script has source to extract. So is a signature the build-time call could not satisfy, such as one whose first two parameters are keyword-only.

A dependency of the function. A module the body imports is a dependency of the function, not of one call: declare it once, on the decoration, and every edge the builder makes waits on it.

@env.PyBuilder(depends=[project.root_dir / "wordcount.py"])
def report(targets, sources, title):
    ...


report(target="report.txt", source=[src / "a.txt"], title="word counts")

The same list grows later, with .depends() on the builder env.PyBuilder() returns, and reaches an edge already made:

@env.PyBuilder()
def report(targets, sources, title):
    ...


made = report(target="report.txt", source=[src / "a.txt"], title="word counts")
report.depends(project.root_dir / "wordcount.py")

Both reach every edge the builder makes, whichever comes first in the script. The call's own depends= still exists, for a dependency of that one edge alone.

Rebuilds. The generated module, the pickle and the runner copy are inputs of the edge, and all three are written only when their bytes change. Regenerating with a pcons whose runner changed re-runs every PyBuilder edge. Edit the function body and every edge that reads it re-runs. Change one of a call's arguments and only that edge re-runs. Edit anything else in the build script, a comment or a line above the decoration, and pcons regenerates the build files but no edge re-runs, because nothing any of them depends on moved.

Several environments are served by a plain Python helper that decorates once per environment. A builder belongs to the environment that decorated it:

def make_report(environment):
    @environment.PyBuilder()
    def report(targets, sources, title):
        from pathlib import Path

        lines = [title]
        for name in sources:
            text = Path(name).read_text(encoding="utf-8")
            lines.append(f"{Path(name).name}: {len(text.split())}")
        Path(targets[0]).write_text("\n".join(lines) + "\n", encoding="utf-8")

    return report


host = project.Environment(name="host")
strict = project.Environment(name="strict")
strict.build_prefix = "strict"

report = make_report(host)
checked = make_report(strict)(
    target="report.txt", source=[src / "a.txt"], title="word counts, strict"
)

build_prefix is what keeps the two report.txt apart, and it gives each environment its own copy of the generated module, build/pybuilder/report.py and build/strict/pybuilder/report.py, with identical bytes. Naming the environments is what lets report@strict pick one of a pair of same-named targets later. See examples/75_multi_env for the multi-environment idiom itself.

The closure ban and this shape fit each other: the body sits inside make_report, where environment is in scope, so everything it needs comes through keywords of the call.

A pipeline. A builder's Target goes into another builder's source= like any other, which is how a chain gets its order:

text = fetch(target=project.build_dir / f"{name}.txt", url=url, field="feed.lipsum")
source = embed(target=project.build_dir / f"{name}.c", source=[text], symbol=name)
project.Default(project.Program(name, env, sources=[source]))

examples/91_python_builder_pipeline is that, with two builders each called twice: one downloads a JSON document and writes a field of it, one turns those bytes into a C program, and project.Program compiles and links the result. Two builders, four edges, two generated modules.

A warm interpreter. Starting Python costs more than a small function does. worker=PythonWorker() runs the edge in an interpreter that is already up:

from pcons.workers.python import PythonWorker

worker = PythonWorker()


@env.PyBuilder(worker=worker)
def report(targets, sources):
    ...


report(target="report.txt", source=[src / "a.txt"])

Post-Build Commands

Add commands that run after a target is built using target.post_build():

plugin = project.SharedLibrary("myplugin", env, sources=["plugin.cpp"])

# Add rpath for macOS plugin loading
plugin.post_build("install_name_tool -add_rpath @loader_path $out")

# Code sign the output
plugin.post_build("codesign --sign - $out")

Variable substitution in post_build:

Variable Description
$out The primary output file path
$in The input files (space-separated)

Commands run in the order they are added. The fluent API allows chaining:

plugin.post_build("cmd1 $out").post_build("cmd2 $out")

target.pre_build() is the mirror image, for commands that must run before the target's own command, with the same $out/$in substitutions.

Custom Builders

Create custom tools for specialized build steps:

from pcons.core.builder import CommandBuilder
from pcons.tools.tool import BaseTool


class ProtobufTool(BaseTool):
    def __init__(self) -> None:
        super().__init__("protoc")

    def default_vars(self) -> dict[str, object]:
        return {
            "cmd": "protoc",
            "protocmd": "$protoc.cmd --cpp_out=$$outdir $$in",
        }

    def builders(self) -> dict[str, object]:
        return {
            "Compile": CommandBuilder(
                "Compile",
                "protoc",
                "protocmd",
                src_suffixes=[".proto"],
                target_suffixes=[".pb.cc", ".pb.h"],
                single_source=True,
            ),
        }


# Use the tool
protoc_tool = ProtobufTool()
protoc_tool.setup(env)
env.protoc.Compile("message.pb.cc", "proto/message.proto")

What depends() means to the steps you create. A build script says target.depends(thing) and pcons builds the thing before any step of the target. How each step your builder contains then handles it is up to the step, so a builder has three rules to follow:

  1. Let each step discover its dependencies whenever the tool can report them: a depfile, or /showIncludes. Make the step with GenericCommandBuilder(command, depfile=".d", deps_style="gcc"), which tells pcons the step discovers, and have the command write the file at $TARGET.d. A step that discovers only waits for a dependency to exist, and reruns when what it discovered changes (ninja records the files and timestamp). A step that doesn't have discovery (i.e. no depfile) reruns whenever any dependency changes.
  2. If a step reads something it cannot discover at runtime (a response file, an options file) and its name is known up front, declare it on that step's node with node.depends().
  3. Don't touch a target's depends() list from inside a builder. It belongs to the build script.

examples/84_asset_pipeline is a custom three-step builder that walks through all three, with rebuild tests for each.


Qt Applications

Pcons has first-class Qt 6 support — a Qt Widgets application is a simple build script:

from pcons import Project, find_c_toolchain
from pcons.toolchains.qt import find_qt

project = Project("myapp")
env = project.Environment(toolchain=find_c_toolchain())
env.cxx.set_standard(17)

qt = find_qt(project, env, modules=["Widgets"])

app = project.QtProgram(
    "myapp",
    env,
    sources=["main.cpp", "mainwindow.cpp", "mainwindow.ui", "icons.qrc"],
    link=[qt.Widgets],
)

find_qt() locates Qt (pkg-config or qtpaths introspection — Linux distro packages, Homebrew, the official installer, Windows) and handles the platform quirks: macOS frameworks, MSVC's required flags, Windows debug library suffixes. QtProgram takes .ui and .qrc files directly in sources and finds Q_OBJECT classes automatically; the scan happens when pcons generates, not during the build, and common mistakes fail with actionable messages.

Also available: QtQmlModule (QML modules with QML_ELEMENT C++ types), QtResources (embed files from a Python list — no .qrc XML), QtTranslations (+ a ninja lupdate utility target), QtDeploy (ninja deploy via macdeployqt/windeployqt), and the low-level env.qt.Moc/Uic/Rcc builders.

See the Qt guide for the full story: how automoc works, the staleness guard, generated-file layout, platform notes, and current limitations. Examples 52_qt_widgets through 56_qt_deploy are working starting points, and the CMake porting guide maps each qt_* CMake command to its pcons equivalent.


Generators and IDE Integration

What pcons writes besides the build file itself: other generators, a dependency diagram, and the compile_commands.json that editors read.

compile_commands.json

Build generators (Ninja, Makefile, Xcode) automatically generate compile_commands.json alongside build files. A symlink is also created at the project root so tools find it automatically. No extra code is needed.

To disable generation entirely, or to keep everything inside the build directory (no project-root symlink), generate explicitly:

from pcons import Generator

Generator().generate(project, compile_commands=False)  # no compile_commands.json
Generator().generate(project, root_symlink=False)  # no root symlink

With multiple build configurations in one project root, the last generation to run owns the root symlink. On Windows, creating a symlink needs Developer Mode or admin rights; without them pcons skips the root link (-v says so). clangd still finds the database in the default build directory.

This enables source inspection and completion features in:

  • VS Code with clangd extension
  • CLion and other JetBrains IDEs
  • Vim/Neovim with coc-clangd
  • Emacs with eglot or lsp-mode

Alternative Generators

While Ninja is the default and recommended build executor, pcons also supports generating Makefiles for environments where Ninja isn't available.

Makefile generator

Generate a traditional Makefile instead of Ninja build files — no script changes needed:

pcons -G make          # generates build/Makefile (and builds with make)

Or pin it in the build script, e.g. for a project that always uses make:

from pcons import Generator

Generator("make").generate(project)  # creates build/Makefile

Then build with:

make -C build

The Makefile generator supports the same project structure as the Ninja generator, so you can switch between them without changing your build script.

Xcode project generator

Pcons can also generate Xcode projects, though the functionality is significantly limited compared to Ninja and Makefiles.

This generator needs the pbxproj package, which a plain install doesn't bring in; ask for it explicitly with pip install 'pcons[xcode]'. Without it, -G xcode says so and stops.

Some of these limitations:

  • Custom commands and generated sources. env.Command(), custom tools, and custom builders have no Xcode equivalent, and its shell-script phases can't consume a depfile, so generated sources never trigger correct rebuilds. That also rules out Qt's moc/uic, LaTeX, command launchers and persistent workers.
  • Variants. Xcode has its own Debug/Release configuration system, and pcons variants don't feed it.
  • Product paths. Xcode writes products into Release/ or Debug/, so built paths don't match what the rest of the graph reports.
  • Languages. C, C++, Objective-C/C++, Swift and assembly compile. Fortran, the wasm toolchains, Metal libraries and Windows resources don't: a project whose targets are all unsupported generates an empty project rather than an error.
  • No test target. Ninja and make emit a test phony; with Xcode, run pcons test instead.
  • ObjectLibrary, alias targets, and pre-compiled .o sources aren't representable in Xcode's target model.

Dependency Visualization

Generate dependency graphs:

from pcons.generators.mermaid import MermaidGenerator

# Generate Mermaid diagram
MermaidGenerator().generate(project)
# Creates build/deps.mmd

Or from the command line:

pcons generate --mermaid=deps.mmd    # To file, relative to the current directory
pcons generate --mermaid             # To stdout
pcons generate --graph=deps.dot      # DOT format

The graph shows what the script declares. Three kinds of detail come from a finished build instead, and --graph-detail asks for them: headers (the compiler's .d files), scan (a scanner's own edges, elided by default), and discovered (the dependencies a scanner found). See Seeing it in the graph.

pcons generate --graph=deps.dot --graph-detail=discovered

Command-line reference

See the complete CLI reference, on its own page: Command-line reference.


Adding custom pcons CLI commands: pcons run

A build script can declare commands of its own, reachable as pcons run <name>:

@project.cli_command()
@click.option("--baud", default=115200)
def flash(baud: int) -> None:
    """Flash the board."""
    ...

The callback remembers the script's project and targets, so it knows the build directory and every output path. It runs with the project already resolved, and builds nothing unless it declared a dependency with e.g. flash.depends(firmware), in which case pcons builds that first. Add-on modules can declare commands too, with pcons.cli_command().

For more detailed info, see: User Commands.


Watching for changes

--watch builds once and then rebuilds whenever anything in the source tree changes. It works with the default command and with pcons build, and takes the same targets and options as a normal build:

pcons --watch            # Build, then rebuild on every change
pcons --watch myapp      # Watch, building only 'myapp'
pcons build --watch -j8

Editing the build script counts as a change: ninja re-runs pcons to bring build.ninja up to date before building, so adding a source file or changing a flag takes effect without restarting the watch. A build that fails does not stop the watch — the next edit is usually the fix. Press Ctrl-C to stop.

The build directory is never watched (reacting to the build's own output would loop forever), nor are VCS directories, virtualenvs, tool caches, or editor scratch files. Anything ninja knows how to build is also left out, wherever it lands — so a command that generates a file next to its sources, or an in-source build (-B .), doesn't retrigger the build that wrote it.

One thing a watch reports that an ordinary build does not is a rebuild loop. If several builds in a row are triggered the instant the previous one finished, all by the same file, the watch stops and names it: that file is written by the build itself, so each build is asking for the next. Declare it as an output of the command that writes it, or send it to the build directory. (A build that did not converge is reported after every successful build, watched or not; see pcons build.)

Watching uses the platform's native filesystem notification (inotify, FSEvents, ReadDirectoryChangesW) through the watchfiles package. It installs with pcons on Linux, macOS and Windows, so --watch works out of the box — including with uvx pcons --watch. On any other platform pcons installs without it and --watch says so; ask for it explicitly with pip install 'pcons[watch]', which builds from source and needs a Rust toolchain.

For more on how watch mode works, see Persistent Workers


Build Variables

You can pass variables to your build script:

pcons PORT=ofx USE_CUDA=1 PREFIX=/usr/local

Access them in pcons-build.py:

from pathlib import Path

from pcons import get_var

port = get_var("PORT", "ofx")
use_cuda = get_var("USE_CUDA", False)
prefix = get_var("PREFIX", Path("/usr/local"))

Typed Variables

The default's type selects the conversion, so a variable never has to be parsed by hand:

use_cuda = get_var("USE_CUDA", False)  # bool
opt_level = get_var("OPT_LEVEL", 2)  # int
scale = get_var("SCALE", 1.0)  # float
port = get_var("PORT", "ofx")  # str
prefix = get_var("PREFIX", Path("/usr/local"))  # Path
ports = get_var("PORTS", ["ofx"])  # list: PORTS=ofx,ae gives ["ofx", "ae"]

Pass type= when there is no default. The result is None when the variable is unset, which is falsy, so it still reads well in a condition:

if get_var("BUILD_TESTS", type=bool):
    ...

A default and a type= together raise: the default already picks the conversion, so the pair is either redundant or a contradiction.

Booleans accept 1, on, yes, true, y and 0, off, no, false, n, case-insensitive. Any other value raises ConfigureError instead of silently reading as false. int and float raise the same way on a value they cannot parse.

A Path is taken verbatim, never resolved, so PREFIX=dist stays relative and you need to decide what it is relative to. An empty value for a Path raises an error.

The default itself is never parsed; it is returned as-is when the variable is unset. With no default and no type=, get_var returns the raw string or None.


Persistent Configuration Cache

Settings you choose on the command line persist per build directory, like CMake's CMakeCache.txt. Configure once, then run bare:

pcons generate PORT=ofx --variant=debug -G ninja   # choose settings
pcons                                               # reuses PORT, variant, generator

What persists: build variables, the variant, and the generator. They are stored in <build_dir>/pcons_cache.json and written only after a successful run.

Precedence, highest to lowest:

  1. This run's command line (PORT=x, --variant, -G)
  2. Environment: PORT=x pcons, VARIANT, GENERATOR, and the PCONS_VARS / PCONS_VARIANT / PCONS_GENERATOR forms
  3. Persisted cache from a prior run
  4. The default passed to get_var / get_variant

An environment value overrides the cache but is not written to it, so exporting one steers a run without changing what a later bare run reuses.

The cache is tied to $PCONS_BUILD_DIR, which pcons always sets (and -B overrides).

Inspect and reset:

pcons cache list      # show persisted vars, variant, generator
pcons cache show      # same, plus the cache file path and source dir
pcons cache path      # print the cache file path
pcons cache clear     # empty the cache
pcons generate --fresh PORT=y   # ignore the old cache, start clean

Change settings through these commands, not by editing pcons_cache.json. The file is not a regeneration input, so a hand-edit is not picked up automatically, and the self-regeneration command pins the values it was generated with, so a manual change would be overwritten on the next run anyway.

Two guards catch stale caches:

  • A variable that was persisted but the build script never reads is reported (pcons FEATRUE=on typo, or a setting you dropped).
  • A cache whose recorded source directory no longer matches (a copied or moved build dir) is ignored with a warning and rebuilt for the current tree.

There is no API to read or write the cache from a build script; it holds only the settings above. If you need structured configuration, write a Python config file and import it from pcons-build.py.


Feature Detection

Pcons provides a two-part configuration system for detecting compiler capabilities and generating config headers. The two parts have distinct roles:

  • ToolChecks — does the real work: compiles test programs to probe for flags, headers, types, functions, and macros. Stores results through Configure.
  • Configure — manages caching (persists results to build/pcons_config.json so subsequent runs are fast), accumulates #define entries, and generates config.h.

ToolChecks: Probing the Compiler

ToolChecks compiles small test programs with your actual compiler to detect what's available. It needs both a Configure (for caching) and an Environment (to know which compiler to run).

from pathlib import Path
from pcons.configure.config import Configure
from pcons.configure.checks import ToolChecks

config = Configure(build_dir=Path("build"))
env = project.Environment(toolchain="c")

# Create a checker for the C compiler
checks = ToolChecks(config, env, "cc")

# Check if a compiler flag is supported
if checks.check_flag("-Wall").success:
    env.cc.flags.append("-Wall")

if checks.check_flag("-std=c++20").success:
    env.cxx.flags.append("-std=c++20")

# Check if a header exists
if checks.check_header("sys/mman.h").success:
    env.cc.defines.append("HAVE_MMAN_H")

# Check if a type exists (optionally specifying which headers to include)
if checks.check_type("size_t", headers=["stddef.h"]).success:
    pass

# Get the size of a type (uses compile-time assertion, no need to run)
int_size = checks.check_type_size("int")  # Returns 4 on most systems
ptr_size = checks.check_type_size("void*")  # 8 on 64-bit, 4 on 32-bit

# Check if a function is available (compiles + links)
if checks.check_function(
    "pthread_create", headers=["pthread.h"], libs=["pthread"]
).success:
    env.link.libs.append("pthread")

# Read a predefined compiler macro
gcc_ver = checks.check_define("__GNUC__")  # e.g. "14"

# Read constants out of the project's own headers -- one preprocessor run
# for as many macros as you like
version = checks.check_defines(
    ["VERSION_NAME", "VERSION_MAJOR", "USE_DONGLES"],
    headers=["core/version.h"],
    include_dirs=[src_dir],
)

# Custom compile check with arbitrary source code
has_neon = checks.try_compile(
    "#include <arm_neon.h>\nint main() { float a[] = {1,1}; vld1q_f32_x2(a); return 0; }"
).success

All results are automatically cached through Configure. On the first run, each check compiles a test program; on subsequent runs, cached results are returned instantly:

result1 = checks.check_flag("-Wall")
assert result1.cached is False  # First run: compiled a test

result2 = checks.check_flag("-Wall")
assert result2.cached is True  # Second run: from cache

The cache key includes a signature of the compiler command and its current flags, so switching compilers — or retargeting the same compiler with a cross preset (--target=, -isysroot) — invalidates the relevant entries automatically. Checks probe the same compilation the build will do.

Checks compile the same way the build does: the tool's flags, defines, and include directories all apply, and are properly cached.

Reading Macros From Headers

check_define() and check_defines() accept headers=, include_dirs=, and defines=, so they read constants out of the project's own headers — version strings, feature flags, install paths — not just compiler builtins. Four outcomes are distinguishable:

in the header returned
(not defined) None
#define FOO ""
#define FOO 42 "42"
#define FOO "MyLib 2024" '"MyLib 2024"'

Quotes are kept, so a string literal is distinguishable from a number and from a defined-but-empty macro, and the value can go straight into a generated config header. Use the batch form when reading several from one header: configure time is dominated by process startup, and check_defines() answers them all in a single preprocessor run.

For a macro you know is a string, as_string=True gives you the string it denotes rather than its expansion text — adjacent literals concatenated, quotes removed, simple C escapes decoded:

# support/config.h:  #define DEFAULT_DIR "/Applications/" "MyApp 2024" "/config"
checks.check_define("DEFAULT_DIR", headers=["support/config.h"], as_string=True)
# -> "/Applications/MyApp 2024/config"

It raises if the macro isn't a string literal, since asking for a string back from #define N 42 is a mistake in the call rather than a value to guess at.

A probe that fails to preprocess at all — a missing header, a bad include path — is not cached, so it'll recheck on the next build.

Configure: Caching, Defines, and Config Headers

Configure serves as the shared state between checks and the config header generator. You can also use it directly to define values, find programs, or record features you know about without needing a compiler check:

config = Configure(build_dir=Path("build"))

# Find a program in PATH (result is cached, keyed by a PATH signature —
# a changed PATH re-searches instead of returning stale results)
ninja = config.find_program("ninja")
if ninja:
    print(f"Found ninja {ninja.version} at {ninja.path}")

# Manually define values for the config header
config.define("VERSION_MAJOR", 1)
config.define("VERSION_MINOR", 2)
config.define("VERSION_STRING", "1.2.0")
config.define("HAVE_FEATURE_A")

# Mark a feature as absent
config.undefine("MISSING_FEATURE")

# Save cache for next run
config.save()

Generating Config Headers

After running checks and defining values, generate a config.h with write_config_header(). This collects all the #define entries accumulated by both ToolChecks (via config.set()) and direct config.define() calls:

# Run checks — results are recorded in config
checks = ToolChecks(config, env, "cc")
if checks.check_header("sys/mman.h").success:
    config.define("HAVE_SYS_MMAN_H")

config.define("VERSION_MAJOR", 1)
config.define("VERSION_STRING", "1.2.0")
config.check_sizeof("int", env=env)  # Defines SIZEOF_INT
config.check_sizeof("void*", env=env)  # Defines SIZEOF_VOIDP
config.undefine("MISSING_FEATURE")

# Generate the header
config.write_config_header(
    Path("build/config.h"),
    guard="MY_CONFIG_H",
    include_platform=True,  # Add PCONS_OS_* and PCONS_ARCH_* defines
)

This generates:

#ifndef MY_CONFIG_H
#define MY_CONFIG_H

/* Platform detection */
#define PCONS_OS_MACOS 1
#define PCONS_ARCH_ARM64 1

/* Feature and header checks */
#define HAVE_SYS_MMAN_H 1

/* Type sizes */
#define SIZEOF_INT 4
#define SIZEOF_VOIDP 8

/* Custom definitions */
#define VERSION_MAJOR 1
#define VERSION_STRING "1.2.0"
/* #undef MISSING_FEATURE */

#endif /* MY_CONFIG_H */

Note: all configure checks — including check_sizeof() — are compile-time only: they ask the configured compiler (with the environment's flags, so cross presets apply) and never execute anything, which makes them correct under cross-compilation. Values like type sizes are computed with compile-time probes (int check[sizeof(T) == N ? 1 : -1]), the same technique autoconf, CMake, and Meson use. If a genuinely run-time answer is ever needed, provide it explicitly with config.define().

Template-Based Config Files with configure_file()

For projects that use template-based configuration (like CMake's configure_file()), pcons provides a configure_file() function that substitutes variables in a template and writes the result:

from pcons import configure_file

configure_file(
    "src/config.h.in",
    "build/config.h",
    {"VERSION": "1.2.3", "HAVE_ZLIB": "1"},
)

Two substitution styles are supported:

CMake style (default) — processes #cmakedefine directives and @VAR@ substitutions:

/* config.h.in */
#define VERSION "@VERSION@"
#cmakedefine01 HAVE_THREADS
#cmakedefine HAVE_ZLIB

With {"VERSION": "1.2.3", "HAVE_THREADS": "1"} this produces:

#define VERSION "1.2.3"
#define HAVE_THREADS 1
/* #undef HAVE_ZLIB */

At style (style="at") — simple @VAR@ replacement only:

configure_file("version.txt.in", "build/version.txt", {"VERSION": "1.2.3"}, style="at")

Options:

  • strict=True (default): raises KeyError if a @VAR@ has no matching key
  • strict=False: missing variables are replaced with empty string
  • Write-if-changed: the output file is only written if its content would change

This is especially useful when porting CMake projects to pcons, since the template files can often be used as-is.


Testing

Declaring tests, running them, discovery and fuzzing have their own page: Testing.


Packaging and Distribution

Pcons includes builders for shipping what you build: install trees, pkg-config files, archives (tar and zip), platform installers, and Python packages.

Installing Files

Copy files to destination directories. Relative destinations are placed under the install prefix, which defaults to <project-root>/dist and can be overridden with the PCONS_INSTALL_PREFIX variable:

pcons PCONS_INSTALL_PREFIX=/usr/local

Absolute (rooted) destinations are used as-is. Pass no_prefix=True to keep a relative destination inside the build directory instead (useful for staging). Such a destination is anchored like any other target path: it is read from the directory of the script that declared it, so an install in a subdirectory lands under that subdirectory's build directory and doesn't collide with a sibling's. Pass env= when the destination has to follow an environment's build_prefix too.

# Install library and headers (Install takes a list of sources)
project.Install("lib", [mylib])  # -> <prefix>/lib/
project.Install("include", header_nodes)  # -> <prefix>/include/

# Install with rename (InstallAs takes a single source, not a list)
project.InstallAs("bundle/plugin.ofx", plugin_lib)

# Install an entire directory tree (recursive copy)
# Copies src_dir/assets/* to <prefix>/assets/*
project.InstallDir(".", src_dir / "assets")

The install_dir() helper returns the conventional install subdirectory for a target type, following the conventions of the platform the environment's toolchain targets (bin for programs, lib for libraries — except DLLs, which go in bin next to the executables that load them):

from pcons import install_dir

exe = project.Program("hello", env, sources=["src/hello.c"])
project.Install(install_dir(env, "program"), [exe])  # -> <prefix>/bin/

Note

Install() accepts a list of sources and copies each to the destination directory. InstallAs() takes exactly one source and copies it to the specified path (with optional rename). If you need to install multiple files with renaming, use multiple InstallAs() calls.

InstallDir uses ninja's depfile mechanism for incremental rebuilds - if any file in the source directory changes, the copy is re-run. The depfile lists the directories as well as the files, so a file added or removed anywhere under the source tree re-runs the copy too, and a copy of a file the source no longer holds is removed.

On Windows, ninja reads a directory's modification time from the parent directory's NTFS index entry, which is refreshed when something opens and closes the directory, not when a file is written into it. A build started right after a file is added can therefore see the old time and copy the file one build late. The next build always sees it, and listing the directory in between (a file manager, an editor, dir) refreshes the entry at once. Edits and removals are unaffected: the file's own entry is refreshed when the writer closes it, and a file that is gone is gone.

Merging Source Trees into One Directory

OverlayDir merges the contents of several source trees into one directory. Use it to assemble a package source directory from a shared tree plus a per-application tree, or any other layered staging job:

stage = project.OverlayDir(
    env,
    "stage/app",
    sources=[shared_dir, app_dir],   # app_dir wins a shared path
    exclude=["*.orig", ".git"],
)

How it differs from InstallDir:

  • Contents, not the directory. InstallDir(dest, "shared") produces dest/shared/.... OverlayDir(env, dest, sources=["shared"]) puts shared's children directly in dest. That is what lets two trees with the same name merge instead of colliding.
  • Several sources. InstallDir takes exactly one.
  • The later source wins. When two trees hold the same relative path, the last one in sources is the one that lands. Argument order is the only rule: not modification time, not depth, not which tree looks more specific.
  • No install prefix. The destination is a staging directory under the environment's build directory, not an install. There is no no_prefix=True to pass.
  • It takes an environment. The destination is anchored under that environment's build directory.

Relative paths are kept, so src/com/example/Thing.java arrives at <dest>/src/com/example/Thing.java. Two trees can each contribute a different child of one shared directory. One target owns the destination and stages all of it with a single build edge, whose only output is a stamp. Individual staged files are not build targets: ninja <dest>/x/y.txt names nothing.

exclude patterns are globs matched against the path relative to each source root, never the destination and never an absolute path. A pattern with no / matches a name at any depth, one with a / is anchored at the root, and matching is case sensitive on every platform. An excluded directory takes its contents with it. Nothing is excluded by default. A pattern that matches nothing is not an error, since source trees differ in what they hold.

An excluded path is dropped from every source tree, so excluding the file that would have won a conflict leaves nothing at that path rather than falling back to the other tree - falling back would stage the very path you asked to drop.

Freshness. Which files win is decided when the edge runs, not when pcons runs. The edge reports every directory it walked and every file it copied in a depfile, so adding a file anywhere - including deep under src/com/example/ - restages on the next ninja, with no hand-run of pcons. Both halves of the depfile are needed: a directory's modification time changes when it gains or loses an entry, an edit in place changes no directory at all.

On Windows, a file added right before a build can be staged one build late; see the note under Installing Files.

A file another build edge generates into a source tree is staged by the same build that writes it. Order the two with depends():

generated = env.Command(target=str(shared_dir / "version.txt"), ...)
stage = project.OverlayDir(env, "stage", sources=[shared_dir, app_dir])
stage.depends(generated)

Removal. A file removed from a source tree loses its staged copy, along with any directory that leaves empty. Only the files this target staged are removed - the stamp records them - so anything else installed into the same destination is left alone.

See examples/78_overlay_dirs for a working two-tree overlay.

Generating pkg-config Files

To make a pcons-built library consumable by downstream CMake or pkg-config projects, generate a .pc file:

lib = project.StaticLibrary("mylib", env, sources=["src/mylib.c"])
lib.public.include_dirs.append("include")

pc = project.generate_pc_file(lib, version="1.0.0", description="My library")
project.Install("lib/pkgconfig", [pc])

The .pc file is derived from the target's public usage requirements (include_dirs, defines, link_libs, link_flags). Dependencies that were found via pkg-config automatically become Requires: entries rather than inlined flags.

Archive Builders (Tarfile and Zipfile)

Pcons provides built-in builders for creating tar and zip archives. These are useful for packaging releases, bundling documentation, or creating distributable artifacts.

Creating Tar Archives

Use project.Tarfile() to create tar archives with optional compression:

# Create a gzipped tarball (compression inferred from extension)
docs_archive = project.Tarfile(
    env,
    output="dist/docs.tar.gz",
    sources=["docs/", "README.md", "LICENSE"],
)

# Create a bz2-compressed tarball
backup = project.Tarfile(
    env,
    output="dist/backup.tar.bz2",
    sources=["data/"],
)

# Create an xz-compressed tarball
release = project.Tarfile(
    env,
    output="dist/release.tar.xz",
    sources=["bin/", "lib/"],
)

# Create an uncompressed tarball
raw = project.Tarfile(
    env,
    output="dist/raw.tar",
    sources=["files/"],
)

Compression options:

Extension Compression
.tar.gz, .tgz gzip
.tar.bz2 bz2
.tar.xz xz
.tar None (uncompressed)

You can also specify compression explicitly:

# Override inferred compression
archive = project.Tarfile(
    env,
    output="dist/archive.tar.gz",
    sources=["files/"],
    compression="bz2",  # Use bz2 despite .tar.gz extension
)

Creating Zip Archives

Use project.Zipfile() to create zip archives:

# Create a zip archive
release_zip = project.Zipfile(
    env,
    output="dist/release.zip",
    sources=["bin/myapp", "lib/libcore.so", "README.md"],
)

Common Options

Both archive builders support:

  • output: Path to the output archive file
  • sources: List of files, directories, or Targets to include
  • base_dir: Base directory for computing archive paths (default: ".")
  • name: Optional name to refer to the target by later

An archive target takes an optional name=, which makes it a named target get_target() and pcons build answer to. Without one pcons labels it by the archive it writes, dist/docs.tar.gz: build it by that path, or give it an alias.

# Custom base_dir to strip source paths
# Files in "build/release/bin/" become just "bin/" in the archive
archive = project.Tarfile(
    env,
    output="dist/package.tar.gz",
    sources=["build/release/bin/", "build/release/lib/"],
    base_dir="build/release",
)
project.Alias("package", archive)   # `ninja package`

Using Archives with Install

Since archive builders return Target objects, you can pass them to Install():

# Create archives
docs_tar = project.Tarfile(env, output="docs.tar.gz", sources=["docs/"])
release_zip = project.Zipfile(env, output="release.zip", sources=["bin/", "lib/"])

# Install archives to a packages directory
project.Install("packages/", [docs_tar, release_zip])

# Set archives as default build targets
project.Default(docs_tar, release_zip)

For a complete example, see examples/06_archive_install/pcons-build.py which creates source and binary tarballs with an install alias:

cd examples/06_archive_install
pcons generate
ninja -f build/build.ninja          # Build the program
ninja -f build/build.ninja install  # Create and install tarballs to ./Installers

Platform Installers

Pcons includes helpers for creating native installers on macOS and Windows. These live in pcons.contrib.installers and integrate into the build graph just like any other target — Ninja handles incremental rebuilds automatically.

macOS: .pkg Installers

Create standard macOS installer packages using pkgbuild and productbuild (requires Xcode Command Line Tools).

Simple component package (wraps pkgbuild):

from pcons.contrib.installers import macos

pkg = macos.create_component_pkg(
    project,
    env,
    identifier="com.example.myapp",
    version="1.0.0",
    sources=[app],
    install_location="/usr/local/bin",
)

Full-featured installer with welcome screen, license, and branding (wraps productbuild):

pkg = macos.create_pkg(
    project,
    env,
    name="MyApp",
    version="1.0.0",
    identifier="com.example.myapp",
    sources=[app],
    install_location="/usr/local/bin",
    min_os_version="10.13",
    welcome=Path("installer/welcome.rtf"),
    license=Path("LICENSE.rtf"),
    readme=Path("installer/readme.html"),
)

Key create_pkg() parameters:

Parameter Description
name Application/package name
version Package version string
identifier Bundle identifier (e.g., "com.example.myapp")
sources List of Targets, FileNodes, or paths to package
install_location Where files are installed (default: "/Applications")
min_os_version Minimum macOS version (e.g., "10.13")
welcome, readme, license, conclusion Installer UI pages (.txt, .rtf or .html)
background Background image for the installer
scripts_dir Directory with preinstall/postinstall scripts
sign_identity Code signing identity
depends Targets to build before the sources are staged, for a directory source that other targets fill

macOS: .dmg Disk Images

Create compressed disk images with hdiutil:

dmg = macos.create_dmg(
    project,
    env,
    name="MyApp",
    sources=[app],
    applications_symlink=True,  # Add /Applications symlink for drag-install
)
Parameter Description
name Application name (used as volume name)
sources Files to include in the disk image
volume_name Custom volume name (defaults to name)
format "UDZO" (zlib, default), "UDBZ" (bzip2), "ULFO" (lzfse), "UDRO" (uncompressed)
applications_symlink Add /Applications symlink for drag-and-drop install (default: True)

macOS: Signing and Notarization

Pcons includes helper functions which return commands you can use with env.Command() or run externally:

# Sign with Developer ID
sign_cmd = macos.sign_pkg(
    Path("build/MyApp-1.0.0.pkg"),
    identity="Developer ID Installer: My Company",
)

# Notarize for distribution
notarize_cmd = macos.notarize_cmd(
    Path("build/MyApp-1.0.0.pkg"),
    apple_id="dev@example.com",
    team_id="TEAM123",
    password_keychain_item="notarize-profile",
)

Windows: .msix Packages

Create modern Windows MSIX packages using MakeAppx.exe (requires Windows SDK):

from pcons.contrib.installers import windows

msix = windows.create_msix(
    project,
    env,
    name="MyApp",
    version="1.0.0.0",
    publisher="CN=Example Corp",
    sources=[app],
    display_name="My Application",
    description="A great application",
    executable="myapp.exe",
)
Parameter Description
name Package name (alphanumeric, no spaces)
version Version in X.Y.Z.W format
publisher Publisher identity (e.g., "CN=Example Corp")
sources Files to package
executable Main executable name (defaults to first source)
display_name User-visible name
description Package description
processor_architecture "x64", "x86", or "arm64" (default: "x64")
sign_cert Path to .pfx certificate for signing
sign_password_env Name of an environment variable holding the certificate password (not the password itself, so it's never baked into build.ninja)

Complete Platform-Conditional Example

from pcons.contrib import platform

installer_targets = []

if platform.is_macos():
    from pcons.contrib.installers import macos

    pkg = macos.create_pkg(
        project,
        env,
        name="MyApp",
        version="1.0.0",
        identifier="com.example.myapp",
        sources=[app],
        install_location="/usr/local/bin",
    )
    dmg = macos.create_dmg(project, env, name="MyApp", sources=[app])
    installer_targets.extend([pkg, dmg])

elif platform.is_windows():
    from pcons.contrib.installers import windows

    msix = windows.create_msix(
        project,
        env,
        name="MyApp",
        version="1.0.0.0",
        publisher="CN=Example Corp",
        sources=[app],
    )
    installer_targets.append(msix)

if installer_targets:
    project.Alias("installers", *installer_targets)

Build with:

pcons                # Build the application
ninja -C build installers  # Build installer packages

For a complete working example, see examples/19_installers/.

Building Python Packages (PEP 517 Backend)

Experimental

The pcons.pyproject backend is new and marked experimental: the [tool.pcons] keys and the PCONS_BUILD_WHEEL convention described below may still change based on feedback.

Pcons includes a PEP 517 build backend, so a Python package with native extensions can use pcons as its build system directly from pyproject.toml — pip install, uv sync, uv build, and editable installs all work with no extra tooling:

[build-system]
requires = ["pcons"]
build-backend = "pcons.pyproject"

[project]
name = "mypkg"
version = "1.0.0"
requires-python = ">=3.11"

[tool.pcons]
variant = "release"          # optional: pcons variant to build
install-target = "install"   # alias to build for wheels (default: "wheel")
# variables = { SOME_VAR = "value" }  # optional: extra pcons variables

How wheels are built

When a frontend (pip, uv, ...) asks for a wheel, the backend:

  1. Runs your pcons-build.py with PCONS_INSTALL_PREFIX pointing at a clean staging directory, and PCONS_BUILD_WHEEL=1 (see below).
  2. Runs ninja on the install-target alias, so your Install() targets copy their outputs into the staging directory.
  3. Packages everything in the staging directory, preserving its directory structure, into the wheel.

The staging directory is the site-packages image: the tree your install target creates there is exactly the tree users get in site-packages.

The PCONS_BUILD_WHEEL variable

This is where the build script comes in. A normal ninja install should follow the usual bin/lib conventions, but a wheel build needs a package-shaped layout (mypkg/__init__.py, mypkg/_ext.so, ...) at the staging root. The backend sets the variable PCONS_BUILD_WHEEL=1 during wheel builds so one build script can serve both:

from pcons import get_var, install_dir

if get_var("PCONS_BUILD_WHEEL", False):
    # Wheel build: the install prefix is the site-packages image.
    # Lay files out exactly as they should appear after installation.
    dest = "."
else:
    # Normal install: usual bin/lib conventions.
    dest = install_dir(env, "shared_library")

project.Install(dest, [my_extension])

If your build script ignores PCONS_BUILD_WHEEL and installs to lib/, the wheel will build and install, but won't have the correct dir layout. Always check the variable in any script that feeds the backend.

Editable installs

pip install -e . / uv sync (PEP 660) skips the staging step entirely: the backend builds the project and writes a wheel containing only a .pth file that puts the build directory on sys.path. Imports resolve directly to the compiled extensions in build/, so after editing C++ sources, re-running ninja is enough — no reinstall needed. (PCONS_BUILD_WHEEL is not set for editable builds.)

Metadata and sdists

The backend honors the PEP 621 [project] fields name, version, requires-python, and dependencies (emitted as Requires-Dist). Any other non-empty [project] field raises an error rather than being silently dropped from the wheel's metadata — remove the field or file an issue. name and version are required.

build_sdist ships the whole source tree (recursively, excluding build output, VCS data, and tool caches) plus the spec-required PKG-INFO.

Ninja is requested automatically as a build requirement in isolated builds when it isn't already on PATH (a NINJA environment variable override is respected).

For a complete working example — a nanobind C++ extension using Conan, exercising editable installs, wheel builds, and sdists via uv — see examples/50_pyproject/.


Dynamic and multi-stage builds

Pcons can have the build tool (e.g. ninja) re-run pcons when the description goes stale, re-run it mid-build to discover staged targets, and keep pcons-spawned worker processes alive while it executes. See also --watch mode, Watching for changes

Re-running Pcons Automatically

Generated build files carry a self-regeneration rule: Ninja's generator = 1 edge, and the equivalent makefile-remake rule for Make. Editing the build script, or anything it read while describing the build, re-runs pcons before anything is built, in the same ninja invocation. This prevents stale rebuilds.

Files that get registered automatically:

  • the build script itself;
  • every Python module imported from inside the project tree, so a description split across build-scripts/*.py is fully covered;
  • configure_file() templates.

Anything else, such as a data file your script reads directly, needs to be added explicitly:

project.add_configure_dependency(project.root_dir / "plugins.def")

The regen rule is omitted when the invocation can't be reconstructed (for example a build script executed in an unusual way). The build files are still written; they just won't re-run pcons on their own.

Staged Generation: Targets Discovered Mid-Build

Some projects can't know their target list until something has run: a definition language, an IDL, a schema, a plugin manifest — and often the program that reads it is built by the same build. Pcons supports this automatically when using ninja and GNU make: they will re-run pcons after the first-round targets are built, to define the rest.

pcons describes the graph and hands it to Ninja; it never creates targets while the build runs. The build system itself drives the staging:

manifest_path = project.build_dir / "gen/plugins-list.txt"

# Pass 1: only the part of the graph that produces the manifest.
lister = project.Program("list-plugins", env, sources=["src/list-plugins.c"])
manifest = env.Command(
    target=manifest_path,
    tool=lister,
    depends=["plugins.def"],
    command="$TOOL $SRCDIR/plugins.def $TARGET",
    write_if_different=True,
)


# Pass 2: runs only once the manifest exists.
@project.when_generated(manifest_path)
def _plugins(path):
    for name in path.read_text().split():
        make_plugin(name)

From a clean tree, one ninja compiles and runs list-plugins, notices that build.ninja depends on the manifest it just produced, re-runs pcons, reloads, and builds the discovered targets.

when_generated is a simple context manager areound the project.generated_input primitive:

path = project.generated_input(manifest_path)  # -> Path | None

Either form registers the path as a configure dependency, so the build system re-runs pcons as soon as it appears or changes. A staged input that no rule produces is flagged as an error — it could never appear, and the build would silently stay incomplete.

Pair it with write_if_different=True (see Custom Commands) or a re-run of the generator will invalidate everything downstream of every output it touched. A complete worked example is examples/57_staged_generation.

Ninja handles this natively; GNU make 4.x does too. GNU make 3.81 — still /usr/bin/make on macOS — compares makefile prerequisite timestamps at whole-second granularity and can miss a manifest written in the same second, so use ninja or a modern GNU make for staged builds.

Staged generation answers "what set of targets exists", and pays for the answer with a reconfigure pass. If the question is instead "what does this file's content imply" — which other artifacts this one depends on, what extra outputs it writes, what flags its command needs — that's a scanner, and it's resolved during the build with no reconfigure. The two compose: a staged pass can define scanned targets, and scanning a generated file needs no staging at all.

Persistent Workers

Some actions cost more to start than to run: loading a large library, opening a connection, or claiming a licence. A normal build pays that cost every time the command runs, so pcons supports workers: subprocesses that start once and persist through the build, serving those actions on request. They are particularly useful in watch mode, to speed up rebuilds. See the worker protocol for details on how workers are defined and used; here we just show a simple example where report.py generates a PDF report, and it uses a slow-to-import heavy_toolkit python module. If the build will produce many such reports, the persistent worker can just start up once and then subsequent commands will be much faster.

from pcons import PythonWorker

env.Command(
    target="report.pdf",
    source="report.py",
    command=[sys.executable, "$SOURCE", "--out", "$TARGET"],
    worker=PythonWorker(preload=["heavy_toolkit"], setup="mypkg.warmup:connect"),
)

PythonWorker holds a warmed-up interpreter: preload is a list of packages to import up front, and if setup="mypkg.warmup:connect" calls a function once. That interpreter will be forked to handle each command.

The first action that needs a worker starts it. Actions declaring the same worker share it, and it exits once the build goes quiet. Each action is served in isolation, so one cannot disturb the next.

A worker is only ever an optimization. Where none can be reached — plain ninja, CI, Windows — the command runs directly and the build is simply slower. Set PCONS_WORKER_DEBUG=1 to see how workers are used, or why one didn't get used.

A worker need not be Python: anything that can serve actions over a socket will do, including a thin client for a service already running. PythonWorker is just the one pcons ships. See the worker protocol to write your own, and see examples/64_persistent_worker for a runnable version.


Integrations

Pcons ships with first-class integrations for tools that aren't build systems themselves but commonly drive — or are driven by — one. Each integration lives under pcons.integrations.<name>.

Rez (VFX/animation package manager)

Rez is the dominant package manager in VFX/animation pipelines. It resolves combinations of tool and library versions and exposes them to a build via environment variables — notably REZ_USED_RESOLVE (the resolved package list) and REZ_<PKG>_ROOT (each package's install root). Rez is explicit that it is not a build system; it expects the package author to plug in their own tool. Pcons fits that gap.

Pcons is a build-time dependency for rez. Once a pcons-built package lives in a rez repo, consumers (rez-env mypackage -- ...) treat it like any other rez package — they don't need pcons installed. So ignore this section if all you do is consume packages.

For the people who do care, the docs below are split by role:

If you are… …jump to
Building an app or library with pcons that depends on rez packages (Maya, OpenFX, Boost, in-house libs, etc.) Consuming rez packages from a pcons project
Maintaining a rez package and want rez-build to drive pcons as the build engine — same way it drives cmake or make today Shipping a rez package built with pcons
Running the rez install at your facility (pipeline TD, build admin) and need to enable build_system = "pcons" for your maintainers Installing the pcons plugin into rez

A common case is the first two combined: a studio plugin's package.py is a rez package (it ships through the studio's pipeline) and its source code links against openfx, boost, etc. (themselves rez packages). The two halves are independent though, so we cover them separately.

Consuming rez packages from a pcons project

Audience: you have a pcons-build.py and your dependencies live in a rez repository. You want -I and -L flags for those deps to appear automatically. Your build is launched from inside rez-env.

The minimum needed in your pcons-build.py:

from pcons import Project
from pcons.integrations.rez import is_in_rez_resolve, rez_environment

project = Project("my_app")
env = project.Environment(toolchain="c")

if is_in_rez_resolve():
    rez_environment(env)  # auto-applies every resolved rez package

app = project.Program("my_app", env, sources=["src/main.cpp"])
project.Default(app)

Then run your build inside a rez-env shell that has the deps you need:

rez-env openfx-1.4 boost-1.82 -- uvx pcons
./build/my_app

Inside that shell, rez_environment(env) walks every resolved package and applies a convention-based scan of its install root:

  • <root>/include → added to include_dirs
  • <root>/lib → added to library_dirs
  • lib<name>.{a,dylib,so} (or <name>.lib on Windows) → added to libraries
  • <root>/lib/pkgconfig/*.pc (if present) → defers to PkgConfigFinder for richer metadata (most well-packaged C/C++ libs ship a .pc file)

The is_in_rez_resolve() guard means the same pcons-build.py works both inside and outside rez — it just degrades to a vanilla pcons build if no rez resolve is active.

The resolve is read from rez's Python API when it's importable in the build interpreter (the resolved context is authoritative); otherwise pcons parses the documented REZ_* environment variables. Either way no rez install is required for the common standalone case.

Picking individual packages

If you only want to apply a subset of the resolve (e.g. you have host-only build tools you don't want pulled into your link line), pass a packages=[...] whitelist:

rez_environment(env, packages=["openfx", "boost"])
Packages with a non-standard layout

The convention scan assumes <root>/include and <root>/lib. A package that ships its own .pc file is handled automatically (it wins over the scan). For one that does neither — multi-arch lib dirs, nested header trees, or several libraries — describe it explicitly with a RezLayout:

from pcons.integrations.rez import RezLayout, rez_environment

rez_environment(
    env,
    layouts={
        "mylib": RezLayout(
            include_dirs=("include", "include/detail"),
            library_dirs=("lib64",),
            libraries=("mylib_core", "mylib_extra"),
        ),
    },
)

A supplied layout is trusted verbatim and wins over both pkg-config and the convention scan; paths are relative to the package's install root. Leave libraries unset to keep lib<name> auto-detection. RezFinder takes the same layouts map: RezFinder({"mylib": RezLayout(...)}).

Per-package access through find_package()

For more control — for example, linking boost to one target but not another — register RezFinder with pcons's standard finder chain:

from pcons.integrations.rez import RezFinder

project.add_package_finder(RezFinder())
boost = project.find_package("boost")
app.link(boost)  # boost flags propagate as a usage requirement

This works exactly like find_package does for pkg-config or Conan; the only difference is the lookup source. Rez has no concept of "components" — passing components=[...] to find() emits a warning and is otherwise ignored.

Shipping a rez package built with pcons

Audience: you maintain a rez package.py and want rez-build to invoke pcons. End users (or your CI) will run rez-build -i (or rez-release) and expect pcons to handle configure → build → install transparently.

There are two ways to wire pcons into a rez package:

Option A — quickest: build_command in package.py

Works out of the box, no plugin install needed. Rez's generic custom build system runs whatever shell command you specify:

# package.py
name = "myplugin"
version = "1.0.0"
requires = ["openfx-1.4", "boost-1.82"]
build_command = "uvx pcons --build-dir {build}"

rez-build resolves the build environment, sets REZ_OPENFX_ROOT etc., and invokes your command. Your pcons-build.py then uses rez_environment(env) to pick up the deps. Good for one-off packages or when you can't modify the rez install.

Option B — native: build_system = "pcons"

Once pcons is installed in the same Python environment as rez (your build admin's responsibility — see Installing the pcons plugin into rez), it registers a rez build_system plugin via Python entry points. Rez then auto-detects pcons the same way it auto-detects cmake from a CMakeLists.txt. Declare it explicitly with build_system = "pcons", or rely on auto-detection from the presence of pcons-build.py:

# package.py
name = "myplugin"
version = "1.0.0"
build_system = "pcons"  # explicit; rez also auto-detects
requires = ["openfx-1.4", "boost-1.82"]


def commands():
    env.PATH.append("{root}/bin")

Then:

cd path/to/myplugin
rez-build -i               # configure → ninja → ninja install
rez-env myplugin -- myplugin

The pcons plugin runs three phases inside the rez-resolved build env:

  1. Configure — pcons generate (executes your pcons-build.py and writes build.ninja), with PCONS_BUILD_DIR, PCONS_INSTALL_DIR, and PCONS_GENERATOR set as env vars.
  2. Build — ninja -C <build_path> (or make).
  3. Install — only when rez-build -i (or rez-release) is used: ninja -C <build_path> install. For this to do anything, your pcons-build.py must declare an install alias — see below.
Install targets

Rez expects ninja install to copy build outputs to $PCONS_INSTALL_DIR. Pcons doesn't auto-create an install target; you wire one up in your pcons-build.py:

import os

# ... build app ...
project.Default(app)

install_dir = os.environ.get("PCONS_INSTALL_DIR")
if install_dir:
    install_target = project.Install(f"{install_dir}/bin", [app])
    project.Alias("install", install_target)  # rez-build invokes "install"
Build options exposed to rez-build

The pcons plugin adds two flags to rez-build:

rez-build -- --pcons-generator=ninja --pcons-jobs=8

Verify the plugin is registered with rez:

rez-build --help    # should list "pcons" under -b {make,pcons,...}
Choosing between Option A and Option B
Concern Option A (build_command) Option B (build_system = "pcons")
Setup Nothing extra one-time facility install of pcons into rez's venv (how)
Discoverability Per-package Site-wide (any pcons-built package "just works")
Install support Hand-rolled Standard rez-build -i
CI/CD friction Low Low once the plugin is installed once on the build host
Right when… You're trying it out, or the rez install isn't yours to modify The studio standardizes on it

A complete worked example — a hello_lib package built with rez's built-in cmake plugin and a hello_app package that uses pcons via build_system = "pcons" and depends on hello_lib through rez_environment — lives in examples/45_rez_integration/. That example exercises both halves of the integration in one place.

Installing the pcons plugin into rez

Audience: you're the pipeline TD or build admin running the rez install at your facility. Maintainers want build_system = "pcons" in their package.py files; you make that work.

Pcons registers a rez build_system plugin via Python entry points, so rez discovers it the same way it discovers cmake, make, and any other plugin: by reading importlib.metadata over its bundled Python environment. The one-time setup is to install pcons into that env.

Assuming rez was installed via its official installer into /opt/rez, install pcons with rez's wrapped Python interpreter:

/opt/rez/bin/rez/rez-python -m pip install pcons

rez-python is rez's bundled interpreter — installing into it puts pcons on the same sys.path rez uses for plugin discovery. Verify the plugin is registered:

rez-build --help    # should list "pcons" under -b {make,pcons,...}

After this, every package on every machine using this rez install can declare build_system = "pcons" and have it work without further setup. To upgrade pcons later, repeat the pip install (add -U).

Troubleshooting

If a maintainer runs rez-build on a package whose package.py declares build_system = "pcons" and pcons isn't installed in rez's bundled Python env, rez raises RezPluginError during argparse setup — before its own error formatter sees it — so they get a Python traceback ending in:

rez.exceptions.RezPluginError: Unrecognised build system plugin: 'pcons'

Fix: re-run the rez-python -m pip install pcons step above. The same traceback shape occurs for any unregistered or misspelled build_system value, including built-in ones like cmake — it's a rez quirk, not pcons-specific.


Cross-Compilation and Multi-Arch Builds

Building for something other than the machine you are on: a second architecture, a universal binary, or another platform entirely.

Multi-Architecture Builds

Pcons supports building for multiple CPU architectures, which is useful for: - macOS: Creating universal binaries that run on both Intel and Apple Silicon - Windows: Building for x64, x86, or ARM64

Target Architecture API

Use env.set_target_arch() to configure an environment for a specific architecture:

from pcons import Project

project = Project("mylib")

# Create environment for arm64
env_arm64 = project.Environment(toolchain="c")
env_arm64.set_target_arch("arm64")
env_arm64.build_dir = Path("build/arm64")

# Create environment for x86_64
env_x86_64 = project.Environment(toolchain="c")
env_x86_64.set_target_arch("x86_64")
env_x86_64.build_dir = Path("build/x86_64")

The architecture setting is orthogonal to build variants, so you can combine them:

env.set_variant("release")
env.set_target_arch("arm64")

Platform-Specific Behavior

macOS (GCC/LLVM): - Adds -arch <arch> flags to compiler and linker - Supported architectures: arm64, x86_64

Windows (MSVC): - Adds /MACHINE:<ARCH> to linker and librarian - For a non-native arch, selects the matching cross toolset: the bin/Host<host>/<arch> compiler binaries plus the VC and Windows SDK <arch> library directories (the dev shell's LIB covers only the host arch). Raises with install guidance if the cross toolset component isn't installed in Visual Studio. - Supported architectures: x64, x86, arm64, arm64ec - Aliases: amd64→x64, x86_64→x64, aarch64→arm64

Windows (Clang-CL): - Adds --target=<triple> to compilers (e.g., --target=aarch64-pc-windows-msvc) - Adds /MACHINE:<ARCH> to linker - For a non-native arch, also adds the VC and Windows SDK <arch> library directories (same requirement as MSVC: the cross build-tools component must be installed)

Linux (GCC/LLVM): - A bare arch name can't retarget the compiler on Linux, so set_target_arch() raises. Use a cross preset instead — e.g. linux_cross(triple="aarch64-linux-gnu") — or a dedicated cross toolchain (see Cross-Compilation Presets).

For example, on a Windows x64 machine this builds an ARM64 binary — no vcvars cross shell needed, just the ARM64 build-tools component:

env = project.Environment(toolchain="c")  # MSVC or clang-cl
env.set_target_arch("arm64")
app = project.Program("myapp", env, sources=["main.c"])

macOS Universal Binaries

To create a universal binary that runs on both Intel and Apple Silicon Macs, build for each architecture separately and combine with lipo:

from pathlib import Path
from pcons import Project
from pcons.util.macos import create_universal_binary

project = Project("mylib")

# Build for arm64
env_arm64 = project.Environment(toolchain="c")
env_arm64.set_target_arch("arm64")
env_arm64.set_variant("release")
lib_arm64 = project.StaticLibrary("mylib", env_arm64, sources=["lib.c"])
# Note: output goes to build/libmylib.a by default

# Build for x86_64 (use different build dir to avoid conflicts)
env_x86_64 = project.Environment(toolchain="c")
env_x86_64.set_target_arch("x86_64")
env_x86_64.set_variant("release")
env_x86_64.build_dir = Path("build/x86_64")
lib_x86_64 = project.StaticLibrary("mylib_x86", env_x86_64, sources=["lib.c"])

# Combine into universal binary
lib_universal = create_universal_binary(
    project,
    inputs=[lib_arm64, lib_x86_64],
    output="universal/libmylib.a",
)

project.Default(lib_universal)

The create_universal_binary() function: - Takes a list of architecture-specific binaries (as Targets, FileNodes, or paths) - Uses lipo -create to combine them - Returns a Target object representing the universal binary

This works for static libraries, dynamic libraries, and executables.

Cross-Compilation Presets

For cross-compiling to other platforms, pcons provides ready-made presets that configure sysroot, target triple, architecture flags, and SDK paths.

from pcons.toolchains.presets import android, ios, linux_cross, pyodide

# Android NDK. stl="c++_shared" is the default and the NDK's own: an app
# made of several shared libraries needs one shared C++ runtime, or each
# library gets its own and exceptions stop crossing between them. Use
# stl="c++_static" only for a single artifact.
env.apply_cross_preset(android(ndk="~/android-ndk", arch="arm64-v8a", api=35))

# iOS — works with both the Swift and LLVM (C/C++/Objective-C++) toolchains;
# the iPhoneOS SDK is resolved via xcrun unless sdk= is given
env.apply_cross_preset(ios(arch="arm64", min_version="15.0"))

# iOS Simulator
env.apply_cross_preset(ios(arch="x86_64"))

# WebAssembly presets apply to the *dedicated* wasm toolchains, which own
# output suffixes (.js/.wasm), shared-library rules, and the link driver —
# applying a wasm preset to a native toolchain raises. The presets add
# target-specific flags, e.g. pyodide() side-module flags:
env = project.Environment(toolchain="emscripten")
env.apply_cross_preset(pyodide("2026_0"))

# Generic Linux cross-compilation
env.apply_cross_preset(
    linux_cross(
        triple="aarch64-linux-gnu",
        sysroot="/opt/aarch64-sysroot",
    )
)

For a fully self-contained WASI build, prefer the dedicated WASI toolchain:

env = project.Environment(toolchain="wasi")
project.Program("hello", env, sources=["src/hello.c"])

Available Factory Functions

Factory Key Arguments Description
android(ndk, arch, *, api, stl) arch: arm64-v8a, armeabi-v7a, x86_64, x86; api: minimum API level, required; stl: c++_shared (default), c++_static, none Android NDK cross-compilation
ios(arch, min_version, sdk) arch: arm64 or x86_64 (simulator); min_version: deployment target iOS cross-compilation
emscripten(emsdk) emsdk: path to Emscripten SDK (optional if emcc in PATH) WebAssembly via Emscripten (requires toolchain="emscripten")
wasi_sdk(sdk_path) sdk_path: path to wasi-sdk (optional, auto-detected) WebAssembly via wasi-sdk (requires toolchain="wasi")
pyodide(abi, emsdk) abi: Pyodide ABI version (default "2026_0") Pyodide extension modules (requires toolchain="emscripten")
linux_cross(triple, sysroot) triple: GCC/Clang target triple; sysroot: target sysroot path Generic Linux cross-compilation

The WebAssembly presets apply only to their dedicated toolchains; applying one to a native toolchain raises.

Custom Cross-Compilation Presets

For targets not covered by the built-in factories, create a CrossPreset directly:

from pcons.toolchains.presets import CrossPreset

# Custom embedded target
preset = CrossPreset(
    name="riscv-bare",
    arch="riscv64",
    triple="riscv64-unknown-elf",
    sysroot="/opt/riscv/sysroot",
    extra_compile_flags=("-march=rv64gc", "-mabi=lp64d"),
    extra_link_flags=("-nostdlib",),
    tool_cmds={
        "cc": "/opt/riscv/bin/riscv64-unknown-elf-gcc",
        "cxx": "/opt/riscv/bin/riscv64-unknown-elf-g++",
        "link": "/opt/riscv/bin/riscv64-unknown-elf-g++",
        "ar": "/opt/riscv/bin/riscv64-unknown-elf-ar",
    },
)
env.apply_cross_preset(preset)

GCC selects targets by binary, not by flag, so a GCC cross preset must name the cross binaries in tool_cmds — including link and ar, or those steps silently run the host tools. Clang-family toolchains retarget via triple and can usually omit tool_cmds.

The CrossPreset fields:

Field Type Description
name str Human-readable name
arch str Target CPU name in the target ecosystem's vocabulary (metadata; the triple encodes the CPU)
triple str \| None Compiler target triple (used with --target on Clang)
sysroot str \| None Root of the target's headers/libraries (--sysroot, or -isysroot/SDK on Apple)
extra_compile_flags tuple[str, ...] Additional compile flags
extra_link_flags tuple[str, ...] Additional link flags
tool_cmds dict[str, str] Per-tool command overrides keyed by pcons tool name (cc, cxx, link, ar, ...)
env_vars dict[str, str] Deprecated alias for tool_cmds using CC/CXX/LD/AR vocabulary; tool_cmds wins on conflict

Add-on Modules

Pcons provides an add-on/plugin system for creating reusable modules that handle domain-specific tasks like plugin bundle creation, SDK configuration, or custom package discovery.

Module Search Paths

Pcons automatically discovers and loads modules from these locations (in priority order):

  1. PCONS_MODULES_PATH - Environment variable (colon/semicolon-separated paths)
  2. ~/.pcons/modules/ - User's global modules
  3. ./pcons_modules/ - Project-local modules

You can also specify additional paths via the CLI:

pcons --modules-path=/path/to/modules

Using Modules

Loaded modules are accessible via the pcons.modules namespace:

from pcons.modules import mymodule

# Or access all loaded modules
import pcons.modules

print(dir(pcons.modules))  # ['mymodule', ...]

Creating a Module

Create a Python file in one of the search paths. Modules follow a simple convention:

# ~/.pcons/modules/ofx.py
"""OFX plugin support for pcons."""

__pcons_module__ = {
    "name": "ofx",
    "version": "1.0.0",
    "description": "OFX plugin bundle creation",
}


def setup_env(env, platform=None):
    """Configure environment for OFX plugin building."""
    env.cxx.includes.extend(
        [
            "openfx/include",
            "openfx/Examples/include",
        ]
    )
    if platform and not platform.is_windows:
        env.cxx.flags.append("-fvisibility=hidden")


def create_bundle(project, env, plugin_name, sources, *, build_dir, version="1.0.0"):
    """Create OFX plugin bundle with proper structure."""
    from pcons.contrib import bundle

    bundle_name = f"{plugin_name}.ofx.bundle"
    bundle_dir = build_dir / bundle_name

    plugin = project.SharedLibrary(plugin_name, env)
    plugin.output_filename = f"{plugin_name}.ofx"
    plugin.add_sources(sources)

    # Install to bundle
    arch_dir = bundle_dir / "Contents" / bundle.get_arch_subdir("darwin", "arm64")
    project.Install(arch_dir, [plugin])

    return plugin


def register():
    """Optional: Register custom builders at load time."""
    # This is called automatically when the module loads
    pass

Then use it in your build script:

# pcons-build.py
from pcons import Project
from pcons.modules import ofx  # Auto-loaded!

project = Project("myplugin")
env = project.Environment(toolchain="c")

ofx.setup_env(env)
plugin = ofx.create_bundle(
    project,
    env,
    "myplugin",
    sources=["src/plugin.cpp"],
    build_dir=project.build_dir,
)

Contrib Modules

Pcons includes built-in helper modules in pcons.contrib:

from pcons.contrib import bundle, platform

# Bundle creation helpers. bundle_dir is under the build directory: the
# bundle is a build product that the installer helpers then package.
plist = bundle.generate_info_plist("MyPlugin", "1.0.0", bundle_type="BNDL")
bundle.create_macos_bundle(project, env, plugin, bundle_dir="build/MyPlugin.bundle")
bundle.create_flat_bundle(project, env, plugin, bundle_dir="build/MyPlugin")
# Resources under their own names, or renamed on the way in; PkgInfo from a file
bundle.create_macos_bundle(
    project, env, plugin, bundle_dir="build/MyPlugin.bundle",
    resources={"com.example.MyPlugin.png": "art/logo-white.png"},
    pkginfo=Path("plugin-pkg.info"),
)
arch_dir = bundle.get_arch_subdir("darwin", "arm64")  # "MacOS-arm-64"

# Platform utilities
if platform.is_macos():
    ext = platform.get_shared_lib_extension()  # ".dylib"
    name = platform.format_shared_lib_name("foo")  # "libfoo.dylib"

Module API Reference

Function/Attribute Description
__pcons_module__ Optional dict with module metadata (name, version, description)
register() Optional function called at load time to register builders, and CLI commands
setup_env(env, ...) Convention: Configure an environment for the module's domain
pcons.modules Function Description
load_modules(extra_paths) Load modules from search paths
get_module(name) Get a loaded module by name
list_modules() List names of all loaded modules
get_search_paths() Get the module search paths
clear_modules() Clear all loaded modules (for testing)
pcons.contrib.bundle Function Description
generate_info_plist(name, version, ...) Generate macOS Info.plist content
create_macos_bundle(...) Create macOS .bundle structure
create_flat_bundle(...) Create flat directory bundle
get_arch_subdir(platform, arch) Get architecture subdirectory name
pcons.contrib.latex Function Description
find_latex_toolchain() Find and configure a LaTeX toolchain (requires latexmk in PATH)
pcons.contrib.platform Function Description
is_macos(), is_linux(), is_windows() Platform checks
get_platform_name() Get platform name ("darwin", "linux", "win32")
get_arch() Get current architecture ("x86_64", "arm64", etc.)
get_shared_lib_extension() Get shared lib extension (".dylib", ".so", ".dll")
format_shared_lib_name(name) Format as shared lib filename

Troubleshooting

No toolchain found

Error: RuntimeError: No C/C++ toolchain found

Solution: Install a compiler:

  • macOS: xcode-select --install
  • Ubuntu/Debian: sudo apt install build-essential
  • Fedora: sudo dnf install gcc gcc-c++
  • Windows: Install Visual Studio with C++ workload, or use msvcup for a lightweight install

Ninja not found

Error: ninja not found in PATH

Solution: Install Ninja:

  • macOS: brew install ninja
  • Ubuntu/Debian: sudo apt install ninja-build
  • pip: pip install ninja

Missing sources

Error: MissingSourceError: File not found: src/missing.cpp

Solution: Check that all source files exist and paths are correct.

Dependency cycles

Error: DependencyCycleError: Cycle detected: A -> B -> A

Solution: Refactor to break the cycle. Two libraries shouldn't depend on each other.


Further Reading