Skip to content
This repository was archived by the owner on Nov 26, 2025. It is now read-only.

Conversation

@cmpute
Copy link

@cmpute cmpute commented Nov 25, 2025

Writing Python extension will be a big use case for translate-c in the foreseeable future. I think it's great to include an minimal example of how to write a Python extension in Zig, to demonstrate the capabilities of translate-c and also act as a unit test for it. This example should also act as a template project for future projects with their own Python extensions.

I have crafted an example, but without comprehensive handling of Python header file paths and extension file names. Will implement this later to ensure the example works across platforms.

Any suggestions or concerns are welcome before this draft turns into a valid PR! 😃

@Vexu
Copy link
Member

Vexu commented Nov 25, 2025

I had to implement Translator.linkSystemLibrary to get this to work on my machine:

Diff in ziglang/translate-c

diff --git a/build/Translator.zig b/build/Translator.zig
index 76a40fc..7fd9381 100644
--- a/build/Translator.zig
+++ b/build/Translator.zig
@@ -208,6 +208,17 @@ pub fn addConfigHeader(t: *const Translator, ch: *Build.Step.ConfigHeader) void
     appendIncludeArg(t.run, "-I", ch.getOutputDir());
 }
 
+/// Links `lib` to `t.mod`, and exposes any headers installed by `lib` to translate-c.
+pub fn linkSystemLibrary(
+    t: *const Translator,
+    name: []const u8,
+    options: std.Build.Module.LinkSystemLibraryOptions,
+) !void {
+    t.mod.linkSystemLibrary(name, options);
+    const res = try t.run.step.runPkgConfig(name);
+    t.run.addArgs(res.cflags);
+}
+
 /// If the value is omitted, it is set to 1.
 /// `name` and `value` need not live longer than the function call.
 pub fn defineCMacro(t: *const Translator, name: []const u8, value: ?[]const u8) void {
diff --git a/examples/python_extension/build.zig b/examples/python_extension/build.zig
index f4fdec0..05e7a68 100644
--- a/examples/python_extension/build.zig
+++ b/examples/python_extension/build.zig
@@ -12,13 +12,11 @@ pub fn build(b: *std.Build) !void {
 
     // Create a step to translate Python.h directly. This also creates a Zig module from the output.
     const python_h: Translator = .init(translate_c, .{
-        .c_source_file = .{ .cwd_relative = "/home/jacobz/miniforge3/include/python3.12/Python.h" },
+        .c_source_file = b.path("python.h"),
         .target = target,
         .optimize = optimize,
     });
-
-    // Add Python include paths for resolving dependencies
-    python_h.addIncludePath(.{ .cwd_relative = "/home/jacobz/miniforge3/include/python3.12" });
+    try python_h.linkSystemLibrary("python", .{});
 
     // Create a module for our Python extension
     const extension_module = b.createModule(.{
@@ -46,40 +44,24 @@ pub fn build(b: *std.Build) !void {
     extension_lib.linker_allow_shlib_undefined = true;
 
     // Install the shared library
-    b.installArtifact(extension_lib);
+    const install_step = b.addInstallArtifact(extension_lib, .{
+        .dest_sub_path = "zig_ext.so",
+    });
+    b.getInstallStep().dependOn(&install_step.step);
 
     // Create build step
     const build_step = b.step("build", "Build the Python extension");
     build_step.dependOn(&extension_lib.step);
 
-    // Create test step
-    const test_step = b.step("test", "Test the Python extension by importing and using it");
-    test_step.dependOn(&extension_lib.step);
-
     // Add a Python script to test the extension
-    const test_python = b.addSystemCommand(&.{
-        "python3", "-c",
-        \\import sys;
-        \\import os;
-        \\# Add the zig-out/lib directory to Python path
-        \\sys.path.insert(0, 'zig-out/lib');
-        \\try:
-        \\    import zig_ext;
-        \\    print("Successfully imported Python extension 'zig_ext'!")
-        \\    print("Testing zig_ext.get_greeting():");
-        \\    greeting = zig_ext.get_greeting();
-        \\    print(greeting)
-        \\    print("Testing zig_ext.add_numbers(10, 20):");
-        \\    result = zig_ext.add_numbers(10, 20);
-        \\    print(f"zig_ext.add_numbers(10, 20) = {result}")
-        \\    print("✅ All tests passed!")
-        \\except Exception as e:
-        \\    print(f"❌ Error: {e}");
-        \\    import traceback;
-        \\    traceback.print_exc();
-        \\    sys.exit(1)
-    });
+    const test_python = std.Build.Step.Run.create(b, "run test.py");
+    test_python.addArgs(&.{ "python", "test.py" });
+
+    test_python.setCwd(.{ .cwd_relative = b.build_root.path orelse "." });
+    test_python.step.dependOn(&install_step.step);
 
+    // Create test step
+    const test_step = b.step("test", "Test the Python extension by importing and using it");
     test_step.dependOn(&test_python.step);
 
     // Set default step to build
diff --git a/examples/python_extension/python.h b/examples/python_extension/python.h
new file mode 100644
index 0000000..576fc6d
--- /dev/null
+++ b/examples/python_extension/python.h
@@ -0,0 +1 @@
+#include <Python.h>
diff --git a/examples/python_extension/test.py b/examples/python_extension/test.py
new file mode 100644
index 0000000..c935e00
--- /dev/null
+++ b/examples/python_extension/test.py
@@ -0,0 +1,19 @@
+import sys;
+import os;
+# Add the zig-out/lib directory to Python path
+sys.path.insert(0, 'zig-out/lib')
+try:
+    import zig_ext;
+    print("Successfully imported Python extension 'zig_ext'!")
+    print("Testing zig_ext.get_greeting():")
+    greeting = zig_ext.get_greeting()
+    print(greeting)
+    print("Testing zig_ext.add_numbers(10, 20):")
+    result = zig_ext.add_numbers(10, 20)
+    print(f"zig_ext.add_numbers(10, 20) = {result}")
+    print("✅ All tests passed!")
+except Exception as e:
+    print(f"❌ Error: {e}")
+    import traceback;
+    traceback.print_exc()
+    sys.exit(1)

diff in ziglang/zig

diff --git a/lib/std/Build/Step.zig b/lib/std/Build/Step.zig
index c247e69461..c67120373c 100644
--- a/lib/std/Build/Step.zig
+++ b/lib/std/Build/Step.zig
@@ -2,6 +2,7 @@ const Step = @This();
 const builtin = @import("builtin");
 
 const std = @import("../std.zig");
+const mem = std.mem;
 const Io = std.Io;
 const Build = std.Build;
 const Allocator = std.mem.Allocator;
@@ -9,6 +10,8 @@ const assert = std.debug.assert;
 const Cache = Build.Cache;
 const Path = Cache.Path;
 const ArrayList = std.ArrayList;
+const PkgConfigPkg = std.Build.PkgConfigPkg;
+const PkgConfigError = std.Build.PkgConfigError;
 
 id: Id,
 name: []const u8,
@@ -996,6 +999,159 @@ pub fn recursiveReset(step: *Step, gpa: Allocator) void {
     }
 }
 
+const PkgConfigResult = struct {
+    cflags: []const []const u8,
+    libs: []const []const u8,
+};
+
+/// Run pkg-config for the given library name and parse the output, returning the arguments
+/// that should be passed to zig to link the given library.
+pub fn runPkgConfig(step: *Step, lib_name: []const u8) !PkgConfigResult {
+    const wl_rpath_prefix = "-Wl,-rpath,";
+
+    const b = step.owner;
+    const pkg_name = match: {
+        // First we have to map the library name to pkg config name. Unfortunately,
+        // there are several examples where this is not straightforward:
+        // -lSDL2 -> pkg-config sdl2
+        // -lgdk-3 -> pkg-config gdk-3.0
+        // -latk-1.0 -> pkg-config atk
+        // -lpulse -> pkg-config libpulse
+        const pkgs = try getPkgConfigList(b);
+
+        // Exact match means instant winner.
+        for (pkgs) |pkg| {
+            if (mem.eql(u8, pkg.name, lib_name)) {
+                break :match pkg.name;
+            }
+        }
+
+        // Next we'll try ignoring case.
+        for (pkgs) |pkg| {
+            if (std.ascii.eqlIgnoreCase(pkg.name, lib_name)) {
+                break :match pkg.name;
+            }
+        }
+
+        // Prefixed "lib" or suffixed ".0".
+        for (pkgs) |pkg| {
+            if (std.ascii.indexOfIgnoreCase(pkg.name, lib_name)) |pos| {
+                const prefix = pkg.name[0..pos];
+                const suffix = pkg.name[pos + lib_name.len ..];
+                if (prefix.len > 0 and !mem.eql(u8, prefix, "lib")) continue;
+                if (suffix.len > 0 and !mem.eql(u8, suffix, ".0")) continue;
+                break :match pkg.name;
+            }
+        }
+
+        // Trimming "-1.0".
+        if (mem.endsWith(u8, lib_name, "-1.0")) {
+            const trimmed_lib_name = lib_name[0 .. lib_name.len - "-1.0".len];
+            for (pkgs) |pkg| {
+                if (std.ascii.eqlIgnoreCase(pkg.name, trimmed_lib_name)) {
+                    break :match pkg.name;
+                }
+            }
+        }
+
+        return error.PackageNotFound;
+    };
+
+    var code: u8 = undefined;
+    const pkg_config_exe = b.graph.env_map.get("PKG_CONFIG") orelse "pkg-config";
+    const stdout = if (b.runAllowFail(&[_][]const u8{
+        pkg_config_exe,
+        pkg_name,
+        "--cflags",
+        "--libs",
+    }, &code, .Ignore)) |stdout| stdout else |err| switch (err) {
+        error.ProcessTerminated => return error.PkgConfigCrashed,
+        error.ExecNotSupported => return error.PkgConfigFailed,
+        error.ExitCodeFailure => return error.PkgConfigFailed,
+        error.FileNotFound => return error.PkgConfigNotInstalled,
+        else => return err,
+    };
+
+    var zig_cflags = std.array_list.Managed([]const u8).init(b.allocator);
+    defer zig_cflags.deinit();
+    var zig_libs = std.array_list.Managed([]const u8).init(b.allocator);
+    defer zig_libs.deinit();
+
+    var arg_it = mem.tokenizeAny(u8, stdout, " \r\n\t");
+    while (arg_it.next()) |arg| {
+        if (mem.eql(u8, arg, "-I")) {
+            const dir = arg_it.next() orelse return error.PkgConfigInvalidOutput;
+            try zig_cflags.appendSlice(&[_][]const u8{ "-I", dir });
+        } else if (mem.startsWith(u8, arg, "-I")) {
+            try zig_cflags.append(arg);
+        } else if (mem.eql(u8, arg, "-L")) {
+            const dir = arg_it.next() orelse return error.PkgConfigInvalidOutput;
+            try zig_libs.appendSlice(&[_][]const u8{ "-L", dir });
+        } else if (mem.startsWith(u8, arg, "-L")) {
+            try zig_libs.append(arg);
+        } else if (mem.eql(u8, arg, "-l")) {
+            const lib = arg_it.next() orelse return error.PkgConfigInvalidOutput;
+            try zig_libs.appendSlice(&[_][]const u8{ "-l", lib });
+        } else if (mem.startsWith(u8, arg, "-l")) {
+            try zig_libs.append(arg);
+        } else if (mem.eql(u8, arg, "-D")) {
+            const macro = arg_it.next() orelse return error.PkgConfigInvalidOutput;
+            try zig_cflags.appendSlice(&[_][]const u8{ "-D", macro });
+        } else if (mem.startsWith(u8, arg, "-D")) {
+            try zig_cflags.append(arg);
+        } else if (mem.startsWith(u8, arg, wl_rpath_prefix)) {
+            try zig_cflags.appendSlice(&[_][]const u8{ "-rpath", arg[wl_rpath_prefix.len..] });
+        } else if (b.debug_pkg_config) {
+            return step.fail("unknown pkg-config flag '{s}'", .{arg});
+        }
+    }
+
+    return .{
+        .cflags = try zig_cflags.toOwnedSlice(),
+        .libs = try zig_libs.toOwnedSlice(),
+    };
+}
+
+fn getPkgConfigList(b: *std.Build) ![]const PkgConfigPkg {
+    if (b.pkg_config_pkg_list) |res| {
+        return res;
+    }
+    var code: u8 = undefined;
+    if (execPkgConfigList(b, &code)) |list| {
+        b.pkg_config_pkg_list = list;
+        return list;
+    } else |err| {
+        const result = switch (err) {
+            error.ProcessTerminated => error.PkgConfigCrashed,
+            error.ExecNotSupported => error.PkgConfigFailed,
+            error.ExitCodeFailure => error.PkgConfigFailed,
+            error.FileNotFound => error.PkgConfigNotInstalled,
+            error.InvalidName => error.PkgConfigNotInstalled,
+            error.PkgConfigInvalidOutput => error.PkgConfigInvalidOutput,
+            else => return err,
+        };
+        b.pkg_config_pkg_list = result;
+        return result;
+    }
+}
+
+fn execPkgConfigList(b: *std.Build, out_code: *u8) (PkgConfigError || Build.RunError)![]const PkgConfigPkg {
+    const pkg_config_exe = b.graph.env_map.get("PKG_CONFIG") orelse "pkg-config";
+    const stdout = try b.runAllowFail(&[_][]const u8{ pkg_config_exe, "--list-all" }, out_code, .Ignore);
+    var list = std.array_list.Managed(PkgConfigPkg).init(b.allocator);
+    errdefer list.deinit();
+    var line_it = mem.tokenizeAny(u8, stdout, "\r\n");
+    while (line_it.next()) |line| {
+        if (mem.trim(u8, line, " \t").len == 0) continue;
+        var tok_it = mem.tokenizeAny(u8, line, " \t");
+        try list.append(PkgConfigPkg{
+            .name = tok_it.next() orelse return error.PkgConfigInvalidOutput,
+            .desc = tok_it.rest(),
+        });
+    }
+    return list.toOwnedSlice();
+}
+
 test {
     _ = CheckFile;
     _ = CheckObject;
diff --git a/lib/std/Build/Step/Compile.zig b/lib/std/Build/Step/Compile.zig
index 4f9900ab59..8e7a508489 100644
--- a/lib/std/Build/Step/Compile.zig
+++ b/lib/std/Build/Step/Compile.zig
@@ -9,8 +9,6 @@ const Sha256 = std.crypto.hash.sha2.Sha256;
 const Allocator = mem.Allocator;
 const Step = std.Build.Step;
 const LazyPath = std.Build.LazyPath;
-const PkgConfigPkg = std.Build.PkgConfigPkg;
-const PkgConfigError = std.Build.PkgConfigError;
 const RunError = std.Build.RunError;
 const Module = std.Build.Module;
 const InstallDir = std.Build.InstallDir;
@@ -695,119 +693,6 @@ pub fn linkLibCpp(compile: *Compile) void {
     compile.root_module.link_libcpp = true;
 }
 
-const PkgConfigResult = struct {
-    cflags: []const []const u8,
-    libs: []const []const u8,
-};
-
-/// Run pkg-config for the given library name and parse the output, returning the arguments
-/// that should be passed to zig to link the given library.
-fn runPkgConfig(compile: *Compile, lib_name: []const u8) !PkgConfigResult {
-    const wl_rpath_prefix = "-Wl,-rpath,";
-
-    const b = compile.step.owner;
-    const pkg_name = match: {
-        // First we have to map the library name to pkg config name. Unfortunately,
-        // there are several examples where this is not straightforward:
-        // -lSDL2 -> pkg-config sdl2
-        // -lgdk-3 -> pkg-config gdk-3.0
-        // -latk-1.0 -> pkg-config atk
-        // -lpulse -> pkg-config libpulse
-        const pkgs = try getPkgConfigList(b);
-
-        // Exact match means instant winner.
-        for (pkgs) |pkg| {
-            if (mem.eql(u8, pkg.name, lib_name)) {
-                break :match pkg.name;
-            }
-        }
-
-        // Next we'll try ignoring case.
-        for (pkgs) |pkg| {
-            if (std.ascii.eqlIgnoreCase(pkg.name, lib_name)) {
-                break :match pkg.name;
-            }
-        }
-
-        // Prefixed "lib" or suffixed ".0".
-        for (pkgs) |pkg| {
-            if (std.ascii.indexOfIgnoreCase(pkg.name, lib_name)) |pos| {
-                const prefix = pkg.name[0..pos];
-                const suffix = pkg.name[pos + lib_name.len ..];
-                if (prefix.len > 0 and !mem.eql(u8, prefix, "lib")) continue;
-                if (suffix.len > 0 and !mem.eql(u8, suffix, ".0")) continue;
-                break :match pkg.name;
-            }
-        }
-
-        // Trimming "-1.0".
-        if (mem.endsWith(u8, lib_name, "-1.0")) {
-            const trimmed_lib_name = lib_name[0 .. lib_name.len - "-1.0".len];
-            for (pkgs) |pkg| {
-                if (std.ascii.eqlIgnoreCase(pkg.name, trimmed_lib_name)) {
-                    break :match pkg.name;
-                }
-            }
-        }
-
-        return error.PackageNotFound;
-    };
-
-    var code: u8 = undefined;
-    const pkg_config_exe = b.graph.env_map.get("PKG_CONFIG") orelse "pkg-config";
-    const stdout = if (b.runAllowFail(&[_][]const u8{
-        pkg_config_exe,
-        pkg_name,
-        "--cflags",
-        "--libs",
-    }, &code, .Ignore)) |stdout| stdout else |err| switch (err) {
-        error.ProcessTerminated => return error.PkgConfigCrashed,
-        error.ExecNotSupported => return error.PkgConfigFailed,
-        error.ExitCodeFailure => return error.PkgConfigFailed,
-        error.FileNotFound => return error.PkgConfigNotInstalled,
-        else => return err,
-    };
-
-    var zig_cflags = std.array_list.Managed([]const u8).init(b.allocator);
-    defer zig_cflags.deinit();
-    var zig_libs = std.array_list.Managed([]const u8).init(b.allocator);
-    defer zig_libs.deinit();
-
-    var arg_it = mem.tokenizeAny(u8, stdout, " \r\n\t");
-    while (arg_it.next()) |arg| {
-        if (mem.eql(u8, arg, "-I")) {
-            const dir = arg_it.next() orelse return error.PkgConfigInvalidOutput;
-            try zig_cflags.appendSlice(&[_][]const u8{ "-I", dir });
-        } else if (mem.startsWith(u8, arg, "-I")) {
-            try zig_cflags.append(arg);
-        } else if (mem.eql(u8, arg, "-L")) {
-            const dir = arg_it.next() orelse return error.PkgConfigInvalidOutput;
-            try zig_libs.appendSlice(&[_][]const u8{ "-L", dir });
-        } else if (mem.startsWith(u8, arg, "-L")) {
-            try zig_libs.append(arg);
-        } else if (mem.eql(u8, arg, "-l")) {
-            const lib = arg_it.next() orelse return error.PkgConfigInvalidOutput;
-            try zig_libs.appendSlice(&[_][]const u8{ "-l", lib });
-        } else if (mem.startsWith(u8, arg, "-l")) {
-            try zig_libs.append(arg);
-        } else if (mem.eql(u8, arg, "-D")) {
-            const macro = arg_it.next() orelse return error.PkgConfigInvalidOutput;
-            try zig_cflags.appendSlice(&[_][]const u8{ "-D", macro });
-        } else if (mem.startsWith(u8, arg, "-D")) {
-            try zig_cflags.append(arg);
-        } else if (mem.startsWith(u8, arg, wl_rpath_prefix)) {
-            try zig_cflags.appendSlice(&[_][]const u8{ "-rpath", arg[wl_rpath_prefix.len..] });
-        } else if (b.debug_pkg_config) {
-            return compile.step.fail("unknown pkg-config flag '{s}'", .{arg});
-        }
-    }
-
-    return .{
-        .cflags = try zig_cflags.toOwnedSlice(),
-        .libs = try zig_libs.toOwnedSlice(),
-    };
-}
-
 /// Deprecated; use `compile.root_module.linkSystemLibrary(name, .{})` instead.
 /// To be removed after 0.15.0 is tagged.
 pub fn linkSystemLibrary(compile: *Compile, name: []const u8) void {
@@ -1225,7 +1110,7 @@ fn getZigArgs(compile: *Compile, fuzz: bool) ![][]const u8 {
                             switch (system_lib.use_pkg_config) {
                                 .no => try zig_args.append(b.fmt("{s}{s}", .{ prefix, system_lib.name })),
                                 .yes, .force => {
-                                    if (compile.runPkgConfig(system_lib.name)) |result| {
+                                    if (compile.step.runPkgConfig(system_lib.name)) |result| {
                                         try zig_args.appendSlice(result.cflags);
                                         try zig_args.appendSlice(result.libs);
                                         try seen_system_libs.put(arena, system_lib.name, result.cflags);
@@ -1967,46 +1852,6 @@ pub fn doAtomicSymLinks(
     };
 }
 
-fn execPkgConfigList(b: *std.Build, out_code: *u8) (PkgConfigError || RunError)![]const PkgConfigPkg {
-    const pkg_config_exe = b.graph.env_map.get("PKG_CONFIG") orelse "pkg-config";
-    const stdout = try b.runAllowFail(&[_][]const u8{ pkg_config_exe, "--list-all" }, out_code, .Ignore);
-    var list = std.array_list.Managed(PkgConfigPkg).init(b.allocator);
-    errdefer list.deinit();
-    var line_it = mem.tokenizeAny(u8, stdout, "\r\n");
-    while (line_it.next()) |line| {
-        if (mem.trim(u8, line, " \t").len == 0) continue;
-        var tok_it = mem.tokenizeAny(u8, line, " \t");
-        try list.append(PkgConfigPkg{
-            .name = tok_it.next() orelse return error.PkgConfigInvalidOutput,
-            .desc = tok_it.rest(),
-        });
-    }
-    return list.toOwnedSlice();
-}
-
-fn getPkgConfigList(b: *std.Build) ![]const PkgConfigPkg {
-    if (b.pkg_config_pkg_list) |res| {
-        return res;
-    }
-    var code: u8 = undefined;
-    if (execPkgConfigList(b, &code)) |list| {
-        b.pkg_config_pkg_list = list;
-        return list;
-    } else |err| {
-        const result = switch (err) {
-            error.ProcessTerminated => error.PkgConfigCrashed,
-            error.ExecNotSupported => error.PkgConfigFailed,
-            error.ExitCodeFailure => error.PkgConfigFailed,
-            error.FileNotFound => error.PkgConfigNotInstalled,
-            error.InvalidName => error.PkgConfigNotInstalled,
-            error.PkgConfigInvalidOutput => error.PkgConfigInvalidOutput,
-            else => return err,
-        };
-        b.pkg_config_pkg_list = result;
-        return result;
-    }
-}
-
 fn addFlag(args: *std.array_list.Managed([]const u8), comptime name: []const u8, opt: ?bool) !void {
     const cond = opt orelse return;
     try args.ensureUnusedCapacity(1);

@mlugg

@cmpute
Copy link
Author

cmpute commented Nov 26, 2025

Python extensions are usually dynamically linked to the Python library, is linkSystemLibrary necessary here?

@cmpute
Copy link
Author

cmpute commented Nov 26, 2025

BTW, what's the equivalent of @cDefine and @cUndef? Is this snippet representing the intended replacement?

const trans_libfoo: Translator = .init(translate_c, .{
.c_source_file = b.addWriteFiles().add("c.h",
\\#include <foo/add.h>
\\#include <foo/print.h>
),
.target = target,
.optimize = optimize,
});

@Vexu
Copy link
Member

Vexu commented Nov 26, 2025

Python extensions are usually dynamically linked to the Python library, is linkSystemLibrary necessary here?

It's not, I guess it would be better to have a function that just gets the cflags with pkg-config.

BTW, what's the equivalent of @cDefine and @cUndef?

#define and #undef or -D and -U.

Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants