Skip to content

Commit f6d6972

Browse files
authored
Merge pull request #32 from pyozig/dev
fix: Windows build support and package mode improvements
2 parents 8939791 + cf9b5dc commit f6d6972

10 files changed

Lines changed: 165 additions & 31 deletions

File tree

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,14 @@ All notable changes to PyOZ will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [0.11.2] - 2026-02-10
9+
10+
### Fixed
11+
- **Windows build support** - Fixed 77 `lld-link: undefined symbol` errors when building PyOZ projects on Windows. The generated `build.zig` template now accepts `-Dpython-lib-dir` and `-Dpython-lib-name` options, and `pyoz build` passes them automatically on Windows to link against `python3.lib` (stable ABI). Windows requires all symbols resolved at link time, unlike Linux/macOS which resolve Python symbols at runtime.
12+
- **Windows output path** - Fixed `FileNotFound` error during wheel creation on Windows. Zig places DLLs (`.pyd`) in `zig-out/bin/` on Windows, not `zig-out/lib/`. The builder, test runner, and benchmark runner now use the correct output directory per platform.
13+
- **Package mode test/bench imports** - In package layout (module name starts with `_`), the generated test and benchmark scripts now also `import ravn` (the package name) in addition to `import _ravn`, so users can write `assert ravn.add(2, 3) == 5` in their tests. The test/bench runners also detect package mode, copy the `.pyd`/`.so` into the package directory, and add the project root to `PYTHONPATH`.
14+
- **ASCII tree output for `pyoz init`** - Replaced UTF-8 box-drawing characters with ASCII in the project structure output, fixing garbled display on Windows PowerShell.
15+
816
## [0.11.1] - 2026-02-10
917

1018
### Added

