Fading Coder

One Final Commit for the Last Sprint

Home > Tech > Content

Compiling Python Modules to Binary Extensions with Cython and Setuptools

Tech 1

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

  1. Discover Python modules to compile (exclude dunder modules and ignored paths).
  2. Let Cython translate .py/.pyx to C/C++ and compile to binary extensions.
  3. Copy all remaining non-compiled files into the build directory, preserving structure.
  4. 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.

Related Articles

Understanding Strong and Weak References in Java

Strong References Strong reference are the most prevalent type of object referencing in Java. When an object has a strong reference pointing to it, the garbage collector will not reclaim its memory. F...

Comprehensive Guide to SSTI Explained with Payload Bypass Techniques

Introduction Server-Side Template Injection (SSTI) is a vulnerability in web applications where user input is improper handled within the template engine and executed on the server. This exploit can r...

Implement Image Upload Functionality for Django Integrated TinyMCE Editor

Django’s Admin panel is highly user-friendly, and pairing it with TinyMCE, an effective rich text editor, simplifies content management significantly. Combining the two is particular useful for bloggi...

Leave a Comment

Anonymous

◎Feel free to join the discussion and share your thoughts.