Pcons User Guide v0.30.0.dev6 (ad98890, 2026-10-10)¶
Pcons is a general-purpose software build system. It constructs a dependency graph of all your sources and targets and commands to build the targets, and generates Ninja build files (or Makefiles or XCode projects). Its configuration language is python, and the tool itself is written in python. It combines some of the best ideas from SCons and CMake: Python as the configuration language, environments with toolchains and tools, and a fast generator architecture with proper dependency tracking.
Why Pcons?¶
Key Features¶
- Python is the language: No custom DSL to learn. Your
pcons-build.pyis real Python with full IDE support, debugging, and all the power of the Python ecosystem. - Fast builds with Ninja: Pcons generates Ninja files and lets Ninja handle the actual compilation. This means fast, parallel builds with minimal overhead.
- Automatic dependency tracking: Pcons tracks dependencies between source files, object files, and outputs, rebuilding only what's necessary.
- Transitive requirements: Like CMake's "usage requirements," include directories and link flags automatically propagate through your dependency tree.
- Tool-agnostic core: The core knows nothing about C++ or any language. All language support comes through Tools and Toolchains, making it extensible.
- Wide language, compiler and OS support: Pcons supports clang, MSVC, gcc, C++ Modules, Fortran, CUDA, WebAssembly, LaTeX, and more, all mixed together. Every commit is tested on Mac, Windows and Linux (various flavors).
- Carefully built and tested: We use highest standards for code quality, with strong test coverage for all code and extensive CI testing.
- Debuggable: Every command line and compiler flag is traceable using
pcons explain, and pcons has full python debugger and IDE support including typings. - Extensible: It's easy to add your own toolchains, tools, builders and more, the same way the shipped tools work.
- Works with
uv: Designed for modern Python workflows withuvas 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):
Or add it to your project:
Or install globally:
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):
2. Create the build script (pcons-build.py):
from pcons import Project
# Create project with build directory
project = Project("hello", build_dir="build")
# Create an environment with the system default C/C++ toolchain
env = project.Environment(toolchain="c")
# Create a program target
hello = project.Program("hello", env)
hello.add_sources(["hello.cpp"])
# Set this as the default target
project.Default(hello)
3. Generate and build:
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:
Understanding the Commands¶
Pcons provides several commands:
pcons # Generate build files AND build (default)
pcons --watch # ... and keep rebuilding as files change
pcons generate # Only generate build.ninja
pcons build # Only run ninja (assumes build.ninja exists)
pcons clean # Clean build artifacts
pcons clean --all # Remove entire build directory
pcons info # Show pcons-build.py documentation
pcons explain # Show all targets, commands used to build them, and where each flag comes from
pcons run # Run a user-defined command
pcons test # Build and run the defined tests
pcons completion # Set up tab-completion for your shell
pcons cache # Manage the per-build-dir cache
pcons init # Create pcons-build.py (adopts existing C/C++ sources,
# or scaffolds a hello-world starter in an empty dir)
See pcons --help for more details.
Supported Languages and Toolchains¶
Pcons ships with built-in support for several languages and toolchains. The core is completely tool-agnostic — all language support comes from toolchain modules that register themselves at import time.
Registered Toolchains¶
The following toolchains are auto-detected. Select one by name — toolchain="c" auto-detects a C/C++ toolchain, a specific name like "gcc" requires that toolchain, and a list is a preference order:
env = project.Environment(toolchain="c") # auto-detect C/C++
env = project.Environment(toolchain="msvc") # require MSVC
env = project.Environment(toolchain=["gcc", "llvm"]) # first available wins
IDE autocompletion for these names comes from the generated KnownToolchain type; any registered name (including user-registered toolchains) also works. For programmatic control, the underlying finder functions like find_c_toolchain(prefer=[...]) remain available and return Toolchain objects.
| Toolchain | Finder | Platforms | Description |
|---|---|---|---|
| Gcc | find_c_toolchain() |
Linux, macOS, Windows | GNU Compiler Collection (gcc/g++) |
| Llvm | find_c_toolchain() |
Linux, macOS, Windows | LLVM/Clang compiler |
| Msvc | find_c_toolchain() |
Windows | Microsoft Visual C/C++ compiler |
| ClangCl | find_c_toolchain() |
Windows | Clang with MSVC-compatible flags |
| Cuda | find_cuda_toolchain() |
Linux, Windows | NVIDIA CUDA compiler (nvcc) |
| Emscripten | find_emscripten_toolchain() |
Linux, macOS | Emscripten C/C++ to WebAssembly + JS (browser/Node.js) |
| Wasi | find_wasi_toolchain() |
Linux, macOS | WASI SDK for standalone WebAssembly (.wasm) |
| Cython | find_cython_toolchain() |
Linux, macOS, Windows | Cython transpiler (.pyx to Python extension) |
| Gfortran | find_fortran_toolchain() |
Linux, macOS | GNU Fortran compiler (gfortran) |
| Latex | find_latex_toolchain() |
Any | LaTeX document compilation via latexmk |
| Qt | find_qt_toolchain() |
Linux, macOS, Windows | Qt code generation tools (moc, uic, rcc) |
| Swift | find_swift_toolchain() |
macOS, Linux, Windows | Swift compiler (whole-module compilation, swiftc links) |
Default C/C++ search order:
- Windows: clang-cl → msvc → llvm → gcc
- Linux / macOS: llvm → gcc
Selecting compilers with environment variables¶
Pcons honors the conventional tool-selection environment variables, same as make/autoconf/CMake/Meson — no build-script changes needed:
$ CXX=g++-15 pcons # build with a specific compiler
$ CC=clang-19 CXX=clang++-19 pcons
$ CXX=/opt/llvm/bin/clang++ pcons
| Variable | Selects | Toolchains |
|---|---|---|
CC |
C compiler (and the link driver) | gcc, llvm, msvc, clang-cl |
CXX |
C++ compiler | gcc, llvm, msvc, clang-cl |
FC |
Fortran compiler | gfortran |
AR |
archiver | gcc, llvm, swift |
SWIFTC |
Swift compiler | swift |
CUDACXX |
CUDA compiler (nvcc) | cuda |
RC |
resource compiler | msvc, clang-cl |
Following the universal convention, these are authoritative, not hints: a set variable selects that command; a value that can't be found is an error. Specifically, the rules are as follows:
- With
toolchain="c"(auto-detect),$CXX/$CCsteer detection to the named compiler's family —CXX=g++-15selects the gcc toolchain even where clang would normally win. Classification checks--version, so macOS'sg++-that-is-really-Apple-clang is identified correctly. - An explicitly requested toolchain that contradicts the variable
(
CXX=g++-15withtoolchain="msvc") is an error. - Values a compiler-id can't classify (e.g. wrapper scripts) are used as-is on whichever toolchain is selected.
- On the gcc toolchain,
$CXXor$CCalso picks the archiver: thegcc-arnext to that driver, keeping its prefix and version (CXX=g++-15archives withgcc-ar-15).gcc-argivesarthat GCC's LTO plugin, so-fltostatic libraries work whatever binutils has installed. A cross preset'stool_cmdsdrivers do the same, and$ARoverrides both. A script that setsenv.cxx.cmditself should setenv.ar.cmdtoo. - Explicit script assignments (
env.cxx.cmd = ...) and cross-presettool_cmdsstill win over the environment; SDK-owned toolchains (emscripten, wasi) ignore these variables entirely, like CMake's Visual Studio generators. env.explain()orpcons explainattribute the result (cxx.cmd <- $CXX), so a forgottenexport CXXin a shell profile will show up there.
CFLAGS/CXXFLAGS/LDFLAGS are not read — in pcons, flag policy belongs to the
build script (variants, presets), not the ambient environment.
Swift is available as toolchain="swift" (requires Xcode on macOS, or a
swift.org toolchain on Linux and Windows). Swift's compilation unit is the module, not the
file: each pcons target compiles as one Swift module in a single whole-module
swiftc invocation, so files in a target see each other without imports.
Importing another target's module is an ordinary dependency — the library's
.swiftmodule search path propagates as a usage requirement:
env = project.Environment(toolchain="swift")
geometry = project.StaticLibrary("Geometry", env, sources=["lib/geometry.swift"])
app = project.Program("shapes", env, sources=["src/main.swift"]) # import Geometry
app.link_private(geometry)
The module name is the target name (sanitized to a Swift identifier); library
targets compile with -parse-as-library. Variants map to -Onone -g /
-O, and werror maps to -warnings-as-errors. compile_commands.json
entries are emitted per source file with the whole-module command, the
convention sourcekit-lsp expects. See examples/46_swift_hello and
examples/47_swift_library.
Swift / C / C++ interop works in both directions. Swift imports a C
library through a module.modulemap shipped in the library's include dir
(the propagated include path serves both the modulemap and headers). For
C++, enable interop mode and header emission:
env = project.Environment(toolchain="swift")
env.add_toolchain("c") # C/C++ compilers for mixed targets
env.swiftc.set_cxx_interop("c++17") # Swift <-> C++ interop mode
env.swiftc.interop_header = True # libraries emit <Module>-Swift.h
analyzer = project.StaticLibrary("Analyzer", env, sources=["analyzer.swift"])
app = project.Program(
"demo", env, sources=["src/main.cpp"]
) # #include "Analyzer-Swift.h"
app.link_private(analyzer)
The generated header lands next to the .swiftmodule in the propagated
include dir, and consumers' C++ compiles automatically wait for it. When a
C header is imported in C++-interop mode it is parsed as C++, so it needs
the usual extern "C" guards. Consuming the generated header from C++ is currently reliable on
macOS; on Linux it depends on the Swift version and C++ standard library in
use. Mixed links are handled automatically:
swiftc drives the link when Swift is involved (bringing the Swift runtime),
and a C/C++-driven link of Swift objects gets the runtime path injected via
swiftc -print-target-info. See examples/48_swift_cxx_interop.
A module.modulemap can be generated instead of hand-written —
clang_module_map(project, "CStats", ["include/cstats.h"]) (from
pcons.toolchains.swift) writes one into the build tree and returns its
directory for public.include_dirs. For distributable libraries,
env.swiftc.library_evolution = True builds with
-enable-library-evolution and emits a .swiftinterface next to the
.swiftmodule.
The Swift language mode is env.swiftc.language_mode, passed to every
compile as -swift-version. It defaults to "5", which is swiftc's own
default on every version; set it to "6" for Swift 6 language mode and its
strict concurrency checking. It is always passed because Swift 6 refuses to
emit a module interface unless the mode is stated explicitly.
And Swift participates in cross presets: two lines target
iOS (see examples/49_swift_ios):
env = project.Environment(toolchain="swift")
env.apply_cross_preset(ios(arch="arm64", min_version="15.0"))
Fortran (gfortran) is available as toolchain="fortran". It supports all standard Fortran source extensions and uses Ninja dyndep to resolve MODULE / USE dependencies at build time (requires Ninja ≥ 1.10):
env = project.Environment(toolchain="fortran")
project.Program("hello", env, sources=["src/main.f90", "src/greetings.f90"])
Mixed C++/Fortran builds use env.add_toolchain(). Runtime libraries are injected automatically in both directions:
# Fortran primary: gfortran links, -lc++ / -lstdc++ injected for C++ objects
env = project.Environment(toolchain="fortran")
env.add_toolchain("c")
# C++ primary: g++/clang++ links, -lgfortran injected for Fortran objects
env = project.Environment(toolchain="c++")
env.add_toolchain("fortran")
project.Program("hello", env, sources=["src/main.f90", "src/helper.cpp"])
CUDA is designed to work alongside a C/C++ toolchain — CUDA handles .cu compilation while the host toolchain handles linking:
Emscripten requires the Emscripten SDK. Set the EMSDK environment variable, or install to ~/emsdk or /opt/emsdk.
WASI requires the WASI SDK. Set WASI_SDK_PATH, or install to /opt/wasi-sdk or ~/.local/share/wasi-sdk (also available via Homebrew).
LaTeX is available as a contrib toolchain using latexmk. It handles multi-pass compilation, BibTeX/Biber bibliography processing, makeindex, cross-references, and automatic dependency tracking (including \input'd files and .bib sources):
from pcons.contrib.latex import find_latex_toolchain
env = project.Environment(toolchain=find_latex_toolchain())
env.latex.Pdf(build_dir / "paper.pdf", src_dir / "paper.tex")
# Optional: change engine or add flags
env.latex.engine = "xelatex"
env.latex.flags.append("-shell-escape")
Rust is supported as interop, not as a native toolchain: pcons does not compile .rs files itself and there is no Rust toolchain to detect or configure. Instead project.CargoBuild() drives cargo build as a black-box sub-build (cargo owns the Rust compile and its intra-Rust incremental logic) and wraps the resulting library so C/C++ consumers can .link() it like any other dependency. Cross-compilation, Rust dialect, and similar settings are configured on the cargo side, not through pcons's environment:
rust_core = project.CargoBuild(
"rust_core",
env,
manifest="rust/Cargo.toml",
crate_type="staticlib", # or "cdylib", "bin"
profile="release",
)
app = project.Program("app", env, sources=["src/main.cpp"])
app.link(rust_core) # -L/-l propagate automatically
Pass generate_header="rust/cbindgen.toml" to also run cbindgen and emit a C header from the Rust sources — pcons wires the header as an implicit dep of consumer compile steps, so the header exists before any #include is processed. See examples/43_rust_cxx_hybrid/ (hand-written FFI header) and examples/44_rust_cxx_cbindgen/ (cbindgen-generated header) for end-to-end examples. Other foreign build tools can be wired up the same way using env.Command(restat=True).
Builder Types¶
All builders are accessible as methods on Project:
| Builder | Type | Platforms | Description |
|---|---|---|---|
project.Appx() |
Installer | Windows | Create a Windows AppX package (legacy MSIX format) |
project.CargoBuild() |
Cargo | All | Build a Rust crate via cargo and expose it as a library |
project.Command() |
Command | All | Create a custom command target |
project.ComponentPkg() |
Installer | macOS | Create a macOS component package using pkgbuild |
project.Dmg() |
Installer | macOS | Create a macOS .dmg disk image |
project.FlatBundle() |
Installer | All | Create a flat directory bundle (cross-platform) |
project.HeaderOnlyLibrary() |
Interface | All | Create a header-only (interface) library target |
project.Install() |
Interface | All | Install files to a destination directory |
project.InstallAs() |
Interface | All | Install a file to a specific destination path |
project.InstallDir() |
Interface | All | Install a directory tree to a destination |
project.MacosBundle() |
Installer | macOS | Create a macOS .bundle or .plugin structure |
project.MetalLibrary() |
Metal Library | macOS | Compile .metal shaders and link them into a .metallib |
project.Msix() |
Installer | Windows | Create a Windows MSIX package |
project.ObjectLibrary() |
Object | All | Create an object library target (compiles but doesn't link) |
project.OverlayDir() |
Interface | All | Merge several source trees into one directory, later sources winning |
project.Pkg() |
Installer | macOS | Create a macOS product archive (.pkg) installer |
project.Program() |
Program | All | Create a program (executable) target |
project.QtDeploy() |
Command | All | Bundle Qt runtime with an app (macdeployqt/windeployqt) |
project.QtProgram() |
Program | All | Qt program with automoc/autouic/autorcc |
project.QtQmlModule() |
Object | All | QML module: QML files + QML_ELEMENT types, one call |
project.QtResources() |
Object | All | Qt resources from a file list (no .qrc XML) |
project.QtSharedLibrary() |
Shared Library | All | Qt shared library with automoc/autouic/autorcc |
project.QtStaticLibrary() |
Static Library | All | Qt static library with automoc/autouic/autorcc |
project.QtTranslations() |
Object | All | Compile .ts catalogs with lrelease and embed the .qm files |
project.SharedLibrary() |
Shared Library | All | Create a shared library target |
project.StaticLibrary() |
Static Library | All | Create a static library target |
project.Tarfile() |
Archive | All | Create a tar archive from source files/directories |
project.Test() |
Test | All | Declare a test to be run by pcons test (or ninja test) |
project.Zipfile() |
Archive | All | Create a zip archive from source files/directories |
Custom Toolchains¶
You can register your own toolchain to support additional languages or compilers:
from pcons.toolchains import toolchain_registry
toolchain_registry.register(
MyToolchain,
aliases=["my-toolchain"],
check_command="my-compiler",
tool_classes=[MyCompiler, MyLinker],
category="c",
platforms=["linux", "darwin", "win32"],
description="My custom compiler",
)
Supported Source File Types¶
Pcons toolchains support various source file types beyond standard C/C++. This table is generated from the registered toolchains, so it lists every source type pcons handles:
| Extension | Description | Toolchains |
|---|---|---|
.c |
C source | GCC, LLVM, MSVC, Clang-CL, Emscripten, WASI |
.cpp, .cxx, .cc, .c++, .C |
C++ source | GCC, LLVM, MSVC, Clang-CL, Emscripten, WASI |
.cppm, .ixx, .cxxm, .c++m |
C++20 module interface unit | GCC, LLVM, MSVC |
.m |
Objective-C | GCC, LLVM |
.mm |
Objective-C++ | GCC, LLVM |
.s |
Assembly (preprocessed) | GCC, LLVM |
.S |
Assembly (needs C preprocessor) | GCC, LLVM |
.asm |
MASM assembly | MSVC, Clang-CL |
.rc |
Windows resource | MSVC, Clang-CL |
.cu |
CUDA source | CUDA |
.f, .for, .ftn |
Fortran, fixed form | gfortran |
.f90, .f95, .f03, .f08, .f18 |
Fortran, free form | gfortran |
.F, .F90 |
Fortran, needs C preprocessor | gfortran |
.swift |
Swift source | Swift |
.pyx |
Cython source | Cython |
.metal |
Metal shader (macOS) | LLVM |
.ui |
Qt Designer form | Qt |
.qrc |
Qt resource collection | Qt |
.ts |
Qt translation source | Qt |
.tex |
LaTeX document | LaTeX |
A toolchain is listed only for the sources it owns. The compiler drivers accept more than that — gfortran will happily compile C — but pcons routes each source to the toolchain whose tool is built for it.
Metal shaders (macOS)¶
project.MetalLibrary is the whole pipeline — each .metal source compiles to an .air, and the .air files link into the single .metallib an application loads at runtime:
shaders = project.MetalLibrary(
"effects", env, sources=["src/blur.metal", "src/warp.metal"]
)
project.Default(shaders)
It returns a Target, so the library can be a default target, an alias member, or something to Install, exactly like a program or a shared library. The output is named verbatim (effects.metallib) — no lib prefix, since shaders are looked up by name at runtime.
env.metal.Object and env.metal.Library drive the two steps separately and return nodes, like every tool-namespace builder. Use those only when an intermediate .air is wanted for its own sake. See examples/62_metal_library.
C++20 modules¶
Modules just work: name the sources, link the targets, build.
env = project.Environment(toolchain="llvm")
env.cxx.set_standard("c++20")
mod = project.StaticLibrary("m", env, sources=["src/mod.cppm"])
app = project.Program("app", env, sources=["src/main.cpp"]) # says `import m;`
app.link(mod)
When a target has at least one source whose extension is in
{.cppm, .ixx, .cxxm, .c++m}, pcons attaches a
scanner to it. Each translation unit gets its own build-time scan edge
(clang-scan-deps, cl /scanDependencies, or GCC's own preprocess-only
pass, all emitting P1689R5), and each target gets one collate edge that
turns those reports into the target's Ninja dyndep file and writes each
compile a modmap carrying the flags that depend on what the scan found:
-x c++-module and -fmodule-file= for clang, mapper lines for GCC,
/interface or /internalPartition and /reference for MSVC.
Partition units that live in .cpp files (interface partitions like
export module M:P; or internal partitions like module M:P;) are
recognized there, from the scan.
Note
- A cross-target import needs the target dependency. A scope resolves
a module name against its own units and against the exports of the
targets it depends on, so
appmustlink()(ordepends()) the target that compiles the interface. The dependency carries the exports; the content decides the compile order.
Scanned builds use dyndep, so they need ninja ≥ 1.11 — pcons writes
that floor into build.ninja — and only the ninja generator can express
them. The Makefile and Xcode generators refuse such a project with a
clear error.
If your project has no sources with one of those extensions but still
uses C++ modules — e.g. fmtlib's src/fmt.cc (primary interface in
.cc), or a target whose only module use is import std; — opt in
explicitly:
env = project.Environment(toolchain="msvc")
env.cxx.modules = True
env.cxx.flags.extend(["/std:c++latest", "/EHsc"])
project.Program("hello", env, sources=["main.cpp"]) # main.cpp does `import std;`
env.cxx.modules has three states. The default, None, is auto: an
extension-tagged module source opts its environment in. True also
scans module units written in .cpp/.cc, as above. False disables
scanning for that environment outright — it beats the extension opt-in,
and warns if a module interface is then going to compile as plain C++.
env.cxx.scan_deps overrides which scanner executable is used, for a
clang-scan-deps that isn't on PATH.
import std; and import std.compat; are entirely dynamic. Configure
describes the standard library's module edges but wires nothing to them;
the first translation unit whose scan reports the import is what pulls
the BMI into the dyndep file and the resulting object into the link. A
project that never imports std builds and links nothing extra. It needs
a standard library that ships the module source: libc++ with
libc++.modules.json (LLVM ≥ 18; on macOS that means Homebrew LLVM, not
Apple Clang), GCC 15+ with libstdc++-15-dev, or MSVC's
%VCToolsInstallDir%/modules/std.ixx. If none is there, the error
arrives when a file actually imports std, naming what was missing.
Compiled module interfaces (BMIs — .gcm / .pcm / .ifc) are only
consumable by translation units built with matching BMI-sensitive flags
(C++ dialect, ABI options, stdlib feature macros). pcons keys each BMI by
a hash of those flags and stores it under
<build_dir>/cxx_modules/<target>/<hash>/, so targets that compile a
module interface with compatible flags share one BMI, while targets using
an incompatible dialect (say -std=c++23 vs -std=c++26) transparently
get their own. See examples/39_bmi_compat.
A module source the build generates itself needs nothing special: its
.cppm suffix is a static fact read off the declared path, and the scan
edge waits for the generator like any other consumer of the file. See
examples/71_cxx_modules_codegen and
examples/72_cxx_modules_codegen_interface.
These are handled automatically when you add sources to a target:
# C/C++ sources
app.add_sources(["main.cpp", "util.c"])
# Windows resources (icons, dialogs, version info)
app.add_sources(["app.rc"])
# Assembly
lib.add_sources(["fast_math.S"]) # Uses C preprocessor
lib.add_sources(["startup.s"]) # Raw assembly
Windows: MSVC Without Visual Studio (msvcup)¶
On Windows, find_c_toolchain() normally discovers the MSVC compiler from an installed Visual Studio. If you don't want to install all of Visual Studio with C++ workloads and Windows SDKs — or if you need a reproducible, locked compiler version — you can use msvcup to download just the MSVC compiler and Windows SDK directly from Microsoft's CDN.
The pcons.contrib.windows.msvcup module wraps the msvcup tool. Call ensure_msvc() at the top of your build script, before find_c_toolchain():
import sys
from pcons import Project
if sys.platform == "win32":
from pcons.contrib.windows.msvcup import ensure_msvc
ensure_msvc("14.44.17.14", "10.0.22621.7")
project = Project("hello", build_dir="build")
env = project.Environment(toolchain="c")
project.Program("hello", env, sources=["hello.c"])
On the first run, ensure_msvc():
- Downloads
msvcup.exefrom GitHub releases (auto-detects x64 vs arm64) - Runs
msvcup installto download the specified MSVC and SDK versions - Runs
msvcup autoenvto create wrapper executables (cl.exe,link.exe, etc.) - 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:
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:
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:
CI Usage¶
msvcup is particularly useful in CI environments where you want reproducible builds without depending on whatever Visual Studio version happens to be pre-installed on the runner. See examples/21_msvcup_hello/ for a complete working example.
Core Concepts¶
Understanding these core concepts will help you write effective pcons build scripts.
Build Script Lifecycle¶
Every pcons build script (pcons-build.py) follows these phases:
- Configure - Set up toolchains, environments, and build options
- Describe - Create targets and define their sources/dependencies
- Resolve - Reify all paths and dependency arcs, and propagate and resolve dependencies
- Generate - Write build files
Your script only describes the build — the resolve and generate steps run automatically once pcons has run it. Ninja is the default generator; select another with pcons -G make (or the PCONS_GENERATOR/GENERATOR environment variables).
Tip
pcons is the program, so the script's __name__ is __pcons__ rather than the usual __main__. A subdirectory script pulled in by add_subdirectory() gets the same name, so a guard works in all pcons build scripts:
For finer control over the phases, you can resolve explicitly:
# ... define targets ...
# Resolve all dependencies now (generation does this automatically if needed)
project.resolve()
Every target has to exist by then. A target created after resolve() is
refused: nothing would resolve it, so it would be missing from the build
files.
Project¶
A Project is the top-level container for your build. It holds all environments, targets, and nodes.
The project's root directory defaults to the directory containing the build script, and relative paths in the script (sources, include dirs, build_dir) are resolved against it — pcons runs the script from there no matter where it was invoked. An explicit root_dir= argument overrides the default.
The project provides factory methods for creating targets, like these:
project.Program()- Create an executableproject.StaticLibrary()- Create a static library (.a/.lib)project.SharedLibrary()- Create a shared library (.so/.dylib/.dll)project.HeaderOnlyLibrary()- Create a header-only library
Tip
It is possible to have multiple Projects in a single script, for some advanced uses; see Multiple projects in one script.
Environment¶
An Environment holds configuration for building: compiler settings, flags, include directories, and more. You can have multiple environments (e.g., for different platforms or variants).
# Create environment with an auto-detected C/C++ toolchain
env = project.Environment(toolchain="c")
# Configure compiler flags
env.cc.flags.extend(["-Wall", "-Wextra"])
env.cxx.flags.extend(["-std=c++17"])
# Add include directories
env.cxx.includes.append("include")
# Add preprocessor defines
env.cxx.defines.append("VERSION=1")
env.cxx.defines.append(("NAME", "value")) # ("NAME", "value") means NAME=value
Each environment has namespaced tool configurations:
- env.cc - C compiler settings
- env.cxx - C++ compiler settings
- env.link - Linker settings
Environments can be cloned and then the clone modified, to set up multiple variants; see Environment Cloning.
Path Conventions¶
Pcons uses consistent path conventions throughout:
- Source paths (inputs): Relative to the project root directory
- Target paths (outputs): Relative to the build directory
- Install destinations: Relative to the install prefix (
PCONS_INSTALL_PREFIX, default<project-root>/dist) — see Installing Files - Absolute paths: Pass through unchanged
This means you don't need to prefix output paths with build_dir (and you typically shouldn't, though pcons will detect that and strip the build_dir prefix):
# Good: output paths are relative to build_dir
project.Tarfile(env, output="packages/release.tar.gz", ...)
# Install destinations are relative to the install prefix (default: dist/)
project.Install("lib", [mylib]) # -> <root>/dist/lib/
project.InstallDir(".", src_dir / "assets") # -> <root>/dist/assets/
# Not needed: build_dir prefix is implicit
# project.Tarfile(env, output=build_dir / "packages/release.tar.gz", ...) # Unnecessary
Tip
If you really want a path named (from the top dir) build_dir / build_dir / foo.obj, specify it twice like that and the first one will be stripped, leaving the second one intact.
Toolchain¶
A Toolchain is a coordinated set of tools (compiler, linker, archiver) that work together. Pcons automatically detects available C/C++ toolchains.
# Auto-detect the best available C/C++ toolchain by name.
# Uses platform-appropriate defaults:
# Windows: clang-cl, msvc, llvm, gcc
# Unix/Mac: llvm, gcc
env = project.Environment(toolchain="c")
# Or give a preference order, or require a specific toolchain
env = project.Environment(toolchain=["gcc", "llvm"])
env = project.Environment(toolchain="msvc")
# For programmatic selection, finder functions return Toolchain objects
from pcons import find_c_toolchain
toolchain = find_c_toolchain(prefer=["gcc", "llvm"])
Available toolchains include at least these, depending on your system: - LLVM (Clang) - Default on macOS and Linux; uses GCC-style flags - Clang-CL - Clang with MSVC-compatible flags for Windows - GCC - Common on Linux - MSVC - Visual Studio on Windows
Target¶
A Target represents something to build: a program, library, or other output. Targets have:
- Sources: Input files to compile
- Dependencies: Other targets this links against or requires
- Usage Requirements: Include dirs, defines, and flags
# Create a program target
app = project.Program("myapp", env)
app.add_sources(["main.cpp", "util.cpp"])
# Create a library target
# Adding "include" as a public include_dir will cause
# the app's build to get the proper include flags to
# find this lib's headers.
lib = project.StaticLibrary("mylib", env)
lib.add_sources(["lib.cpp"])
lib.public.include_dirs.append(Path("include"))
# Link the program against the library
app.link_private(lib)
Target Types¶
| Method | Output | Use Case |
|---|---|---|
Program() |
Executable | Applications, tools |
StaticLibrary() |
.a / .lib | Code reuse, no runtime dependency |
SharedLibrary() |
.so / .dylib / .dll | Plugins, shared code |
HeaderOnlyLibrary(name, env) |
None | Template libraries |
Note that, like CMake, targets have public and private libs and include_dirs. Public includes and libs are propagated to other targets that use this one.
Node¶
A Node is the low-level representation of a file in the dependency graph. You should rarely need to create them manually, though you'll see them when debugging.
Builder¶
A Builder defines how to create output files from inputs. Builders are provided by tools within a toolchain. You typically don't create builders directly; instead, use the high-level target API.
Behind the scenes, when you call project.Program(), pcons uses:
- The Object builder to compile .cpp files to .o files
- The Program builder to link .o files into an executable
Dependency Graph¶
Pcons builds a dependency graph of all files and their relationships:
When you run pcons build, Ninja uses this graph to:
1. Check timestamps on all files
2. Rebuild only files whose dependencies changed
3. Execute builds in parallel where possible
A dependency you declare yourself with target.depends() joins this graph
before every step of the target; how each step holds it is described under
Custom Commands.
What Gets Built: Build Tiers, Default() and Aliases¶
Every target is in one of three build tiers, which decide when it gets built:
build_tier |
built by |
|---|---|
"default" |
plain pcons, ninja or make, with no targets named |
"all" |
pcons all / ninja all / make all |
"manual" |
asking for it only |
Asking for a target means pcons build <name> when the script named it (see
Named and anonymous targets), or its output
path, or an alias. Any of those builds it whatever its tier.
Whatever a target's tier, anything it depends on gets built along with
it, as usual. (So just to be clear, marking a target as manual but
having a program depend on it will still build that target whenever
the program is built.)
Each builder puts its targets in a sensible tier. Programs, libraries,
Commands, LaTeX documents, bundles, and custom builders' outputs are
default, so a plain pcons builds everything the project makes. Installs,
overlays, archives and installers are all: they operate on the things you
built, and you don't usually want them running on every build. Test targets,
Qt's lupdate and QtDeploy are manual: run them by name (pcons test,
ninja lupdate), which works because each declares an alias. Where a target sits in the source tree makes no difference
to any of this, apart from Default()'s reach and a subdirectory included as a dependency, both below.
To change a target's tier, just set it:
zipfile.build_tier = "default" # add the zip to the default build
bench.build_tier = "all" # `ninja all` or `ninja build/bench`, not every build
lupdate.build_tier = "manual" # it rewrites sources: only when asked for
Default() is rarely needed now. When you do call it, it resets the
default tier to exactly the targets you name (several calls add to that
set), so the script has complete control of the default build:
project.Default(app) # only app, whatever else the project can make
project.Default(lib, tools) # these too
A Default() call reaches the products of its own script's directory and
below. In the top-level script that's the whole project. In a subdirectory's
script it trims that subdirectory's products and leaves the rest of the tree
alone, so a library's script can't knock your app out of the default build.
Naming a step this way (project.Default(installed)) puts it in the default
build. Most of the time it's simpler to set build_tier on the one or two
targets you want to move, and leave the rest alone.
None of this depends on the order your script does things in: the tiers are
worked out once, at generate time, from every build_tier, every Default()
call and every builder's placement. Naming a target in Default() and also
setting its build_tier to "all" or "manual" is an error, and pcons
tells you which two lines disagree.
To see every target's tier and why it's there:
pcons explain # includes a "build tiers" section: one list per tier
pcons -v # logs the same lines while generating
build_by_default
The older boolean attribute introduced in 0.23.0 will still work for one release: True means
"default", False means "manual" (which is what False did: out of
the default build and out of all). Use build_tier instead; it can
also say "all".
Aliases create named phony targets for convenient building:
# Create an alias - builds with "ninja install"
project.Alias("install", installed_lib, installed_headers)
# Create an alias for tests (note: for standard tests, pcons already creates a `test` alias)
project.Alias("run-test", test_runner)
# Now you can run:
# ninja install # Build and install
# ninja test # Build and run tests
Aliases are Ninja or Makefile phony targets: they produce no file of their own, they depend on other targets, and their name is one the build tool knows.
A target's own name is not, but pcons build <name> works anyway: pcons translates the name into the output paths before the build tool sees them.
pcons build myapp # the target named myapp, wherever it writes
pcons build myapp@mcu # one environment's copy of it
ninja install # the install alias, a phony ninja knows
ninja myapp.exe # myapp on Windows, by output path
Run ninja or make yourself and you're back to what they know: output paths and phonies. ninja myapp works only where the name and the output path coincide, which a build_prefix, an output_name or a Windows suffix each break. Make an alias when you want a name that works everywhere.
Named and anonymous targets¶
Name a target when you need to refer to it by name, from another script above all. Otherwise leave the name out and keep the Target the builder returned.
Named targets. Program, StaticLibrary, SharedLibrary, ObjectLibrary, HeaderOnlyLibrary, MetalLibrary, CargoBuild for a library crate, find_package and the Qt program and library builders always take a name, because it's also the base name of the file they write. Command, PyBuilder calls, Install, InstallAs, InstallDir, OverlayDir, Tarfile, Zipfile, android_apk(), sign_apk() and create_universal_binary() take one as an optional keyword, name=. Either way the name is the target's identity, unique within its project and environment for its kind of target — one name may belong to a program and a library at once, since foo and libfoo.so are different files — and it does three jobs:
- It names what the build writes, or rather the base of it: the toolchain adds the prefix and suffix.
Program("myapp")writesmyapp, ormyapp.exeon Windows;SharedLibrary("net")writeslibnet.so,libnet.dylibornet.dll. That is why a script names the target rather than the file: the filename differs on every platform.output_name,output_prefixandoutput_suffixoverride the parts;output_filenamereplaces the whole name. - It names the build subdir for that target. A program's objects go in
obj.<name>/; every other compiled kind adds its own word, so a static library's objects go inobj.<name>.static/, a shared library's inobj.<name>.shared/, an object library's inobj.<name>.object/and aMetalLibrary's inobj.<name>.metal/. A Qt target's generated sources sit beside its objects, inqt.<name>/orqt.<name>.shared/, and a Cargo crate's tree incargo/<name>/. - It's how everything refers to the target.
get_target(),Default("myapp"),pcons explain myappandpcons build myappall take it, as domyapp@envandsub::myapp@env, and it's how a script reaches a target another subdirectory declared. Where one name does belong to two kinds, no spelling picks one out, so all of those refuse it and say why: keep theTargetthe builder returned, or give one an alias.
The first two jobs belong to the builders that derive a path from the name. A name= on Command, Install or an archive changes no path, since the call already says what to write; it's there for the third.
Anonymous targets. A builder called without a name makes one, as do Test and the steps built on Command that pass no name through: a CargoBuild bin crate, the Qt deploy steps, the installers. Pcons labels each by what it builds: a command or an archive by its first output's path as the build tool writes it, an install by its flattened destination, a Test by the test's own name. A label is there to be read, in pcons info --targets, pcons explain, the build-tiers report and error messages; labels may repeat, and no lookup answers to one.
header = env.Command(target="gen/config.h", command="...") # labelled `gen/config.h`
source = env.Command(target="gen/config.c", command="...") # labelled `gen/config.c`
project.get_target("gen/config.h") # KeyError: a label is not a name
Keep the Target the call returned, which is what Default(), depends() and Install() want anyway. To build one from the command line, give its output path, or make an alias:
Calling Alias() multiple times with the same alias name adds targets to that alias, and you can have Aliases that contain (depend on) other Aliases.
depends() takes an alias too, and it means every target and file the alias groups, nested aliases included. It's read when the project resolves, so a member some later Alias() call adds counts as well.
Pcons defines a few aliases automatically: all (every target except the manual tier), and when the project has Test() targets, test (build and run them) and test-build (just build them). With the Makefile generator there's also clean; ninja has ninja -t clean built in. Some helpers add their own, like lupdate and deploy from the Qt tools. There's no built-in install alias; make one with Alias("install", ...) as above.
Writing your own builder? Importantly, a builder that puts the target's name into any path has to take that name from the user, the way Program does. A label derived by pcons may not be unique, and two targets building into one directory would collide.
Multi-Platform Builds¶
Handle platform differences in your build script using normal python:
import sys
env = project.Environment(toolchain="c")
# Add platform-specific flags
if sys.platform == "darwin":
env.link.flags.append("-framework CoreFoundation")
elif sys.platform == "linux":
env.link.libs.extend(["pthread", "dl"])
elif sys.platform == "win32":
env.cxx.defines.append("WIN32")
# Add toolchain-specific warning flags
# clang-cl and msvc use MSVC-style flags (/W4)
# gcc and llvm use GCC-style flags (-Wall)
if env.toolchain.name in ("msvc", "clang-cl"):
env.cxx.flags.append("/W4")
else:
env.cxx.flags.extend(["-Wall", "-Wextra"])
Subdirectories and Composable Libraries¶
add_subdirectory() runs another directory's pcons-build.py as part of the
current build, and every name assigned at module scope in that script comes back
as an attribute:
The point of this is that a library can be built either way — on its own during development, and pulled into a larger tree when something depends on it. Write the script the natural way and it works in both:
project = Project("libfoo")
if project.is_top_level:
env = project.Environment(toolchain="c")
else:
env = project.default_environment # the enclosing build's toolchain
config = configure_file("config.h.in", project.build_dir / "config.h", vars)
lib = project.StaticLibrary("foo", env, sources=["src/foo.c"])
lib.public.include_dirs.append(project.build_dir)
project.root_dir and project.build_dir always mean this
project's source directory and this project's build output, wherever
it sits. Built directly, build_dir is build/; embedded one level
down, it is build/libfoo/. The same holds several levels deep, and
sibling subdirectories stay in separate build directories.
Every relative path a script writes is read from the script's own
directory: sources=, depends=, include directories, project.node(),
configure_file(), cwd=, a Path in a command, and $SRCDIR.
project.build_dir and env.build_dir are absolute, so a path built on
them names one file wherever it goes: project.build_dir / "config.h"
works as a source, a target, a command argument, or a file on disk.
env.build_dir includes the environment's build_prefix, where a relative
target= lands; project.build_dir doesn't. The one exception to the
rule is a PathToken, whose relative path is read from the top of the
tree; give it an absolute one.
Notes:
- The subdirectory must live under the top-level project. Pointing
add_subdirectory()at a sibling checkout elsewhere on disk is an error. add_subdirectory()is the only way to nest: a second bareProject()call is an independent sibling. See Multiple projects in one script.- Only the environment needs the
is_top_levelbranch, because a standalone build has no parent to take a toolchain from.default_environmentsearches enclosing projects, so a library nested several levels down still finds it.
Someone else's project is included the same way, with the widest tier its targets may take:
Nothing the included tree declares goes into a wider tier than that, whatever
its own script chose, including its own Default(). With "manual" its
targets build only when something needs them (the app above links fmt, so it
builds) or when you ask for them, like CMake's EXCLUDE_FROM_ALL; with
"all", ninja all builds them too. A Default() in your script can still
name any of them. See examples/93_vendored_subdirectory.
A subdirectory script imports Python modules sitting next to it, the way a root build script does:
Its own directory comes first, so a module there shadows a same-named one beside the root script while the inclusion runs. Editing an imported module re-runs pcons, like editing the build script itself. Modules imported by an included script are private to that inclusion; a directory included twice imports its own copy each time.
See examples/13_subdirs for a worked example, including a library nested two
levels down that declares its own generator with relative paths.
One subdirectory, once per environment¶
add_subdirectory(..., env=...) sets the environment the included tree builds
with. That becomes the default environment for the duration of the call, so the script
above needs no change: its default_environment answers with the environment
the caller passed. Include the same directory twice to build it twice:
host = project.Environment(toolchain="c", name="host")
host.build_prefix = "host"
mcu = project.Environment(toolchain="gcc", name="mcu")
mcu.build_prefix = "mcu"
add_subdirectory("libfoo", env=host) # build/host/libfoo/libfoo.a
add_subdirectory("libfoo", env=mcu) # build/mcu/libfoo/libfoo.a
project.get_target("foo@host")
Reaching a target another script declared¶
There are three ways across the boundary, one per direction.
add_subdirectory()'s return value is the way up, for what the parent wants
from what the child made; that's the libfoo.libfoo above.
imports= is the way down, for what a directory needs from its parent or from
a sibling:
# the parent
icons = add_subdirectory("icons")
add_subdirectory("app", imports={"icons": icons.icon_header})
Anything travels that way: targets, environments, paths, functions, plain
values. Each inclusion has its own mapping, so a grandchild sees only what its
own parent passed, and a directory included once per environment can get
different objects each time. Built standalone, a script gets an empty mapping,
so it reads project.imports.get("icons") and does without.
examples/13_subdirs hands a library down this way to the sibling that links
it.
The third way is by name, and it goes either direction: the declaring script names the target, and any script in the build looks it up.
Prefer the objects: the return value when the parent wants the child's target,
imports= when the child wants the parent's or a sibling's. Prefer the name
when threading objects around is awkward, such as a loop over environments that
declares a target per pass, where every pass writes the same name= and the
environment tells the copies apart. examples/75_multi_env does that, with
parity::stamp@host and parity::stamp@strict.
Configuring what you include¶
add_subdirectory(..., vars={...}) sets the build variables the included tree
reads with get_var, for the duration of the call:
Values are python, not command-line strings: False, 2,
Path("/opt"). They shadow the command line. To let the user
override one, say so explicitly:
Names not overridden keep whatever the command line or parent gave them.
This replaces setting os.environ before the call, which is not recommended.
Some rules and notes for setting variables like this:
- Both environments must be named and must differ in
build_prefix. The names are what tell the twofootargets apart, and the prefixes are what keep their output files apart. - A nested
add_subdirectory()inherits the parent's variables. Everything under the call builds in that environment, however deep, unless an inner call passes its ownenv=, which wins for its own subtree only. - It overrides every other source, including the environments the enclosing project registered.
- A script that makes its own environment is not overridden. That environment is local to the script.
See examples/75_multi_env for a worked example: parity/ is described once
and built for both environments, with each environment's flags.
Multiple projects in one script¶
Some builds contain more than one separate project: firmware plus the
host tools that flash it, an application plus its installer, two
configurations of one source tree, or a monorepo with several
independent sub-projects. Each Project() created outside
add_subdirectory() is an independent top-level project, with its own
build directory, environments, node namespace, defaults and build
files:
device = Project("device") # the default build dir
denv = device.Environment(toolchain="c")
denv.cc.defines.append("DEVICE_BUILD")
device.Program("app", denv, sources=["src/main.c"])
host = Project("host", build_dir=f"{device.build_dir}-host") # its own build dir
henv = host.Environment(toolchain="c")
host.Program("app", henv, sources=["src/main.c"])
One pcons run generates and builds both, in script order. The rules:
- Each project owns a build directory. The
-B/PCONS_BUILD_DIRdefault gets assigned to the first project; every later sibling must passbuild_dir=, and two projects claiming the same directory is an error. - Targets belong to the project that made them.
device.Program(...)binds todevice. BareTarget()andEnvironment()calls bind to the most recently created project, so in a multi-project script, create things through the project or an env. - Names can be qualified to avoid collisions.
pcons appis ambiguous above;pcons device::appbuilds one project's target.pconswith no targets builds every project;pcons -B build-host buildselects just that project's build dir and targets. - Aliases group across projects. An alias is a user-level grouping, so
one name declared in several places means all of them:
pcons docsbuilds every project'sdocsalias, and within a tree, declarations in subprojects merge into one group (ninja docsbuilds them all). A name that is an alias in one project and a plain target in another must be qualified. - Directory-scoped commands act on one directory.
cleanandtestnever run the build script, so they see only the-Bdirectory (default: the first project's); scope them withpcons -B build-host test.--graph/--mermaidfiles are written per project: the first under the requested name, each sibling with its name suffixed (deps.dot,deps-host.dot). - Subdirectories anchor explicitly.
device.add_subdirectory("lib")(oradd_subdirectory("lib", project=device)) parents into that project. Both siblings may embed the same directory: each inclusion re-runs the script in its project's tree, so the library compiles per project, with that project's flags. - The root
compile_commands.jsonsymlink points at the first project's database; the others stay in their build directories. - One Ninja/Makefile per project: Each project keeps its own
build.ninjaorMakefile, so to build manually, you'd need individualninja -Ccalls.
Sometimes multiple projects are the best way to separate concerns, and sometimes when there's a bit more coupling, multiple environments are best. Use whichever makes most sense for your build (and you can use both!)
See examples/66_multi_project for a worked multi-project example.
Building Projects Step by Step¶
Let's walk through a few progressively more complex examples.
Hello World - Single File Program¶
The simplest possible project: one source file, one output.
File structure:
hello.c:
pcons-build.py:
from pcons import Project
# Setup
project = Project("hello", build_dir="build")
env = project.Environment(toolchain="c")
# Create program
hello = project.Program("hello", env)
hello.add_sources(["hello.c"])
hello.private.compile_flags.extend(["-Wall", "-Wextra"])
project.Default(hello)
Build and run:
Multiple Source Files¶
A program with multiple source files and a header.
File structure:
include/math_ops.h:
src/math_ops.c:
#include "math_ops.h"
int add(int a, int b) {
return a + b;
}
int multiply(int a, int b) {
return a * b;
}
src/main.c:
#include <stdio.h>
#include "math_ops.h"
int main(void) {
int a = 5, b = 3;
printf("add(%d, %d) = %d\n", a, b, add(a, b));
printf("multiply(%d, %d) = %d\n", a, b, multiply(a, b));
return 0;
}
pcons-build.py:
from pcons import Project
project = Project("calculator", build_dir="build")
env = project.Environment(toolchain="c")
calculator = project.Program("calculator", env)
calculator.add_sources(["src/main.c", "src/math_ops.c"])
calculator.private.include_dirs.append("include")
calculator.private.compile_flags.extend(["-Wall", "-Wextra"])
project.Default(calculator)
Static Library¶
Create a reusable static library and link it to a program.
File structure:
pcons-build.py:
from pcons import Project
project = Project("myproject", build_dir="build")
env = project.Environment(toolchain="c")
# Create static library
libmath = project.StaticLibrary("math", env)
libmath.add_sources(["src/math_utils.c"])
# Public includes propagate to consumers
libmath.public.include_dirs.append("include")
# Public link libs (e.g., math library on Linux).
# link("m") adds a raw -l library (placed after objects on the link line);
# use link_flags for other linker flags (placed before objects).
libmath.link("m")
# Create program that uses the library
app = project.Program("myapp", env)
app.add_sources(["src/main.c"])
app.link_private(libmath) # Gets libmath's public includes automatically!
project.Default(app)
Key points:
- public.include_dirs propagates to targets that link against this library
- app.link_private(libmath) adds libmath as a dependency and applies its public requirements. Use link_private() to keep the dependency local (as here, since app is the final program) or link() to re-export it to consumers of this target.
link() / link_private() vs. the link_libs lists
target.link(...) and target.link_private(...) are the recommended high-level forms. They are exactly equivalent to appending to target.public.link_libs and target.private.link_libs respectively — those lists remain fully supported as the low-level form, and accept the same Target objects and library-name strings.
Static Libraries That Need Each Other¶
Two static libraries may link each other:
That is a dependency cycle, and for static libraries it is allowed. Neither has
to be built before the other (compiling one needs only the other's headers),
and the linker gets both archives in the form it wants: wrapped in
-Wl,--start-group ... -Wl,--end-group for GNU ld and lld, which search an
archive once, and as they are for Apple's ld and MSVC's link, which rescan.
Object libraries and header-only libraries may be in such a cycle too. A
program or shared library may not, since it would have to be built before its
own dependency; a cycle through one is an error that says which target is the
problem. See examples/85_static_lib_cycle.
System Include Directories¶
Vendored third-party headers are a special case: you want them found, but you don't want their warnings, and you certainly don't want -Werror failing your build on code you can't change. Every compiler has a second kind of include path for this — -isystem on GCC/Clang, /external:I on MSVC (pcons adds /external:W0 alongside it), -imsvc on clang-cl. In pcons it's system_includes, on the tool or as a usage requirement:
# On the environment
env.cxx.system_includes.append("vendor/ae-sdk")
# Or as a usage requirement, so consumers inherit the headers
# without inheriting the warnings
sdk = project.HeaderOnlyLibrary("ae_sdk")
sdk.public.system_include_dirs.append("vendor/ae-sdk")
app.link(sdk)
Everything that works for includes works here: transitive propagation, deduplication, and path relativization in the generated build files. See examples/58_system_includes.
An external package takes the same treatment through a system= argument, which moves its include dirs across without any list surgery:
doctest = project.find_package("doctest", system=True)
nanobind = ImportedTarget.from_package(description, system=True)
env.use(description, system=True)
system= is off by default, deliberately: -isystem on a directory the compiler already searches — which is what a system or pkg-config prefix usually is — reorders the include search and can break the standard library. Best to use it on prefixes owned by a package manager or a fetched source tree. Packages fetched by pcons-fetch are already recorded that way, and opt out per package with system = false in deps.toml.
A package that uses -isystem in its pkg-config Cflags needs also works: both the pkg-config and Conan finders read it into system_include_dirs, so even MSVC properly gets /external:I.
To systemize a target someone else created, make_includes_system() moves its include dirs in place:
Shared/Dynamic Library¶
Create a shared library (.so on Linux, .dylib on macOS, .dll on Windows).
pcons-build.py:
from pcons import Project
project = Project("myproject", build_dir="build")
env = project.Environment(toolchain="c")
# Create shared library
libplugin = project.SharedLibrary("plugin", env)
libplugin.add_sources(["src/plugin.c"])
libplugin.public.include_dirs.append("include")
# Optional: name the file outright, prefix and suffix included
libplugin.output_filename = "myplugin.ofx" # instead of libplugin.so
# Output naming defaults (output_name replaces the base name below;
# output_filename replaces the whole thing):
# SharedLibrary "foo":
# Linux: libfoo.so
# macOS: libfoo.dylib
# Windows: foo.dll
# StaticLibrary "foo":
# Linux/macOS: libfoo.a
# Windows: foo.lib
# Program "foo":
# Linux/macOS: foo
# Windows: foo.exe
# Create program that uses the library
app = project.Program("host", env)
app.add_sources(["src/main.c"])
app.link_private(libplugin)
project.Default(app, libplugin)
Project with Subdirectories¶
Organize a larger project with separate directories.
File structure:
project/
├── pcons-build.py
├── include/
│ ├── math_utils.h
│ └── physics.h
└── src/
├── main.c
├── math_utils.c
└── physics.c
pcons-build.py:
This single top-level build script builds the whole system:
from pcons import Project
project = Project("simulator", build_dir="build")
env = project.Environment(toolchain="c")
# Library: libmath - low-level math utilities
libmath = project.StaticLibrary("math", env)
libmath.add_sources(["src/math_utils.c"])
libmath.public.include_dirs.append("include")
libmath.link("m") # Link math library
# Library: libphysics - depends on libmath
libphysics = project.StaticLibrary("physics", env)
libphysics.add_sources(["src/physics.c"])
libphysics.link(libmath) # Re-exports libmath's includes to consumers
# Program: simulator - main application
simulator = project.Program("simulator", env)
simulator.add_sources(["src/main.c"])
simulator.link_private(libphysics) # Gets BOTH physics and math includes!
# Set defaults
project.Default(simulator)
For larger projects, you may want to create a build script for each lib in its own dir and use add_subdirectory(). See Subdirectories and Composable Libraries.
Debug and Release Variants¶
Use set_variant() to switch between debug and release builds.
pcons-build.py:
from pcons import Project, get_variant
# Get variant from command line: pcons --variant=debug
# Defaults to "release"
variant = get_variant("release")
# Build into per-variant dir
project = Project("myapp", build_dir=f"build/{variant}")
env = project.Environment(toolchain="c")
# Apply variant settings
# debug: -O0 -g
# release: -O2 -DNDEBUG
# release-fastest: -O3 -DNDEBUG (/O2 /Ob3 on MSVC)
env.set_variant(variant)
# Add extra flags
env.cc.flags.append("-Wall")
app = project.Program("myapp", env)
app.add_sources(["main.c"])
project.Default(app)
print(f"Variant: {variant}")
The variants are debug, release, release-fastest, relwithdebinfo and
minsizerel; each toolchain realizes them in its own flags. release-fastest
is the compiler's highest optimization level that doesn't change results, so
it never turns on fast-math; add that yourself if you want it.
Usage:
# Release build (default)
uvx pcons
./build/release/myapp
# Debug build
uvx pcons --variant=debug
./build/debug/myapp
To see what a variant (or a preset, or anything else) did to the build, pcons explain myapp prints each target's concrete commands and where every flag and include directory came from. For the example above, in release mode:
## Explanation of Targets and Environments: /home/user/myapp
Commands are shown as the build runs them, from the build directory (build/release).
=== myapp (program) [env #1] pcons-build.py:19
* build/release/obj.myapp/main.c.o <- main.c
/usr/bin/clang -O2 -Wall -DNDEBUG -MD -MF obj.myapp/main.c.o.d -c -o obj.myapp/main.c.o ../../main.c
* build/release/myapp <- build/release/obj.myapp/main.c.o
/usr/bin/clang -o myapp obj.myapp/main.c.o
requirements:
defines:
NDEBUG <- env.cc (base)
Environment #1 (toolchain: llvm) pcons-build.py:9
cc.flags:
-O2 <- release (variant)
-Wall <- (manual)
cc.defines:
NDEBUG <- release (variant)
cxx.flags:
-O2 <- release (variant)
cxx.defines:
NDEBUG <- release (variant)
Warning
Notice that this example adds -Wall to env.cc.flags. cc and cxx are distinct in pcons, so if you want both you have to add it to env.cxx.flags as well, or just use the "warnings" preset; see below.
Semantic Presets¶
In addition to build variants (debug/release), pcons provides presets for common development workflows. Presets are orthogonal to variants — you can combine them freely.
# Apply warning flags (all warnings; add "werror" to make them errors)
env.apply_preset("warnings")
# Promote warnings to errors (compose with "warnings")
env.apply_preset("werror")
# Apply address/undefined behavior sanitizers
env.apply_preset("sanitize")
# Enable profiling
env.apply_preset("profile")
# Enable link-time optimization
env.apply_preset("lto")
# Enable security hardening flags
env.apply_preset("hardened")
Presets are toolchain-specific — each toolchain produces the appropriate flags:
| Preset | Unix (GCC/LLVM) | MSVC |
|---|---|---|
warnings |
-Wall -Wextra -Wpedantic |
/W4 |
werror |
-Werror |
/WX |
sanitize |
-fsanitize=address,undefined -fno-omit-frame-pointer |
/fsanitize=address |
profile |
-pg -g (compile+link) |
/PROFILE (linker) |
lto |
-flto (compile+link) |
/GL (compile) + /LTCG (link) |
hardened |
-fstack-protector-strong -D_FORTIFY_SOURCE=2 -fPIE + -pie -Wl,-z,relro,-z,now |
/GS /guard:cf + /DYNAMICBASE /NXCOMPAT /guard:cf |
Combine presets with variants for a complete configuration:
Variants act like a knob: calling set_variant() again replaces the
previous variant's flags rather than piling on top of them, so
env.set_variant("release") followed by env.set_variant("debug") switches
cleanly. To build both variants side by side, clone the environment (see
Environment Cloning).
Where Did This Flag Come From? (pcons explain)¶
Once variants, presets, and manual edits combine in a complex build, it can be
unclear which setting produced a given flag. pcons explain (optionally with
target names) prints each target's concrete commands and attributes every
flag, define, and command override to the variant, preset, or environment that
contributed it; anything set directly (or a toolchain default) is labelled
(manual). It also shows the build-script line where each target and
environment was created. Given:
from pcons import Project
project = Project("myapp", build_dir="build")
env = project.Environment(toolchain="c")
env.set_variant("release")
env.apply_preset("warnings")
env.cc.flags.append("-fno-strict-aliasing")
app = project.Program("myapp", env, sources=["main.c"])
pcons explain prints:
## Explanation of Targets and Environments: /home/user/myapp
Commands are shown as the build runs them, from the build directory (build).
=== myapp (program) [env #1] pcons-build.py:9
* build/obj.myapp/main.c.o <- main.c
/usr/bin/clang -O2 -Wall -Wextra -Wpedantic -fno-strict-aliasing -DNDEBUG -MD -MF obj.myapp/main.c.o.d -c -o obj.myapp/main.c.o ../main.c
* build/myapp <- build/obj.myapp/main.c.o
/usr/bin/clang -o myapp obj.myapp/main.c.o
requirements:
defines:
NDEBUG <- env.cc (base)
Environment #1 (toolchain: llvm) pcons-build.py:4
cc.flags:
-O2 <- release (variant)
-Wall <- warnings (feature)
-Wextra <- warnings (feature)
-Wpedantic <- warnings (feature)
-fno-strict-aliasing <- (manual)
cc.defines:
NDEBUG <- release (variant)
cxx.flags:
-O2 <- release (variant)
-Wall <- warnings (feature)
-Wextra <- warnings (feature)
-Wpedantic <- warnings (feature)
cxx.defines:
NDEBUG <- release (variant)
Note
The same attribution is available inside a build script as a string:
env.explain(), or env.cc.explain() for one tool. Cross presets and
SDK wiring show up the same way (e.g. cc.cmd <- wasi-sdk), so explain
is the first tool to reach for when a build uses a flag or a compiler
you didn't expect.
Working with Environments¶
An Environment carries the toolchain, flags, and tools a target is built with.
Environment Cloning¶
Create variant environments by cloning:
# Base environment
env = project.Environment(toolchain="c")
# Clone for profiling - gets a COPY of all settings
profile_env = env.clone()
profile_env.cxx.flags.extend(["-pg", "-fno-omit-frame-pointer"])
# Build both variants
app_release = project.Program("app", env)
app_profile = project.Program("app_profile", profile_env)
Key points about environments:
- Each
project.Environment()call creates a fresh environment with toolchain defaults env.clone()creates a deep copy - changes to the clone don't affect the original. A clone has no name;env.clone(name="host")gives it one. Two environments need names, and differentbuild_prefixsettings, before they can hold targets with the same name- Environments don't share state - there's no "base" environment that accumulates
- You can clone at any point and re-tune the clone:
set_variant()(and other exclusive presets) replace the previous setting, sodebug_env = release_env.clone(); debug_env.set_variant("debug")works - If you see duplicate flags, check if you're accidentally adding flags multiple times in your script or use
pcons explain
Temporary Environment Overrides¶
env.override() yields a temporary clone of the environment; the original is untouched. You can then modify that clone — it's an ordinary Environment, so a flag list is an ordinary Python list and every operation is just Python:
with env.override() as tuned:
tuned.cxx.flags.append("-O1") # add
tuned.cxx.flags.remove("-Werror") # remove one
tuned.cxx.flags = [
f
for f in tuned.cxx.flags # remove by pattern
if not f.startswith("-W")
]
tuned.cxx.flags.insert(0, "-fno-strict-aliasing") # order matters
tuned.cxx.flags = ["-O1"] # replace outright
project.Library("mylib", tuned, sources=["lib.cpp"])
Keyword arguments are a shorthand that assigns, so they are for scalars:
with env.override(variant="debug", cc__cmd="clang") as temp_env:
project.Program("app_debug", temp_env, sources=["main.cpp"])
Tool settings use tool__attr notation because Python keywords can't contain a dot.
override() is clone() plus a scope, and the block isn't required — it just saves the assignment and shows where the modified environment applies. When the modified environment outlives one stretch of the script, keep a clone instead:
careful = env.clone()
careful.cc.flags.remove("-O2")
careful.cc.flags.append("-O1")
lib.add_sources(["cuda-support.cxx"], env=careful)
lib.add_sources(["other-touchy.cxx"], env=careful)
Keyword arguments don't take lists
env.override(cxx__flags=["-O1"]) raises. It can only mean "assign", but at a call site it looks like it ought to mean "add -O1" — and assigning would silently discard all existing flags. Since which of add / remove / reorder / replace you meant can't be inferred, you have to say it in the block:
The error message calls out the flags the call would have dropped and shows each form.
Per-File Flags¶
To compile one file in a target differently, pass the override environment along with the source.
lib = project.StaticLibrary("core", env, sources=common_sources)
with env.override() as careful:
careful.cxx.flags.append("-O1") # this file miscompiles at -O2
lib.add_sources(["cuda-support.cxx"], env=careful)
The file stays part of the target, so it keeps the target's include dirs, defines, and everything inherited from its dependencies — only the environment layer changes.
env.cc.Object() (see examples/17_object_sources) is another way to apply unique flags: compiling a standalone object that several targets can link directly. It sits outside any target, so no target's usage requirements apply to it, and it can use its own or any environment. Its first argument is the object's path; a relative one is placed in the environment's build directory (under its build_prefix, if any), so env.cc.Object("helper.o", "src/helper.c") and the example's build_dir / "helper.o" land in the same place.
Multiple Toolchains¶
Pcons supports combining multiple toolchains in a single environment. This is useful for projects that mix languages, such as C++ with CUDA, or C++ with Cython.
Adding Additional Toolchains¶
Use env.add_toolchain() to add extra toolchains to an environment:
from pcons import Project
project = Project("gpu_app", build_dir="build")
# Create environment with C/C++ toolchain
env = project.Environment(toolchain="c++")
# Add CUDA toolchain for .cu files
env.add_toolchain("cuda")
# Now this target can have both .cpp and .cu sources
app = project.Program("gpu_app", env)
app.add_sources(
[
"main.cpp", # Compiled with C++ compiler
"kernel.cu", # Compiled with CUDA nvcc
]
)
How Source Routing Works¶
When a target has sources with different file extensions, pcons routes each source to the appropriate compiler:
.cfiles → C compiler from primary toolchain.cpp,.cxx,.ccfiles → C++ compiler from primary toolchain.cufiles → CUDA compiler from CUDA toolchain (if added)
The primary toolchain (passed to project.Environment()) has precedence. If multiple toolchains claim to handle the same file type, the primary toolchain wins.
Variant Support with Multiple Toolchains¶
When you call env.set_variant(), the variant is applied to all toolchains:
env = project.Environment(toolchain="c++")
env.add_toolchain("cuda")
# This applies "debug" settings to both C++ AND CUDA compilers
env.set_variant("debug")
# C++ gets: -O0 -g
# CUDA gets: -G -g (device debugging)
Available Toolchain Finders¶
Toolchain name strings resolve through finder functions, which are also available directly for programmatic use:
| Function | Description |
|---|---|
find_c_toolchain() |
Find C/C++ toolchain (LLVM, GCC, MSVC, etc.) |
find_cuda_toolchain() |
Find CUDA toolchain (returns None if nvcc not found) |
One Library, Several Environments¶
A firmware build needs two toolchains at once: the cross compiler that makes the image, and the host compiler for the generators and tests that run on the build machine. Some code belongs to both.
Each environment owns where its targets are built:
mcu = project.Environment(toolchain="gcc", name="mcu")
mcu.build_prefix = "mcu" # everything this environment writes
mcu.archive_directory = "lib" # its static libraries, below that
host = project.Environment(toolchain="gcc", name="host")
host.build_prefix = "host"
host.archive_directory = "lib"
| Setting | What it moves |
|---|---|
env.build_prefix |
Everything: link outputs, object files, env.Command() targets |
env.runtime_directory |
Programs |
env.library_directory |
Shared libraries |
env.archive_directory |
Static libraries, and Windows import libraries |
All four are relative to the build directory, and empty by default: a project
that sets none of them sees no path change. The toolchain still decides the
lib prefix and the .a / .lib suffix, which is why these are directories
and output_prefix is not (that one replaces the toolchain's filename prefix).
build_prefix sits under the build directory and above the add_subdirectory
offset. With env.build_dir = "build/rel", a build_prefix of mcu gives
build/rel/mcu, and a sub-project keeps its own offset inside that slice, at
build/rel/mcu/sub. Setting env.build_dir names the whole directory, so a
sub-project that does it drops its own offset, and its targets keep theirs.
Everything the environment writes follows both settings, objects and artifacts
alike. env.build_dir is the one place that decides where an environment builds.
Because the two environments write in different directories, a target name can be repeated, and building one library for both is a plain Python function:
def common_lib(env):
lib = project.StaticLibrary("common", env, sources=["src/common.c"])
lib.public.include_dirs.append(project.root_dir / "src")
return lib
common = common_lib(mcu) # build/mcu/lib/libcommon.a
common_host = common_lib(host) # build/host/lib/libcommon.a
Two targets may share a name when both environments are named and the names
differ, or when the two are different kinds of target: a Program and a
SharedLibrary called smudge write smudge and libsmudge.so. One name for
one kind in one environment is still refused, and the error says so. Name both
environments but give neither a build_prefix and the artifacts stay apart
while the objects would not; pcons refuses that collision too, naming both
targets. This is about names: two anonymous targets may always share a label
(see Named and anonymous targets).
Naming one of them: name@env¶
@ selects the environment, :: selects the project, and @ binds tighter, so
sub::common@mcu is target common of sub-project sub, built in mcu. It
works wherever a target can be named, in the script and on the command line
alike:
project.get_target("common@mcu")
project.Default("app@host", "app@strict")
app.link(project.get_target("common@mcu"))
A link() string is always a raw link token, so link("m") means -lm and a
target is linked by looking it up first.
A plain common matches two targets here, so pcons stops and prints both
qualified names rather than picking one.
See examples/75_multi_env for the smallest complete case, and
examples/74_bare_metal for the cross-compiled one.
Compiler Cache¶
Speed up rebuilds by wrapping compile commands with ccache or sccache:
# Auto-detect: tries sccache first, then ccache
env.use_compiler_cache()
# Explicit choice
env.use_compiler_cache("ccache")
env.use_compiler_cache("sccache")
This sets the cache as the launcher on the cc and cxx tools (see below). Only compile commands are affected — the linker and archiver have nothing to cache. If the requested tool isn't in PATH, a warning is logged and no changes are made.
Notes:
- On MSVC (cl.exe), only sccache works. If you request ccache with an MSVC toolchain, pcons warns and does nothing.
- Calling use_compiler_cache() twice is a no-op, and it leaves any launcher you set yourself in place.
Command Launchers¶
A launcher runs in front of the command an edge would otherwise run: ccache ahead of the compiler, valgrind ahead of a test. Set it on a tool namespace and it follows that tool:
env.cc.launcher = ["ccache"]
env.cc.launcher = ["ccache", "time"] # prepended in order (so first becomes the outermost)
Like every command in pcons, a launcher is a list of tokens rather than a string, so a program whose path contains a space stays one argument.
A launcher can also belong to a single command rather than to a tool, so you could use that for a wrapper for a single expensive step:
env.Command(
target="model.stl",
source="model.py",
command="python $SOURCE --out $TARGET",
launcher=["valgrind", "-q"], # launcher is just a list of tokens, so args are OK too
)
Both compose, outermost first: a launcher on the tool runs outside the one on the command.
Notes:
- Launcher tokens are passed through as written. They are a program and its arguments, and/or multiple programs, not paths in the dependency graph, so pcons does not rewrite them for the directory the build runs in. Use absolute paths (
project.root_dir / "tools" / "wrap.py"if they're not on$PATH). compile_commands.jsonreports the compiler itself, without launchers, so clangd and other tools see the real compile.- Environment variables for one command are a launcher under the hood:
env_vars=onCommand()renders asenv NAME=VALUE(or a pcons helper on Windows) innermost in the chain. See Custom Commands below.
See examples/63_command_launcher for two stacked launchers wrapping every C compile, and a third belonging to one command.
Running clang-tidy With Every Compile¶
env.use_clang_tidy() runs clang-tidy on each C and C++ source, with that compile's own flags, and then compiles it. The compile runs either way, so the object is still produced and the diagnostic repeats on the next build rather than turning into a missing file downstream. What fails the build is clang-tidy's own exit status: a warning doesn't fail it, an error does, and --warnings-as-errors=* turns the warnings into failures too.
env.use_clang_tidy() # clang-tidy from PATH
env.use_clang_tidy("/opt/llvm/bin/clang-tidy") # a specific one
env.use_clang_tidy(args=["--warnings-as-errors=*"])
Put the checks in a .clang-tidy file next to the sources; clang-tidy finds it by itself. args is for the rest: --use-color, --export-fixes=..., --warnings-as-errors=....
Under the hood this is a launcher, python -m pcons.tools.co_compile, and it composes with use_compiler_cache() in either order: the cache keeps wrapping the compiler, and the analysis never sees it. If clang-tidy isn't installed, a warning is logged and the compiles are left alone. compile_commands.json still reports the compiler itself.
See examples/76_clang_tidy.
Working with External Dependencies¶
Finding Packages with project.find_package()¶
The simplest way to use an external package is project.find_package(). It searches for the package using available finders (pkg-config, system paths) and returns an ImportedTarget that you can link against or apply to an environment.
from pcons import Project
project = Project("myapp", build_dir="build")
env = project.Environment(toolchain="c")
# Find packages (raises PackageNotFoundError if not found)
zlib = project.find_package("zlib")
openssl = project.find_package("openssl", version=">=3.0")
# Find with components
boost = project.find_package("boost", components=["filesystem", "system"])
# Optional dependency — returns None if not found
optional = project.find_package("optional-dep", required=False)
# Third-party headers as system headers (-isystem): found the same way,
# but their warnings never reach your -Werror. See "System Include Directories".
doctest = project.find_package("doctest", system=True)
# Use as a dependency (public requirements auto-propagate)
app = project.Program("myapp", env, sources=["main.cpp"])
app.link_private(zlib)
# Or apply directly to an environment
env.use(openssl)
By default, find_package() tries PkgConfigFinder first, then SystemFinder. You can prepend custom finders:
from pcons.packages.finders import ConanFinder
# Add a Conan finder — it will be tried first
project.add_package_finder(ConanFinder(config, conanfile="conanfile.txt"))
# Now find_package() tries: Conan → PkgConfig → System
fmt = project.find_package("fmt")
Precedence: first finder to return a result wins. add_package_finder() call prepends, so the most recently added finder is
consulted first, then the defaults (PkgConfig, then System). A finder added
after a package was already looked up still applies to every later lookup. A finder that comes up empty (wrong version, tool
missing the package) falls through to the next, and the winning source is
recorded on the package as a found_by property (e.g. "pkg-config", "rez+pkg-config",
"system"). A finder whose tool isn't installed is skipped with a warning. Run with --debug configure (see debug logging) to
see which finder answered (or passed on) each package.
Results are cached per (environment, name, version, components) — including
negative results, so repeated find_package(..., required=False) probes
don't re-run the finder chain and its subprocesses; a later required=True
call for the same failed key raises from the cache.
One search per environment¶
A cross build and a host build must not share an answer: the host's headers are the wrong ones for the cross compiler, and using them fails much later as a missing header. So each environment searches its own chain and holds its own result.
mcu = project.Environment(toolchain="gcc", name="mcu")
mcu.apply_cross_preset(linux_cross(triple="aarch64-linux-gnu", sysroot="/opt/aarch64"))
host = project.Environment(toolchain="gcc", name="host")
zlib_mcu = project.find_package("zlib", env=mcu) # searches /opt/aarch64
zlib_host = project.find_package("zlib", env=host) # searches this machine
An environment given a cross preset with a sysroot searches that sysroot and
nothing else, through PKG_CONFIG_SYSROOT_DIR and PKG_CONFIG_LIBDIR for
pkg-config and the sysroot's usr/include and usr/lib for the system finder.
One with a cross preset and no sysroot searches nothing and raises
PackageNotFoundError, which is the honest answer: pcons does not know where
that target's packages are. Add a finder that does:
An env= finder is used in that environment only, and is tried before the
project-wide ones. Without env=, a finder is used everywhere, which is what
a single-environment project wants.
Omitting env= on find_package() uses the environment a target created
without one would get, so a single-environment project needs none of this and
behaves as before. Environments are told apart by their name, so scoping
needs a named environment.
See Using Conan Packages below for more details about using Conan with pcons.
Header-Only and Manual Packages¶
Some libraries (especially header-only ones) don't have .pc files and can't be found by find_package(). Create an ImportedTarget manually using PackageDescription:
from pcons import ImportedTarget, PackageDescription
# Header-only library with no .pc file
httplib = ImportedTarget.from_package(
PackageDescription(
name="cpp-httplib",
include_dirs=["/opt/homebrew/include"],
defines=["CPPHTTPLIB_OPENSSL_SUPPORT"],
)
)
If the manual package depends on another package, link() it to wire up transitive dependencies — you don't need to copy public requirements manually:
openssl = project.find_package("openssl")
httplib = # ... see above
httplib.link(openssl) # openssl requirements propagate to anything linking httplib
# Now any target that links httplib automatically gets openssl too
app = project.Program("myapp", env, sources=["main.cpp"])
app.link_private(httplib) # gets httplib AND openssl includes, libs, flags
Using pkg-config¶
The PkgConfigFinder uses the system's pkg-config to find packages.
from pcons.packages.finders import PkgConfigFinder
# Create finder
finder = PkgConfigFinder()
if finder.is_available():
# Find a package
zlib = finder.find("zlib", version=">=1.2")
if zlib:
print(f"Found zlib {zlib.version}")
print(f"Includes: {zlib.include_dirs}")
print(f"Libraries: {zlib.libraries}")
# Apply to environment
env.use(zlib)
Using Conan Packages¶
The ConanFinder integrates with Conan 2.x for package management.
conanfile.txt:
pcons-build.py:
from pcons import Project, get_variant
from pcons.configure.config import Configure
from pcons.packages.finders import ConanFinder
variant = get_variant("release")
# Configure and find toolchain
config = Configure(build_dir="build")
toolchain = find_c_toolchain()
# Set up Conan
conan = ConanFinder(
config,
conanfile="conanfile.txt",
output_folder="build/conan",
)
# Create project and environment
project = Project("conan_example", build_dir="build")
env = project.Environment(toolchain=toolchain)
env.set_variant(variant)
env.cxx.flags.append("-std=c++17")
# Sync Conan profile with toolchain settings. build_type follows the
# environment's variant; cppstd can be set explicitly, or inferred from
# env.cxx.flags.
conan.sync_profile(toolchain, env=env)
# Install packages (cached, only runs when needed)
packages = conan.install()
# Get the fmt package
fmt_pkg = packages.get("fmt")
if not fmt_pkg:
raise RuntimeError("fmt package not found")
# Apply package settings with env.use()
env.use(fmt_pkg)
# Build program
hello = project.Program("hello_fmt", env)
hello.add_sources([project_dir / "src" / "main.cpp"])
project.Default(hello)
sync_profile() Reference¶
conan.sync_profile() generates a Conan profile from pcons settings:
conan.sync_profile(
toolchain, # Detects compiler, version, OS, arch
env=env, # build_type from env.variant; cppstd from env.cxx.flags
build_type="Release", # Overrides the variant: Release, Debug, RelWithDebInfo, MinSizeRel
cppstd="23", # Explicit C++ standard (overrides env inference)
)
build_type follows the environment's variant when you leave it out: debug is Debug, release and release-fastest are Release, relwithdebinfo and minsizerel their Conan names. With one environment per variant, one ConanFinder per variant (each with its own output_folder) gives each its matching packages.
The cppstd parameter sets compiler.cppstd in the Conan profile, which many packages require. If omitted, it's inferred from env.cxx.flags (e.g., -std=c++23 becomes compiler.cppstd=23). You can also use the lower-level conan.set_profile_setting("compiler.cppstd", "23") before calling sync_profile().
A conf entry set with conan.set_profile_conf(), such as tools.build:cxxflags, is not part of Conan's package id: changing it reuses the binary Conan already built unless you tell Conan the conf matters:
conan.set_profile_conf("tools.build:cxxflags", '["-march=native"]')
conan.set_profile_conf("tools.info.package_id:confs", '["tools.build:cxxflags"]')
The env.use() Helper¶
The env.use() method is the simplest way to apply package settings:
# Apply all settings from a package
env.use(pkg)
# This automatically:
# - Adds include_dirs to cxx.includes
# - Adds defines to cxx.defines
# - Adds library_dirs to link.libdirs
# - Adds libraries to link.libs
# - Adds link_flags to link.flags
# Same, but the include dirs land on cxx.system_includes (-isystem), so the
# package's headers produce no warnings. The package itself is unchanged.
env.use(pkg, system=True)
If your project uses rez, see Integrations → Rez for native rez-resolve support —
RezFinderplugs intofind_package()andrez_environment(env)injects every resolved package's flags.
macOS Framework Linking¶
On macOS, link against system frameworks using env.Framework():
import sys
if sys.platform == "darwin":
# Link a single framework
env.Framework("CoreFoundation")
# Link multiple frameworks
env.Framework("Foundation", "Metal", "QuartzCore")
# Add framework search paths for non-system frameworks
env.link.frameworkdirs.append("/Library/Frameworks")
env.Framework("SomeThirdParty")
This adds the appropriate -framework and -F flags to the linker command. Framework linking is only available on macOS with GCC or LLVM toolchains.
env.Framework() puts the flags on the environment, so every target built with it links the framework. To scope a framework to one library and have it propagate only to targets that link that library, use the target-level usage requirement instead: lib.public.frameworks.append("CoreFoundation") (and lib.public.framework_dirs for search paths). See examples/89_macos_frameworks for a worked example.
For more complex scenarios where you need framework flags in compile commands (e.g., for headers), you can also access the raw flags:
# Manual approach (usually not needed)
env.link.flags.extend(["-framework", "Metal"])
env.link.flags.extend(["-F", "/path/to/frameworks"])
Paths in Linker Flags (PathToken)¶
Sometimes you need to embed a file path inside a linker flag, such as -Wl,-force_load,<path> (macOS whole-archive linking) or -Wl,--version-script=<path>. Plain strings don't work here because the path needs to be relativized correctly for the generator (Ninja runs from the build directory, so paths must be relative to it).
Use PathToken to embed paths in flags:
from pcons import PathToken, Project
project = Project("myapp")
env = project.Environment(toolchain="c")
lib = project.StaticLibrary("mylib", env)
lib.add_sources(["src/mylib.c"])
prog = project.Program("myapp", env)
prog.add_sources(["src/main.c"])
prog.link_private(lib)
# Force-load all symbols from the static library (macOS)
prog.private.link_flags.append(
PathToken(prefix="-Wl,-force_load,", path="libmylib.a", path_type="build")
)
PathToken takes three key arguments:
- prefix: The flag text before the path (e.g., "-Wl,-force_load,", "-Wl,--version-script=")
- path: The file path
- path_type: How the path should be interpreted:
- "build" — relative to the build directory (for build outputs like libraries)
- "project" — relative to the project root (for source tree files)
- "absolute" — used as-is
See examples/33_path_in_flags for a complete working example.
Custom Build Steps¶
Not every build step is a compile or a link. env.Command() runs an arbitrary command as part of the graph, post-build commands attach to a target that was just built, and a custom builder packages either one up for reuse.
Custom Commands with env.Command()¶
Use env.Command() to run arbitrary shell commands as build steps. This is useful for code generators, asset processing, or any tool that doesn't fit the standard compile/link model.
# Generate a header from a template
env.Command(
"config.h", # Target file(s)
["config.h.in", "version.txt"], # Source file(s)
"python generate_config.py $SOURCES > $TARGET",
)
# Run a code generator with multiple outputs
env.Command(
["parser.c", "parser.h"], # Multiple targets
"grammar.y", # Single source
"bison -d -o ${TARGETS[0]} $SOURCE",
)
# Command with no source dependencies
env.Command(
"timestamp.txt",
None, # No sources
"date > $TARGET",
)
Variable substitution:
| Variable | Description |
|---|---|
$SOURCE, $SOURCES |
All source files (space-separated) — the two spellings mean the same thing; use ${SOURCES[0]} for the first one |
$TARGET, $TARGETS |
All target files (space-separated) |
${SOURCES[n]} |
Indexed source access (0-based) |
${TARGETS[n]} |
Indexed target access (0-based) |
${SOURCES[n:m]} |
A range of sources — either end may be omitted |
${TARGETS[n:m]} |
A range of targets |
$TOOL |
The program tool= names, spelled so the shell will run it |
$SRCDIR |
The source directory of the script declaring the command (the project root, at the top level) |
$$ |
Literal $ (escaped) |
Anything else inside ${...} is an error. An unrecognized form would otherwise reach build.ninja as a shell-escaped literal and run as nonsense.
Sources keep the order you wrote them in. ${SOURCES[0]} is the first source declared, whether or not it's another target's output:
# ${SOURCES[0]} is the template; ${SOURCES[1:]} is however many .def files there are
env.Command(
target=gen_dir / "entries.c",
source=[template, *def_files],
command="python $SRCDIR/tools/fill.py ${SOURCES[0]} $TARGET ${SOURCES[1:]}",
)
A substitution can be part of an argument rather than all of it — the text around it comes along:
Run a program the build made with tool=. The program that runs a command is not one of its inputs, so it has its own argument, and pcons writes it the way the shell running the build executes it: ./collate for a POSIX shell, which looks a bare name up on $PATH rather than in the build directory, and a backslashed path for cmd.exe, which reads a / as a switch. Don't write ./${SOURCES[0]} by hand; it gets one of those wrong. See examples/59_codegen_sources.
env.Command(
target=gen_dir / "entries.c",
tool=collate_tool,
source=def_files,
command="$TOOL $TARGET $SOURCES",
)
tool= becomes an implicit dependency, never a source, so $SOURCES keeps meaning what the command consumes and the indices you already wrote keep their files. A Target is a program this build produces, from any environment — a host tool over a cross-compiled artifact is the case it exists for. A str or a Path is a program that already exists: an absolute path to an installed tool, or a bare name looked up on $PATH. Neither of those adds a dependency, since there is nothing to build.
$TOOL and tool= go together: one without the other is an error.
A list-form command can name its program directly. In the list form a token may be a Target or a FileNode rather than a string. It expands to that output's path and becomes an implicit dependency, without joining $SOURCES:
env.Command(
target=gen_dir / "entries.c",
source=def_files,
command=[collate_tool, "$TARGET", "$SOURCES"],
)
A Path in the list names a file the same way (read from the script's directory when relative, like sources=) but adds no dependency, since it may be a directory the command only writes to. Pass the Path itself, not str() of it, and pcons writes it from wherever the command runs.
First in the list, that token is the program, spelled to run just as $TOOL is. Anywhere else it is an argument and stays a plain path, which is what a wrapper reading the file wants. Where a command gives both, index 0 is what runs and $TOOL is one more path on the line. A Target that builds several files is ambiguous and raises: name the one that is meant, parser.output_nodes[0].
Text attached to a form that expands to several paths repeats on each of them, which is what such a flag always means: -i${SOURCES[1:]} becomes -ione.def -itwo.def, not one -i welded to the first path.
An attached substitution does not change any other substitution in the command. For a command with multiple outputs, -o $TARGET -Map=${TARGET}.map still passes every output after -o, then emits one -Map= argument per output. A compile or link has one output, so the same form in a flag names it: app.private.link_flags += ["-Wl,-Map=${TARGET}.map"] puts the map file next to the program. One substitution per argument: ${SOURCE} and ${TARGET} in the same token is an error.
A slice is the right tool when the input count is a property of the project rather than of the rule — adding a .def file above changes nothing in the build script. See examples/59_codegen_sources, which also shows why a glob needs project.add_configure_dependency() on the directory it read.
Use $SRCDIR to reference files in the source tree that aren't listed as sources. Since the build runs from the build directory, relative paths to source-tree files won't resolve correctly without this:
# Run a source-tree script that isn't a build dependency
env.Command(
target="generated.h",
source="schema.json",
command="python $SRCDIR/tools/codegen.py $SOURCE -o $TARGET",
)
Each of the three kinds of path has its own base. sources= and depends= are relative to the directory of the script that declares the command; in a subdirectory reached through add_subdirectory that is the subdirectory, not the project root. target= is relative to the build directory of that same script — build/<subdir>/, where its programs and libraries also build, under the environment's build_prefix — so target=env.build_dir / "out.txt" and target="out.txt" name the same file. The command's own tokens are the third kind, and worth stating plainly: a relative string inside a command is looked for under the build directory, where the command runs, so "tools/gen.pl" will not be found. Write $SRCDIR/tools/gen.pl, pass a Path in the list form (project.current_dir / "tools/gen.pl", which pcons writes from wherever the command runs), or move the whole command with cwd= below. A build-directory path a tool takes as an argument, an output directory say, is the same: pass env.build_dir / "stage" as a Path.
To use a generated file as a source, pass the target. sources= names files in the source tree, so a generated file passed the way its target= was — sources=["gen/parser.c"] — would erroneously look in the source tree. Using the proper build path works fine: sources=[project.build_dir / "gen/parser.c"]. That will also correctly add the dependency. Passing the target is even better; no path to keep in sync:
parser = env.Command(
target="gen/parser.c",
source="grammar.y",
command="bison -o $TARGET $SOURCE",
)
project.Program("app", env, sources=["src/main.c", parser])
A target with several outputs hands over all of them — a .c/.h pair, say. Just slice to get the one(s) you want: parser.output_nodes[0].
Pcons warns when a command token names a path under the build directory (-Wl,build/libfoo.dylib): the command runs in the build directory, so that path resolves to build/build/.... Pass a Path built on env.build_dir instead. Set PCONS_WARN_BUILD_DIR_PATHS=0 on the occasion the path really is right as written.
A file named when the build resolves. target= and source= also take a callable, and so does a token of a list-form command. pcons calls it when the edge resolves, after the edge's dependencies have, so it can read what they settled. That's how a command names its output after a program, whose file name is the toolchain's business: firmware here, firmware.exe on Windows.
firmware = project.Program("firmware", env, sources=["src/main.c"])
env.Command(
target=lambda: f"{firmware.output_nodes[0].path.stem}.hex",
source=[firmware],
command=["objcopy", "-O", "ihex", "$SOURCE", "$TARGET"],
)
A callable returns what the script could have written in its place: files for target= and source=, and text, a Path or a node for a token, where a node becomes a dependency as it does above. What it reads must have resolved by then: a target in source= or depends= has, and so has one the source= callable returns. The edge needs no name; it's labelled by its output once it has one. env.PyBuilder()'s emitter= is the same idea, packaged with a builder.
Don't quote tokens yourself. pcons keeps a command as a list of tokens and quotes each one for the shell it is writing for, so command=f'"{tool}" $SOURCE' reaches the program with the quotes still attached and it reports that no such file exists. Write it bare; a token that must contain a space goes in the list form, which isn't split on whitespace. pcons raises on a token that starts with a quote — a trailing one is ordinary, since -DNAME="value" wants its quotes delivered. When the quotes really are meant, say so with Verbatim:
from pcons import Verbatim
env.Command(
target="counts.txt",
source="log.txt",
command=["awk", Verbatim("'{print $1}'"), "$SOURCE", ">", "$TARGET"],
)
Running somewhere else: cwd=
Build tools run from the build directory, and pcons writes every path in a command relative to it. Some tools can't live with that — they open an input by a path relative to the source root, or write beside their inputs. cwd= moves the command, and moves its paths with it: $SOURCE, $TARGET and $SRCDIR all come out relative to the directory you named, so nothing else in the rule changes. A relative cwd is read from the script's directory, as sources= is.
# The tool finds its input at "data/items.txt" -- relative to the source root
env.Command(
target=gen_dir / "items.c",
source=[make_items],
depends=["data/items.txt"],
command="$SOURCE $TARGET",
cwd=project.root_dir,
)
Paths stay relative wherever they can, so build.ninja remains as relocatable as it was; only a directory no relative path can reach forces an absolute one. Makefiles already write their source paths absolutely, so a moved command there is absolute throughout.
Don't write the cd into the command yourself. It looks equivalent but isn't: pcons wraps your command with steps of its own — post_build() commands, and the write_if_different stash below — that run in the build directory and name their files relative to it. A one-way cd strands them. cwd= changes back. See examples/60_command_cwd.
Extra dependencies with depends=: Files listed in depends= trigger a rebuild when they change, but don't appear in $SOURCE/$SOURCES. Use this for scripts, config files, or other build-time inputs:
# Rebuild when the codegen script or its config changes
env.Command(
target="generated.h",
source="schema.json",
command="python $SRCDIR/tools/codegen.py $SOURCE -o $TARGET",
depends=["tools/codegen.py", "tools/codegen.cfg"],
)
You can also add dependencies to any target after creation using target.depends():
app = project.Program("app", ["main.c"])
app.depends("app.ld") # Relink when the linker script changes
app.depends(gen) # Build the generator first; don't link it
What depends() means. If anything in your target needs something built
first, whether the target's own output or one of its objects, say
target.depends(thing). The thing can be a target or a file. Pcons
builds it (if needed) before any step of your target, then each step
of that builder then decides for itself whether a change to it means
rerunning: a step that discovers its dependencies as it runs (like a
compile, through its depfile, that records all included file paths)
reruns only when what it discovered changes, so a generated header it
never included doesn't recompile it, and neither does a linker script;
a step that doesn't do any discovery (the link step, an env.Command
without depfile=)
reruns whenever the dependency changes. A target given to depends()
also passes its public usage requirements on, as if it were link()ed,
but it is not passed to the linker command line.
A command discovers its dependencies with depfile=. If the tool
writes a make-style depfile, name it and ninja rebuilds against whatever
the file lists. depfile=".d" means the tool writes <target>.d. A tool
that names the file itself gets its path instead, written like target=:
env.Command(
target="gen/schema.h",
source="schema.json",
command="python $SRCDIR/tools/codegen.py $SOURCE -o $TARGET",
depfile=".d", # codegen.py writes gen/schema.h.d
)
env.Command(
target=artifact,
command=["cargo", "build", ...],
depfile=artifact.with_name("libfoo.d"), # cargo's own name for it
)
Either way the command has one target, and ninja deletes the file once it has read it.
A step that discovers its dependencies can still miss one: a response
file, a sanitizer ignore-list, anything the tool reads but never writes
to its depfile. For those, say target.depends(thing, on_change=True)
and every step reruns when it changes, whether or not the step's own
record mentions it. The opposite, on_change=False, says the thing only
has to exist first and never reruns a step by itself.
A file a command names in a flag is a dependency of that command without
any depends() at all, when pcons knows the file: write_file() and
configure_file() register what they write, and the build knows what it
produces. So PathToken(prefix="-Wl,--version-script=", path=exports) in
link_flags relinks when the export list changes. Include and library
directories are never files, so they're unaffected.
You may pin a dependency to one step if you want, by building that
step yourself: obj = env.cc.Object(...) then obj.depends(thing). For
an ordinary compile-and-link target that doesn't buy you anything,
because the compile already discovers what extra files it read.
The dependency edge is propagated forward: a library whose public
headers are generated says lib.depends(gen) once, and every target
that links lib compiles after the generator too. See
examples/83_generated_public_headers. If you write a builder of your
own, see Custom Builders for your half of the rule.
Use $$ for a literal dollar sign. pcons delivers it to the command verbatim: it is quoted and escaped so that neither ninja, nor make, nor the shell gets to interpret it. That is what tools that have their own use for a dollar need — the ELF dynamic linker, awk, sed:
# Set rpath to $ORIGIN for portable shared libraries
env.link.flags.append("-Wl,-rpath,$$ORIGIN")
# The program, not the shell, sees the dollar
env.Command(
target="rev.txt",
source="in.txt",
command="stamper --keyword=$$Revision$$ --out=$TARGET $SOURCE",
)
So $$HOME is not a shell variable reference — it is the five characters $HOME. Build scripts are Python, so read environment variables there, at configure time, where the value is visible to pcons and recorded in the build files:
import os
env.Command(
target="output.txt",
source="input.txt",
command=f"pack --home={os.environ['HOME']} $SOURCE $TARGET",
)
The command runs during the build phase, and Ninja tracks dependencies so the command only re-runs when sources change.
Multiple commands: Chain commands with shell operators:
# Run multiple steps with && (stops on first failure)
env.Command(
target="output.txt",
source="input.txt",
command="step1 $SOURCE -o temp.txt && step2 temp.txt -o $TARGET",
)
Generators that rewrite everything: write_if_different=True
Ninja's restat skips downstream work when a command's output didn't actually change — but only if the generator leaves unchanged files alone, and most generators rewrite every output on every run. write_if_different=True fixes that without the generator's cooperation: pcons stashes the outputs, runs the command, and restores any output that came back byte-identical, timestamp included. It implies restat=True.
env.Command(
target=[gen_dir / f"S_{name}.c" for name in names],
source=[manifest],
command=f"{python} $SRCDIR/tools/gen.py $SOURCE",
write_if_different=True, # one changed input != recompile everything
)
Without it, adding one entry to a 280-plugin manifest recompiles all 280. With it, only the new one. See examples/57_staged_generation.
The two halves of that stash have to run in the same directory, so a command that changes directory and doesn't change back fails the build with an explanation, rather than restoring nothing and exiting 0. Use cwd= (above) instead of a bare cd.
Passing only some sources: ${SOURCES[n]}¶
$SOURCE and $SOURCES contain the whole list of the target's sources. Sometimes a command already knows the paths of some of its sources and you don't want to pass them all to the command line. In this example below, organizer.py knows about gridfinity.py and that shouldn't be passed on the command line, but it should rerun when gridfinity.py changes, so just use [0] to slice it:
env.Command(
target="organizer.stl",
source=["organizer.py", "gridfinity.py"], # both must trigger a rebuild
command=[python, "${SOURCES[0]}", "--out", "."], # only the first is run; presumably the command knows about gridfinity.py through other means
)
With plain $SOURCES, that command would be python organizer.py gridfinity.py --out ., so the shared module would be passed as an extra argument.
Note: pcons warns when you write the singular form and the command has more than one source. Use the plural $SOURCES when consuming them all is the intent — cat $SOURCES > $TARGET for example.
Tip
Instead of passing and slicing implicit sources like this, you can also set up a separate dependency: target.depends('gridfinity.py').
Environment variables for one command: env_vars=
A tool that reads its configuration from the environment — a signing server URL, a license key — needs that variable set for its own command and no other:
env.Command(
target="firmware.signed",
source="firmware.bin",
command="sign-tool $SOURCE -o $TARGET",
env_vars={"SIGNING_URL": "https://signer.example.com"},
)
The variables are written into the generated build file, in front of the one command they belong to: env NAME=VALUE on POSIX, a small pcons helper on Windows (which has no env). So they survive a direct ninja or make run, and no other command sees them. Setting os.environ in the build script would do neither — it reaches every command, and only while pcons itself runs. See examples/73_command_env.
Exporting Only Some Symbols¶
Some shared libs, plugins for instance, should export only certain symbols. -fvisibility=hidden can be insufficient in some cases. The .set_option("exported_symbols", [...]) method on a shared library or executable target allows users to specify a list of symbol names to be exported, in an OS-independent way: a symbol list on macOS, a version script on Linux, a .def file with MSVC and clang-cl.
plugin = project.SharedLibrary("myplugin", env, sources=["plugin.cpp"])
plugin.set_option("exported_symbols", ["OfxGetPlugin", "OfxGetNumberOfPlugins"])
The names are the C names; pcons adds the Darwin underscore. macOS and Linux
accept * patterns ("Spark*"), MSVC doesn't support patterns so it will complain if passed one.
The list is written under the target's build directory and the link depends
on it, so changing the list relinks.
If you already have an OS-specific linker def or symbol list file,
just pass it as a source to the builder: a .def in the target's
sources list on MSVC and clang-cl (it becomes /DEF:), and a version script
or symbol list as a PathToken in link_flags on Linux and macOS, which
the link then depends on.
Python Functions as Build Steps: env.PyBuilder()¶
env.Command() runs a program. env.PyBuilder() turns a Python function you wrote in the build script into a builder, the way env.Program is a builder. Calling it makes one build edge, and calling it again makes another:
@env.PyBuilder()
def report(targets, sources, title):
from pathlib import Path
lines = [title]
for name in sources:
text = Path(name).read_text(encoding="utf-8")
lines.append(f"{Path(name).name}: {len(text.split())}")
Path(targets[0]).write_text("\n".join(lines) + "\n", encoding="utf-8")
first = report(
target=project.build_dir / "report.txt",
source=[src / "a.txt", src / "b.txt"],
title="word counts",
)
second = report(
target=project.build_dir / "report2.txt",
source=[src / "c.txt", src / "d.txt"],
title="word counts, second set",
)
project.Default(first, second)
report is a builder; first and second are the Targets its calls returned. The whole of examples/90_python_builder is that, plus a second environment.
Underneath, a PyBuilder edge is an ordinary command edge: it takes restat= and worker= the way env.Command() does, and pcons explain shows it the same way, as a (command) edge with its command line, sources, environment and call site.
The function does not run while the build is described. pcons writes its source once to a generated module under the environment's build directory, build/pybuilder/report.py, writes each call's arguments to a pickle beside it, named after the target it builds, build/pybuilder/report.txt.args.pkl, and each call emits an ordinary edge that runs the module. So the work happens when ninja decides it is needed, in parallel with every other edge, and not again until an input changes. It is a build step, not a configure step. Both files are written when pcons resolves the build, not by the call, so a script that only describes the build writes nothing. The runner that loads them is copied once per build directory, to build/pybuilder/pcons-runner/pcons-runner.py, a directory of its own, so build.ninja names no file inside the pcons installation.
The function is called as fn(targets, sources, **kwargs). Both path lists are spelled as the build tool sees them, so they open as written. Its return value is reserved and must be None.
What the function sees. targets, sources, and the call's own keywords, and nothing else. No context object, no implicit handle on the build script, the environment or the project: the closure ban and the script-global ban below are exactly what keeps a function from reaching past its own parameters. A file the function opens without naming it in source= is not a declared input of the edge.
When the keywords are read. When the build resolves, as the environment is: not at the call, and not at build time. The call still checks them at once, pickling them with a placeholder for anything resolve fills in, so a bad one is blamed on the line that passed it. Most keywords are plain data, pickled as written, dollar signs and all. Two kinds of value aren't:
- A
Targetarrives as the list of paths of the files it builds, and a node,project.node("data.csv"), as its path. Both open as written from where the function runs, assourcesdo. The edge depends on them, so a change to one reruns the function. - A
Subst("$cc.flags")arrives asenv.subst_list()expands it in the edge's environment:["-O2", "-Wall"], the flags the build settled on rather than the ones in force at the call.
Either one expands where it sits: in a list, a dictionary value, or an attribute of your own object. A set element or a dictionary key can't hold a list, so a Target or Subst there is refused. So is anything else of pcons's, because unpickling it at build time would import pcons: Subst("$cc.flags") works, env.cc.flags itself doesn't. The environment, the project and an alias are refused too, for the reason given below.
The decoration says how the function runs, the call says what to build. No option sits at both levels, except depends=: on the decoration it is a dependency of every edge the builder makes, on the call it is a dependency of that edge alone. Two edges that must otherwise run differently are two decorations.
on env.PyBuilder() |
on the call |
|---|---|
python=, worker=, cwd=, launcher= |
target=, source=, name= |
env_vars=, restat=, write_if_different= |
the function's own arguments, as plain keywords |
depends=, emitter=, discovers= |
depends= |
write_if_different=True is worth knowing here, because a Python function usually rewrites its output every run. See the env.Command() section above.
Discovered inputs. A function may read files its call never named: the entries of a manifest, a template's includes. Declare the builder discovers=True and the function returns {"inputs": [...]}, the files it read, and the build tool reruns the edge when one of them changes. A relative path is read from the build directory, where the function runs, so a path built from one of its sources is right as it is. pcons writes the depfile: that's why depfile= and deps_style= aren't options here. The edge may have one target, which the depfile is named after, and cwd= can't be combined with it.
@env.PyBuilder(discovers=True)
def bundle(targets, sources):
from pathlib import Path
manifest = Path(sources[0])
listed = [manifest.parent / name for name in manifest.read_text().split()]
Path(targets[0]).write_text("".join(path.read_text() for path in listed))
return {"inputs": listed}
bundle(target="bundle.txt", source=["manifest.txt"])
A builder that names its own targets: emitter=. project.Program works out a program's file name from what it was given; a PyBuilder does the same with an emitter. pcons calls it when the edge resolves, after the edge's dependencies have, so it may read what they settled:
firmware = project.Program("firmware", env, sources=[src / "main.c"])
def named_after_the_program(targets, sources, env, **kwargs):
program = sources[0].output_nodes[0].path
return [*targets, f"{program.stem}.hex"], sources
@env.PyBuilder(emitter=named_after_the_program)
def to_hex(targets, sources):
from pathlib import Path
Path(targets[0]).write_bytes(Path(sources[0]).read_bytes()[:16].hex().encode())
dump = to_hex(source=[firmware])
It's called as emitter(targets, sources, env, **kwargs) and returns the pair (targets, sources) the edge really has. The lists are what the call passed, either possibly empty, so an emitter may add to them or replace them; the targets source= holds are still Targets, resolved by now. The keywords are the call's own, the ones the function gets, so an emitter that names a file after one declares that parameter and takes **kwargs for the rest. With an emitter the call may leave out target=, and needs no name=: the edge is labelled by its output, and its argument pickle is named after it too, build/pybuilder/firmware.hex.args.pkl. examples/92_python_builder_emitter is this, built end to end.
The emitter is an ordinary function of the build script, unlike the decorated one: it runs in this process while pcons resolves, so it may use anything around it. A target it reads has to be one the call passed in source=, or one the edge otherwise depends on; nothing else is resolved in time.
Discovered outputs. An emitter decides names from what the build description knows at resolve. An output whose name or count only a built file decides is out of its reach. Staged Generation still gets there, no new mechanism needed: a first call whose only declared target is a small manifest, and a second call, made from inside a project.when_generated() block once ninja has built that manifest and re-run pcons, whose targets come from what it says. examples/57_staged_generation is the worked example. It uses env.Command() for both calls, and a PyBuilder() call plays the same role there.
Edge names and labels. name= on the call is optional and does what it does on env.Command(): it makes the edge a named target, which get_target(), Default() and pcons build all answer to. Without one pcons labels the edge by its first target's path, so reports read the way the build file does, and two edges may wear one label without colliding:
one = report(target="out/report.txt", source=[src / "a.txt"], title="one")
two = report(target="tmp/report.txt", source=[src / "b.txt"], title="two")
A label is only there to be read (see Named and anonymous targets). Build an unnamed edge by its output path, or give it an alias with project.Alias().
Reserved parameter names. target, source, name and depends are refused as parameters of the function, because the call spends them on the edge. Rename them; the error says which ones and what the call does with them.
Three rules follow from the function travelling alone.
- It imports what it needs inside its own body. The generated module holds the function and nothing else, so a name this script imported does not exist there. pcons refuses a body that reads one, naming it, rather than letting the build fail later with
NameError. The function runs with thesys.pathits script had when it was decorated, so it imports what the script itself could, a module beside the script included.python=names another interpreter instead, and the function then runs with that interpreter's own path. Editing such a module does not re-run the edge on its own: it is a dependency of the function, not of one call, so it belongs on the decoration or on.depends(), both below, rather than repeated in every call's owndepends=. - It reads nothing from around it. A function that closes over a variable of an enclosing function is refused for the same reason. Take the value as a parameter and pass it at the call, where it travels in the pickle, so every value there must be picklable. Some that pickle are refused anyway: an environment, a project, an alias and a tool namespace all belong to the build description, which does not exist when the function runs. From an environment, read the values you want here and pass those,
Subst("$cc.flags")rather thanenv.cc. A target or a node is fine: it arrives as its files' paths, as described above. - The call returns the
Target.firstabove is a target, like everything else a pcons builder returns. It goes toproject.Default(), toproject.Install(), or into another target'ssource=.
A lambda, a functools.partial, a method and a builtin are all refused: only a plain def written out in a build script has source to extract. So is a signature the build-time call could not satisfy, such as one whose first two parameters are keyword-only.
A dependency of the function. A module the body imports is a dependency of the function, not of one call: declare it once, on the decoration, and every edge the builder makes waits on it.
@env.PyBuilder(depends=[project.root_dir / "wordcount.py"])
def report(targets, sources, title):
...
report(target="report.txt", source=[src / "a.txt"], title="word counts")
The same list grows later, with .depends() on the builder env.PyBuilder() returns, and reaches an edge already made:
@env.PyBuilder()
def report(targets, sources, title):
...
made = report(target="report.txt", source=[src / "a.txt"], title="word counts")
report.depends(project.root_dir / "wordcount.py")
Both reach every edge the builder makes, whichever comes first in the script. The call's own depends= still exists, for a dependency of that one edge alone.
Rebuilds. The generated module, the pickle and the runner copy are inputs of the edge, and all three are written only when their bytes change. Regenerating with a pcons whose runner changed re-runs every PyBuilder edge. Edit the function body and every edge that reads it re-runs. Change one of a call's arguments and only that edge re-runs. Edit anything else in the build script, a comment or a line above the decoration, and pcons regenerates the build files but no edge re-runs, because nothing any of them depends on moved.
Several environments are served by a plain Python helper that decorates once per environment. A builder belongs to the environment that decorated it:
def make_report(environment):
@environment.PyBuilder()
def report(targets, sources, title):
from pathlib import Path
lines = [title]
for name in sources:
text = Path(name).read_text(encoding="utf-8")
lines.append(f"{Path(name).name}: {len(text.split())}")
Path(targets[0]).write_text("\n".join(lines) + "\n", encoding="utf-8")
return report
host = project.Environment(name="host")
strict = project.Environment(name="strict")
strict.build_prefix = "strict"
report = make_report(host)
checked = make_report(strict)(
target="report.txt", source=[src / "a.txt"], title="word counts, strict"
)
build_prefix is what keeps the two report.txt apart, and it gives each environment its own copy of the generated module, build/pybuilder/report.py and build/strict/pybuilder/report.py, with identical bytes. Naming the environments is what lets report@strict pick one of a pair of same-named targets later. See examples/75_multi_env for the multi-environment idiom itself.
The closure ban and this shape fit each other: the body sits inside make_report, where environment is in scope, so everything it needs comes through keywords of the call.
A pipeline. A builder's Target goes into another builder's source= like any other, which is how a chain gets its order:
text = fetch(target=project.build_dir / f"{name}.txt", url=url, field="feed.lipsum")
source = embed(target=project.build_dir / f"{name}.c", source=[text], symbol=name)
project.Default(project.Program(name, env, sources=[source]))
examples/91_python_builder_pipeline is that, with two builders each called twice: one downloads a JSON document and writes a field of it, one turns those bytes into a C program, and project.Program compiles and links the result. Two builders, four edges, two generated modules.
A warm interpreter. Starting Python costs more than a small function does. worker=PythonWorker() runs the edge in an interpreter that is already up:
from pcons.workers.python import PythonWorker
worker = PythonWorker()
@env.PyBuilder(worker=worker)
def report(targets, sources):
...
report(target="report.txt", source=[src / "a.txt"])
Post-Build Commands¶
Add commands that run after a target is built using target.post_build():
plugin = project.SharedLibrary("myplugin", env, sources=["plugin.cpp"])
# Add rpath for macOS plugin loading
plugin.post_build("install_name_tool -add_rpath @loader_path $out")
# Code sign the output
plugin.post_build("codesign --sign - $out")
Variable substitution in post_build:
| Variable | Description |
|---|---|
$out |
The primary output file path |
$in |
The input files (space-separated) |
Commands run in the order they are added. The fluent API allows chaining:
target.pre_build() is the mirror image, for commands that must run before the target's own command, with the same $out/$in substitutions.
Custom Builders¶
Create custom tools for specialized build steps:
from pcons.core.builder import CommandBuilder
from pcons.tools.tool import BaseTool
class ProtobufTool(BaseTool):
def __init__(self) -> None:
super().__init__("protoc")
def default_vars(self) -> dict[str, object]:
return {
"cmd": "protoc",
"protocmd": "$protoc.cmd --cpp_out=$$outdir $$in",
}
def builders(self) -> dict[str, object]:
return {
"Compile": CommandBuilder(
"Compile",
"protoc",
"protocmd",
src_suffixes=[".proto"],
target_suffixes=[".pb.cc", ".pb.h"],
single_source=True,
),
}
# Use the tool
protoc_tool = ProtobufTool()
protoc_tool.setup(env)
env.protoc.Compile("message.pb.cc", "proto/message.proto")
What depends() means to the steps you create. A build script says
target.depends(thing) and pcons builds the thing before any step of
the target. How each step your builder contains then handles it is up
to the step, so a builder has three rules to follow:
- Let each step discover its dependencies whenever the tool can
report them: a depfile, or
/showIncludes. Make the step withGenericCommandBuilder(command, depfile=".d", deps_style="gcc"), which tells pcons the step discovers, and have the command write the file at$TARGET.d. A step that discovers only waits for a dependency to exist, and reruns when what it discovered changes (ninja records the files and timestamp). A step that doesn't have discovery (i.e. no depfile) reruns whenever any dependency changes. - If a step reads something it cannot discover at runtime (a response
file, an options file) and its name is known up front, declare it
on that step's node with
node.depends(). - Don't touch a target's
depends()list from inside a builder. It belongs to the build script.
examples/84_asset_pipeline is a custom three-step builder that walks through
all three, with rebuild tests for each.
Qt Applications¶
Pcons has first-class Qt 6 support — a Qt Widgets application is a simple build script:
from pcons import Project, find_c_toolchain
from pcons.toolchains.qt import find_qt
project = Project("myapp")
env = project.Environment(toolchain=find_c_toolchain())
env.cxx.set_standard(17)
qt = find_qt(project, env, modules=["Widgets"])
app = project.QtProgram(
"myapp",
env,
sources=["main.cpp", "mainwindow.cpp", "mainwindow.ui", "icons.qrc"],
link=[qt.Widgets],
)
find_qt() locates Qt (pkg-config or qtpaths introspection — Linux
distro packages, Homebrew, the official installer, Windows) and handles
the platform quirks: macOS frameworks, MSVC's required flags, Windows
debug library suffixes. QtProgram takes .ui and .qrc files
directly in sources and finds Q_OBJECT classes automatically; the
scan happens when pcons generates, not during the build, and common
mistakes fail with actionable messages.
Also available: QtQmlModule (QML modules with QML_ELEMENT C++
types), QtResources (embed files from a Python list — no .qrc
XML), QtTranslations (+ a ninja lupdate utility target), QtDeploy
(ninja deploy via macdeployqt/windeployqt), and the low-level
env.qt.Moc/Uic/Rcc builders.
See the Qt guide for the full story: how automoc works,
the staleness guard, generated-file layout, platform notes, and current
limitations. Examples 52_qt_widgets through 56_qt_deploy are
working starting points, and the
CMake porting guide maps each qt_* CMake
command to its pcons equivalent.
Generators and IDE Integration¶
What pcons writes besides the build file itself: other generators, a dependency diagram, and the compile_commands.json that editors read.
compile_commands.json¶
Build generators (Ninja, Makefile, Xcode) automatically generate compile_commands.json alongside build files. A symlink is also created at the project root so tools find it automatically. No extra code is needed.
To disable generation entirely, or to keep everything inside the build directory (no project-root symlink), generate explicitly:
from pcons import Generator
Generator().generate(project, compile_commands=False) # no compile_commands.json
Generator().generate(project, root_symlink=False) # no root symlink
With multiple build configurations in one project root, the last generation
to run owns the root symlink. On Windows, creating a symlink needs Developer
Mode or admin rights; without them pcons skips the root link (-v says so).
clangd still finds the database in the default build directory.
This enables source inspection and completion features in:
- VS Code with clangd extension
- CLion and other JetBrains IDEs
- Vim/Neovim with coc-clangd
- Emacs with eglot or lsp-mode
Alternative Generators¶
While Ninja is the default and recommended build executor, pcons also supports generating Makefiles for environments where Ninja isn't available.
Makefile generator¶
Generate a traditional Makefile instead of Ninja build files — no script changes needed:
Or pin it in the build script, e.g. for a project that always uses make:
Then build with:
The Makefile generator supports the same project structure as the Ninja generator, so you can switch between them without changing your build script.
Xcode project generator¶
Pcons can also generate Xcode projects, though the functionality is significantly limited compared to Ninja and Makefiles.
This generator needs the pbxproj package,
which a plain install doesn't bring in; ask for it explicitly with
pip install 'pcons[xcode]'. Without it, -G xcode says so and stops.
Some of these limitations:
- Custom commands and generated sources.
env.Command(), custom tools, and custom builders have no Xcode equivalent, and its shell-script phases can't consume a depfile, so generated sources never trigger correct rebuilds. That also rules out Qt's moc/uic, LaTeX, command launchers and persistent workers. - Variants. Xcode has its own Debug/Release configuration system, and pcons variants don't feed it.
- Product paths. Xcode writes products into
Release/orDebug/, so built paths don't match what the rest of the graph reports. - Languages. C, C++, Objective-C/C++, Swift and assembly compile. Fortran, the wasm toolchains, Metal libraries and Windows resources don't: a project whose targets are all unsupported generates an empty project rather than an error.
- No
testtarget. Ninja and make emit atestphony; with Xcode, runpcons testinstead. - ObjectLibrary, alias targets, and pre-compiled
.osources aren't representable in Xcode's target model.
Dependency Visualization¶
Generate dependency graphs:
from pcons.generators.mermaid import MermaidGenerator
# Generate Mermaid diagram
MermaidGenerator().generate(project)
# Creates build/deps.mmd
Or from the command line:
pcons generate --mermaid=deps.mmd # To file, relative to the current directory
pcons generate --mermaid # To stdout
pcons generate --graph=deps.dot # DOT format
The graph shows what the script declares. Three kinds of detail come from a
finished build instead, and --graph-detail asks for them: headers (the
compiler's .d files), scan (a scanner's own edges, elided by default),
and discovered (the dependencies a scanner found). See
Seeing it in the graph.
Command-line reference¶
See the complete CLI reference, on its own page: Command-line reference.
Adding custom pcons CLI commands: pcons run¶
A build script can declare commands of its own, reachable as pcons run <name>:
@project.cli_command()
@click.option("--baud", default=115200)
def flash(baud: int) -> None:
"""Flash the board."""
...
The callback remembers the script's project and targets, so it knows the
build directory and every output path. It runs with the
project already resolved, and builds nothing unless it declared a dependency with e.g.
flash.depends(firmware), in which case pcons builds that first. Add-on
modules can declare commands too, with pcons.cli_command().
For more detailed info, see: User Commands.
Watching for changes¶
--watch builds once and then rebuilds whenever anything in the source tree
changes. It works with the default command and with pcons build, and takes
the same targets and options as a normal build:
pcons --watch # Build, then rebuild on every change
pcons --watch myapp # Watch, building only 'myapp'
pcons build --watch -j8
Editing the build script counts as a change: ninja re-runs pcons to bring
build.ninja up to date before building, so adding a source file or changing a
flag takes effect without restarting the watch. A build that fails does not
stop the watch — the next edit is usually the fix. Press Ctrl-C to stop.
The build directory is never watched (reacting to the build's own output would
loop forever), nor are VCS directories, virtualenvs, tool caches, or editor
scratch files. Anything ninja knows how to build is also left out, wherever it
lands — so a command that generates a file next to its sources, or an in-source
build (-B .), doesn't retrigger the build that wrote it.
One thing a watch reports that an ordinary build does not is a rebuild
loop. If several builds in a row are triggered the instant the previous one
finished, all by the same file, the watch stops and names it: that file is
written by the build itself, so each build is asking for the next. Declare it
as an output of the command that writes it, or send it to the build directory.
(A build that did not converge is reported after every successful build,
watched or not; see pcons build.)
Watching uses the platform's native filesystem notification (inotify, FSEvents,
ReadDirectoryChangesW) through the
watchfiles package. It installs with
pcons on Linux, macOS and Windows, so --watch works out of the box — including
with uvx pcons --watch. On any other platform pcons installs without it and
--watch says so; ask for it explicitly with pip install 'pcons[watch]',
which builds from source and needs a Rust toolchain.
For more on how watch mode works, see Persistent Workers
Build Variables¶
You can pass variables to your build script:
Access them in pcons-build.py:
from pathlib import Path
from pcons import get_var
port = get_var("PORT", "ofx")
use_cuda = get_var("USE_CUDA", False)
prefix = get_var("PREFIX", Path("/usr/local"))
Typed Variables¶
The default's type selects the conversion, so a variable never has to be parsed by hand:
use_cuda = get_var("USE_CUDA", False) # bool
opt_level = get_var("OPT_LEVEL", 2) # int
scale = get_var("SCALE", 1.0) # float
port = get_var("PORT", "ofx") # str
prefix = get_var("PREFIX", Path("/usr/local")) # Path
ports = get_var("PORTS", ["ofx"]) # list: PORTS=ofx,ae gives ["ofx", "ae"]
Pass type= when there is no default. The result is None when the variable is
unset, which is falsy, so it still reads well in a condition:
A default and a type= together raise: the default already picks the
conversion, so the pair is either redundant or a contradiction.
Booleans accept 1, on, yes, true, y and 0, off, no, false, n,
case-insensitive. Any other value raises ConfigureError instead of silently
reading as false. int and float raise the same way on a value they cannot parse.
A Path is taken verbatim, never resolved, so PREFIX=dist stays relative and
you need to decide what it is relative to. An empty value for a Path raises an error.
The default itself is never parsed; it is returned as-is when the variable is
unset. With no default and no type=, get_var returns the raw string or
None.
Persistent Configuration Cache¶
Settings you choose on the command line persist per build directory, like
CMake's CMakeCache.txt. Configure once, then run bare:
pcons generate PORT=ofx --variant=debug -G ninja # choose settings
pcons # reuses PORT, variant, generator
What persists: build variables, the variant, and the generator. They are stored
in <build_dir>/pcons_cache.json and written only after a successful run.
Precedence, highest to lowest:
- This run's command line (
PORT=x,--variant,-G) - Environment:
PORT=x pcons,VARIANT,GENERATOR, and thePCONS_VARS/PCONS_VARIANT/PCONS_GENERATORforms - Persisted cache from a prior run
- The
defaultpassed toget_var/get_variant
An environment value overrides the cache but is not written to it, so exporting one steers a run without changing what a later bare run reuses.
The cache is tied to $PCONS_BUILD_DIR, which pcons always sets (and -B
overrides).
Inspect and reset:
pcons cache list # show persisted vars, variant, generator
pcons cache show # same, plus the cache file path and source dir
pcons cache path # print the cache file path
pcons cache clear # empty the cache
pcons generate --fresh PORT=y # ignore the old cache, start clean
Change settings through these commands, not by editing pcons_cache.json. The
file is not a regeneration input, so a hand-edit is not picked up automatically,
and the self-regeneration command pins the values it was generated with, so a
manual change would be overwritten on the next run anyway.
Two guards catch stale caches:
- A variable that was persisted but the build script never reads is reported
(
pcons FEATRUE=ontypo, or a setting you dropped). - A cache whose recorded source directory no longer matches (a copied or moved build dir) is ignored with a warning and rebuilt for the current tree.
There is no API to read or write the cache from a build script; it holds only
the settings above. If you need structured configuration, write a Python config
file and import it from pcons-build.py.
Feature Detection¶
Pcons provides a two-part configuration system for detecting compiler capabilities and generating config headers. The two parts have distinct roles:
ToolChecks— does the real work: compiles test programs to probe for flags, headers, types, functions, and macros. Stores results throughConfigure.Configure— manages caching (persists results tobuild/pcons_config.jsonso subsequent runs are fast), accumulates#defineentries, and generatesconfig.h.
ToolChecks: Probing the Compiler¶
ToolChecks compiles small test programs with your actual compiler to detect what's available. It needs both a Configure (for caching) and an Environment (to know which compiler to run).
from pathlib import Path
from pcons.configure.config import Configure
from pcons.configure.checks import ToolChecks
config = Configure(build_dir=Path("build"))
env = project.Environment(toolchain="c")
# Create a checker for the C compiler
checks = ToolChecks(config, env, "cc")
# Check if a compiler flag is supported
if checks.check_flag("-Wall").success:
env.cc.flags.append("-Wall")
if checks.check_flag("-std=c++20").success:
env.cxx.flags.append("-std=c++20")
# Check if a header exists
if checks.check_header("sys/mman.h").success:
env.cc.defines.append("HAVE_MMAN_H")
# Check if a type exists (optionally specifying which headers to include)
if checks.check_type("size_t", headers=["stddef.h"]).success:
pass
# Get the size of a type (uses compile-time assertion, no need to run)
int_size = checks.check_type_size("int") # Returns 4 on most systems
ptr_size = checks.check_type_size("void*") # 8 on 64-bit, 4 on 32-bit
# Check if a function is available (compiles + links)
if checks.check_function(
"pthread_create", headers=["pthread.h"], libs=["pthread"]
).success:
env.link.libs.append("pthread")
# Read a predefined compiler macro
gcc_ver = checks.check_define("__GNUC__") # e.g. "14"
# Read constants out of the project's own headers -- one preprocessor run
# for as many macros as you like
version = checks.check_defines(
["VERSION_NAME", "VERSION_MAJOR", "USE_DONGLES"],
headers=["core/version.h"],
include_dirs=[src_dir],
)
# Custom compile check with arbitrary source code
has_neon = checks.try_compile(
"#include <arm_neon.h>\nint main() { float a[] = {1,1}; vld1q_f32_x2(a); return 0; }"
).success
All results are automatically cached through Configure. On the first run, each check compiles a test program; on subsequent runs, cached results are returned instantly:
result1 = checks.check_flag("-Wall")
assert result1.cached is False # First run: compiled a test
result2 = checks.check_flag("-Wall")
assert result2.cached is True # Second run: from cache
The cache key includes a signature of the compiler command and its current flags, so switching compilers — or retargeting the same compiler with a cross preset (--target=, -isysroot) — invalidates the relevant entries automatically. Checks probe the same compilation the build will do.
Checks compile the same way the build does: the tool's flags, defines, and include directories all apply, and are properly cached.
Reading Macros From Headers¶
check_define() and check_defines() accept headers=, include_dirs=, and defines=, so they read constants out of the project's own headers — version strings, feature flags, install paths — not just compiler builtins. Four outcomes are distinguishable:
| in the header | returned |
|---|---|
| (not defined) | None |
#define FOO |
"" |
#define FOO 42 |
"42" |
#define FOO "MyLib 2024" |
'"MyLib 2024"' |
Quotes are kept, so a string literal is distinguishable from a number and from a defined-but-empty macro, and the value can go straight into a generated config header. Use the batch form when reading several from one header: configure time is dominated by process startup, and check_defines() answers them all in a single preprocessor run.
For a macro you know is a string, as_string=True gives you the string it denotes rather than its expansion text — adjacent literals concatenated, quotes removed, simple C escapes decoded:
# support/config.h: #define DEFAULT_DIR "/Applications/" "MyApp 2024" "/config"
checks.check_define("DEFAULT_DIR", headers=["support/config.h"], as_string=True)
# -> "/Applications/MyApp 2024/config"
It raises if the macro isn't a string literal, since asking for a string back from #define N 42 is a mistake in the call rather than a value to guess at.
A probe that fails to preprocess at all — a missing header, a bad include path — is not cached, so it'll recheck on the next build.
Configure: Caching, Defines, and Config Headers¶
Configure serves as the shared state between checks and the config header generator. You can also use it directly to define values, find programs, or record features you know about without needing a compiler check:
config = Configure(build_dir=Path("build"))
# Find a program in PATH (result is cached, keyed by a PATH signature —
# a changed PATH re-searches instead of returning stale results)
ninja = config.find_program("ninja")
if ninja:
print(f"Found ninja {ninja.version} at {ninja.path}")
# Manually define values for the config header
config.define("VERSION_MAJOR", 1)
config.define("VERSION_MINOR", 2)
config.define("VERSION_STRING", "1.2.0")
config.define("HAVE_FEATURE_A")
# Mark a feature as absent
config.undefine("MISSING_FEATURE")
# Save cache for next run
config.save()
Generating Config Headers¶
After running checks and defining values, generate a config.h with write_config_header(). This collects all the #define entries accumulated by both ToolChecks (via config.set()) and direct config.define() calls:
# Run checks — results are recorded in config
checks = ToolChecks(config, env, "cc")
if checks.check_header("sys/mman.h").success:
config.define("HAVE_SYS_MMAN_H")
config.define("VERSION_MAJOR", 1)
config.define("VERSION_STRING", "1.2.0")
config.check_sizeof("int", env=env) # Defines SIZEOF_INT
config.check_sizeof("void*", env=env) # Defines SIZEOF_VOIDP
config.undefine("MISSING_FEATURE")
# Generate the header
config.write_config_header(
Path("build/config.h"),
guard="MY_CONFIG_H",
include_platform=True, # Add PCONS_OS_* and PCONS_ARCH_* defines
)
This generates:
#ifndef MY_CONFIG_H
#define MY_CONFIG_H
/* Platform detection */
#define PCONS_OS_MACOS 1
#define PCONS_ARCH_ARM64 1
/* Feature and header checks */
#define HAVE_SYS_MMAN_H 1
/* Type sizes */
#define SIZEOF_INT 4
#define SIZEOF_VOIDP 8
/* Custom definitions */
#define VERSION_MAJOR 1
#define VERSION_STRING "1.2.0"
/* #undef MISSING_FEATURE */
#endif /* MY_CONFIG_H */
Note: all configure checks — including check_sizeof() — are compile-time only: they ask the configured compiler (with the environment's flags, so cross presets apply) and never execute anything, which makes them correct under cross-compilation. Values like type sizes are computed with compile-time probes (int check[sizeof(T) == N ? 1 : -1]), the same technique autoconf, CMake, and Meson use. If a genuinely run-time answer is ever needed, provide it explicitly with config.define().
Template-Based Config Files with configure_file()¶
For projects that use template-based configuration (like CMake's configure_file()), pcons provides a configure_file() function that substitutes variables in a template and writes the result:
from pcons import configure_file
configure_file(
"src/config.h.in",
"build/config.h",
{"VERSION": "1.2.3", "HAVE_ZLIB": "1"},
)
Two substitution styles are supported:
CMake style (default) — processes #cmakedefine directives and @VAR@ substitutions:
With {"VERSION": "1.2.3", "HAVE_THREADS": "1"} this produces:
At style (style="at") — simple @VAR@ replacement only:
Options:
strict=True(default): raisesKeyErrorif a@VAR@has no matching keystrict=False: missing variables are replaced with empty string- Write-if-changed: the output file is only written if its content would change
This is especially useful when porting CMake projects to pcons, since the template files can often be used as-is.
Testing¶
Declaring tests, running them, discovery and fuzzing have their own page: Testing.
Packaging and Distribution¶
Pcons includes builders for shipping what you build: install trees, pkg-config files, archives (tar and zip), platform installers, and Python packages.
Installing Files¶
Copy files to destination directories. Relative destinations are placed under
the install prefix, which defaults to <project-root>/dist and can be
overridden with the PCONS_INSTALL_PREFIX variable:
Absolute (rooted) destinations are used as-is. Pass no_prefix=True to keep a
relative destination inside the build directory instead (useful for staging).
Such a destination is anchored like any other target path: it is read from the
directory of the script that declared it, so an install in a subdirectory lands
under that subdirectory's build directory and doesn't collide with a sibling's.
Pass env= when the destination has to follow an environment's build_prefix
too.
# Install library and headers (Install takes a list of sources)
project.Install("lib", [mylib]) # -> <prefix>/lib/
project.Install("include", header_nodes) # -> <prefix>/include/
# Install with rename (InstallAs takes a single source, not a list)
project.InstallAs("bundle/plugin.ofx", plugin_lib)
# Install an entire directory tree (recursive copy)
# Copies src_dir/assets/* to <prefix>/assets/*
project.InstallDir(".", src_dir / "assets")
The install_dir() helper returns the conventional install subdirectory for a
target type, following the conventions of the platform the environment's
toolchain targets (bin for programs, lib for libraries — except DLLs, which
go in bin next to the executables that load them):
from pcons import install_dir
exe = project.Program("hello", env, sources=["src/hello.c"])
project.Install(install_dir(env, "program"), [exe]) # -> <prefix>/bin/
Note
Install() accepts a list of sources and copies each to the destination directory. InstallAs() takes exactly one source and copies it to the specified path (with optional rename). If you need to install multiple files with renaming, use multiple InstallAs() calls.
InstallDir uses ninja's depfile mechanism for incremental rebuilds - if any file in the source directory changes, the copy is re-run. The depfile lists the directories as well as the files, so a file added or removed anywhere under the source tree re-runs the copy too, and a copy of a file the source no longer holds is removed.
On Windows, ninja reads a directory's modification time from the parent
directory's NTFS index entry, which is refreshed when something opens and
closes the directory, not when a file is written into it. A build started right
after a file is added can therefore see the old time and copy the file one
build late. The next build always sees it, and listing the directory in
between (a file manager, an editor, dir) refreshes the entry at once. Edits
and removals are unaffected: the file's own entry is refreshed when the writer
closes it, and a file that is gone is gone.
Merging Source Trees into One Directory¶
OverlayDir merges the contents of several source trees into one directory.
Use it to assemble a package source directory from a shared tree plus a
per-application tree, or any other layered staging job:
stage = project.OverlayDir(
env,
"stage/app",
sources=[shared_dir, app_dir], # app_dir wins a shared path
exclude=["*.orig", ".git"],
)
How it differs from InstallDir:
- Contents, not the directory.
InstallDir(dest, "shared")producesdest/shared/....OverlayDir(env, dest, sources=["shared"])putsshared's children directly indest. That is what lets two trees with the same name merge instead of colliding. - Several sources.
InstallDirtakes exactly one. - The later source wins. When two trees hold the same relative path, the
last one in
sourcesis the one that lands. Argument order is the only rule: not modification time, not depth, not which tree looks more specific. - No install prefix. The destination is a staging directory under the
environment's build directory, not an install. There is no
no_prefix=Trueto pass. - It takes an environment. The destination is anchored under that environment's build directory.
Relative paths are kept, so src/com/example/Thing.java arrives at
<dest>/src/com/example/Thing.java. Two trees can each contribute a different
child of one shared directory. One target owns the destination and stages all of
it with a single build edge, whose only output is a stamp. Individual staged
files are not build targets: ninja <dest>/x/y.txt names nothing.
exclude patterns are globs matched against the path relative to each source
root, never the destination and never an absolute path. A pattern with no /
matches a name at any depth, one with a / is anchored at the root, and
matching is case sensitive on every platform. An excluded directory takes its
contents with it. Nothing is excluded by default. A pattern that matches
nothing is not an error, since source trees differ in what they hold.
An excluded path is dropped from every source tree, so excluding the file that would have won a conflict leaves nothing at that path rather than falling back to the other tree - falling back would stage the very path you asked to drop.
Freshness. Which files win is decided when the edge runs, not when pcons
runs. The edge reports every directory it walked and every file it copied in a
depfile, so adding a file anywhere - including deep under src/com/example/ -
restages on the next ninja, with no hand-run of pcons. Both halves of the
depfile are needed: a directory's modification time changes when it gains or
loses an entry, an edit in place changes no directory at all.
On Windows, a file added right before a build can be staged one build late; see the note under Installing Files.
A file another build edge generates into a source tree is staged by the same
build that writes it. Order the two with depends():
generated = env.Command(target=str(shared_dir / "version.txt"), ...)
stage = project.OverlayDir(env, "stage", sources=[shared_dir, app_dir])
stage.depends(generated)
Removal. A file removed from a source tree loses its staged copy, along with any directory that leaves empty. Only the files this target staged are removed - the stamp records them - so anything else installed into the same destination is left alone.
See examples/78_overlay_dirs for a working two-tree overlay.
Generating pkg-config Files¶
To make a pcons-built library consumable by downstream CMake or pkg-config projects, generate a .pc file:
lib = project.StaticLibrary("mylib", env, sources=["src/mylib.c"])
lib.public.include_dirs.append("include")
pc = project.generate_pc_file(lib, version="1.0.0", description="My library")
project.Install("lib/pkgconfig", [pc])
The .pc file is derived from the target's public usage requirements (include_dirs, defines, link_libs, link_flags). Dependencies that were found via pkg-config automatically become Requires: entries rather than inlined flags.
Archive Builders (Tarfile and Zipfile)¶
Pcons provides built-in builders for creating tar and zip archives. These are useful for packaging releases, bundling documentation, or creating distributable artifacts.
Creating Tar Archives¶
Use project.Tarfile() to create tar archives with optional compression:
# Create a gzipped tarball (compression inferred from extension)
docs_archive = project.Tarfile(
env,
output="dist/docs.tar.gz",
sources=["docs/", "README.md", "LICENSE"],
)
# Create a bz2-compressed tarball
backup = project.Tarfile(
env,
output="dist/backup.tar.bz2",
sources=["data/"],
)
# Create an xz-compressed tarball
release = project.Tarfile(
env,
output="dist/release.tar.xz",
sources=["bin/", "lib/"],
)
# Create an uncompressed tarball
raw = project.Tarfile(
env,
output="dist/raw.tar",
sources=["files/"],
)
Compression options:
| Extension | Compression |
|---|---|
.tar.gz, .tgz |
gzip |
.tar.bz2 |
bz2 |
.tar.xz |
xz |
.tar |
None (uncompressed) |
You can also specify compression explicitly:
# Override inferred compression
archive = project.Tarfile(
env,
output="dist/archive.tar.gz",
sources=["files/"],
compression="bz2", # Use bz2 despite .tar.gz extension
)
Creating Zip Archives¶
Use project.Zipfile() to create zip archives:
# Create a zip archive
release_zip = project.Zipfile(
env,
output="dist/release.zip",
sources=["bin/myapp", "lib/libcore.so", "README.md"],
)
Common Options¶
Both archive builders support:
output: Path to the output archive filesources: List of files, directories, or Targets to includebase_dir: Base directory for computing archive paths (default: ".")name: Optional name to refer to the target by later
An archive target takes an optional name=, which makes it a named target get_target() and pcons build answer to. Without one pcons labels it by the archive it writes, dist/docs.tar.gz: build it by that path, or give it an alias.
# Custom base_dir to strip source paths
# Files in "build/release/bin/" become just "bin/" in the archive
archive = project.Tarfile(
env,
output="dist/package.tar.gz",
sources=["build/release/bin/", "build/release/lib/"],
base_dir="build/release",
)
project.Alias("package", archive) # `ninja package`
Using Archives with Install¶
Since archive builders return Target objects, you can pass them to Install():
# Create archives
docs_tar = project.Tarfile(env, output="docs.tar.gz", sources=["docs/"])
release_zip = project.Zipfile(env, output="release.zip", sources=["bin/", "lib/"])
# Install archives to a packages directory
project.Install("packages/", [docs_tar, release_zip])
# Set archives as default build targets
project.Default(docs_tar, release_zip)
For a complete example, see examples/06_archive_install/pcons-build.py which creates source and binary tarballs with an install alias:
cd examples/06_archive_install
pcons generate
ninja -f build/build.ninja # Build the program
ninja -f build/build.ninja install # Create and install tarballs to ./Installers
Platform Installers¶
Pcons includes helpers for creating native installers on macOS and Windows. These live in pcons.contrib.installers and integrate into the build graph just like any other target — Ninja handles incremental rebuilds automatically.
macOS: .pkg Installers¶
Create standard macOS installer packages using pkgbuild and productbuild (requires Xcode Command Line Tools).
Simple component package (wraps pkgbuild):
from pcons.contrib.installers import macos
pkg = macos.create_component_pkg(
project,
env,
identifier="com.example.myapp",
version="1.0.0",
sources=[app],
install_location="/usr/local/bin",
)
Full-featured installer with welcome screen, license, and branding (wraps productbuild):
pkg = macos.create_pkg(
project,
env,
name="MyApp",
version="1.0.0",
identifier="com.example.myapp",
sources=[app],
install_location="/usr/local/bin",
min_os_version="10.13",
welcome=Path("installer/welcome.rtf"),
license=Path("LICENSE.rtf"),
readme=Path("installer/readme.html"),
)
Key create_pkg() parameters:
| Parameter | Description |
|---|---|
name |
Application/package name |
version |
Package version string |
identifier |
Bundle identifier (e.g., "com.example.myapp") |
sources |
List of Targets, FileNodes, or paths to package |
install_location |
Where files are installed (default: "/Applications") |
min_os_version |
Minimum macOS version (e.g., "10.13") |
welcome, readme, license, conclusion |
Installer UI pages (.txt, .rtf or .html) |
background |
Background image for the installer |
scripts_dir |
Directory with preinstall/postinstall scripts |
sign_identity |
Code signing identity |
depends |
Targets to build before the sources are staged, for a directory source that other targets fill |
macOS: .dmg Disk Images¶
Create compressed disk images with hdiutil:
dmg = macos.create_dmg(
project,
env,
name="MyApp",
sources=[app],
applications_symlink=True, # Add /Applications symlink for drag-install
)
| Parameter | Description |
|---|---|
name |
Application name (used as volume name) |
sources |
Files to include in the disk image |
volume_name |
Custom volume name (defaults to name) |
format |
"UDZO" (zlib, default), "UDBZ" (bzip2), "ULFO" (lzfse), "UDRO" (uncompressed) |
applications_symlink |
Add /Applications symlink for drag-and-drop install (default: True) |
macOS: Signing and Notarization¶
Pcons includes helper functions which return commands you can use with env.Command() or run externally:
# Sign with Developer ID
sign_cmd = macos.sign_pkg(
Path("build/MyApp-1.0.0.pkg"),
identity="Developer ID Installer: My Company",
)
# Notarize for distribution
notarize_cmd = macos.notarize_cmd(
Path("build/MyApp-1.0.0.pkg"),
apple_id="dev@example.com",
team_id="TEAM123",
password_keychain_item="notarize-profile",
)
Windows: .msix Packages¶
Create modern Windows MSIX packages using MakeAppx.exe (requires Windows SDK):
from pcons.contrib.installers import windows
msix = windows.create_msix(
project,
env,
name="MyApp",
version="1.0.0.0",
publisher="CN=Example Corp",
sources=[app],
display_name="My Application",
description="A great application",
executable="myapp.exe",
)
| Parameter | Description |
|---|---|
name |
Package name (alphanumeric, no spaces) |
version |
Version in X.Y.Z.W format |
publisher |
Publisher identity (e.g., "CN=Example Corp") |
sources |
Files to package |
executable |
Main executable name (defaults to first source) |
display_name |
User-visible name |
description |
Package description |
processor_architecture |
"x64", "x86", or "arm64" (default: "x64") |
sign_cert |
Path to .pfx certificate for signing |
sign_password_env |
Name of an environment variable holding the certificate password (not the password itself, so it's never baked into build.ninja) |
Complete Platform-Conditional Example¶
from pcons.contrib import platform
installer_targets = []
if platform.is_macos():
from pcons.contrib.installers import macos
pkg = macos.create_pkg(
project,
env,
name="MyApp",
version="1.0.0",
identifier="com.example.myapp",
sources=[app],
install_location="/usr/local/bin",
)
dmg = macos.create_dmg(project, env, name="MyApp", sources=[app])
installer_targets.extend([pkg, dmg])
elif platform.is_windows():
from pcons.contrib.installers import windows
msix = windows.create_msix(
project,
env,
name="MyApp",
version="1.0.0.0",
publisher="CN=Example Corp",
sources=[app],
)
installer_targets.append(msix)
if installer_targets:
project.Alias("installers", *installer_targets)
Build with:
For a complete working example, see examples/19_installers/.
Building Python Packages (PEP 517 Backend)¶
Experimental
The pcons.pyproject backend is new and marked experimental: the
[tool.pcons] keys and the PCONS_BUILD_WHEEL convention described below
may still change based on feedback.
Pcons includes a PEP 517 build backend,
so a Python package with native extensions can use pcons as its build system
directly from pyproject.toml — pip install, uv sync, uv build, and
editable installs all work with no extra tooling:
[build-system]
requires = ["pcons"]
build-backend = "pcons.pyproject"
[project]
name = "mypkg"
version = "1.0.0"
requires-python = ">=3.11"
[tool.pcons]
variant = "release" # optional: pcons variant to build
install-target = "install" # alias to build for wheels (default: "wheel")
# variables = { SOME_VAR = "value" } # optional: extra pcons variables
How wheels are built¶
When a frontend (pip, uv, ...) asks for a wheel, the backend:
- Runs your
pcons-build.pywithPCONS_INSTALL_PREFIXpointing at a clean staging directory, andPCONS_BUILD_WHEEL=1(see below). - Runs ninja on the
install-targetalias, so yourInstall()targets copy their outputs into the staging directory. - Packages everything in the staging directory, preserving its directory structure, into the wheel.
The staging directory is the site-packages image: the tree your install
target creates there is exactly the tree users get in site-packages.
The PCONS_BUILD_WHEEL variable¶
This is where the build script comes in. A normal ninja install should
follow the usual bin/lib conventions, but a wheel build needs a
package-shaped layout (mypkg/__init__.py, mypkg/_ext.so, ...) at the
staging root. The backend sets the variable PCONS_BUILD_WHEEL=1 during wheel
builds so one build script can serve both:
from pcons import get_var, install_dir
if get_var("PCONS_BUILD_WHEEL", False):
# Wheel build: the install prefix is the site-packages image.
# Lay files out exactly as they should appear after installation.
dest = "."
else:
# Normal install: usual bin/lib conventions.
dest = install_dir(env, "shared_library")
project.Install(dest, [my_extension])
If your build script ignores PCONS_BUILD_WHEEL and installs to lib/, the
wheel will build and install, but won't have the correct dir layout. Always check the variable in
any script that feeds the backend.
Editable installs¶
pip install -e . / uv sync (PEP 660) skips the staging step entirely: the
backend builds the project and writes a wheel containing only a .pth file
that puts the build directory on sys.path. Imports resolve directly to
the compiled extensions in build/, so after editing C++ sources, re-running
ninja is enough — no reinstall needed. (PCONS_BUILD_WHEEL is not set for
editable builds.)
Metadata and sdists¶
The backend honors the PEP 621 [project] fields name, version,
requires-python, and dependencies (emitted as Requires-Dist). Any other
non-empty [project] field raises an error rather than being silently
dropped from the wheel's metadata — remove the field or file an issue.
name and version are required.
build_sdist ships the whole source tree (recursively, excluding build
output, VCS data, and tool caches) plus the spec-required PKG-INFO.
Ninja is requested automatically as a build requirement in isolated builds
when it isn't already on PATH (a NINJA environment variable override is
respected).
For a complete working example — a nanobind
C++ extension using Conan, exercising editable installs, wheel builds, and
sdists via uv — see examples/50_pyproject/.
Dynamic and multi-stage builds¶
Pcons can have the build tool (e.g. ninja) re-run pcons when the description goes stale, re-run it mid-build to discover staged targets, and keep pcons-spawned worker processes alive while it executes. See also --watch mode, Watching for changes
Re-running Pcons Automatically¶
Generated build files carry a self-regeneration rule: Ninja's generator = 1 edge, and the equivalent makefile-remake rule for Make. Editing the build script, or anything it read while describing the build, re-runs pcons before anything is built, in the same ninja invocation. This prevents stale rebuilds.
Files that get registered automatically:
- the build script itself;
- every Python module imported from inside the project tree, so a description split across
build-scripts/*.pyis fully covered; configure_file()templates.
Anything else, such as a data file your script reads directly, needs to be added explicitly:
The regen rule is omitted when the invocation can't be reconstructed (for example a build script executed in an unusual way). The build files are still written; they just won't re-run pcons on their own.
Staged Generation: Targets Discovered Mid-Build¶
Some projects can't know their target list until something has run: a definition language, an IDL, a schema, a plugin manifest — and often the program that reads it is built by the same build. Pcons supports this automatically when using ninja and GNU make: they will re-run pcons after the first-round targets are built, to define the rest.
pcons describes the graph and hands it to Ninja; it never creates targets while the build runs. The build system itself drives the staging:
manifest_path = project.build_dir / "gen/plugins-list.txt"
# Pass 1: only the part of the graph that produces the manifest.
lister = project.Program("list-plugins", env, sources=["src/list-plugins.c"])
manifest = env.Command(
target=manifest_path,
tool=lister,
depends=["plugins.def"],
command="$TOOL $SRCDIR/plugins.def $TARGET",
write_if_different=True,
)
# Pass 2: runs only once the manifest exists.
@project.when_generated(manifest_path)
def _plugins(path):
for name in path.read_text().split():
make_plugin(name)
From a clean tree, one ninja compiles and runs list-plugins, notices that build.ninja depends on the manifest it just produced, re-runs pcons, reloads, and builds the discovered targets.
when_generated is a simple context manager areound the project.generated_input primitive:
Either form registers the path as a configure dependency, so the build system re-runs pcons as soon as it appears or changes. A staged input that no rule produces is flagged as an error — it could never appear, and the build would silently stay incomplete.
Pair it with write_if_different=True (see Custom Commands) or a re-run of the generator will invalidate everything downstream of every output it touched. A complete worked example is examples/57_staged_generation.
Ninja handles this natively; GNU make 4.x does too. GNU make 3.81 — still /usr/bin/make on macOS — compares makefile prerequisite timestamps at whole-second granularity and can miss a manifest written in the same second, so use ninja or a modern GNU make for staged builds.
Staged generation answers "what set of targets exists", and pays for the answer with a reconfigure pass. If the question is instead "what does this file's content imply" — which other artifacts this one depends on, what extra outputs it writes, what flags its command needs — that's a scanner, and it's resolved during the build with no reconfigure. The two compose: a staged pass can define scanned targets, and scanning a generated file needs no staging at all.
Persistent Workers¶
Some actions cost more to start than to run: loading a large library, opening a
connection, or claiming a licence. A normal build pays that cost every time the
command runs, so pcons supports workers: subprocesses that start once and
persist through the build, serving those actions on request. They are
particularly useful in watch mode, to speed up
rebuilds. See the worker protocol for details on how workers are defined and used; here we just show a simple example where report.py generates a PDF report, and it uses a slow-to-import heavy_toolkit python module. If the build will produce many such reports, the persistent worker can just start up once and then subsequent commands will be much faster.
from pcons import PythonWorker
env.Command(
target="report.pdf",
source="report.py",
command=[sys.executable, "$SOURCE", "--out", "$TARGET"],
worker=PythonWorker(preload=["heavy_toolkit"], setup="mypkg.warmup:connect"),
)
PythonWorker holds a warmed-up interpreter: preload is a list of
packages to import up front, and if setup="mypkg.warmup:connect" calls a
function once. That interpreter will be forked to handle each command.
The first action that needs a worker starts it. Actions declaring the same worker share it, and it exits once the build goes quiet. Each action is served in isolation, so one cannot disturb the next.
A worker is only ever an optimization. Where none can be reached — plain
ninja, CI, Windows — the command runs directly and the build is simply
slower. Set PCONS_WORKER_DEBUG=1 to see how workers are used, or why one didn't get used.
A worker need not be Python: anything that can serve actions over a socket
will do, including a thin client for a service already running.
PythonWorker is just the one pcons ships. See the worker
protocol to write your own, and see
examples/64_persistent_worker for a runnable version.
Integrations¶
Pcons ships with first-class integrations for tools that aren't build
systems themselves but commonly drive — or are driven by — one. Each
integration lives under pcons.integrations.<name>.
Rez (VFX/animation package manager)¶
Rez is the dominant package manager in
VFX/animation pipelines. It resolves combinations of tool and library
versions and exposes them to a build via environment variables —
notably REZ_USED_RESOLVE (the resolved package list) and
REZ_<PKG>_ROOT (each package's install root). Rez is explicit that
it is not a build system; it expects the package author to plug in
their own tool. Pcons fits that gap.
Pcons is a build-time dependency for rez. Once a pcons-built package
lives in a rez repo, consumers (rez-env mypackage -- ...) treat it
like any other rez package — they don't need pcons installed. So
ignore this section if all you do is consume packages.
For the people who do care, the docs below are split by role:
| If you are… | …jump to |
|---|---|
| Building an app or library with pcons that depends on rez packages (Maya, OpenFX, Boost, in-house libs, etc.) | Consuming rez packages from a pcons project |
Maintaining a rez package and want rez-build to drive pcons as the build engine — same way it drives cmake or make today |
Shipping a rez package built with pcons |
Running the rez install at your facility (pipeline TD, build admin) and need to enable build_system = "pcons" for your maintainers |
Installing the pcons plugin into rez |
A common case is the first two combined: a studio plugin's
package.py is a rez package (it ships through the studio's pipeline)
and its source code links against openfx, boost, etc.
(themselves rez packages). The two halves are independent though, so
we cover them separately.
Consuming rez packages from a pcons project¶
Audience: you have a
pcons-build.pyand your dependencies live in a rez repository. You want-Iand-Lflags for those deps to appear automatically. Your build is launched from insiderez-env.
The minimum needed in your pcons-build.py:
from pcons import Project
from pcons.integrations.rez import is_in_rez_resolve, rez_environment
project = Project("my_app")
env = project.Environment(toolchain="c")
if is_in_rez_resolve():
rez_environment(env) # auto-applies every resolved rez package
app = project.Program("my_app", env, sources=["src/main.cpp"])
project.Default(app)
Then run your build inside a rez-env shell that has the deps you need:
Inside that shell, rez_environment(env) walks every resolved package
and applies a convention-based scan of its install root:
<root>/include→ added toinclude_dirs<root>/lib→ added tolibrary_dirslib<name>.{a,dylib,so}(or<name>.libon Windows) → added tolibraries<root>/lib/pkgconfig/*.pc(if present) → defers toPkgConfigFinderfor richer metadata (most well-packaged C/C++ libs ship a.pcfile)
The is_in_rez_resolve() guard means the same pcons-build.py works
both inside and outside rez — it just degrades to a vanilla pcons
build if no rez resolve is active.
The resolve is read from rez's Python API when it's importable in the
build interpreter (the resolved context is authoritative); otherwise
pcons parses the documented REZ_* environment variables. Either way
no rez install is required for the common standalone case.
Picking individual packages¶
If you only want to apply a subset of the resolve (e.g. you have
host-only build tools you don't want pulled into your link line), pass
a packages=[...] whitelist:
Packages with a non-standard layout¶
The convention scan assumes <root>/include and <root>/lib. A package
that ships its own .pc file is handled automatically (it wins over the
scan). For one that does neither — multi-arch lib dirs, nested header
trees, or several libraries — describe it explicitly with a RezLayout:
from pcons.integrations.rez import RezLayout, rez_environment
rez_environment(
env,
layouts={
"mylib": RezLayout(
include_dirs=("include", "include/detail"),
library_dirs=("lib64",),
libraries=("mylib_core", "mylib_extra"),
),
},
)
A supplied layout is trusted verbatim and wins over both pkg-config and
the convention scan; paths are relative to the package's install root.
Leave libraries unset to keep lib<name> auto-detection. RezFinder
takes the same layouts map: RezFinder({"mylib": RezLayout(...)}).
Per-package access through find_package()¶
For more control — for example, linking boost to one target but not
another — register RezFinder with pcons's standard finder chain:
from pcons.integrations.rez import RezFinder
project.add_package_finder(RezFinder())
boost = project.find_package("boost")
app.link(boost) # boost flags propagate as a usage requirement
This works exactly like find_package does for pkg-config or Conan;
the only difference is the lookup source. Rez has no concept of
"components" — passing components=[...] to find() emits a warning
and is otherwise ignored.
Shipping a rez package built with pcons¶
Audience: you maintain a rez
package.pyand wantrez-buildto invoke pcons. End users (or your CI) will runrez-build -i(orrez-release) and expect pcons to handle configure → build → install transparently.
There are two ways to wire pcons into a rez package:
Option A — quickest: build_command in package.py¶
Works out of the box, no plugin install needed. Rez's generic custom
build system runs whatever shell command you specify:
# package.py
name = "myplugin"
version = "1.0.0"
requires = ["openfx-1.4", "boost-1.82"]
build_command = "uvx pcons --build-dir {build}"
rez-build resolves the build environment, sets REZ_OPENFX_ROOT
etc., and invokes your command. Your pcons-build.py then uses
rez_environment(env)
to pick up the deps. Good for one-off packages or when you can't
modify the rez install.
Option B — native: build_system = "pcons"¶
Once pcons is installed in the same Python environment as rez (your
build admin's responsibility — see Installing the pcons plugin into
rez), it registers a rez
build_system plugin via Python entry points. Rez then auto-detects
pcons the same way it auto-detects cmake from a CMakeLists.txt.
Declare it explicitly with build_system = "pcons", or rely on
auto-detection from the presence of pcons-build.py:
# package.py
name = "myplugin"
version = "1.0.0"
build_system = "pcons" # explicit; rez also auto-detects
requires = ["openfx-1.4", "boost-1.82"]
def commands():
env.PATH.append("{root}/bin")
Then:
The pcons plugin runs three phases inside the rez-resolved build env:
- Configure —
pcons generate(executes yourpcons-build.pyand writesbuild.ninja), withPCONS_BUILD_DIR,PCONS_INSTALL_DIR, andPCONS_GENERATORset as env vars. - Build —
ninja -C <build_path>(ormake). - Install — only when
rez-build -i(orrez-release) is used:ninja -C <build_path> install. For this to do anything, yourpcons-build.pymust declare aninstallalias — see below.
Install targets¶
Rez expects ninja install to copy build outputs to
$PCONS_INSTALL_DIR. Pcons doesn't auto-create an install target;
you wire one up in your pcons-build.py:
import os
# ... build app ...
project.Default(app)
install_dir = os.environ.get("PCONS_INSTALL_DIR")
if install_dir:
install_target = project.Install(f"{install_dir}/bin", [app])
project.Alias("install", install_target) # rez-build invokes "install"
Build options exposed to rez-build¶
The pcons plugin adds two flags to rez-build:
Verify the plugin is registered with rez:
Choosing between Option A and Option B¶
| Concern | Option A (build_command) |
Option B (build_system = "pcons") |
|---|---|---|
| Setup | Nothing extra | one-time facility install of pcons into rez's venv (how) |
| Discoverability | Per-package | Site-wide (any pcons-built package "just works") |
| Install support | Hand-rolled | Standard rez-build -i |
| CI/CD friction | Low | Low once the plugin is installed once on the build host |
| Right when… | You're trying it out, or the rez install isn't yours to modify | The studio standardizes on it |
A complete worked example — a hello_lib package built with rez's
built-in cmake plugin and a hello_app package that uses pcons via
build_system = "pcons" and depends on hello_lib through
rez_environment — lives in
examples/45_rez_integration/.
That example exercises both halves of the integration in one place.
Installing the pcons plugin into rez¶
Audience: you're the pipeline TD or build admin running the rez install at your facility. Maintainers want
build_system = "pcons"in theirpackage.pyfiles; you make that work.
Pcons registers a rez build_system plugin via Python entry points,
so rez discovers it the same way it discovers cmake, make, and any
other plugin: by reading importlib.metadata over its bundled Python
environment. The one-time setup is to install pcons into that env.
Assuming rez was installed via its official
installer
into /opt/rez, install pcons with rez's wrapped Python interpreter:
rez-python is rez's bundled interpreter — installing into it puts
pcons on the same sys.path rez uses for plugin discovery. Verify
the plugin is registered:
After this, every package on every machine using this rez install can
declare build_system = "pcons" and have it work without further
setup. To upgrade pcons later, repeat the pip install (add -U).
Troubleshooting¶
If a maintainer runs rez-build on a package whose package.py
declares build_system = "pcons" and pcons isn't installed in
rez's bundled Python env, rez raises RezPluginError during argparse
setup — before its own error formatter sees it — so they get a
Python traceback ending in:
Fix: re-run the rez-python -m pip install pcons step above. The
same traceback shape occurs for any unregistered or misspelled
build_system value, including built-in ones like cmake — it's a
rez quirk, not pcons-specific.
Cross-Compilation and Multi-Arch Builds¶
Building for something other than the machine you are on: a second architecture, a universal binary, or another platform entirely.
Multi-Architecture Builds¶
Pcons supports building for multiple CPU architectures, which is useful for: - macOS: Creating universal binaries that run on both Intel and Apple Silicon - Windows: Building for x64, x86, or ARM64
Target Architecture API¶
Use env.set_target_arch() to configure an environment for a specific architecture:
from pcons import Project
project = Project("mylib")
# Create environment for arm64
env_arm64 = project.Environment(toolchain="c")
env_arm64.set_target_arch("arm64")
env_arm64.build_dir = Path("build/arm64")
# Create environment for x86_64
env_x86_64 = project.Environment(toolchain="c")
env_x86_64.set_target_arch("x86_64")
env_x86_64.build_dir = Path("build/x86_64")
The architecture setting is orthogonal to build variants, so you can combine them:
Platform-Specific Behavior¶
macOS (GCC/LLVM):
- Adds -arch <arch> flags to compiler and linker
- Supported architectures: arm64, x86_64
Windows (MSVC):
- Adds /MACHINE:<ARCH> to linker and librarian
- For a non-native arch, selects the matching cross toolset: the
bin/Host<host>/<arch> compiler binaries plus the VC and Windows SDK
<arch> library directories (the dev shell's LIB covers only the host
arch). Raises with install guidance if the cross toolset component isn't
installed in Visual Studio.
- Supported architectures: x64, x86, arm64, arm64ec
- Aliases: amd64→x64, x86_64→x64, aarch64→arm64
Windows (Clang-CL):
- Adds --target=<triple> to compilers (e.g., --target=aarch64-pc-windows-msvc)
- Adds /MACHINE:<ARCH> to linker
- For a non-native arch, also adds the VC and Windows SDK <arch> library
directories (same requirement as MSVC: the cross build-tools component
must be installed)
Linux (GCC/LLVM):
- A bare arch name can't retarget the compiler on Linux, so
set_target_arch() raises. Use a cross preset instead — e.g.
linux_cross(triple="aarch64-linux-gnu") — or a dedicated cross
toolchain (see Cross-Compilation Presets).
For example, on a Windows x64 machine this builds an ARM64 binary — no vcvars cross shell needed, just the ARM64 build-tools component:
env = project.Environment(toolchain="c") # MSVC or clang-cl
env.set_target_arch("arm64")
app = project.Program("myapp", env, sources=["main.c"])
macOS Universal Binaries¶
To create a universal binary that runs on both Intel and Apple Silicon Macs, build for each architecture separately and combine with lipo:
from pathlib import Path
from pcons import Project
from pcons.util.macos import create_universal_binary
project = Project("mylib")
# Build for arm64
env_arm64 = project.Environment(toolchain="c")
env_arm64.set_target_arch("arm64")
env_arm64.set_variant("release")
lib_arm64 = project.StaticLibrary("mylib", env_arm64, sources=["lib.c"])
# Note: output goes to build/libmylib.a by default
# Build for x86_64 (use different build dir to avoid conflicts)
env_x86_64 = project.Environment(toolchain="c")
env_x86_64.set_target_arch("x86_64")
env_x86_64.set_variant("release")
env_x86_64.build_dir = Path("build/x86_64")
lib_x86_64 = project.StaticLibrary("mylib_x86", env_x86_64, sources=["lib.c"])
# Combine into universal binary
lib_universal = create_universal_binary(
project,
inputs=[lib_arm64, lib_x86_64],
output="universal/libmylib.a",
)
project.Default(lib_universal)
The create_universal_binary() function:
- Takes a list of architecture-specific binaries (as Targets, FileNodes, or paths)
- Uses lipo -create to combine them
- Returns a Target object representing the universal binary
This works for static libraries, dynamic libraries, and executables.
Cross-Compilation Presets¶
For cross-compiling to other platforms, pcons provides ready-made presets that configure sysroot, target triple, architecture flags, and SDK paths.
from pcons.toolchains.presets import android, ios, linux_cross, pyodide
# Android NDK. stl="c++_shared" is the default and the NDK's own: an app
# made of several shared libraries needs one shared C++ runtime, or each
# library gets its own and exceptions stop crossing between them. Use
# stl="c++_static" only for a single artifact.
env.apply_cross_preset(android(ndk="~/android-ndk", arch="arm64-v8a", api=35))
# iOS — works with both the Swift and LLVM (C/C++/Objective-C++) toolchains;
# the iPhoneOS SDK is resolved via xcrun unless sdk= is given
env.apply_cross_preset(ios(arch="arm64", min_version="15.0"))
# iOS Simulator
env.apply_cross_preset(ios(arch="x86_64"))
# WebAssembly presets apply to the *dedicated* wasm toolchains, which own
# output suffixes (.js/.wasm), shared-library rules, and the link driver —
# applying a wasm preset to a native toolchain raises. The presets add
# target-specific flags, e.g. pyodide() side-module flags:
env = project.Environment(toolchain="emscripten")
env.apply_cross_preset(pyodide("2026_0"))
# Generic Linux cross-compilation
env.apply_cross_preset(
linux_cross(
triple="aarch64-linux-gnu",
sysroot="/opt/aarch64-sysroot",
)
)
For a fully self-contained WASI build, prefer the dedicated WASI toolchain:
Available Factory Functions¶
| Factory | Key Arguments | Description |
|---|---|---|
android(ndk, arch, *, api, stl) |
arch: arm64-v8a, armeabi-v7a, x86_64, x86; api: minimum API level, required; stl: c++_shared (default), c++_static, none |
Android NDK cross-compilation |
ios(arch, min_version, sdk) |
arch: arm64 or x86_64 (simulator); min_version: deployment target |
iOS cross-compilation |
emscripten(emsdk) |
emsdk: path to Emscripten SDK (optional if emcc in PATH) |
WebAssembly via Emscripten (requires toolchain="emscripten") |
wasi_sdk(sdk_path) |
sdk_path: path to wasi-sdk (optional, auto-detected) |
WebAssembly via wasi-sdk (requires toolchain="wasi") |
pyodide(abi, emsdk) |
abi: Pyodide ABI version (default "2026_0") |
Pyodide extension modules (requires toolchain="emscripten") |
linux_cross(triple, sysroot) |
triple: GCC/Clang target triple; sysroot: target sysroot path |
Generic Linux cross-compilation |
The WebAssembly presets apply only to their dedicated toolchains; applying one to a native toolchain raises.
Custom Cross-Compilation Presets¶
For targets not covered by the built-in factories, create a CrossPreset directly:
from pcons.toolchains.presets import CrossPreset
# Custom embedded target
preset = CrossPreset(
name="riscv-bare",
arch="riscv64",
triple="riscv64-unknown-elf",
sysroot="/opt/riscv/sysroot",
extra_compile_flags=("-march=rv64gc", "-mabi=lp64d"),
extra_link_flags=("-nostdlib",),
tool_cmds={
"cc": "/opt/riscv/bin/riscv64-unknown-elf-gcc",
"cxx": "/opt/riscv/bin/riscv64-unknown-elf-g++",
"link": "/opt/riscv/bin/riscv64-unknown-elf-g++",
"ar": "/opt/riscv/bin/riscv64-unknown-elf-ar",
},
)
env.apply_cross_preset(preset)
GCC selects targets by binary, not by flag, so a GCC cross preset must name
the cross binaries in tool_cmds — including link and ar, or those
steps silently run the host tools. Clang-family toolchains retarget via
triple and can usually omit tool_cmds.
The CrossPreset fields:
| Field | Type | Description |
|---|---|---|
name |
str |
Human-readable name |
arch |
str |
Target CPU name in the target ecosystem's vocabulary (metadata; the triple encodes the CPU) |
triple |
str \| None |
Compiler target triple (used with --target on Clang) |
sysroot |
str \| None |
Root of the target's headers/libraries (--sysroot, or -isysroot/SDK on Apple) |
extra_compile_flags |
tuple[str, ...] |
Additional compile flags |
extra_link_flags |
tuple[str, ...] |
Additional link flags |
tool_cmds |
dict[str, str] |
Per-tool command overrides keyed by pcons tool name (cc, cxx, link, ar, ...) |
env_vars |
dict[str, str] |
Deprecated alias for tool_cmds using CC/CXX/LD/AR vocabulary; tool_cmds wins on conflict |
Add-on Modules¶
Pcons provides an add-on/plugin system for creating reusable modules that handle domain-specific tasks like plugin bundle creation, SDK configuration, or custom package discovery.
Module Search Paths¶
Pcons automatically discovers and loads modules from these locations (in priority order):
PCONS_MODULES_PATH- Environment variable (colon/semicolon-separated paths)~/.pcons/modules/- User's global modules./pcons_modules/- Project-local modules
You can also specify additional paths via the CLI:
Using Modules¶
Loaded modules are accessible via the pcons.modules namespace:
from pcons.modules import mymodule
# Or access all loaded modules
import pcons.modules
print(dir(pcons.modules)) # ['mymodule', ...]
Creating a Module¶
Create a Python file in one of the search paths. Modules follow a simple convention:
# ~/.pcons/modules/ofx.py
"""OFX plugin support for pcons."""
__pcons_module__ = {
"name": "ofx",
"version": "1.0.0",
"description": "OFX plugin bundle creation",
}
def setup_env(env, platform=None):
"""Configure environment for OFX plugin building."""
env.cxx.includes.extend(
[
"openfx/include",
"openfx/Examples/include",
]
)
if platform and not platform.is_windows:
env.cxx.flags.append("-fvisibility=hidden")
def create_bundle(project, env, plugin_name, sources, *, build_dir, version="1.0.0"):
"""Create OFX plugin bundle with proper structure."""
from pcons.contrib import bundle
bundle_name = f"{plugin_name}.ofx.bundle"
bundle_dir = build_dir / bundle_name
plugin = project.SharedLibrary(plugin_name, env)
plugin.output_filename = f"{plugin_name}.ofx"
plugin.add_sources(sources)
# Install to bundle
arch_dir = bundle_dir / "Contents" / bundle.get_arch_subdir("darwin", "arm64")
project.Install(arch_dir, [plugin])
return plugin
def register():
"""Optional: Register custom builders at load time."""
# This is called automatically when the module loads
pass
Then use it in your build script:
# pcons-build.py
from pcons import Project
from pcons.modules import ofx # Auto-loaded!
project = Project("myplugin")
env = project.Environment(toolchain="c")
ofx.setup_env(env)
plugin = ofx.create_bundle(
project,
env,
"myplugin",
sources=["src/plugin.cpp"],
build_dir=project.build_dir,
)
Contrib Modules¶
Pcons includes built-in helper modules in pcons.contrib:
from pcons.contrib import bundle, platform
# Bundle creation helpers. bundle_dir is under the build directory: the
# bundle is a build product that the installer helpers then package.
plist = bundle.generate_info_plist("MyPlugin", "1.0.0", bundle_type="BNDL")
bundle.create_macos_bundle(project, env, plugin, bundle_dir="build/MyPlugin.bundle")
bundle.create_flat_bundle(project, env, plugin, bundle_dir="build/MyPlugin")
# Resources under their own names, or renamed on the way in; PkgInfo from a file
bundle.create_macos_bundle(
project, env, plugin, bundle_dir="build/MyPlugin.bundle",
resources={"com.example.MyPlugin.png": "art/logo-white.png"},
pkginfo=Path("plugin-pkg.info"),
)
arch_dir = bundle.get_arch_subdir("darwin", "arm64") # "MacOS-arm-64"
# Platform utilities
if platform.is_macos():
ext = platform.get_shared_lib_extension() # ".dylib"
name = platform.format_shared_lib_name("foo") # "libfoo.dylib"
Module API Reference¶
| Function/Attribute | Description |
|---|---|
__pcons_module__ |
Optional dict with module metadata (name, version, description) |
register() |
Optional function called at load time to register builders, and CLI commands |
setup_env(env, ...) |
Convention: Configure an environment for the module's domain |
pcons.modules Function |
Description |
|---|---|
load_modules(extra_paths) |
Load modules from search paths |
get_module(name) |
Get a loaded module by name |
list_modules() |
List names of all loaded modules |
get_search_paths() |
Get the module search paths |
clear_modules() |
Clear all loaded modules (for testing) |
pcons.contrib.bundle Function |
Description |
|---|---|
generate_info_plist(name, version, ...) |
Generate macOS Info.plist content |
create_macos_bundle(...) |
Create macOS .bundle structure |
create_flat_bundle(...) |
Create flat directory bundle |
get_arch_subdir(platform, arch) |
Get architecture subdirectory name |
pcons.contrib.latex Function |
Description |
|---|---|
find_latex_toolchain() |
Find and configure a LaTeX toolchain (requires latexmk in PATH) |
pcons.contrib.platform Function |
Description |
|---|---|
is_macos(), is_linux(), is_windows() |
Platform checks |
get_platform_name() |
Get platform name ("darwin", "linux", "win32") |
get_arch() |
Get current architecture ("x86_64", "arm64", etc.) |
get_shared_lib_extension() |
Get shared lib extension (".dylib", ".so", ".dll") |
format_shared_lib_name(name) |
Format as shared lib filename |
Troubleshooting¶
No toolchain found¶
Error: RuntimeError: No C/C++ toolchain found
Solution: Install a compiler:
- macOS:
xcode-select --install - Ubuntu/Debian:
sudo apt install build-essential - Fedora:
sudo dnf install gcc gcc-c++ - Windows: Install Visual Studio with C++ workload, or use msvcup for a lightweight install
Ninja not found¶
Error: ninja not found in PATH
Solution: Install Ninja:
- macOS:
brew install ninja - Ubuntu/Debian:
sudo apt install ninja-build - pip:
pip install ninja
Missing sources¶
Error: MissingSourceError: File not found: src/missing.cpp
Solution: Check that all source files exist and paths are correct.
Dependency cycles¶
Error: DependencyCycleError: Cycle detected: A -> B -> A
Solution: Refactor to break the cycle. Two libraries shouldn't depend on each other.
Further Reading¶
- Qt Guide - Building Qt applications: automoc, QML, translations, deployment
- Architecture Document - Design details and implementation status
- Example Projects - Working examples to learn from
- Contributing Guide - How to contribute to pcons