build.zig.zon

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
.{
22
.name = .PyOZ,
3-
.version = "0.11.1",
3+
.version = "0.11.2",
44
.fingerprint = 0x4d3668413e69d99e,
55
.dependencies = .{},
66
.paths = .{

pypi/build.zig.zon

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
.{
22
.name = .pyoz,
3-
.version = "0.11.1",
3+
.version = "0.11.2",
44
.fingerprint = 0x43eec3150282fd1f,
55
.dependencies = .{
66
.PyOZ = .{

pypi/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "pyoz"
7-
version = "0.11.1"
7+
version = "0.11.2"
88
description = "Python extension modules in Zig, made easy"
99
readme = "README.md"
1010
license = "MIT"

pypi/setup.cfg

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[metadata]
22
name = pyoz
3-
version = 0.11.1
3+
version = 0.11.2
44

55
[options]
66
packages = pyoz

src/cli/builder.zig

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,7 @@ pub fn buildModule(allocator: std.mem.Allocator, release: bool) !BuildResult {
163163
// Determine optimization level:
164164
// - If --release flag is passed, always use ReleaseFast
165165
// - Otherwise, use the optimize setting from pyproject.toml (empty = debug)
166-
var argv_buf: [4][]const u8 = undefined;
166+
var argv_buf: [6][]const u8 = undefined;
167167
var argv_len: usize = 2;
168168
argv_buf[0] = "zig";
169169
argv_buf[1] = "build";
@@ -183,6 +183,22 @@ pub fn buildModule(allocator: std.mem.Allocator, release: bool) !BuildResult {
183183
argv_len += 1;
184184
}
185185

186+
// On Windows, pass Python library info so zig build can link against python3.lib
187+
// (Windows linker requires all symbols resolved at link time, unlike Unix)
188+
var lib_dir_arg: ?[]const u8 = null;
189+
defer if (lib_dir_arg) |a| allocator.free(a);
190+
if (builtin.os.tag == .windows) {
191+
if (python.lib_dir) |lib_dir| {
192+
lib_dir_arg = std.fmt.allocPrint(allocator, "-Dpython-lib-dir={s}", .{lib_dir}) catch null;
193+
if (lib_dir_arg) |arg| {
194+
argv_buf[argv_len] = arg;
195+
argv_len += 1;
196+
}
197+
}
198+
argv_buf[argv_len] = "-Dpython-lib-name=python3";
199+
argv_len += 1;
200+
}
201+
186202
const argv: []const []const u8 = argv_buf[0..argv_len];
187203

188204
var child = std.process.Child.init(argv, allocator);
@@ -196,7 +212,9 @@ pub fn buildModule(allocator: std.mem.Allocator, release: bool) !BuildResult {
196212
}
197213

198214
const module_name = try std.fmt.allocPrint(allocator, "{s}{s}", .{ config.getModuleName(), ext });
199-
const module_path = try std.fmt.allocPrint(allocator, "zig-out/lib/{s}", .{module_name});
215+
// On Windows, Zig places DLLs (.pyd) in zig-out/bin/, import libs in zig-out/lib/
216+
const out_dir = if (builtin.os.tag == .windows) "zig-out/bin" else "zig-out/lib";
217+
const module_path = try std.fmt.allocPrint(allocator, "{s}/{s}", .{ out_dir, module_name });
200218

201219
return BuildResult{
202220
.module_path = module_path,

src/cli/commands.zig

Lines changed: 82 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -207,10 +207,39 @@ pub fn runTests(allocator: std.mem.Allocator, args: []const []const u8) !void {
207207
return;
208208
}
209209

210+
// Load project config to detect package mode
211+
var config = project.toml.loadPyProject(allocator) catch |err| {
212+
if (err == error.PyProjectNotFound) {
213+
std.debug.print("Error: pyproject.toml not found. Run 'pyoz init' first.\n", .{});
214+
}
215+
return err;
216+
};
217+
defer config.deinit(allocator);
218+
219+
// Detect package mode: module name starts with '_' and a py-package matches project name
220+
const is_package_mode = blk: {
221+
const mod_name = config.getModuleName();
222+
if (mod_name.len > 0 and mod_name[0] == '_') {
223+
for (config.py_packages.items) |pkg| {
224+
if (std.mem.eql(u8, pkg, config.name)) break :blk true;
225+
}
226+
}
227+
break :blk false;
228+
};
229+
210230
// Build the module
211231
var build_result = try builder.buildModule(allocator, release);
212232
defer build_result.deinit(allocator);
213233

234+
// In package mode, copy .pyd/.so into the package directory so `import ravn` works
235+
if (is_package_mode) {
236+
const pkg_module_path = try std.fmt.allocPrint(allocator, "{s}/{s}", .{ config.name, build_result.module_name });
237+
defer allocator.free(pkg_module_path);
238+
std.fs.cwd().copyFile(build_result.module_path, std.fs.cwd(), pkg_module_path, .{}) catch |err| {
239+
std.debug.print("Warning: Could not copy module into package directory: {s}\n", .{@errorName(err)});
240+
};
241+
}
242+
214243
// Extract tests from the compiled module
215244
const test_content = symreader.extractTests(allocator, build_result.module_path) catch |err| {
216245
std.debug.print("Error: Could not extract tests: {}\n", .{err});
@@ -230,7 +259,7 @@ pub fn runTests(allocator: std.mem.Allocator, args: []const []const u8) !void {
230259
defer allocator.free(test_content.?);
231260

232261
// Write test file next to the built module
233-
const test_file = "zig-out/lib/__pyoz_test.py";
262+
const test_file = if (builtin.os.tag == .windows) "zig-out/bin/__pyoz_test.py" else "zig-out/lib/__pyoz_test.py";
234263
{
235264
const cwd = std.fs.cwd();
236265
const f = cwd.createFile(test_file, .{}) catch |err| {
@@ -266,10 +295,18 @@ pub fn runTests(allocator: std.mem.Allocator, args: []const []const u8) !void {
266295
const existing_pp = std.process.getEnvVarOwned(allocator, "PYTHONPATH") catch "";
267296
defer if (existing_pp.len > 0) allocator.free(existing_pp);
268297

269-
const new_pp = if (existing_pp.len > 0)
270-
try std.fmt.allocPrint(allocator, "zig-out/lib{s}{s}", .{ path_sep, existing_pp })
298+
// On Windows, Zig places DLLs (.pyd) in zig-out/bin/, so use the correct directory
299+
const out_dir = if (builtin.os.tag == .windows) "zig-out/bin" else "zig-out/lib";
300+
// In package mode, also add project root so `import ravn` finds ravn/__init__.py
301+
const new_pp = if (is_package_mode)
302+
if (existing_pp.len > 0)
303+
try std.fmt.allocPrint(allocator, ".{s}{s}{s}{s}", .{ path_sep, out_dir, path_sep, existing_pp })
304+
else
305+
try std.fmt.allocPrint(allocator, ".{s}{s}", .{ path_sep, out_dir })
306+
else if (existing_pp.len > 0)
307+
try std.fmt.allocPrint(allocator, "{s}{s}{s}", .{ out_dir, path_sep, existing_pp })
271308
else
272-
try allocator.dupe(u8, "zig-out/lib");
309+
try allocator.dupe(u8, out_dir);
273310
defer allocator.free(new_pp);
274311

275312
// Build argv
@@ -330,10 +367,39 @@ pub fn runBench(allocator: std.mem.Allocator, args: []const []const u8) !void {
330367
return;
331368
}
332369

370+
// Load project config to detect package mode
371+
var config = project.toml.loadPyProject(allocator) catch |err| {
372+
if (err == error.PyProjectNotFound) {
373+
std.debug.print("Error: pyproject.toml not found. Run 'pyoz init' first.\n", .{});
374+
}
375+
return err;
376+
};
377+
defer config.deinit(allocator);
378+
379+
// Detect package mode
380+
const is_package_mode = blk: {
381+
const mod_name = config.getModuleName();
382+
if (mod_name.len > 0 and mod_name[0] == '_') {
383+
for (config.py_packages.items) |pkg| {
384+
if (std.mem.eql(u8, pkg, config.name)) break :blk true;
385+
}
386+
}
387+
break :blk false;
388+
};
389+
333390
// Always build in release mode for benchmarks
334391
var build_result = try builder.buildModule(allocator, true);
335392
defer build_result.deinit(allocator);
336393

394+
// In package mode, copy .pyd/.so into the package directory so `import ravn` works
395+
if (is_package_mode) {
396+
const pkg_module_path = try std.fmt.allocPrint(allocator, "{s}/{s}", .{ config.name, build_result.module_name });
397+
defer allocator.free(pkg_module_path);
398+
std.fs.cwd().copyFile(build_result.module_path, std.fs.cwd(), pkg_module_path, .{}) catch |err| {
399+
std.debug.print("Warning: Could not copy module into package directory: {s}\n", .{@errorName(err)});
400+
};
401+
}
402+
337403
// Extract benchmarks from the compiled module
338404
const bench_content = symreader.extractBenchmarks(allocator, build_result.module_path) catch |err| {
339405
std.debug.print("Error: Could not extract benchmarks: {}\n", .{err});
@@ -353,7 +419,7 @@ pub fn runBench(allocator: std.mem.Allocator, args: []const []const u8) !void {
353419
defer allocator.free(bench_content.?);
354420

355421
// Write benchmark file next to the built module
356-
const bench_file = "zig-out/lib/__pyoz_bench.py";
422+
const bench_file = if (builtin.os.tag == .windows) "zig-out/bin/__pyoz_bench.py" else "zig-out/lib/__pyoz_bench.py";
357423
{
358424
const cwd = std.fs.cwd();
359425
const f = cwd.createFile(bench_file, .{}) catch |err| {
@@ -388,10 +454,18 @@ pub fn runBench(allocator: std.mem.Allocator, args: []const []const u8) !void {
388454
const existing_pp = std.process.getEnvVarOwned(allocator, "PYTHONPATH") catch "";
389455
defer if (existing_pp.len > 0) allocator.free(existing_pp);
390456

391-
const new_pp = if (existing_pp.len > 0)
392-
try std.fmt.allocPrint(allocator, "zig-out/lib{s}{s}", .{ path_sep, existing_pp })
457+
// On Windows, Zig places DLLs (.pyd) in zig-out/bin/, so use the correct directory
458+
const bench_out_dir = if (builtin.os.tag == .windows) "zig-out/bin" else "zig-out/lib";
459+
// In package mode, also add project root so `import ravn` finds ravn/__init__.py
460+
const new_pp = if (is_package_mode)
461+
if (existing_pp.len > 0)
462+
try std.fmt.allocPrint(allocator, ".{s}{s}{s}{s}", .{ path_sep, bench_out_dir, path_sep, existing_pp })
463+
else
464+
try std.fmt.allocPrint(allocator, ".{s}{s}", .{ path_sep, bench_out_dir })
465+
else if (existing_pp.len > 0)
466+
try std.fmt.allocPrint(allocator, "{s}{s}{s}", .{ bench_out_dir, path_sep, existing_pp })
393467
else
394-
try allocator.dupe(u8, "zig-out/lib");
468+
try allocator.dupe(u8, bench_out_dir);
395469
defer allocator.free(new_pp);
396470

397471
var env_map = std.process.getEnvMap(allocator) catch |err| {

src/cli/project.zig

Lines changed: 36 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -110,15 +110,15 @@ pub fn create(allocator: std.mem.Allocator, name_opt: ?[]const u8, in_current_di
110110
\\
111111
\\Project structure:
112112
\\ {s}/
113-
\\ ├── pyproject.toml # Project configuration
114-
\\ ├── build.zig # Zig build script
115-
\\ ├── build.zig.zon # Zig dependencies
116-
\\ ├── README.md
117-
\\ ├── .gitignore
118-
\\ ├── src/
119-
\\ └── lib.zig # Your Zig extension code
120-
\\ └── {s}/
121-
\\ └── __init__.py # Python package entry point
113+
\\ +-- pyproject.toml # Project configuration
114+
\\ +-- build.zig # Zig build script
115+
\\ +-- build.zig.zon # Zig dependencies
116+
\\ +-- README.md
117+
\\ +-- .gitignore
118+
\\ +-- src/
119+
\\ | +-- lib.zig # Your Zig extension code
120+
\\ +-- {s}/
121+
\\ +-- __init__.py # Python package entry point
122122
\\
123123
\\Next steps:
124124
\\
@@ -130,13 +130,13 @@ pub fn create(allocator: std.mem.Allocator, name_opt: ?[]const u8, in_current_di
130130
\\
131131
\\Project structure:
132132
\\ {s}/
133-
\\ ├── pyproject.toml # Project configuration
134-
\\ ├── build.zig # Zig build script
135-
\\ ├── build.zig.zon # Zig dependencies
136-
\\ ├── README.md
137-
\\ ├── .gitignore
138-
\\ └── src/
139-
\\ └── lib.zig # Your module code
133+
\\ +-- pyproject.toml # Project configuration
134+
\\ +-- build.zig # Zig build script
135+
\\ +-- build.zig.zon # Zig dependencies
136+
\\ +-- README.md
137+
\\ +-- .gitignore
138+
\\ +-- src/
139+
\\ +-- lib.zig # Your module code
140140
\\
141141
\\Next steps:
142142
\\
@@ -571,6 +571,16 @@ const build_zig_template =
571571
\\ // Link libc (required for Python C API)
572572
\\ lib.linkLibC();
573573
\\
574+
\\ // On Windows, link against the Python stable ABI library (python3.lib).
575+
\\ // These options are passed automatically by `pyoz build`.
576+
\\ // For manual `zig build` on Windows, pass: -Dpython-lib-dir=<path> -Dpython-lib-name=python3
577+
\\ if (b.option([]const u8, "python-lib-dir", "Python library directory")) |lib_dir| {
578+
\\ lib.addLibraryPath(.{ .cwd_relative = lib_dir });
579+
\\ }
580+
\\ if (b.option([]const u8, "python-lib-name", "Python library name")) |lib_name| {
581+
\\ lib.linkSystemLibrary(lib_name);
582+
\\ }
583+
\\
574584
\\ // Determine extension based on target OS (.pyd for Windows, .so otherwise)
575585
\\ const ext = if (builtin.os.tag == .windows) ".pyd" else ".so";
576586
\\
@@ -628,6 +638,16 @@ const build_zig_package_template =
628638
\\ // Link libc (required for Python C API)
629639
\\ lib.linkLibC();
630640
\\
641+
\\ // On Windows, link against the Python stable ABI library (python3.lib).
642+
\\ // These options are passed automatically by `pyoz build`.
643+
\\ // For manual `zig build` on Windows, pass: -Dpython-lib-dir=<path> -Dpython-lib-name=python3
644+
\\ if (b.option([]const u8, "python-lib-dir", "Python library directory")) |lib_dir| {
645+
\\ lib.addLibraryPath(.{ .cwd_relative = lib_dir });
646+
\\ }
647+
\\ if (b.option([]const u8, "python-lib-name", "Python library name")) |lib_name| {
648+
\\ lib.linkSystemLibrary(lib_name);
649+
\\ }
650+
\\
631651
\\ // Determine extension based on target OS (.pyd for Windows, .so otherwise)
632652
\\ const ext = if (builtin.os.tag == .windows) ".pyd" else ".so";
633653
\\

src/lib/root.zig

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -663,9 +663,17 @@ fn generateTestContent(comptime config: anytype) []const u8 {
663663
break :blk config.name[0..len];
664664
};
665665

666+
// In package mode (module name starts with '_'), also import the package name
667+
// so users can write `assert ravn.add(2, 3) == 5` instead of `assert _ravn.add(2, 3) == 5`
668+
const pkg_import: []const u8 = if (mod_name.len > 1 and mod_name[0] == '_')
669+
"import " ++ mod_name[1..] ++ "\n"
670+
else
671+
"";
672+
666673
var result: []const u8 =
667674
"import unittest\n" ++
668675
"import " ++ mod_name ++ "\n" ++
676+
pkg_import ++
669677
"\n" ++
670678
"\n" ++
671679
"class Test" ++ capitalizeFirst(mod_name) ++ "(unittest.TestCase):\n";
@@ -706,9 +714,15 @@ fn generateBenchContent(comptime config: anytype) []const u8 {
706714
break :blk config.name[0..len];
707715
};
708716

717+
const bench_pkg_import: []const u8 = if (mod_name.len > 1 and mod_name[0] == '_')
718+
"import " ++ mod_name[1..] ++ "\n"
719+
else
720+
"";
721+
709722
var result: []const u8 =
710723
"import timeit\n" ++
711724
"import " ++ mod_name ++ "\n" ++
725+
bench_pkg_import ++
712726
"\n" ++
713727
"\n" ++
714728
"def run_benchmarks():\n" ++

src/version.zig

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
pub const major: u8 = 0;
55
pub const minor: u8 = 11;
6-
pub const patch: u8 = 1;
6+
pub const patch: u8 = 2;
77

88
/// Pre-release identifier (e.g., "alpha", "beta", "rc1", or null for release)
99
pub const pre_release: ?[]const u8 = null;

0 commit comments

Comments
 (0)