Skip to content

Pcons User Guide v0.20.1.dev1 (5ccfe30, 2026-06-18)

Pcons is a Python-based build system that generates Ninja build files for C/C++ projects. It combines some of the best ideas from SCons and CMake: Python as the configuration language, environments with 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.
  • 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):

#!/usr/bin/env python3
from pcons import Project, find_c_toolchain, Generator

# 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=find_c_toolchain())

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

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

# Resolve dependencies and generate build files
Generator().generate(project, "build")

3. Generate and build:

# Using uvx (recommended)
uvx pcons

# Or if pcons is installed
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 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 init               # Create a template pcons-build.py

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. Use find_c_toolchain() for C/C++, or the specialized finders listed below.

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

Default C/C++ search order:

  • Windows: clang-cl → msvc → llvm → gcc
  • Linux / macOS: llvm → gcc
from pcons import find_c_toolchain

toolchain = find_c_toolchain()                       # auto-detect
toolchain = find_c_toolchain(prefer=["gcc", "llvm"]) # prefer GCC
env = project.Environment(toolchain=toolchain)

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

from pcons import find_fortran_toolchain

env = project.Environment(toolchain=find_fortran_toolchain())
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:

from pcons import find_c_toolchain, find_fortran_toolchain

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

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

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:

from pcons import find_c_toolchain, find_cuda_toolchain

env = project.Environment(toolchain=find_c_toolchain())
env.add_toolchain(find_cuda_toolchain())

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")

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.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.Msix() Installer Windows Create a Windows MSIX package
project.ObjectLibrary() Object All Create an object library target (compiles but doesn't link)
project.Pkg() Installer macOS Create a macOS product archive (.pkg) installer
project.Program() Program All Create a program (executable) target
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",
)

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 three phases:

  1. Configure - Set up toolchains, environments, and build options
  2. Describe - Create targets and define their sources/dependencies
  3. Generate - Resolve dependencies and write build files

Your script must call a generator at the end:

# ... define targets ...

# OPTIONAL: Resolve all dependencies (computes effective requirements)
# Generators will resolve the project if it's not already resolved.
project.resolve()

# REQUIRED: Generate build files (Ninja is the default generator, but Makefile and Xcode generators are also included)
Generator().generate(project, build_dir)

The pcons CLI executes your script but does NOT automatically call resolve/generate - your script controls when and how this happens. This gives you flexibility for conditional generation or multiple generators.

Project

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

from pcons import Project

# Create a project
project = Project("myproject", build_dir="build")

# Optionally specify the root directory
project = Project(
    "myproject",
    root_dir=Path(__file__).parent,
    build_dir="build"
)

The project provides factory methods for creating targets:

  • 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

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 toolchain
env = project.Environment(toolchain=toolchain)

# 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")

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

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:

# 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

If you accidentally include the build directory name in a relative path (e.g., "build/dist"), pcons will warn you but keep the path as-is, in case you intentionally want a build/ subdirectory inside the build directory.

Toolchain

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

from pcons import find_c_toolchain

# Auto-detect the best available toolchain
# Uses platform-appropriate defaults:
#   Windows: clang-cl, msvc, llvm, gcc
#   Unix/Mac: llvm, gcc
toolchain = find_c_toolchain()

# Or specify a preference order
toolchain = find_c_toolchain(prefer=["gcc", "llvm"])

Available toolchains: - 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

