Compiling Python Modules to Binary Extensions with Cython and Setuptools
Compiling Python sources to binary extensions (.pyd on Windows, .so on Linux/macOS) hardens distribution by hiding implementation details while preserving import semantics. The workflow below uses Cython and setuptools to compile eligible .py/.pyx files and mirrors all non-compiled assets into a build directory.
Goals
- Produce binary extensions for selected Python modules with Cython.
- Place all compiled artifacts in a dedicated build directory.
- Copy every other file (resources, configs, scripts) except those explicitly ignored.
- Support multi-level ignore rules with all paths relative to the project root.
Environment
- Python 3.8+
- Cython
- setuptools
- A C/C++ toolchain compatible with your Python:
- Windows: Microsoft C++ Build Tools (matching your Python’s MSVC version)
- Linux: GCC
- macOS: Xcode Command Line Tools
Build sequence
- Discover Python modules to compile (exclude dunder modules and ignored paths).
- Let Cython translate .py/.pyx to C/C++ and compile to binary extensions.
- Copy all remaining non-compiled files into the build directory, preserving structure.
- Remove temporary C/C++ sources and the intermediate build directory.
Precautions
- Do not compile your application entry point (e.g., app.py, scripts named with leading double underscores). Keep it as pure Python to allow normal startup and argument parsing.
- If a compiled extension (.pyd/.so) fails to load on a target system, either remove the broken extension in the build output to fall back to the .py implementation or ensure the target has a matching ABI and dependencies.
Reference implementation (place at project root as build.py)
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import argparse
import os
import shutil
import sys
from pathlib import Path
from typing import Iterable, List, Set
from setuptools import setup
from Cython.Build import cythonize
ROOT = Path(__file__).resolve().parent
# Output locations
BUILD_DIR = ROOT / "build"
TEMP_DIR = BUILD_DIR / "temp"
# Relative paths (POSIX-style) for directories and files to ignore
IGNORE_DIRS: Set[str] = {
"dist",
"build",
".git",
"__pycache__",
"tests",
"data",
"orm/data", # multi-level example
}
IGNORE_FILES: Set[str] = {
Path(__file__).name, # this build script
"main.py",
"frozen_dir.py",
"libs/time_it.py",
}
# Extensions to skip when copying non-compiled files
SKIP_COPY_EXTS: Set[str] = {".pyc", ".pyo", ".pyx"}
def posix_rel(p: Path) -> str:
return p.relative_to(ROOT).as_posix()
def in_ignored_dirs(rel_path: Path) -> bool:
# Match any ancestor against IGNORE_DIRS (all paths are relative)
for ancestor in [rel_path] + list(rel_path.parents):
if ancestor == Path('.'):
continue
if ancestor.as_posix() in IGNORE_DIRS:
return True
return False
def find_python_modules(source_root: Path) -> List[Path]:
candidates: List[Path] = []
for path in source_root.rglob("*"):
if path.is_dir():
# Prune traversal implicitly by skip check on files
continue
rel = path.relative_to(ROOT)
if in_ignored_dirs(rel):
continue
if path.is_file() and path.suffix in {".py", ".pyx"}:
# Skip dunder files like __init__.py, __main__.py, etc.
if path.name.startswith("__"):
continue
# Skip explicitly ignored files
if posix_rel(path) in IGNORE_FILES:
continue
candidates.append(path)
return candidates
def build_extensions(python_sources: List[Path]) -> None:
if not python_sources:
return
# Convert to string paths relative to this file (absolute also fine)
modules = [str(p) for p in python_sources]
setup(
name="cy_build",
ext_modules=cythonize(
modules,
language_level=3,
compiler_directives={"binding": False},
),
script_args=[
"build_ext",
"--build-lib", str(BUILD_DIR),
"--build-temp", str(TEMP_DIR),
],
)
def copy_non_compiled(source_root: Path, compiled_rel: Set[str]) -> None:
for path in source_root.rglob("*"):
if path.is_dir():
continue
rel = path.relative_to(ROOT)
rel_posix = rel.as_posix()
if in_ignored_dirs(rel):
continue
if rel_posix in IGNORE_FILES:
continue
if path.suffix in SKIP_COPY_EXTS:
continue
# Do not copy .py/.pyx files that were compiled
if path.suffix in {".py", ".pyx"} and rel_posix in compiled_rel:
continue
dest = BUILD_DIR / rel
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(path, dest)
def cleanup_intermediate_files(compiled: Iterable[Path]) -> None:
for src in compiled:
c_file = src.with_suffix(".c")
cpp_file = src.with_suffix(".cpp")
for gen in (c_file, cpp_file):
if gen.exists():
try:
gen.unlink()
except Exception:
pass
if TEMP_DIR.exists():
shutil.rmtree(TEMP_DIR, ignore_errors=True)
def main(argv: List[str]) -> int:
parser = argparse.ArgumentParser(
description="Compile Python modules to binary extensions and copy other files to build/",
)
parser.add_argument(
"--src",
default=".",
help="Relative subdirectory to build from (defaults to project root)",
)
args = parser.parse_args(argv)
source_root = (ROOT / args.src).resolve()
if not source_root.exists() or not source_root.is_dir():
print(f"Source directory not found: {source_root}")
return 2
to_compile = find_python_modules(source_root)
compiled_rel: Set[str] = {posix_rel(p) for p in to_compile}
try:
build_extensions(to_compile)
except Exception as exc:
print(f"Build failed: {exc}")
return 1
copy_non_compiled(source_root, compiled_rel)
cleanup_intermediate_files(to_compile)
print("Build complete →", BUILD_DIR)
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
Usage
- Compile and assemble from the project root:
- python build.py
- Compile from a subdirectory relative to the project root (e.g., src):
- python build.py --src src
Details
- Compiled modules end up in build/ with the same package layout as sources; the platform-specific suffix is chosen automatically (.pyd on Windows, .so on POSIX).
- Files not turned into extensions (including resources such as JSON, YAML, images, etc.) are copied into build/ preserving relaitve paths.
- init.py is not compiled and is copied so packages remain importable.
- All path rules (ignore directories and files) are relative to the project root and can reference nested folders like orm/data.
- Temporary sources generated by Cython (.c or .cpp) are removed after a successful build.
- If an extension fails to import on a target system, remove the corresponding .pyd/.so from build/ to re-enable the pure-Python module with the same name.