Pcons Architecture¶
A modern Python-based build system that generates Ninja (or other) build files.
Implementation Status Summary¶
| Component | Status | Notes |
|---|---|---|
| Core System | ||
| Node hierarchy (FileNode, DirNode, etc.) | Implemented | Full support |
| Environment with namespaced tools | Implemented | Full support |
| Variable substitution | Implemented | Recursive, with functions |
| Target with usage requirements | Implemented | Public/private requirements |
| Project container | Implemented | Full support |
| Resolver (lazy node creation) | Implemented | Full support |
| Builder Registry | Implemented | Extensible builder system |
| Builders | ||
| Program, StaticLibrary, SharedLibrary | Implemented | Compile/link builders |
| Install, InstallAs, InstallDir | Implemented | File installation builders |
| Tarfile, Zipfile | Implemented | Archive builders |
| HeaderOnlyLibrary, ObjectLibrary | Implemented | Interface and object-only |
| Command | Implemented | Custom shell commands |
| Configure Phase | ||
| Configure class | Partial | Basic program/toolchain finding |
| Feature checks (compile tests) | Partial | Methods exist, need toolchain integration |
| Configuration caching | Implemented | JSON-based |
| Config header generation | Implemented | write_config_header() |
| load_config() function | Implemented | Loads saved config |
| Toolchains | ||
| Toolchain base class | Implemented | Plugin registry, ToolchainContext |
| GCC toolchain | Implemented | Auto-detection, C/C++ |
| LLVM/Clang toolchain | Implemented | Auto-detection, C/C++ |
| MSVC toolchain | Implemented | Auto-detection, C/C++ |
| Clang-cl toolchain | Implemented | Clang with MSVC compatibility |
| GFortran toolchain | Implemented | GNU Fortran; Ninja dyndep for module deps |
| Generators | ||
| Ninja generator | Implemented | Primary, full support |
| compile_commands.json | Implemented | For IDE integration |
| Mermaid diagram generator | Implemented | For visualization |
| Makefile generator | Implemented | For environments without Ninja |
| Xcode generator | Implemented | macOS, limited custom command support |
| VSCode generator | Planned | Not yet implemented |
| Package Management | ||
| PackageDescription | Implemented | TOML format |
| ImportedTarget | Implemented | Wraps external deps |
| pkg-config finder | Implemented | Reads .pc files |
| System finder | Implemented | Manual search |
| Conan finder | Implemented | Conan 2.x with PkgConfigDeps |
| vcpkg finder | Planned | Not yet implemented |
| pcons-fetch tool | Implemented | CMake/autotools support |
| Module System | ||
| Module discovery | Implemented | Auto-load from search paths |
| pcons.modules namespace | Implemented | Access loaded modules |
| pcons.contrib package | Implemented | Bundle/platform helpers |
| Scanners | ||
| Scanner interface | Implemented | Protocol defined |
| C/C++ header scanner | Planned | Relies on depfiles |
| Build-time depfiles | Implemented | Via Ninja |
| Fortran module scanner | Implemented | Ninja dyndep via fortran_scanner.py |
Legend: - Implemented - Feature is complete and working - Partial - Feature exists but has limitations or missing integration - Planned - Documented but not yet implemented
Design Philosophy¶
Configuration, not execution. Unlike SCons which both configures and executes builds, Pcons is purely a build file generator. Python scripts describe what to build; Ninja (or Make) executes it. This separation provides:
- Fast incremental builds (Ninja handles this well)
- Clear mental model (configure once, build many times)
- Simpler codebase (no need for parallel execution, job scheduling, etc.)
Python is the language. No custom DSL. Build scripts are Python programs with access to the full language. This means real debugging, real testing, real IDE support.
Language-agnostic. The core system knows nothing about C++ or any specific language. All language support comes through Tools and Toolchains. Building LaTeX documents, protobuf files, or custom asset pipelines should be as natural as building C++.
Explicit over implicit. Dependencies should be discoverable and traceable. When something rebuilds unexpectedly (or fails to rebuild), users should be able to understand why.
uv-first Python. The project uses uv for Python package management. All scripts support PEP 723 inline metadata, and the project uses pyproject.toml with uv.lock for reproducible development environments.
Execution Model: Three Distinct Phases¶
Phase 1: Configure¶
Status: Partial - Configure class exists with program finding, caching, and config header generation. Feature checks (compile tests) require toolchain integration.
Separate from build description. Tool detection is complex and must complete before builds are defined.
- Platform detection (OS, architecture)
- Toolchain discovery (find compilers, linkers, etc.)
- Tool feature detection (run test compiles, check #defines, probe capabilities)
- Cache results for subsequent runs
Output: Configuration cache (e.g., pcons_config.json or Python pickle)
Why separate? Tool detection often requires:
- Running executables (gcc --version, cl /?)
- Test compilations (check if -std=c++20 works)
- Feature probes (does this compiler support __attribute__((visibility)))
This is slow and shouldn't run on every build description parse.
# pcons-configure.py - runs during configure phase
from pcons import Configure
config = Configure()
# Find a C++ toolchain
cxx = config.find_toolchain('cxx', candidates=['gcc', 'clang', 'msvc'])
# Probe features
cxx.check_flag('-std=c++20')
cxx.check_header('optional')
cxx.check_define('__cpp_concepts')
# Save configuration
config.save()
Phase 2: Build Description¶
Status: Implemented - Project, Environment, Target, and Resolver all fully functional.
Uses cached configuration. Fast, runs every time build files might need updating.
- Load configuration cache
- Execute build scripts (Python)
- Build dependency graph (Nodes, Targets)
- Validate graph (cycles, missing sources)
- Run configure-time scanners if needed
Output: In-memory Project with complete dependency graph
# pcons-build.py - runs during generate phase
from pcons import Project, load_config
config = load_config() # Fast: loads cached results
project = Project('myapp', config)
env = project.Environment(toolchain=config.cxx)
# ... define builds ...
Phase 3: Resolve¶
The resolver takes the build description, and: 1. Propagates build flags (public includes, link flags etc.) from dependencies forward to their targets 2. Resolves the build into actual Nodes per each source/target file 3. Substitutes variables in each Target command(s), producing the final commands to execute for that target
The resolve phase is significant; before that, the node graph is sparse, with build info for targets but many nodes not yet defined. After resolve(), the node graph is complete and we can generate the build file. Some targets, like Install, defer much of their actual work until resolve(). For instance, when Install is passed a Target or a directory, it doesn't know at that point what exact nodes or files will be contained in it, so it can't yet know how exactly to install the given files or dirs. During resolve, it lazily creates that info so it's up to date when the generator asks for it.
Phase 4: Generate¶
Status: Partial - Ninja generator fully implemented. compile_commands.json and Mermaid diagram generators available. Makefile and IDE generators planned.
- Generator traverses the dependency graph
- Adjust paths as needed for the generator (e.g. Ninja target paths are specified relative to build dir)
- Emits build rules and definitions into e.g.
build.ninjaorMakefile - Generates auxiliary files (
compile_commands.json, IDE projects)
Output: Build files ready for execution
Phase 4: Build¶
User runs ninja (or make). Pcons is not involved.
Core Abstractions¶
Node¶
Status: Implemented
The fundamental unit in the dependency graph. A Node represents something that can be a dependency or a target.
Node (abstract)
├── FileNode # A file (source or generated)
├── DirNode # A directory (first-class, see semantics below)
├── ValueNode # A computed value (e.g., config hash, version string)
└── AliasNode # A named group of targets (phony)
Key properties:
- explicit_deps: Dependencies declared by the user
- implicit_deps: Dependencies discovered by scanners or from depfiles
- builder: The Builder that produces this node (if it's a target)
- defined_at: Source location where this node was created (for debugging)
Directory Node Semantics¶
Status: Implemented
Directories require special handling. Their semantics differ based on usage:
Directory as Target: A directory target is up-to-date when all specified files within it are up-to-date. It acts as a collector.
# install_dir depends on all files installed into it
install_dir = env.InstallDir('dist/lib', [lib1, lib2, lib3])
# install_dir is up-to-date iff lib1, lib2, lib3 are all installed
Implementation: DirNode as target holds references to its member file nodes. The generator emits the dir as a phony target depending on all members.
Directory as Source: A directory source represents the directory and all files within it that are part of the build (sources or targets). Files present on disk but not declared in the build are ignored.
# asset_dir as source - depends on all declared assets within
assets = env.Glob('assets/*.png') # Explicitly declared files
packed = env.PackAssets('game.pak', asset_dir)
# Rebuilds if any declared asset changes, not if random files appear
This avoids the SCons problem where touching an unrelated file in a source directory triggers rebuilds.
Directory Existence: For cases where you only need the directory to exist (e.g., output directories), use order-only dependencies:
obj = env.cc.Object('build/obj/foo.o', 'foo.c')
# Generator emits: build build/obj/foo.o: cc foo.c || build/obj
Environment with Namespaced Tools¶
Status: Implemented
Environments provide namespaced configuration for each tool, avoiding the SCons problem of flat variable collisions.
env = project.Environment(toolchain='gcc')
# Tool-specific namespaces
env.cc.cmd = 'gcc'
env.cc.flags = ['-Wall', '-O2']
env.cc.includes = ['/usr/include']
env.cc.defines = ['NDEBUG']
env.cxx.cmd = 'g++'
env.cxx.flags = ['-Wall', '-O2', '-std=c++20']
env.cxx.includes = ['/usr/include']
env.link.cmd = 'g++'
env.link.flags = ['-L/usr/lib']
env.link.libs = ['m', 'pthread']
env.ar.cmd = 'ar'
env.ar.flags = ['rcs']
Why namespaces matter:
- CFLAGS vs CXXFLAGS vs FFLAGS confusion is eliminated
- Each tool owns its configuration
- Cloning an environment clones all tool configs
- Tools can have tool-specific variables without collision
Namespace structure:
env.{tool_name}.{variable}
# Examples:
env.cc.flags # C compiler flags
env.cxx.flags # C++ compiler flags
env.fortran.flags # Fortran compiler flags
env.link.flags # Linker flags
env.ar.flags # Archiver flags
env.protoc.flags # Protobuf compiler flags
env.tarfile.compression # Compression for building a tar file (e.g. "gzip")
Cross-tool variables live at the environment level:
Variable Substitution (Always Recursive)¶
Status: Implemented
Variable expansion is always recursive. This is essential for building complex command lines.
env.cc.cmd = 'gcc'
env.cc.flags = ['-Wall', '$cc.opt_flag']
env.cc.opt_flag = '-O2'
env.cc.include_flags = ['-I$inc' for inc in env.cc.includes]
env.cc.define_flags = ['-D$d' for d in env.cc.defines]
# Command line template - references other variables
env.cc.cmdline = ['$cc.cmd', '$cc.flags', '$cc.include_flags', '$cc.define_flags', '-c', '-o', '$out', '$in']
# Expansion happens recursively:
# 1. $cc.cmdline expands, revealing $cc.cmd, $cc.flags, etc.
# 2. $cc.flags expands, revealing $cc.opt_flag
# 3. $cc.opt_flag expands to '-O2'
# ... and so on until no $ references remain
Substitution rules:
1. $var or ${var} - expand variable (recursive)
2. $tool.var or ${tool.var} - expand tool-namespaced variable
3. $$ - literal $
4. List values are space-joined when interpolated into strings
5. Unknown variables are errors (not silent empty strings)
6. Circular references are detected and reported as errors
Special variables (set by builders at expansion time):
- $in - input file(s)
- $out - output file(s)
- $in[0] - first input file
- $out[0] - first output file
Tool¶
Status: Implemented - Base Tool class and protocol defined. GCC toolchain implemented with C/C++ tools.
A Tool knows how to perform a specific type of transformation. Tools are namespaced within environments and provide Builders.
class Tool(Protocol):
name: str # e.g., 'cc', 'cxx', 'fortran', 'ar', 'link'
def configure(self, config: Configure) -> ToolConfig:
"""Detect and configure this tool. Called during configure phase."""
...
def setup(self, env: Environment) -> None:
"""Initialize tool namespace in environment. Called when tool is added."""
...
def builders(self) -> dict[str, Builder]:
"""Return builders this tool provides."""
...
Key insight: Builders are tool-specific, not suffix-specific.
The "Object builder" problem in SCons: multiple tools produce .o files (C, C++, Fortran, CUDA, etc.). SCons's single Object() builder is ambiguous.
Solution: Each tool provides its own object builder:
# Explicit tool selection
c_obj = env.cc.Object('foo.o', 'foo.c') # C compiler
cxx_obj = env.cxx.Object('bar.o', 'bar.cpp') # C++ compiler
f_obj = env.fortran.Object('baz.o', 'baz.f90') # Fortran compiler
cuda_obj = env.cuda.Object('qux.o', 'qux.cu') # CUDA compiler
Convenience with explicit defaults:
# env.Object() can exist as a dispatcher based on suffix
# but the mapping is explicit and user-configurable
env.object_builders = {
'.c': env.cc,
'.cpp': env.cxx,
'.cxx': env.cxx,
'.f90': env.fortran,
'.cu': env.cuda,
}
obj = env.Object('foo.o', 'foo.cpp') # Dispatches to env.cxx.Object
Toolchain¶
Status: Implemented - Base Toolchain class with ToolchainContext support. GCC, LLVM, MSVC, and Clang-cl toolchains all working.
A Toolchain is a coordinated set of Tools that work together.
class Toolchain:
name: str
tools: dict[str, Tool] # name -> tool
def configure(self, config: Configure) -> bool:
"""Configure all tools in this toolchain."""
...
def setup(self, env: Environment) -> None:
"""Add all tools to environment."""
...
Why Toolchains matter: - GCC toolchain: gcc (cc), g++ (cxx), ar, ld - LLVM toolchain: clang (cc), clang++ (cxx), llvm-ar, lld - MSVC toolchain: cl (cc, cxx), lib (ar), link - Cross-compilation: arm-none-eabi-gcc toolchain
Toolchain guarantees: - All tools in a toolchain are compatible - Switching toolchains switches all related tools atomically - No mixing GCC compiler with MSVC linker
# configure.py
gcc = config.find_toolchain('gcc')
llvm = config.find_toolchain('llvm')
# pcons-build.py
env_gcc = project.Environment(toolchain=gcc)
env_llvm = project.Environment(toolchain=llvm)
Builder Registry and Extensible Builders¶
Status: Implemented
All builders in pcons register through a unified BuilderRegistry. This ensures user-defined builders are on equal footing with built-ins - there's no special treatment for built-in builders like Program or Install.
Key components:
-
BuilderRegistration - Metadata for a registered builder:
@dataclass class BuilderRegistration: name: str # e.g., "Program", "Install" create_target: Callable # Function to create a Target target_type: str # e.g., "program" factory_class: type | None # Optional NodeFactory for resolution requires_env: bool # Whether builder needs an Environment description: str # Human-readable description -
BuilderRegistry - Global registry:
-
@builder decorator - Easy registration:
@builder("InstallSymlink", target_type="interface") class InstallSymlinkBuilder: @staticmethod def create_target(project, dest, source, **kwargs): target = Target(...) target._builder_name = "InstallSymlink" target._builder_data = {"dest": dest, "source": source} project.add_target(target) return target # Immediately available on any Project: project.InstallSymlink("dist/latest", app)
How it works:
- Registration: Builders register with
BuilderRegistryat module load time (via@builderdecorator) - Dynamic dispatch:
Project.__getattr__checksBuilderRegistryand returns a bound method - IDE support:
Project.__dir__includes registered builder names for auto-completion - Resolution: Builders can provide a
factory_classthat handles target resolution
Built-in builders (in pcons/builders/):
- compile.py: Program, StaticLibrary, SharedLibrary, ObjectLibrary, HeaderOnlyLibrary, Command
- install.py: Install, InstallAs, InstallDir
- archive.py: Tarfile, Zipfile
Creating custom builders:
from pcons.core.builder_registry import builder
from pcons.core.target import Target
@builder("CompileShaders", target_type="command", requires_env=True)
class ShaderBuilder:
@staticmethod
def create_target(project, env, *, output, sources, **kwargs):
target = Target(output, target_type="command")
target._env = env
target._project = project
target._builder_name = "CompileShaders"
target._builder_data = {"output": output, "sources": sources}
# ... set up build info ...
project.add_target(target)
return target
# Now available:
project.CompileShaders(env, output="shaders.pak", sources=["*.glsl"])
Expansion packs - packages that add multiple builders:
# pcons_gamedev/__init__.py
def register(project=None):
# Import triggers @builder registration
from pcons_gamedev import shaders, assets
NodeFactory Protocol¶
Builders that need custom resolution logic can provide a factory_class:
class NodeFactory(Protocol):
def __init__(self, project: Project) -> None:
"""Initialize with project reference."""
...
def resolve(self, target: Target, env: Environment | None) -> None:
"""Resolve target in phase 1 (compilation)."""
...
def resolve_pending(self, target: Target) -> None:
"""Resolve pending sources in phase 2 (after outputs are populated)."""
...
The Resolver builds a dispatch table from registered factories and uses it during resolution.
Transitive Tool Requirements (Language Propagation)¶
Status: Implemented
When linking, the linker must match the "strongest" language used in the objects.
Problem: If you link C objects with one C++ object, you need the C++ linker (for libstdc++, C++ runtime init, etc.).
Solution: Objects carry their source language, which propagates to link decisions.
c_obj = env.cc.Object('a.o', 'a.c') # c_obj.language = 'c'
cxx_obj = env.cxx.Object('b.o', 'b.cpp') # cxx_obj.language = 'cxx'
# Program builder examines all objects' languages
# Finds 'cxx', so uses C++ linker
exe = env.Program('myapp', [c_obj, cxx_obj])
# Automatically: uses g++ to link, adds -lstdc++ if needed
Language strength ordering (configurable per toolchain):
# Higher = stronger, wins link-time tool selection
# Default (BaseToolchain.DEFAULT_LANGUAGE_PRIORITY):
language_strength = {
'c': 1,
'cxx': 2,
'cuda': 4, # CUDA requires nvcc link step
}
# GfortranToolchain adds: 'fortran': 3
# Keeping fortran out of the default prevents C/C++ toolchains from
# accidentally claiming linker authority over Fortran objects.
Implementation: _setup_link_node() collects actual object languages from target.intermediate_nodes, then uses the primary toolchain's language_priority to select the link language. Each toolchain's get_runtime_libs() / get_runtime_libdirs() methods inject any required runtime libraries for mixed-language builds.
Target (Build Specification with Usage Requirements)¶
Status: Implemented
A Target represents a high-level build artifact with usage requirements that propagate to dependents.
class Target:
name: str
nodes: list[Node] # The actual files produced
required_languages: set[str] # Languages used (for linker selection)
# Usage requirements (propagate to dependents transitively)
public_include_dirs: list[DirNode]
public_link_libs: list[Target]
public_defines: list[str]
public_link_flags: list[str]
# Build requirements (for building this target only)
private_include_dirs: list[DirNode]
private_link_libs: list[Target]
private_defines: list[str]
Usage requirements propagate transitively:
# libbase has public includes
libbase = env.StaticLibrary('base', base_sources,
public_include_dirs=['include/base'])
# libfoo uses libbase, and exposes its own includes
libfoo = env.StaticLibrary('foo', foo_sources,
public_include_dirs=['include/foo'],
private_link_libs=[libbase]) # libbase is private impl detail
# libbar uses libfoo publicly
libbar = env.StaticLibrary('bar', bar_sources,
public_link_libs=[libfoo])
# app links libbar, transitively gets:
# - libbar's public includes
# - libfoo's public includes (via libbar)
# - libbase is NOT exposed (was private to libfoo)
app = env.Program('app', ['main.cpp'],
link_libs=[libbar])
Target Resolution and Lazy Node Creation¶
Status: Implemented
Targets represent builds without containing output nodes initially.
When you call project.SharedLibrary("mylib", env), it returns a Target object that describes what to build, but doesn't yet contain the actual output nodes. The Target is a configuration object:
lib = project.SharedLibrary("mylib", env, sources=["lib.cpp"])
lib.output_name = "mylib.ofx" # Customize output filename
# At this point:
# - lib.sources contains the source FileNodes
# - lib.output_nodes is EMPTY []
# - lib.intermediate_nodes is EMPTY []
Resolution populates the nodes. The Resolver, called via project.resolve(), processes all targets in dependency order and:
- Computes effective requirements (flags from transitive dependencies)
- Creates object nodes for each source file
- Creates output nodes (library/program files) with proper naming
- Sets up build_info with commands and flags
project.resolve()
# Now:
# - lib.intermediate_nodes contains [FileNode("build/obj.mylib/lib.o")]
# - lib.output_nodes contains [FileNode("build/mylib.ofx")]
Why this design? The output filename and build flags depend on:
- The output_name attribute (may be set after target creation)
- Toolchain defaults (platform-specific naming like .dylib vs .so)
- Effective requirements from dependencies (must be computed in dependency order)
Pending sources for lazy resolution. Some operations, like Install(), need to reference a target's outputs. Rather than requiring users to carefully order their build script, targets can have _pending_sources - references that are resolved after the main resolution phase:
# These can appear in any order:
lib = project.SharedLibrary("mylib", env, sources=["lib.cpp"])
install = project.Install("dist/lib", [lib]) # lib.output_nodes is empty here!
# resolve() handles it:
# 1. Phase 1: Resolve build targets (populates lib.output_nodes)
# 2. Phase 2: Resolve pending sources (install now sees lib.output_nodes)
project.resolve()
This makes build scripts declarative - the order of declarations doesn't matter.
ToolchainContext: Extensible Build Variables¶
Status: Implemented
Problem: The core shouldn't know about C/C++ concepts like -I include flags or -D defines, but generators need to write these flags to build files. How do we keep the core tool-agnostic while supporting toolchain-specific flag formatting?
Solution: The ToolchainContext protocol provides a clean abstraction layer between the resolver and generators.
┌─────────────────────────────────────────────────────────────────────────┐
│ Resolution Phase │
│ │
│ Target + Environment │
│ │ │
│ ▼ │
│ compute_effective_requirements() ──► EffectiveRequirements │
│ │ (includes, defines, flags, etc.) │
│ ▼ │
│ toolchain.create_build_context() ──► ToolchainContext │
│ │ (CompileLinkContext for C/C++) │
│ ▼ │
│ node._build_info["context"] = context │
└─────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────┐
│ Generation Phase │
│ │
│ Generator reads node._build_info["context"] │
│ │ │
│ ▼ │
│ context.get_env_overrides() ──► {"includes": ["/path1", "/path2"], │
│ "defines": ["FOO", "BAR=1"], │
│ "extra_flags": ["-Wall", "-O2"], │
│ "ldflags": ["-pthread"], │
│ "libs": ["foo", "bar"], │
│ "libdirs": ["/path1", "/path2"]} │
│ │ │
│ ▼ │
│ Resolver sets overrides on env.<tool>.* namespace │
│ │ │
│ ▼ │
│ subst() expands command template with effective values │
└─────────────────────────────────────────────────────────────────────────┘
Key design points:
-
ToolchainContext Protocol - Defines a single method:
class ToolchainContext(Protocol): def get_env_overrides(self) -> dict[str, object]: """Return values to set on env.<tool>.* before command expansion. These values are set on the environment's tool namespace so that template expressions like ${prefix(cc.iprefix, cc.includes)} are expanded during subst() with the effective requirements. """ ... -
CompileLinkContext - Standard implementation for C/C++ toolchains:
@dataclass class CompileLinkContext: includes: list[str] # Include directories (no prefix) defines: list[str] # Preprocessor definitions (no prefix) flags: list[str] # Additional compiler flags link_flags: list[str] # Linker flags (placed before objects) libs: list[str] # Libraries to link (placed after objects) libdirs: list[str] # Library search directories (no prefix) # Prefixes (customizable per toolchain) include_prefix: str = "-I" define_prefix: str = "-D" libdir_prefix: str = "-L" lib_prefix: str = "-l" def get_env_overrides(self) -> dict[str, object]: # Returns unprefixed values - subst() applies prefixes via ${prefix(...)} # Values: {"includes": ["/path1", "/path2"], "defines": ["FOO", "BAR"]} ... -
MsvcCompileLinkContext - MSVC-specific formatting:
Variable substitution flow:
-
Command templates in toolchains include placeholders and prefix functions:
-
Resolver applies context overrides before template expansion:
-
subst() expands command with effective values (prefix function applies toolchain prefixes):
Note: The ${prefix(...)} function applies the toolchain's include/define prefixes
(e.g., -I, -D for GCC/LLVM or /I, /D for MSVC). Paths with spaces are
quoted appropriately for the target shell format.
Shell quoting and command formatting:
Commands flow through two stages before becoming the final string written to build files:
┌─────────────────────────────────────────────────────────────────────────┐
│ Command Template │
│ ["$cc.cmd", "$cc.flags", "-c", "-o", "$$out", "$$in"] │
│ (stored as list of tokens in toolchain) │
└─────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────┐
│ subst() - Variable Expansion │
│ │
│ Input: ["$cc.cmd", "$cc.flags", "-c", "-o", "$$out", "$$in"] │
│ Output: ["clang", "-Wall", "-O2", "-c", "-o", "$out", "$in"] │
│ │
│ Returns list[str] - tokens stay separate, no quoting yet │
│ $$out becomes $out (ninja variables preserved) │
└─────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────┐
│ to_shell_command() - Final Formatting │
│ │
│ shell="ninja": No quoting - Ninja handles its own escaping │
│ $in, $out preserved as-is │
│ Output: "clang -Wall -O2 -c -o $out $in" │
│ │
│ shell="bash": POSIX quoting for special chars (spaces, $, etc.) │
│ Output: "clang -Wall -O2 -c -o '$out' '$in'" │
│ │
│ shell="cmd": Windows CMD quoting rules │
│ shell="powershell": PowerShell quoting rules │
└─────────────────────────────────────────────────────────────────────────┘
Key implementation details (in pcons/core/subst.py):
subst()expands variables recursively but returns a list of tokens, preserving structureto_shell_command()joins tokens with shell-appropriate quoting via_quote_for_shell()shell="ninja"is special: no quoting is applied because Ninja handles its own variable expansion and escaping. Ninja variables like$in,$out,$out.dpass through unmodified.- Lists stay as lists until the final
to_shell_command()call - this ensures proper quoting of paths with spaces, special characters, etc.
Generators call env.subst(template, shell=...) which internally calls both functions:
# In ninja.py - Ninja handles quoting, preserve $in/$out
command = env.subst(cmd_template, shell="ninja")
# In makefile.py - Need POSIX quoting
command = env.subst(command_template, shell="posix")
Shell quoting during command expansion:
When subst() expands command templates, it applies shell-appropriate quoting
based on the target format (Ninja or POSIX shell):
┌─────────────────────────────────────────────────────────────────────────┐
│ context.get_env_overrides() │
│ │
│ Returns: {"includes": ["/path", "/My Headers"], │
│ "defines": ["FOO", 'MSG="Hello World"']} │
│ (unprefixed values - prefix function applies toolchain prefixes) │
└─────────────────────────────────────────────────────────────────────────┘
│
┌───────────────────┴───────────────────┐
▼ ▼
┌───────────────────────────┐ ┌───────────────────────────┐
│ env.subst(shell="ninja")│ │ env.subst(shell="posix") │
│ │ │ │
│ Paths with spaces get │ │ Paths with spaces get │
│ double-quoted for Ninja │ │ single-quoted for shell │
│ │ │ │
│ Output: │ │ Output: │
│ -I/path "-I/My Headers" │ │ -I/path '-I/My Headers' │
└───────────────────────────┘ └───────────────────────────┘
This design ensures paths with spaces (e.g., /Users/Alice/My Projects/include)
and defines with special characters (e.g., MSG="Hello World") work correctly
across all output formats.
Why this design?
- Core stays generic: The core only sees
ToolchainContext.get_env_overrides() -> dict[str, object] - Toolchains control formatting: GCC uses
-I, MSVC uses/I- prefix functions in command templates use toolchain-specific prefixes - subst() handles quoting: The
subst()function knows the target shell format and applies appropriate quoting - Paths with spaces work: By keeping tokens as lists and quoting during expansion, paths like
/My Projects/includeare properly handled - Extensible: A hypothetical LaTeX toolchain could define
DocumentContextwith completely different variables
Custom toolchains can provide their own context classes:
@dataclass
class DocumentContext:
"""Context for document generation (hypothetical)."""
input_format: str = "markdown"
output_format: str = "pdf"
template: str | None = None
def get_env_overrides(self) -> dict[str, object]:
result = {"format": [f"--from={self.input_format}", f"--to={self.output_format}"]}
if self.template:
result["template"] = [f"--template={self.template}"]
return result
All Build Outputs Are Targets¶
Status: Implemented - All builder methods return Target objects for consistency.
Design principle: Every builder that creates output files should return a Target object, not raw FileNodes or list[FileNode].
Why this matters:
- Consistency: Users can pass any build output to Install(), other builders, etc.
- Dependency tracking: Targets participate in the dependency resolution system
- Future extensibility: Targets can have usage requirements if needed later
Correct pattern:
# Project methods return Target
lib = project.StaticLibrary("mylib", env, sources=["lib.c"])
archive = project.Tarfile(env, output="dist/docs.tar.gz", sources=["docs/"])
generated = env.Command(target="out.h", source="in.txt", command="...")
# All can be used uniformly
project.Install("dist/", [lib, archive, generated])
Builder methods that return Target:
- project.Program() - Executable programs
- project.StaticLibrary() - Static libraries
- project.SharedLibrary() - Shared/dynamic libraries
- project.HeaderOnlyLibrary() - Header-only interface libraries
- project.ObjectLibrary() - Object files without linking
- project.Tarfile() - Tar archives (.tar, .tar.gz, .tar.bz2, .tar.xz)
- project.Zipfile() - Zip archives
- project.Install() - Install/copy operations
- project.InstallAs() - Install with rename
- env.Command() - Custom shell commands
Historical note: Early versions of env.Command() returned list[FileNode] for simplicity. This was changed in v0.2.0 to return Target for consistency. The new signature uses keyword-only arguments for clarity.
Implementation guideline: When adding new builders:
1. Create a Target object with appropriate target_type
2. Store build info in target._build_info
3. Register with project.add_target(target)
4. Return the Target, not the output nodes
Scanner¶
Status: Partial - Scanner protocol defined. Build-time depfiles work via Ninja. Configure-time scanning not yet implemented.
A Scanner discovers implicit dependencies (e.g., C/C++ header includes).
class Scanner(Protocol):
def scan(self, node: FileNode, env: Environment) -> list[Node]:
"""Return implicit dependencies of this node."""
...
def depfile_rule(self) -> str | None:
"""Return depfile generation flags, or None for configure-time scanning."""
# e.g., '-MD -MF $out.d' for GCC
...
Current Status Note:
Configure-time scanning (parsing source files during the generate phase to extract dependencies) is not yet implemented and is deferred. For C/C++ projects, this is not a problem because modern compilers support depfile generation, which is more accurate and doesn't require pcons to understand the preprocessor.
Why configure-time scanning is deferred: - Build-time depfiles are more accurate (compiler knows all includes, macros, etc.) - Implementing a correct C/C++ scanner requires handling preprocessor conditionals - Most tools that pcons targets already support depfile generation - Adding a scanner for a language can be done later without breaking existing builds
Scanning strategies:
-
Build-time depfiles (preferred): Compiler generates deps during build
This is what pcons uses for C/C++ via toolchain SourceHandler.depfile settings. -
Configure-time scanning (not yet implemented): Parse sources during generate phase
- Would be used when tool doesn't support depfiles
- Results would be embedded in build graph
- Example use case: custom template languages, document includes
Generator¶
Status: Implemented - Ninja and Makefile generators fully implemented. CompileCommandsGenerator and MermaidGenerator available. IDE generators planned.
A Generator transforms the dependency graph into build files.
class Generator(Protocol):
name: str
def generate(self, project: Project, output_dir: Path) -> None:
"""Write build files for this project."""
...
Generators:
- NinjaGenerator: Primary output format - Implemented
- CompileCommandsGenerator: For IDE/tooling integration - Implemented
- MermaidGenerator: For dependency graph visualization - Implemented
- MakefileGenerator: For environments without Ninja - Implemented
- XcodeGenerator: Xcode project files - Implemented (with limitations, see below)
- VSCodeGenerator: VSCode project files - Planned
Generator responsibilities: - Translate Nodes and Builders into build rules - Handle platform-specific details (path separators, response files on Windows) - Emit depfile rules for incremental builds - Properly handle directory semantics (order-only vs real deps)
Xcode Generator Limitations¶
The Xcode generator creates .xcodeproj bundles that can be opened in Xcode or built
with xcodebuild. However, Xcode has a fundamentally different build model than
Ninja/Make, which imposes some limitations:
Supported:
- Program, StaticLibrary, SharedLibrary targets (native PBXNativeTarget)
- Install, InstallAs, InstallDir targets (via PBXAggregateTarget with shell scripts)
- Tarfile, Zipfile targets (via PBXAggregateTarget with shell scripts)
- Target dependencies (including implicit dependencies from Install/Archive sources)
- Compile flags, defines, include paths, link flags
- Debug/Release configurations
Not Supported:
- Source generators / custom commands with dependency tracking: Xcode's
PBXShellScriptBuildPhase doesn't support ninja-style depfiles, so commands that
generate source files won't trigger rebuilds when their dependencies change.
- Fine-grained incremental builds for script phases: Xcode's script phases use
input/output file lists, not depfiles, so incremental rebuild detection is limited.
- Aliases: Xcode doesn't have a direct equivalent to ninja aliases. Use explicit
target names or create aggregate targets manually.
- ObjectLibrary: Not directly representable in Xcode's target model.
Path handling differences:
- Xcode puts built products in Release/ or Debug/ subdirectories
- The generator handles path translation for shell scripts automatically
- Source files use paths relative to the project root (via $topdir variable)
Testing note: The Xcode generator works for building, but automated tests in
test_examples.py use ninja-specific commands (e.g., ninja -C build install) that
don't have direct xcode equivalents in the test harness.
Project¶
Status: Implemented
The top-level container for the entire build specification. The Project serves as the virtual filesystem for the build: it maintains a registry of all nodes keyed by canonical path, ensuring that the same path always yields the same node object. All production code must create nodes through project.node(path) (or project.dir_node(path)), never via bare FileNode(path) — this guarantees that metadata like _build_info and dependencies are never split across duplicate objects for the same file.
class Project:
name: str
config: Config # Loaded from configure phase
root_dir: Path
build_dir: Path
environments: list[Environment]
targets: list[Target]
default_targets: list[Target]
nodes: dict[Path, Node] # All nodes, keyed by canonical path
def node(self, path: Path | str) -> FileNode:
"""Get or create a file node. Same canonical path = same object."""
...
def Environment(self, toolchain: Toolchain = None, **kwargs) -> Environment:
"""Create a new environment in this project."""
...
def Default(self, *targets: Target) -> None:
"""Set default build targets."""
...
def generate(self, generators: list[Generator] = None) -> None:
"""Generate build files."""
...
Key Design Decisions¶
Tool-Agnostic Core¶
Status: Implemented - Core modules (
pcons/core/) are language-agnostic. All tool-specific code is inpcons/tools/andpcons/toolchains/.
The core (pcons/core/) must remain completely tool-agnostic. It knows nothing about:
- Compiler flags (-O2, /Od, -g, etc.)
- Preprocessor defines (-D, /D)
- Language-specific concepts (C flags, C++ flags, linker flags)
- Specific tool names (gcc, clang, msvc)
Why this matters: Pcons should support any build tool - C/C++ compilers, Rust, Go, LaTeX, game engines, Python bundlers, protobuf compilers, and tools we haven't imagined yet. The core provides: - Dependency graph management - Variable substitution - Environment and tool namespaces - Node and target abstractions
Toolchains own their semantics: Each toolchain (GCC, LLVM, MSVC, etc.) implements its own apply_variant() method to handle build variants like "debug" or "release". The core only knows the variant name - toolchains define what it means.
# Core only provides:
env.set_variant("debug") # Just a name, delegates to toolchain
# GCC toolchain implements:
def apply_variant(self, env, variant, **kwargs):
if variant == "debug":
env.cc.flags.extend(["-O0", "-g"])
env.cc.defines.extend(["-DDEBUG"])
# A hypothetical LaTeX toolchain might implement:
def apply_variant(self, env, variant, **kwargs):
if variant == "draft":
env.latex.options.append("draft")
Guidelines for new code:
- Never add compiler flags, tool names, or language-specific logic to pcons/core/
- Tool-specific code belongs in pcons/toolchains/ or pcons/tools/
- If you need build configuration, implement it in the toolchain
Rebuild Detection: Timestamps vs Signatures¶
Status: Implemented - Pcons generates Ninja files which handle rebuild detection.
Decision: Rely on Ninja's timestamp + command comparison.
SCons uses content signatures (MD5/SHA) stored in a database. This is powerful but: - Requires reading every source file on every build - Database can become corrupted or out of sync - Adds complexity
Ninja uses: - File modification timestamps - Command line comparison (rebuild if command changes) - Depfiles for implicit dependencies
This is sufficient for most cases and much simpler. The tradeoff: - Touching a file without changing it triggers rebuild (rare in practice) - Ninja handles this well and is battle-tested
Error Handling¶
Status: Implemented - Custom error hierarchy with source location tracking.
Fail fast, fail clearly.
- Missing source file: Error at generate time
- Missing tool: Error at configure time (not silent skip)
- Dependency cycle: Error with cycle path shown
- Unknown variable: Error (not silent empty string)
- Circular variable reference: Error with chain shown
Traceability: - Every Node knows where it was defined (file:line) - Error messages include this information - Debug mode shows full dependency chains
Extensibility Points¶
Status: Implemented - Builder registry fully implemented. Toolchain, scanner, and generator registries also available.
Builders are plugins (fully implemented):
from pcons.core.builder_registry import builder
from pcons.core.target import Target
@builder("MyBuilder", target_type="command")
class MyBuilder:
@staticmethod
def create_target(project, ...):
...
# Immediately available: project.MyBuilder(...)
Toolchains are plugins:
Scanners are plugins:
Generators are plugins:
Expansion packs - third-party packages can add multiple builders and toolchains:
# my_expansion/__init__.py
def register():
from my_expansion import builders, toolchains # triggers @builder registration
Module/Add-on System¶
Status: Implemented
Pcons provides an add-on/plugin system for creating reusable modules that handle domain-specific tasks (plugin bundles, SDK configuration, custom package discovery).
Module discovery:
Modules are automatically discovered from these locations (in priority order):
1. PCONS_MODULES_PATH environment variable
2. ~/.pcons/modules/ - User's global modules
3. ./pcons_modules/ - Project-local modules
# ~/.pcons/modules/ofx.py
"""OFX plugin support."""
__pcons_module__ = {
"name": "ofx",
"version": "1.0.0",
}
def setup_env(env):
env.cxx.flags.append("-fvisibility=hidden")
def register():
"""Called automatically at load time."""
pass
Module access:
Contrib modules: Generic helpers ship with pcons in pcons.contrib:
- pcons.contrib.bundle - macOS bundle and flat bundle creation helpers
- pcons.contrib.platform - Platform detection utilities
from pcons.contrib import bundle, platform
plist = bundle.generate_info_plist("MyPlugin", "1.0.0")
if platform.is_macos():
bundle.create_macos_bundle(project, env, plugin, bundle_dir="...")
Platform-Specific Features¶
Windows Manifest Support¶
Status: Implemented - Located in
pcons/contrib/windows/manifest.py
Windows applications require SxS manifests for proper DPI awareness, visual styles, UAC elevation, and assembly dependencies. Pcons provides helpers to generate these manifests and embed them in executables.
from pcons.contrib.windows import manifest
# Create application manifest with common settings
app_manifest = manifest.create_app_manifest(
project, env,
output="app.manifest",
dpi_aware="PerMonitorV2", # Windows 10+ DPI awareness
visual_styles=True, # Modern UI controls
uac_level="asInvoker", # Run without elevation
supported_os=["win10", "win81", "win7"],
)
# Add to program sources - automatically embedded by MSVC linker
app = project.Program("myapp", env)
app.add_sources(["main.c", app_manifest])
For private DLL assemblies:
# Create assembly manifest for DLL collection
assembly = manifest.create_assembly_manifest(
project, env,
name="MyApp.Libraries",
version="1.0.0.0",
dlls=[mylib, helper_lib],
)
Installer Generation¶
Status: Implemented - Located in
pcons/contrib/installers/
Pcons provides platform-specific installer generation helpers:
macOS (pcons/contrib/installers/macos.py):
- create_component_pkg(): Simple .pkg with pkgbuild
- create_pkg(): Full product archive with productbuild (UI customization, license)
- create_dmg(): Disk image with optional /Applications symlink
from pcons.contrib.installers import macos
# Create a .pkg installer
pkg = macos.create_pkg(
project, env,
name="MyApp",
version="1.0.0",
identifier="com.example.myapp",
sources=[app],
install_location="/Applications",
welcome=Path("installer/welcome.rtf"),
)
# Create a drag-and-drop .dmg
dmg = macos.create_dmg(
project, env,
name="MyApp",
sources=[app_bundle],
applications_symlink=True,
)
Windows (pcons/contrib/installers/windows.py):
- create_msix(): Modern MSIX package for Windows 10+
Staging directories (.pkg_staging/, .dmg_staging/, .msix_staging/) are
validated to ensure they don't conflict with user build outputs.
File Organization¶
Note: This shows the file organization with implementation status.
pcons/
├── __init__.py
├── __main__.py # CLI entry point .................... [Implemented]
├── cli.py # Command-line interface ............. [Implemented]
├── modules.py # Module discovery and loading ....... [Implemented]
├── contrib/
│ ├── __init__.py # Contrib package init ............... [Implemented]
│ ├── bundle.py # Bundle creation helpers ............ [Implemented]
│ ├── platform.py # Platform detection utilities ....... [Implemented]
│ ├── installers/ # Platform installer generation
│ │ ├── __init__.py
│ │ ├── macos.py # .pkg and .dmg creation ............ [Implemented]
│ │ └── windows.py # MSIX package creation .............. [Implemented]
│ └── windows/
│ └── manifest.py # Windows SxS manifest generation .... [Implemented]
├── core/
│ ├── __init__.py
│ ├── node.py # Node hierarchy ..................... [Implemented]
│ ├── environment.py # Environment with namespaced tools .. [Implemented]
│ ├── builder.py # Builder base class ................. [Implemented]
│ ├── builder_registry.py # Extensible builder registration .... [Implemented]
│ ├── paths.py # PathResolver for path handling ..... [Implemented]
│ ├── scanner.py # Scanner interface .................. [Partial]
│ ├── target.py # Target with usage requirements ..... [Implemented]
│ ├── project.py # Project container .................. [Implemented]
│ ├── subst.py # Variable substitution engine ....... [Implemented]
│ └── build_context.py # ToolchainContext implementations ... [Implemented]
├── builders/
│ ├── __init__.py # Builder registration ............... [Implemented]
│ ├── compile.py # Program, Library builders .......... [Implemented]
│ ├── install.py # Install, InstallAs, InstallDir ..... [Implemented]
│ └── archive.py # Tarfile, Zipfile builders .......... [Implemented]
├── configure/
│ ├── __init__.py
│ ├── config.py # Configure context and caching ...... [Implemented]
│ ├── checks.py # Feature checks (compile tests) ..... [Partial - needs toolchain]
│ └── platform.py # Platform detection ................. [Implemented]
├── tools/
│ ├── __init__.py # Tool registry ...................... [Implemented]
│ ├── tool.py # Tool base class .................... [Implemented]
│ ├── toolchain.py # Toolchain base class ............... [Implemented]
│ ├── cc.py # C compiler tool .................... [Implemented]
│ ├── cxx.py # C++ compiler tool .................. [Implemented]
│ ├── link.py # Linker tools ....................... [Implemented]
│ └── ... # Other tools
├── toolchains/
│ ├── __init__.py
│ ├── gcc.py # GCC toolchain ...................... [Implemented]
│ ├── llvm.py # LLVM/Clang toolchain ............... [Implemented]
│ ├── msvc.py # MSVC toolchain ..................... [Implemented]
│ ├── clang_cl.py # Clang-cl toolchain ................. [Implemented]
│ ├── gfortran.py # GNU Fortran toolchain .............. [Implemented]
│ ├── fortran_scanner.py # Fortran module dyndep scanner ...... [Implemented]
│ └── unix.py # Base Unix toolchain ................ [Implemented]
├── generators/
│ ├── __init__.py # Generator registry ................. [Implemented]
│ ├── generator.py # Generator base class ............... [Implemented]
│ ├── ninja.py # Ninja generator .................... [Implemented]
│ ├── mermaid.py # Mermaid diagram generator .......... [Implemented]
│ ├── compile_commands.py # compile_commands.json .............. [Implemented]
│ └── makefile.py # Makefile generator ................. [Implemented]
├── scanners/
│ ├── __init__.py # Scanner registry ................... [Planned]
│ ├── c.py # C/C++ header scanner ............... [Planned - uses depfiles]
│ └── ...
├── packages/
│ ├── __init__.py # Package loading utilities .......... [Implemented]
│ ├── description.py # PackageDescription class ........... [Implemented]
│ ├── imported.py # ImportedTarget class ............... [Implemented]
│ ├── finders/
│ │ ├── __init__.py
│ │ ├── base.py # Base finder class .................. [Implemented]
│ │ ├── pkgconfig.py # pkg-config finder .................. [Implemented]
│ │ ├── system.py # Manual system search ............... [Implemented]
│ │ ├── conan.py # Conan finder ....................... [Implemented]
│ │ └── vcpkg.py # vcpkg finder ....................... [Planned]
│ └── fetch/
│ ├── __init__.py
│ ├── cli.py # pcons-fetch CLI .................... [Implemented]
│ └── ... # (CMake/autotools builders inline)
└── util/
├── __init__.py
├── path.py # Path utilities ..................... [Implemented]
└── ...
Example: Complete Build¶
pcons-build.py¶
from pcons import Generator, ImportedTarget, PackageDescription, Project, find_c_toolchain
project = Project('myapp', build_dir='build')
toolchain = find_c_toolchain()
env = project.Environment(toolchain=toolchain)
env.cxx.flags.extend(['-std=c++20', '-Wall'])
# Find dependencies via pkg-config/system search
zlib = project.find_package('zlib')
openssl = project.find_package('openssl', version='>=3.0')
# Header-only lib without a .pc file — create manually
httplib = ImportedTarget.from_package(PackageDescription(
name='cpp-httplib',
include_dirs=['/opt/homebrew/include'],
defines=['CPPHTTPLIB_OPENSSL_SUPPORT'],
))
httplib.link(openssl) # transitive: consumers get openssl too
# Build library
libcore = project.StaticLibrary('core', env, sources=['src/core.cpp'])
libcore.public.include_dirs.append(Path('include'))
libcore.link(zlib) # zlib is a private dep (not re-exported)
# Build executable — gets zlib transitively through libcore's link deps
app = project.Program('myapp', env, sources=['src/main.cpp'])
app.link(libcore, openssl, httplib)
project.Default(app)
Generator().generate(project)
Open Questions¶
-
Configuration caching: What format? JSON for readability, or pickle for speed? When to invalidate? (Probably: hash of configure.py + tool versions)
-
Variant builds: Handled via
env.set_variant("debug")which delegates to the toolchain'sapply_variant()method. Each toolchain defines what variants mean for its tools. Environment cloning allows multiple variant builds in the same project. -
Distributed builds: distcc/icecream/sccache should "just work" by wrapping compiler commands. Do we need explicit support?
-
Test integration: Should test discovery be built-in? Leaning toward: provide hooks, let pytest/gtest handle discovery.
Package Management Integration¶
Status: Implemented - PackageDescription, ImportedTarget, pkg-config finder, system finder, Conan finder, and pcons-fetch tool are all implemented.
project.find_package()provides the high-level API with FinderChain (PkgConfig → System by default, extensible viaproject.add_package_finder()).
Core principle: Pcons handles consumption, not acquisition.
Pcons is not a package manager. External tools (Conan, vcpkg, pcons-fetch, manual builds) handle fetching and building dependencies. Pcons imports the results through a standard description format.
┌─────────────────────────────────────────────────────────────┐
│ Package Sources │
├──────────┬──────────┬──────────┬──────────┬────────────────┤
│ Conan │ vcpkg │ System │ Source │ Manual │
│ │ │ (apt, │ (pcons- │ (prebuilt │
│ │ │ brew) │ fetch) │ in tree) │
└────┬─────┴────┬─────┴────┬─────┴────┬─────┴───────┬────────┘
│ │ │ │ │
▼ ▼ ▼ ▼ ▼
┌─────────────────────────────────────────────────────────────┐
│ Package Description Files │
│ (.pcons-pkg.toml) │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Pcons │
│ Imports as ImportedTarget with usage requirements │
└─────────────────────────────────────────────────────────────┘
Package Description Format¶
Status: Implemented - PackageDescription class with TOML serialization.
A simple TOML format that any tool can generate:
# zlib.pcons-pkg.toml
[package]
name = "zlib"
version = "1.2.13"
[usage]
include_dirs = ["/usr/local/include"]
library_dirs = ["/usr/local/lib"]
libraries = ["z"] # becomes -lz
defines = []
compile_flags = []
link_flags = []
# Other packages this depends on (for transitive deps)
[dependencies]
# none for zlib
For component-based packages (Boost, Qt, etc.):
# boost.pcons-pkg.toml
[package]
name = "boost"
version = "1.84.0"
[usage]
# Base usage (header-only parts)
include_dirs = ["/opt/boost/include"]
# Named components
[components.filesystem]
library_dirs = ["/opt/boost/lib"]
libraries = ["boost_filesystem"]
dependencies = ["boost:system"] # depends on another component
[components.system]
library_dirs = ["/opt/boost/lib"]
libraries = ["boost_system"]
[components.headers]
# Header-only, no libraries
ImportedTarget¶
Status: Implemented - Full API including
from_package()factory and transitive dependency propagation vialink().
An ImportedTarget represents an external dependency. It has usage requirements but no build rules. Created via project.find_package() or manually via ImportedTarget.from_package(PackageDescription(...)).
# Preferred: use project.find_package()
zlib = project.find_package("zlib")
app.link(zlib) # public requirements propagate automatically
# Manual: for header-only libs or packages without .pc files
httplib = ImportedTarget.from_package(PackageDescription(
name="cpp-httplib",
include_dirs=["/usr/include"],
defines=["CPPHTTPLIB_OPENSSL_SUPPORT"],
))
httplib.link(openssl) # transitive propagation via link()
Package Finders¶
Status: Implemented - PkgConfigFinder, SystemFinder, and ConanFinder implemented. FinderChain provides ordered search.
Finders locate packages and return PackageDescription objects. The easiest way to use them is through project.find_package(), which manages a FinderChain internally:
# High-level API (preferred) — uses PkgConfig → System by default
zlib = project.find_package("zlib")
openssl = project.find_package("openssl", version=">=3.0")
# Add Conan as the first finder to try
from pcons.packages.finders import ConanFinder
project.add_package_finder(ConanFinder(config, conanfile="conanfile.txt"))
fmt = project.find_package("fmt") # tries Conan → PkgConfig → System
# Low-level API — use finders directly
from pcons.packages.finders import PkgConfigFinder, SystemFinder
finder = PkgConfigFinder()
desc = finder.find("zlib", version=">=1.2") # returns PackageDescription or None
Using Packages in Builds¶
Status: Implemented -
find_package()returns ImportedTargets;link()propagates requirements.
from pcons import Generator, Project, find_c_toolchain
project = Project('myapp', build_dir='build')
env = project.Environment(toolchain=find_c_toolchain())
# find_package returns ImportedTarget — link() propagates requirements
zlib = project.find_package('zlib')
openssl = project.find_package('openssl')
boost = project.find_package('boost', components=['filesystem'])
app = project.Program('myapp', env, sources=['main.cpp'])
app.link(zlib, openssl, boost)
# Automatically gets all include dirs, library dirs, libraries, flags
Generator().generate(project)
pcons-fetch: Source Dependency Tool¶
Status: Implemented - CLI tool with CMake and autotools support. Generates .pcons-pkg.toml files.
For building dependencies from source, pcons provides pcons-fetch, a companion tool that:
1. Downloads/clones source code
2. Builds using the dependency's native build system
3. Generates .pcons-pkg.toml describing the result
deps.toml format¶
# deps.toml - source dependencies to fetch and build
[settings]
prefix = "deps/install" # where to install
source_dir = "deps/src" # where to download sources
build_dir = "deps/build" # where to build
# Compiler/flags to use (passed via environment variables)
[settings.env]
CC = "gcc"
CXX = "g++"
CFLAGS = "-O2"
CXXFLAGS = "-O2 -std=c++17"
[dependencies.zlib]
url = "https://github.com/madler/zlib/archive/refs/tags/v1.3.1.tar.gz"
sha256 = "..." # optional integrity check
build_system = "cmake" # cmake, autotools, meson, make, custom
cmake_args = ["-DBUILD_SHARED_LIBS=OFF"]
[dependencies.json]
url = "https://github.com/nlohmann/json"
type = "git"
tag = "v3.11.3"
build_system = "cmake"
cmake_args = ["-DJSON_BuildTests=OFF"]
[dependencies.sqlite]
url = "https://www.sqlite.org/2024/sqlite-autoconf-3450000.tar.gz"
build_system = "autotools"
configure_args = ["--disable-shared", "--enable-static"]
[dependencies.custom_lib]
url = "https://example.com/custom.tar.gz"
build_system = "custom"
build_commands = [
"make CC=$CC CFLAGS=$CFLAGS",
"make install PREFIX=$PREFIX",
]
How pcons-fetch works¶
- Download: Fetch and extract sources (or git clone)
- Configure: Run build system's configure step with appropriate flags
- Build: Run the build
- Install: Install to the specified prefix
- Generate: Create
.pcons-pkg.tomlby examining installed files
Flag propagation uses environment variables (CC, CXX, CFLAGS, CXXFLAGS, LDFLAGS). This is imperfect but universal - almost every build system respects these.
# pcons-fetch internally does something like:
env = os.environ.copy()
env['CC'] = settings.env.CC
env['CXX'] = settings.env.CXX
env['CFLAGS'] = settings.env.CFLAGS
env['CXXFLAGS'] = settings.env.CXXFLAGS
if build_system == 'cmake':
subprocess.run([
'cmake', source_dir,
'-DCMAKE_INSTALL_PREFIX=' + prefix,
'-DCMAKE_C_COMPILER=' + env['CC'],
'-DCMAKE_CXX_COMPILER=' + env['CXX'],
*cmake_args
], env=env)
subprocess.run(['cmake', '--build', '.'], env=env)
subprocess.run(['cmake', '--install', '.'], env=env)
Generated package description¶
After building, pcons-fetch examines the install prefix and generates:
# deps/install/zlib.pcons-pkg.toml (auto-generated)
[package]
name = "zlib"
version = "1.3.1"
built_by = "pcons-fetch"
source = "https://github.com/madler/zlib/archive/refs/tags/v1.3.1.tar.gz"
[usage]
include_dirs = ["deps/install/include"]
library_dirs = ["deps/install/lib"]
libraries = ["z"]
[build_info]
# For debugging/reproducibility
cc = "gcc"
cxx = "g++"
cflags = "-O2"
cxxflags = "-O2 -std=c++17"
Integration with External Package Managers¶
Status: Partial - Conan integration implemented via ConanFinder. vcpkg finder planned.
Conan Integration¶
ConanFinder runs conan install with PkgConfigDeps generator, then reads the generated .pc files:
from pcons.packages.finders import ConanFinder
conan = ConanFinder(config, conanfile="conanfile.txt", output_folder=build_dir / "conan")
conan.sync_profile(toolchain, build_type="release")
packages = conan.install()
# Use via project.add_package_finder for seamless find_package() integration
project.add_package_finder(conan)
fmt = project.find_package("fmt")
# Or use packages dict directly
env.use(packages.get("fmt"))
See examples/07_conan_example/ for a complete working example.
Package Search Order¶
Status: Implemented - FinderChain tries finders in order;
project.find_package()manages the chain.
# Default chain: PkgConfig → System
zlib = project.find_package("zlib")
# Prepend custom finders
project.add_package_finder(ConanFinder(config, conanfile="conanfile.txt"))
# Now: Conan → PkgConfig → System
fmt = project.find_package("fmt")
Limitations and Tradeoffs¶
ABI Compatibility: When building from source, pcons-fetch uses environment variables for compiler/flags. This works for most cases but:
- Not all flags should propagate (e.g., -Werror might break deps)
- C++ ABI compatibility requires matching compiler versions
- Some build systems ignore environment variables
Recommendation: For complex C++ dependencies with ABI concerns, use Conan with matching profiles. For simpler C libraries or when building everything from source with the same compiler, pcons-fetch works well.
What pcons-fetch is NOT: - A full dependency resolver (no SAT solving, no version constraints) - A binary cache (always builds from source) - A replacement for Conan/vcpkg for complex projects
It's intentionally simple: fetch, build with your flags, generate description.
Non-Goals¶
- Being a package manager: Use Conan, vcpkg, or system packages
- Being an executor: Ninja/Make handle this better
- Supporting legacy SCons scripts: Clean break, new API
- Hiding complexity: Power users need access to the full graph