Targets

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.private.link_libs.append(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() None Template libraries

Nodes

Nodes represent files in the dependency graph. Use project.node() to get or create a node:

# Create or get a node for a file
src_node = project.node("src/main.cpp")

# Nodes track:
# - Path to the file
# - Builder that creates it (if any)
# - Dependencies

When to use project.node() vs raw paths:

Most pcons APIs accept raw paths (strings or Path objects) and convert them to nodes internally. You only need project.node() when:

# Usually NOT needed - these are equivalent:
project.Install("dist", ["file.txt"])           # Path string - works fine
project.Install("dist", [Path("file.txt")])     # Path object - works fine
project.Install("dist", [project.node("file.txt")])  # Explicit node - also works

# Needed when you want to add explicit dependencies to a source file:
header = project.node("generated.h")
header.depends([generator_target])  # Now generated.h depends on generator
app.add_sources(["main.cpp"])       # main.cpp will rebuild when generated.h changes

Builders

Builders define how to create output files from inputs. They're 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

Default and Alias Targets

Default targets are built when you run ninja with no arguments:

# Set default targets - these build when you run just "ninja"
project.Default(app)
project.Default(lib, app)  # Can specify multiple

If you don't call project.Default(), all programs and libraries (static and shared) in the project are built by default. This is usually what you want for simple projects. Use Default() when you want to build only a subset by default — for example, to exclude test programs or optional tools from the default build.

ninja all (or make all) builds every target in the project, including custom commands, installers, and archives.

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
project.Alias("test", test_runner)

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

Aliases are Ninja phony targets - they don't produce files but depend on other targets. Target names (like "myapp" in project.Program("myapp", env)) are also usable with Ninja:

ninja myapp      # Build just the myapp target
ninja libfoo     # Build just libfoo
ninja install    # Build the install alias

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:

#!/usr/bin/env python3
from pcons import Project, find_c_toolchain, Generator

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

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

# Generate
project.Default(hello)
Generator().generate(project, "build")

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:

#!/usr/bin/env python3
from pathlib import Path
from pcons import Project, find_c_toolchain, Generator

# Directories
src_dir = Path(__file__).parent / "src"
include_dir = Path(__file__).parent / "include"

# Setup
toolchain = find_c_toolchain()
project = Project("calculator", build_dir="build")
env = project.Environment(toolchain=toolchain)

# Create program with multiple sources
calculator = project.Program("calculator", env)
calculator.add_sources([
    src_dir / "main.c",
    src_dir / "math_ops.c",
])

# Add include directory (private - only for building this target)
calculator.private.include_dirs.append(include_dir)
calculator.private.compile_flags.extend(["-Wall", "-Wextra"])

# Generate
project.Default(calculator)
Generator().generate(project, "build")

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:

#!/usr/bin/env python3
from pathlib import Path
from pcons import Project, find_c_toolchain, Generator

src_dir = Path(__file__).parent / "src"
include_dir = Path(__file__).parent / "include"

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

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

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

# Public link libs (e.g., math library on Linux).
# Use link_libs for -l libraries (placed after objects on the link line).
# Use link_flags for other linker flags (placed before objects).
libmath.public.link_libs.append("m")

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

project.Default(app)
Generator().generate(project, "build")

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

Deprecated target.link()

Earlier versions used app.link(libmath). That method is deprecated; it is equivalent to app.public.link_libs.append(libmath). Prefer appending to public.link_libs or private.link_libs directly, which lets you control propagation.

Shared/Dynamic Library

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

pcons-build.py:

#!/usr/bin/env python3
from pathlib import Path
from pcons import Project, find_c_toolchain, Generator

src_dir = Path(__file__).parent / "src"
include_dir = Path(__file__).parent / "include"

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

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

# Optional: customize output name (overrides platform defaults)
libplugin.output_name = "myplugin.so"  # Override default libplugin.so

# Output naming defaults (can be overridden with output_name):
#   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_dir / "main.c"])
app.private.link_libs.append(libplugin)

project.Default(app, libplugin)
Generator().generate(project, "build")

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:

#!/usr/bin/env python3
from pathlib import Path
from pcons import Project, find_c_toolchain, Generator

project_dir = Path(__file__).parent
src_dir = project_dir / "src"
include_dir = project_dir / "include"
build_dir = project_dir / "build"

toolchain = find_c_toolchain()
project = Project("simulator", root_dir=project_dir, build_dir=build_dir)
env = project.Environment(toolchain=toolchain)

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

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

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

# Set defaults and generate
project.Default(simulator)

# Generate build files (also auto-generates compile_commands.json)
Generator().generate(project)

Debug and Release Variants

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

pcons-build.py:

#!/usr/bin/env python3
from pathlib import Path
from pcons import Project, find_c_toolchain, Generator, get_variant

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

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

# Apply variant settings
# debug: -O0 -g
# release: -O2 -DNDEBUG
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)
Generator().generate(project, build_dir)

print(f"Variant: {variant}")
print(f"Build dir: {build_dir}")

Usage:

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

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

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 + warnings-as-errors)
env.apply_preset("warnings")

# 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 -Werror /W4 /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")

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, find_c_toolchain, Generator

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

# 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)

# Use as a dependency (public requirements auto-propagate)
app = project.Program("myapp", env, sources=["main.cpp"])
app.private.link_libs.append(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")

Results are cached: calling find_package("zlib") twice returns the same target.

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, append it to public.link_libs to wire up transitive dependencies — don't copy public requirements manually:

openssl = project.find_package("openssl")

httplib = # ... see above
httplib.public.link_libs.append(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.private.link_libs.append(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:

#!/usr/bin/env python3
from pathlib import Path
from pcons import Project, find_c_toolchain, Generator, get_variant
from pcons.configure.config import Configure
from pcons.packages.finders import ConanFinder

project_dir = Path(__file__).parent
build_dir = project_dir / "build"
variant = get_variant("release")

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

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

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

# Sync Conan profile with toolchain settings.
# cppstd can be set explicitly, or inferred from env.cxx.flags.
conan.sync_profile(toolchain, env=env, build_type=variant.capitalize())

# 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)
Generator().generate(project, build_dir)

sync_profile() Reference

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

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

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().

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

Build Commands

pcons generate

Generate Ninja build files without building:

pcons generate                     # Generate build.ninja
pcons generate --variant=debug     # Generate for debug build
pcons generate CC=clang CXX=clang++  # Pass variables

pcons build

Build targets using Ninja:

pcons build              # Build all default targets
pcons build myapp        # Build specific target
pcons build -j8          # Use 8 parallel jobs
pcons build --verbose    # Show commands being run

pcons (default)

Running pcons without a subcommand does both generate and build:

pcons                    # Generate + Build
pcons --variant=debug    # Generate + Build with variant
pcons FOO=bar            # Pass variables

pcons clean

Clean build artifacts:

pcons clean        # Run ninja -t clean
pcons clean --all  # Remove entire build directory

Command-Line Options

Option Description
--variant=NAME or -v NAME Set build variant (debug, release)
-B DIR or --build-dir=DIR Set build directory (default: build)
-C or --reconfigure Force re-run configuration
-j N or --jobs=N Number of parallel build jobs
--verbose Show verbose output
--debug Show debug output
KEY=value Pass build variables

Build Variables

Pass variables to your build script:

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

Access them in pcons-build.py:

from pcons import get_var

port = get_var('PORT', default='ofx')
use_cuda = get_var('USE_CUDA', default='0') == '1'
prefix = get_var('PREFIX', default='/usr/local')

Testing

Pcons keeps the build-system / test-runner split clean: build scripts declare tests via project.Test(...), the configure step writes a JSON manifest (<build_dir>/tests.json), and a separate runner — pcons test (or ninja test) — executes them. This is the same model CMake uses with CTest, and it keeps pcons itself out of the business of running things at build time.

Declaring Tests

test_prog = project.Program("math_test", env,
                            sources=["src/math.c", "src/test_math.c"])

# Most basic: run the program; pass = exit 0.
project.Test("math.add", test_prog, args=["add"], labels=["unit", "fast"])
project.Test("math.mul", test_prog, args=["mul"], labels=["unit", "fast"])

# should_fail=True inverts the exit code (XFAIL-style assertions).
project.Test("math.expected_failure", test_prog, args=["bad-input"],
             should_fail=True, labels=["xfail"])

# disabled=True keeps the test in the manifest but always skips it.
project.Test("math.slow", test_prog, args=["heavy"],
             labels=["slow"], disabled=True, timeout=60)

The full set of fields (all keyword-only):

Field Type Purpose
args Sequence[str] Arguments passed after the program.
cwd Path \| str \| None Working directory; defaults to the build dir.
env dict[str, str] \| None Extra environment variables for the test process.
labels Sequence[str] Tags for filtering (unit, integration, slow, fuzz, ...).
timeout float \| None Seconds before the runner kills the test.
should_fail bool If True, a non-zero exit code is a pass.
serial bool Don't run in parallel with other tests.
disabled bool Record the test but always skip.
data Sequence[Path \| str] Runtime data files (informational in v1).
depends_on Sequence[str] Names of tests that must pass before this one runs.
discover "gtest" \| "doctest" \| "catch2" \| None Expand into one entry per test case in the binary.

program can be a Target (the typical case — depending on it ensures ninja test-build compiles it first), or a Path / str to an existing script or external binary.

Fixtures and Test Ordering: depends_on

depends_on lets one test gate another. If a dependency fails (or times out, errors, or skips because of its own failed dep), all dependent tests are reported as skipped — no point running them.

# Fixture-style: start a server, run tests against it, stop it.
start = project.Test("server.start", start_script, labels=["fixture"])
project.Test("api.list_users", api_test, args=["list-users"],
             depends_on=["server.start"], labels=["api"])
project.Test("api.create_user", api_test, args=["create-user"],
             depends_on=["server.start"], labels=["api"])
project.Test("server.stop", stop_script, depends_on=["server.start"],
             labels=["fixture"])

When you filter with -L / -R, deps of selected tests are auto-included so fixtures keep working:

pcons test -L api    # still runs server.start (and server.stop)

Test-Case Discovery: discover

For binaries built against GoogleTest, doctest, or Catch2, listing every test case in the Python build script is tedious. Set discover= and the runner queries the binary at run time, expanding your single project.Test() into one entry per test case:

unit_tests = project.Program("unit_tests", env, sources=[...])
project.Test("unit", unit_tests, discover="gtest", labels=["unit"])

Run-time output:

Test project: myproject (84 tests)
      Start  1: unit.MathSuite.Add
 1/84 Test #1: unit.MathSuite.Add ..................... Passed  0.01s
      Start  2: unit.MathSuite.Subtract
 2/84 Test #2: unit.MathSuite.Subtract ................ Passed  0.01s
 ...

Each case becomes a separate test that:

  • Inherits the parent's labels, timeout, env, cwd, etc.
  • Runs the binary with the framework's single-case filter (--gtest_filter=..., --test-case=..., or a positional name).
  • Appears in --list, JUnit output, and label/regex filters.

Protocols supported:

discover= Listing flag Invocation
"gtest" --gtest_list_tests <bin> --gtest_filter=Suite.Case
"doctest" --list-test-cases --no-version <bin> --test-case="<name>"
"catch2" --list-test-names-only <bin> "<name>"

If discovery fails (binary missing, framework not cooperative), the runner falls back to running the parent as a single test and prints a warning. References to the discovered parent in another test's depends_on are rewritten to wait on every child.

Tweaking Tests After Creation: set_test_property

Sometimes a property depends on context that isn't known at the project.Test() call site — a slow test that needs a bigger timeout under a particular toolchain, a label applied to every test in a generated batch. set_test_property() / set_test_properties() update an unresolved test, mirroring CMake's set_tests_properties():

from pcons import set_test_property, set_test_properties

t = project.Test("slow_one", prog)
set_test_property(t, "timeout", 600)

# Bulk: one call, many tests, many properties.
slow_tests = [t1, t2, t3]
set_test_properties(*slow_tests, timeout=600, labels=["slow"])

Valid property names are the same kwargs accepted by project.Test(): args, cwd, env, labels, timeout, should_fail, serial, disabled, data, depends_on, discover, program.

Running Tests

ninja test                    # build, then run, all tests
pcons test                    # same effect, runs the test runner directly
pcons test -L unit            # only run "unit"-labeled tests
pcons test -L unit -LE slow   # unit, excluding slow
pcons test -R '^math\.'       # only run tests whose name matches the regex
pcons test --list             # show what would run, without running
pcons test -j 1               # serial mode (default: CPU count)
pcons test --junit out.xml    # emit JUnit XML for CI
pcons test --stop-on-fail     # stop after the first failure
pcons test -V                 # verbose: show stderr from failed tests

The runner picks up the manifest from the current build directory (or walks upward to find one). Exit code is 0 if every selected test passed, non-zero otherwise — which is why ninja test "just works" for CI failure detection.

The Test Manifest

<build_dir>/tests.json is plain JSON, versioned, and stable:

{
  "version": 1,
  "project": "myproject",
  "build_dir": "build",
  "tests": [
    {
      "name": "math.add",
      "command": ["test_math", "add"],
      "cwd": null,
      "env": {},
      "labels": ["unit", "fast"],
      "timeout": null,
      "should_fail": false,
      "serial": false,
      "disabled": false,
      "data": [],
      "defined_at": "pcons-build.py:34"
    }
  ]
}

You can read it, grep it, and feed it to any other runner you like — nothing in the format depends on the pcons runtime.

Fuzzing

Pcons doesn't have a dedicated fuzz-test builder — a fuzz target is just a fuzzer-instrumented Program plus one or two Tests. The same shape works for libFuzzer, AFL++, and Honggfuzz; only the build flags and the campaign invocation change. See examples/41_fuzzing/ for a complete libFuzzer harness; the recipes below show the engine-specific bits.

The recommended pattern is two tests per harness:

  • a regression test that replays a committed seed corpus (fast, deterministic, runs on every commit), and
  • a campaign test that actually fuzzes for a bounded wall-clock time (labelled ["fuzz"] so pcons test -L fuzz runs only campaigns and pcons test -LE fuzz excludes them).

libFuzzer (clang built-in)

Same LLVMFuzzerTestOneInput entrypoint; libFuzzer's main() is linked in by -fsanitize=fuzzer.

fuzz_flags = ["-fsanitize=fuzzer,address", "-g", "-O1"]
env.cc.flags.extend(fuzz_flags)
env.link.flags.extend(fuzz_flags)

harness = project.Program("fuzz_parser", env,
                          sources=["fuzz_parser.c", "parser.c"])

project.Test("parser.regression", harness,
             args=["-runs=0", corpus_dir], timeout=30)
project.Test("parser.campaign", harness,
             args=["-create_missing_dirs=1", "campaign-corpus", corpus_dir,
                   "-max_total_time=60"],
             labels=["fuzz"], timeout=90)
int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) {
    parse_keyvalue(data, size);
    return 0;
}

AFL++

The same LLVMFuzzerTestOneInput entrypoint works in persistent mode. The compiler driver becomes afl-clang-fast; the campaign is run by afl-fuzz rather than the harness binary itself.

# Easiest setup: run pcons with CC=afl-clang-fast (and CXX=afl-clang-fast++).
# Or point a custom toolchain at the AFL++ driver explicitly.

harness = project.Program("fuzz_parser", env,
                          sources=["fuzz_parser.c", "parser.c"])

project.Test("parser.regression", "afl-showmap",
             args=["-o", "/dev/null", "-i", corpus_dir, "--",
                   str(harness.output_nodes[0].path), "@@"])

project.Test("parser.campaign", "afl-fuzz",
             args=["-V", "60", "-i", corpus_dir, "-o", findings_dir,
                   "--", str(harness.output_nodes[0].path)],
             labels=["fuzz"], timeout=120)

Honggfuzz

Same harness; build with hfuzz-clang and link against libhfuzz.a. The campaign is run by the honggfuzz binary.

# Easiest setup: CC=hfuzz-clang (and CXX=hfuzz-clang++).

harness = project.Program("fuzz_parser", env,
                          sources=["fuzz_parser.c", "parser.c"])

project.Test("parser.campaign", "honggfuzz",
             args=["-i", corpus_dir, "-o", findings_dir, "--run_time", "60",
                   "--", str(harness.output_nodes[0].path)],
             labels=["fuzz"], timeout=120)

Conventions

  • Keep the seed corpus in the source tree (committed) and pass it as an absolute path so it resolves correctly from the build directory.
  • For libFuzzer's regression mode, pass -runs=0 before the corpus dir. Without it, libFuzzer treats the directory as a live corpus and keeps fuzzing instead of exiting.
  • For the campaign, give libFuzzer two corpus paths — a build-relative output dir first (writable; new findings go there) and the seed corpus second (treated read-only). Without this split, the campaign writes every new-coverage input back into the seed corpus and pollutes the source tree.
  • Label fuzz tests ["fuzz"] so users can filter them on or off.
  • Pair a fast regression test with a slower campaign — CI on every commit runs the regression; nightly CI runs the campaign with a real budget (-max_total_time=600, -V 600, --run_time 600).

Platform notes

  • Linux: works out of the box with a modern clang.
  • macOS: Apple's Xcode clang does not ship libFuzzer; install Homebrew LLVM (brew install llvm) and use that clang. The examples/41_fuzzing/pcons-build.py script also adds an explicit -L<llvm>/lib/c++ to the link line, because Homebrew's libFuzzer archive references libc++ symbols that resolve only against Homebrew's libc++ — not Apple's SDK libc++.
  • Windows: clang-cl supports -fsanitize=fuzzer,address, but the CRT and ASan DLL setup is more involved than what fits in a small example.

Advanced Topics

Supported Source File Types

Pcons toolchains support various source file types beyond standard C/C++:

Extension Description Toolchains
.c C source All
.cpp, .cxx, .cc C++ source All
.cppm, .ixx, .cxxm, .c++m C++20 module interface unit LLVM, MSVC
.m Objective-C LLVM
.mm Objective-C++ 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
.metal Metal shaders (macOS) LLVM

C++20 modules

When a target has at least one source whose extension is in {.cppm, .ixx, .cxxm, .c++m}, pcons runs the C++ module scanner (cl /scanDependencies for MSVC, clang-scan-deps for LLVM/Clang) on every C++ TU in that target at configure time, and uses the P1689R5 output to inject the right compile flags (/interface vs /internalPartition on MSVC, -fmodule-output and -x c++-module on clang) and to produce the Ninja dyndep file that orders compilations. Partition units that live in .cpp files (interface partitions like export module M:P; or internal partitions like module M:P;) are detected from the scan output and handled correctly.

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=find_c_toolchain(prefer=["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;`

import std; and import std.compat; work out of the box on MSVC: pcons synthesizes a build node for %VCToolsInstallDir%/modules/std.ixx (or std.compat.ixx), wires its .ifc into the dyndep file, and adds the resulting .obj to every importing target's link inputs.

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/<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.

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

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("build/message.pb.cc", "proto/message.proto")

Multi-Platform Builds

Handle platform differences in your build script:

import sys
from pcons import find_c_toolchain

toolchain = find_c_toolchain()

# 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 toolchain.name in ("msvc", "clang-cl"):
    env.cxx.flags.append("/W4")
else:
    env.cxx.flags.extend(["-Wall", "-Wextra"])

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, find_c_toolchain, Generator

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=find_c_toolchain())
project.Program("hello", env, sources=["hello.c"])
Generator().generate(project)

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.

IDE Integration

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 — just call Generator().generate(project) as usual.

To disable auto-generation:

Generator().generate(project, compile_commands=False)

This enables 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.

MakefileGenerator

Generate a traditional Makefile instead of Ninja build files:

from pcons.generators.makefile import MakefileGenerator

# Generate Makefile
MakefileGenerator().generate(project, build_dir)
# Creates build/Makefile

Then build with:

make -C build

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

Dependency Visualization

Generate dependency graphs:

from pcons.generators.mermaid import MermaidGenerator

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

Or from the command line:

pcons generate --mermaid=deps.mmd    # To file
pcons generate --mermaid             # To stdout
pcons generate --graph=deps.dot      # DOT format

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).

# 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.

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.

Environment Cloning

Create variant environments by cloning:

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

# 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
  • Environments don't share state - there's no "base" environment that accumulates
  • If you see duplicate flags, check if you're accidentally adding flags multiple times in your script

Temporary Environment Overrides

Use env.override() as a context manager to temporarily modify settings for specific files or targets. This creates a cloned environment with the specified changes, leaving the original unchanged.

# Override cross-tool variables
with env.override(variant="profile") as profile_env:
    project.Program("app_profile", profile_env, sources=["main.cpp"])

# Override tool settings using double-underscore notation
# (because Python kwargs can't contain dots)
with env.override(cxx__flags=["-fno-exceptions"]) as no_except_env:
    project.Library("mylib", no_except_env, sources=["lib.cpp"])

# The yielded env is a full clone - you can modify it further
with env.override(variant="debug") as debug_env:
    debug_env.cxx.defines.append("EXTRA_DEBUG")
    debug_env.cxx.flags.extend(["-g3", "-fno-omit-frame-pointer"])
    project.Library("mylib_debug", debug_env, sources=["lib.cpp"])

# Combine multiple overrides
with env.override(variant="debug", cc__cmd="clang") as temp_env:
    # temp_env has both changes applied
    pass

This is particularly useful when you need to compile a few files with different settings without creating a permanent cloned environment.

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 First source file
$SOURCES All source files (space-separated)
$TARGET First target file
$TARGETS All target files (space-separated)
${SOURCES[n]} Indexed source access (0-based)
${TARGETS[n]} Indexed target access (0-based)
$SRCDIR Project source tree root directory
$$ Literal $ (escaped)

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"
)

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("version.txt")  # Rebuild when version.txt changes

Use $$ to include a literal dollar sign in commands. This is useful for shell variables that should be expanded at build time rather than generation time:

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

# Use shell environment variables
env.Command("output.txt", "input.txt", "echo $$HOME > $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"
)

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")

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 target name for ninja <name> (default: derived from output path)
# 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",
)

# Custom target name
archive = project.Tarfile(
    env,
    output="dist/docs.tar.gz",
    sources=["docs/"],
    name="package_docs",  # Run with: ninja package_docs
)

Using Archives with Install

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

# Create archives
docs_tar = project.Tarfile(env, output="build/docs.tar.gz", sources=["docs/"])
release_zip = project.Zipfile(env, output="build/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
python pcons-build.py
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 (.rtf or .html)
background Background image for the installer
scripts_dir Directory with preinstall/postinstall scripts
sign_identity Code signing identity

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

Helper functions 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 Certificate password

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.tomlpip 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"):
    # 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], name="install")

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/.

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.

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, find_c_toolchain

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

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

prog = project.Program("myapp", env)
prog.add_sources(["src/main.c"])
prog.private.link_libs.append(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.

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, find_c_toolchain, Generator

project = Project("mylib")
toolchain = find_c_toolchain()

# Create environment for arm64
env_arm64 = project.Environment(toolchain=toolchain)
env_arm64.set_target_arch("arm64")
env_arm64.build_dir = Path("build/arm64")

# Create environment for x86_64
env_x86_64 = project.Environment(toolchain=toolchain)
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 - Supported architectures: x64, x86, arm64, arm64ec - Aliases: amd64x64, x86_64x64, aarch64arm64

Windows (Clang-CL): - Adds --target=<triple> to compilers (e.g., --target=aarch64-pc-windows-msvc) - Adds /MACHINE:<ARCH> to linker

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, find_c_toolchain, Generator
from pcons.util.macos import create_universal_binary

project = Project("mylib")
toolchain = find_c_toolchain()

# Build for arm64
env_arm64 = project.Environment(toolchain=toolchain)
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=toolchain)
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,
    "mylib_universal",
    inputs=[lib_arm64, lib_x86_64],
    output="build/universal/libmylib.a"
)

project.Default(lib_universal)
Generator().generate(project, "build")

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, emscripten, wasi_sdk, linux_cross

# Android NDK
env.apply_cross_preset(android(ndk="~/android-ndk", arch="arm64-v8a"))

# iOS
env.apply_cross_preset(ios(arch="arm64", min_version="15.0"))

# iOS Simulator
env.apply_cross_preset(ios(arch="x86_64"))

# WebAssembly via Emscripten
env.apply_cross_preset(emscripten(emsdk="~/emsdk"))
# Or if emcc is already in PATH:
env.apply_cross_preset(emscripten())

# WebAssembly via wasi-sdk (as a cross-preset on an LLVM toolchain)
env.apply_cross_preset(wasi_sdk())

# 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:

from pcons import find_wasi_toolchain

toolchain = find_wasi_toolchain()
env = project.Environment(toolchain=toolchain)
project.Program("hello", env, sources=["src/hello.c"])

Available Factory Functions

Factory Key Arguments Description
android(ndk, arch, api) arch: arm64-v8a, armeabi-v7a, x86_64, x86; api: minimum API level (default 21) 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
wasi_sdk(sdk_path) sdk_path: path to wasi-sdk (optional, auto-detected) WebAssembly via wasi-sdk (cross-preset)
linux_cross(triple, sysroot) triple: GCC/Clang target triple; sysroot: target sysroot path Generic Linux cross-compilation

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",),
    env_vars={
        "CC": "/opt/riscv/bin/riscv64-unknown-elf-gcc",
        "CXX": "/opt/riscv/bin/riscv64-unknown-elf-g++",
    },
)
env.apply_cross_preset(preset)

The CrossPreset fields:

Field Type Description
name str Human-readable name
arch str Target architecture
triple str \| None Compiler target triple (used with --target on Clang)
sysroot str \| None Path to target sysroot (--sysroot)
sdk_path str \| None Path to SDK root
extra_compile_flags tuple[str, ...] Additional compile flags
extra_link_flags tuple[str, ...] Additional link flags
env_vars dict[str, str] CC/CXX command overrides

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 prepends the cache tool to the cc and cxx commands. Only compile commands are wrapped — the linker and archiver are left unchanged. 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. - Commands are never double-wrapped: calling use_compiler_cache() when commands are already wrapped is a no-op.

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, find_c_toolchain
from pcons.toolchains import find_cuda_toolchain

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

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

# Add CUDA toolchain for .cu files
cuda_toolchain = find_cuda_toolchain()
if cuda_toolchain:
    env.add_toolchain(cuda_toolchain)

# 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_toolchain)
env.add_toolchain(cuda_toolchain)

# 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

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)
from pcons.toolchains import find_c_toolchain, find_cuda_toolchain

# Both return None if not available
c_toolchain = find_c_toolchain()
cuda_toolchain = find_cuda_toolchain()

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=toolchain)

# 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"

# 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 the compiler path, so switching compilers invalidates the relevant entries automatically.

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)
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")     # Defines SIZEOF_INT
config.check_sizeof("void*")   # 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: config.check_sizeof() uses Python's ctypes to determine sizes on the host machine. For cross-compilation where host and target sizes differ, use ToolChecks.check_type_size() instead — it compiles a test program with the target compiler.

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.


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.


Reference

Project Methods

Method Description
Project(name, build_dir) Create a project
project.Environment(toolchain) Create an environment
project.Program(name, env) Create a program target
project.StaticLibrary(name, env) Create a static library
project.SharedLibrary(name, env) Create a shared library
project.HeaderOnlyLibrary(name) Create a header-only library
project.Install(dir, sources) Install files to a directory
project.InstallAs(dest, source) Install with rename
project.Tarfile(env, output, sources) Create tar archive (.tar, .tar.gz, etc.)
project.Zipfile(env, output, sources) Create zip archive
project.Default(*targets) Set default build targets
project.Alias(name, *targets) Create a named alias
project.resolve() Resolve all dependencies
project.node(path) Get/create a file node
project.find_package(name, ...) Find external package (returns ImportedTarget)
project.add_package_finder(finder) Prepend a custom package finder

Target Methods

Method Description
target.add_source(path) Add a source file
target.add_sources(paths) Add multiple source files
target.public.link_libs.append(t) Link a dependency and re-export its public requirements
target.private.link_libs.append(t) Link a dependency, keeping its requirements local
target.add_dependency(t) Add a non-link build dependency
target.public.include_dirs Include dirs for consumers
target.public.link_libs Libraries to link (-l; placed after objects)
target.public.link_flags Linker flags (placed before objects; use link_libs for -l libraries). Use PathToken for flags containing paths.
target.public.defines Defines for consumers
target.private.compile_flags Flags for this target only

Environment Methods

Method Description
env.set_variant(name) Set debug/release variant
env.set_target_arch(arch) Set target CPU architecture
env.apply_preset(name) Apply flag preset (warnings, sanitize, profile, lto, hardened)
env.apply_cross_preset(preset) Apply cross-compilation preset
env.use_compiler_cache(tool=None) Wrap compilers with ccache/sccache
env.use(package) Apply package settings
env.clone() Create a copy
env.override(**kwargs) Context manager for temporary overrides
env.add_toolchain(toolchain) Add additional toolchain (e.g., CUDA)
env.Command(target, source, cmd) Run arbitrary shell command
env.Framework(*names) Link macOS frameworks (macOS only)
env.Glob(pattern) Find files matching a glob pattern
env.cc C compiler settings
env.cxx C++ compiler settings
env.link Linker settings

Helper Functions

Function Description
find_c_toolchain() Find an available C/C++ toolchain (platform-aware defaults)
find_c_toolchain(prefer=[...]) Find toolchain with explicit preference order
find_cuda_toolchain() Find CUDA toolchain (returns None if nvcc not found)
configure_file(template, output, vars) Substitute variables in a template file (CMake or @VAR@ style)
get_var(name, default) Get a build variable
get_variant(default) Get the build variant
ensure_msvc(msvc_ver, sdk_ver) Install MSVC toolchain via msvcup (Windows only; import from pcons.contrib.windows.msvcup)

Generators

Class Description
Generator Generate build files using default generator (specified by cmdline, env, or default: Ninja)
NinjaGenerator Generate Ninja build files
MakefileGenerator Generate traditional Makefiles
CompileCommandsGenerator Generate compile_commands.json for IDEs
MermaidGenerator Generate Mermaid dependency diagrams

Configuration and Feature Detection

Class/Method Description
Configure(build_dir) Create configuration context
config.define(name, value=1) Define a preprocessor symbol
config.undefine(name) Mark a symbol as undefined
config.check_sizeof(type) Get the size of a type and define SIZEOF_*
config.check_header(name) Check if a header exists
config.write_config_header(path) Generate a config.h file
ToolChecks(config, env, tool) Create feature checker for a tool
checks.check_flag(flag) Check if compiler accepts a flag
checks.check_header(name) Check if a header exists
checks.check_type(name, headers=[]) Check if a type exists
checks.check_type_size(name) Get the size of a type
checks.check_function(name) Check if a function is available
checks.check_define(name) Get value of a predefined macro
checks.try_compile(source) Try to compile arbitrary source code

macOS Utilities

Function Description
create_universal_binary(project, name, inputs, output) Combine arch-specific binaries into universal binary (returns Target)
get_dylib_install_name(path) Get a dylib's install name
fix_dylib_references(target, dylibs, lib_dir) Fix dylib references for bundle creation

Import from pcons.util.macos.


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_name = 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, find_c_toolchain, Generator
from pcons.modules import ofx  # Auto-loaded!

project = Project("myplugin")
toolchain = find_c_toolchain()
env = project.Environment(toolchain=toolchain)

ofx.setup_env(env)
plugin = ofx.create_bundle(
    project, env, "myplugin",
    sources=["src/plugin.cpp"],
    build_dir=project.build_dir,
)

Generator().generate(project)

Contrib Modules

Pcons includes built-in helper modules in pcons.contrib:

from pcons.contrib import bundle, platform

# Bundle creation helpers
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")
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
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

Further Reading