diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 18bfba5..74cfce7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -74,7 +74,7 @@ jobs: - os: ubuntu-24.04-arm python-version: '3.13' - - os: macos-13 + - os: macos-x86_64 python-version: '3.13' - os: macos-15 python-version: '3.13' @@ -91,9 +91,6 @@ jobs: uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - - name: Configure Python - if: matrix.os == 'windows-11-arm' - run: echo "UV_NO_MANAGED_PYTHON=1" >> "${GITHUB_ENV}" - name: Install the latest version of uv uses: astral-sh/setup-uv@v6 with: diff --git a/CHANGES.md b/CHANGES.md index 532a440..e413ee9 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,5 +1,9 @@ # Release Notes +## 0.0.3 + +Support `--sh-boot` PEXes and more targets. + ## 0.0.2 Support PEXes with user sources. diff --git a/build.zig b/build.zig index 3da4509..fcaf316 100644 --- a/build.zig +++ b/build.zig @@ -1,23 +1,102 @@ const std = @import("std"); -// TODO: XXX: Uncomment musl variants. There are currently issues building libzip / zstd / zlib. +// Linux targets: +const linux_aarch64_gnu: std.Target.Query = .{ + .cpu_arch = .aarch64, + .os_tag = .linux, + .abi = .gnu, +}; +const linux_aarch64_musl: std.Target.Query = .{ + .cpu_arch = .aarch64, + .os_tag = .linux, + .abi = .musl, +}; +const linux_arm32hf: std.Target.Query = .{ + .cpu_arch = .arm, + .os_tag = .linux, + .abi = .gnueabihf, +}; +const linux_ppc64le: std.Target.Query = .{ + .cpu_arch = .powerpc64le, + .os_tag = .linux, + .abi = .gnu, +}; +const linux_riscv64: std.Target.Query = .{ + .cpu_arch = .riscv64, + .os_tag = .linux, + .abi = .gnu, +}; +const linux_s390x: std.Target.Query = .{ + .cpu_arch = .s390x, + .os_tag = .linux, + .abi = .gnu, +}; +const linux_x86_64_gnu: std.Target.Query = .{ + .cpu_arch = .x86_64, + .os_tag = .linux, + .abi = .gnu, +}; +const linux_x86_64_musl: std.Target.Query = .{ + .cpu_arch = .x86_64, + .os_tag = .linux, + .abi = .musl, +}; + +// Macos targets: +const macos_aarch64: std.Target.Query = .{ + .cpu_arch = .aarch64, + .os_tag = .macos, +}; +const macos_x86_64: std.Target.Query = .{ + .cpu_arch = .x86_64, + .os_tag = .macos, +}; + +// Windows targets: +const windows_aarch64: std.Target.Query = .{ + .cpu_arch = .aarch64, + .os_tag = .windows, +}; +const windows_x86_64: std.Target.Query = .{ + .cpu_arch = .x86_64, + .os_tag = .windows, +}; + const supported_targets: []const std.Target.Query = &.{ - // Linux targets: - .{ .cpu_arch = .aarch64, .os_tag = .linux, .abi = .gnu }, - // .{ .cpu_arch = .aarch64, .os_tag = .linux, .abi = .musl }, - .{ .cpu_arch = .s390x, .os_tag = .linux, .abi = .gnu }, - .{ .cpu_arch = .arm, .os_tag = .linux, .abi = .gnueabihf }, - .{ .cpu_arch = .powerpc64le, .os_tag = .linux, .abi = .gnu }, - .{ .cpu_arch = .x86_64, .os_tag = .linux, .abi = .gnu }, - // .{ .cpu_arch = .x86_64, .os_tag = .linux, .abi = .musl }, - // Macos targets: - .{ .cpu_arch = .aarch64, .os_tag = .macos }, - .{ .cpu_arch = .x86_64, .os_tag = .macos }, - // Windows targets: - .{ .cpu_arch = .aarch64, .os_tag = .windows }, - .{ .cpu_arch = .x86_64, .os_tag = .windows }, + linux_aarch64_gnu, + linux_aarch64_musl, + linux_arm32hf, + linux_ppc64le, + linux_riscv64, + linux_s390x, + linux_x86_64_gnu, + linux_x86_64_musl, + macos_aarch64, + macos_x86_64, + windows_aarch64, + windows_x86_64, }; +fn build_exe( + b: *std.Build, + target: std.Build.ResolvedTarget, + optimize: std.builtin.OptimizeMode, + lib: *std.Build.Module, + config: *std.Build.Module, +) *std.Build.Step.Compile { + const exe = b.addExecutable(.{ + .name = "pexcz", + .root_module = b.createModule(.{ + .root_source_file = b.path("src/main.zig"), + .target = target, + .optimize = optimize, + }), + }); + exe.root_module.addImport("pexcz", lib); + exe.root_module.addImport("config", config); + return exe; +} + pub fn build(b: *std.Build) !void { const benches = b.option(bool, "benches", "Build benchmarking executables.") orelse false; @@ -43,14 +122,18 @@ pub fn build(b: *std.Build) !void { const tool = b.addExecutable(.{ .name = "fetch_virtualenv", - .root_source_file = b.path("tools/fetch_virtualenv.zig"), - .target = b.graph.host, + .root_module = b.createModule(.{ + .root_source_file = b.path("tools/fetch_virtualenv.zig"), + .target = b.graph.host, + .optimize = optimize, + }), }); const tool_step = b.addRunArtifact(tool); // TODO(John Sirois): Plumb --sha arg from a build option. const virtualenv_py_resource = tool_step.addOutputFileArg("virtualenv.py"); const known_folders = b.dependency("known_folders", .{}).module("known-folders"); + const zeit = b.dependency("zeit", .{}).module("zeit"); var target_dirs = try std.ArrayList([]const u8).initCapacity(b.allocator, target_queries.len); defer target_dirs.deinit(); @@ -79,11 +162,13 @@ pub fn build(b: *std.Build) !void { }); lib.addAnonymousImport("virtualenv.py", .{ .root_source_file = virtualenv_py_resource }); lib.addImport("known-folders", known_folders); + lib.addImport("zeit", zeit); lib.linkLibrary(libzip_dep); - const clib = b.addSharedLibrary(.{ + const clib = b.addLibrary(.{ .name = "pexcz", - .root_module = b.addModule("pexcz", .{ + .linkage = .dynamic, + .root_module = b.createModule(.{ .root_source_file = b.path("src/clib.zig"), .target = rt, .optimize = optimize, @@ -105,16 +190,7 @@ pub fn build(b: *std.Build) !void { clib_output.dest_sub_path = b.pathJoin(&.{ target_dir, clib_output.dest_sub_path }); b.getInstallStep().dependOn(&clib_output.step); - const exe = b.addExecutable(.{ - .name = "pexcz", - .root_module = b.addModule("pexcz", .{ - .root_source_file = b.path("src/main.zig"), - .target = rt, - .optimize = optimize, - }), - }); - exe.root_module.addImport("pexcz", lib); - exe.root_module.addImport("config", config); + const exe = build_exe(b, rt, optimize, lib, config); exe.step.dependOn(&update_source_files.step); var exe_output = b.addInstallArtifact(exe, .{}); exe_output.dest_sub_path = b.pathJoin(&.{ target_dir, exe_output.dest_sub_path }); @@ -133,17 +209,21 @@ pub fn build(b: *std.Build) !void { const run_step = b.step("run", "Run the app"); run_step.dependOn(&run_cmd.step); + const exe_check = build_exe(b, rt, optimize, lib, config); + const check = b.step("check", "Check if pexcz compiles (used by zls)."); + check.dependOn(&exe_check.step); + if (benches) { const zip_exe = b.addExecutable(.{ .name = "zipopen", - .root_module = b.addModule("zipopen", .{ + .root_module = b.createModule(.{ .root_source_file = b.path("bench/zipopen.zig"), .target = rt, .optimize = optimize, + .link_libc = true, }), }); zip_exe.root_module.addImport("pexcz", lib); - zip_exe.linkLibC(); const zip_exe_output = b.addInstallArtifact(zip_exe, .{}); zip_exe_output.dest_sub_path = b.pathJoin(&.{ target_dir, zip_exe_output.dest_sub_path }); b.getInstallStep().dependOn(&zip_exe_output.step); @@ -159,25 +239,30 @@ pub fn build(b: *std.Build) !void { "For named tests, only run those with names matching the filter.", ) orelse &.{}; const lib_unit_tests = b.addTest(.{ - .root_source_file = b.path("src/lib.zig"), - .target = cur_tgt, - .optimize = optimize, .filters = test_filters, + .root_module = b.createModule(.{ + .root_source_file = b.path("src/lib.zig"), + .target = cur_tgt, + .optimize = optimize, + }), }); lib_unit_tests.root_module.addAnonymousImport( "virtualenv.py", .{ .root_source_file = virtualenv_py_resource }, ); lib_unit_tests.root_module.addImport("known-folders", known_folders); + lib_unit_tests.root_module.addImport("zeit", zeit); const run_lib_unit_tests = b.addRunArtifact(lib_unit_tests); const exe_test_options = b.addOptions(); exe_test_options.addOptionPath("pexcz_exe", pexcz_emitted_bin.?); const exe_unit_tests = b.addTest(.{ - .root_source_file = b.path("src/main.zig"), - .target = cur_tgt, - .optimize = optimize, .filters = test_filters, + .root_module = b.createModule(.{ + .root_source_file = b.path("src/main.zig"), + .target = cur_tgt, + .optimize = optimize, + }), }); exe_unit_tests.root_module.addImport("options", exe_test_options.createModule()); const run_exe_unit_tests = b.addRunArtifact(exe_unit_tests); @@ -232,10 +317,15 @@ fn build_libzip( .ZIP_UINT64_T = "uint64_t", }, ); - const lib = b.addStaticLibrary(.{ + const lib = b.addLibrary(.{ .name = "zip", - .target = target, - .optimize = optimize, + .linkage = .static, + .root_module = b.createModule(.{ + .target = target, + .optimize = optimize, + .link_libc = true, + .pic = true, + }), }); const zip_lib_dir = upstream.path("lib"); const flags: []const []const u8 = res: { @@ -401,15 +491,56 @@ fn build_libzip( lib.addConfigHeader(config); lib.addConfigHeader(zip_config); - const zlib_dep = b.dependency("zlib", .{ - .target = target, - .optimize = optimize, + const zlib_dep = b.dependency("zlib", .{}); + const zlib = b.addLibrary(.{ + .name = "z", + .linkage = .static, + .root_module = b.createModule(.{ + .target = target, + .optimize = optimize, + .link_libc = true, + .pic = true, + }), }); - lib.linkLibrary(zlib_dep.artifact("z")); + zlib.addCSourceFiles(.{ + .root = zlib_dep.path(""), + .files = &.{ + "adler32.c", + "crc32.c", + "deflate.c", + "infback.c", + "inffast.c", + "inflate.c", + "inftrees.c", + "trees.c", + "zutil.c", + "compress.c", + "uncompr.c", + "gzclose.c", + "gzlib.c", + "gzread.c", + "gzwrite.c", + }, + .flags = &.{ + "-DHAVE_SYS_TYPES_H", + "-DHAVE_STDINT_H", + "-DHAVE_STDDEF_H", + "-DZ_HAVE_UNISTD_H", + }, + }); + zlib.installHeadersDirectory(zlib_dep.path(""), "", .{ + .include_extensions = &.{ + "zconf.h", + "zlib.h", + }, + }); + lib.linkLibrary(zlib); const zstd_dependency = b.dependency("zstd", .{ .target = target, .optimize = optimize, + .linkage = .static, + .pie = true, .minify = true, .dictbuilder = false, .@"exclude-compressors-dfast-and-up" = true, @@ -417,8 +548,6 @@ fn build_libzip( }); lib.linkLibrary(zstd_dependency.artifact("zstd")); - lib.linkLibC(); - const tool = b.addExecutable(.{ .name = "generate_zip_error_strings", .root_module = b.createModule(.{ diff --git a/build.zig.zon b/build.zig.zon index 561afb3..b7a7cf9 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -1,7 +1,7 @@ // See: https://github.com/ziglang/zig/blob/master/doc/build.zig.zon.md .{ .name = .pexcz, - .version = "0.0.2", + .version = "0.0.3", .fingerprint = 0x97ea894d71a986ac, .minimum_zig_version = "0.14.1", @@ -16,13 +16,17 @@ .hash = "N-V-__8AAGRUYABUylxlPgK8vgvvxVLufaQalXyPsIKYJRMw", }, .zlib = .{ - .url = "git+https://github.com/allyourcodebase/zlib#6c72830882690c1eb2567a537525c3f432c1da50", - .hash = "zlib-1.3.1-ZZQ7lVgMAACwO4nUUd8GLhsuQ5JQq_VAhlEiENJTUv6h", + .url = "https://github.com/madler/zlib/archive/refs/tags/v1.3.1.tar.gz", + .hash = "N-V-__8AAB0eQwD-0MdOEBmz7intriBReIsIDNlukNVoNu6o", }, .zstd = .{ .url = "git+https://github.com/allyourcodebase/zstd#01327d49cbc56dc24c20a167bb0055d7fc23de84", .hash = "zstd-1.5.7-KEItkJ8vAAC5_rRlKmLflYQ-eKXbAIQBWZNmmJtS18q0", }, + .zeit = .{ + .url = "git+https://github.com/rockorager/zeit#991f38266f86535e68431675e8feb84efa1f011b", + .hash = "zeit-0.6.0-5I6bk0t7AgCPM_cY1DoqJB2pnmG7MMtpdO5IxNpryJDy", + }, }, .paths = .{ diff --git a/pyproject.toml b/pyproject.toml index 1088730..cfec62f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,7 @@ backend-path = ["build-system"] [project] name = "pexcz" -version = "0.0.2" +version = "0.0.3" description = "Native Pex." readme = "README.md" authors = [ @@ -116,8 +116,10 @@ accepts-extra-args = true [tool.dev-cmd.tasks] test = [["zig-test", "pytest"]] -[tool.dev-cmd.tasks.checks] +[tool.dev-cmd.tasks.checks_lt313] +name = "checks" description = "Runs all development checks, including auto-formatting code." +when = "python_version < '3.13'" steps = [[ # Zig formatting and testing are independent of Python checks so we run these as two parallel # groups. @@ -134,6 +136,26 @@ steps = [[ ] ]] +[tool.dev-cmd.tasks.checks_gte313] +name = "checks" +description = "Runs all development checks, including auto-formatting code." +when = "python_version >= '3.13'" +steps = [[ + # Zig formatting and testing are independent of Python checks so we run these as two parallel + # groups. + [ + "zig-fmt", + "zig-test" + ], + [ + "fmt", + "lint", + # Parallelizing the type checks and test is safe (they don't modify files), and it nets a + # ~3x speedup over running them all serially. + ["type-check-py3.{5..13}", "pytest"], + ] +]] + [tool.dev-cmd.tasks.ci] description = "Runs all checks used for CI." # None of the CI checks modify files; so they can all be run in parallel which nets a ~1.5x speedup. diff --git a/src/lib.zig b/src/lib.zig index 2ca2efa..32da1ae 100644 --- a/src/lib.zig +++ b/src/lib.zig @@ -1,23 +1,36 @@ const std = @import("std"); +const ProjectName = @import("lib/ProjectName.zig"); +const Specifier = @import("lib/Specifier.zig"); +const Version = @import("lib/Version.zig"); +const WheelInfo = @import("lib/WheelInfo.zig"); +const layout = @import("lib/layout.zig"); + pub const Allocator = @import("lib/heap.zig").Allocator; pub const Environ = @import("lib/process.zig").Environ; +pub const Interpreter = @import("lib/Interpreter.zig"); +pub const InterpreterConstraints = @import("lib/InterpreterConstraints.zig"); +pub const PexInfo = @import("lib/PexInfo.zig"); +pub const VenvPex = @import("lib/VenvPex.zig"); pub const Zip = @import("lib/Zip.zig"); -const boot = @import("lib/boot.zig"); -pub const bootPexZPosix = boot.bootPexZPosix; -pub const bootPexZWindows = boot.bootPexZWindows; -pub const mount = boot.mount; pub const cache = @import("lib/cache.zig"); pub const fs = @import("lib/fs.zig"); pub const sliceZ = @import("lib/process.zig").sliceZ; +pub const string = @import("lib/string.zig"); -const ProjectName = @import("lib/ProjectName.zig"); -const WheelInfo = @import("lib/WheelInfo.zig"); -const interpreter = @import("lib/interpreter.zig"); +const boot = @import("lib/boot.zig"); +pub const bootPexZPosix = boot.bootPexZPosix; +pub const bootPexZWindows = boot.bootPexZWindows; +pub const mount = boot.mount; test { + _ = InterpreterConstraints; _ = ProjectName; + _ = Specifier; + _ = Version; _ = WheelInfo; _ = cache; - _ = interpreter; + _ = Interpreter; + _ = layout; + _ = string; } diff --git a/src/lib/interpreter.zig b/src/lib/Interpreter.zig similarity index 61% rename from src/lib/interpreter.zig rename to src/lib/Interpreter.zig index e648355..d78c785 100644 --- a/src/lib/interpreter.zig +++ b/src/lib/Interpreter.zig @@ -6,7 +6,7 @@ const std = @import("std"); const TempDirs = @import("fs.zig").TempDirs; const cache = @import("cache.zig"); -const getenv = @import("os.zig").getenv; +const os = @import("os.zig"); const subprocess = @import("subprocess.zig"); pub const Marker = @import("Marker.zig"); pub const RankedTags = @import("RankedTags.zig"); @@ -18,9 +18,7 @@ const Version = struct { major: u8, minor: u8, - const Self = @This(); - - fn parse(version: []const u8) !Self { + fn parse(version: []const u8) !Version { var version_component_iter = std.mem.splitScalar(u8, version, '.'); const major = version_component_iter.next() orelse return error.VersionParseError; const minor = version_component_iter.next() orelse return error.VersionParseError; @@ -35,9 +33,7 @@ const Manylinux = struct { armhf: bool, i686: bool, - const Self = @This(); - - fn fromHeader(parse_source: anytype, header: std.elf.Header, version: ?Version) !Self { + fn fromHeader(parse_source: anytype, header: std.elf.Header, version: ?Version) !Manylinux { const @"32bit little endian" = !header.is_64 and header.endian == .little; const armhf = res: { if (!@"32bit little endian" or header.machine != .ARM) { @@ -73,9 +69,7 @@ const Linux = union(enum) { manylinux: Manylinux, muslinux: Version, - const Self = @This(); - - fn detect(allocator: std.mem.Allocator, python: []const u8) !?Self { + fn detect(allocator: std.mem.Allocator, python: []const u8) !?Linux { if (native_os != .linux) { return null; } @@ -204,175 +198,180 @@ pub const VersionInfo = struct { major: u8, minor: u8, micro: u8, - releaselevel: []const u8, - serial: u8, + releaselevel: []const u8 = "final", + serial: u8 = 0, }; -pub const Interpreter = struct { - path: []const u8, - realpath: []const u8, - prefix: []const u8, - base_prefix: ?[]const u8, - version: VersionInfo, - marker_env: Marker.Env, - macos_framework_build: bool, - has_ensurepip: bool, - - // TODO: XXX: See if we can just keep tags as []const u8 opaque strings for set membership - // tests. - supported_tags: []const Tag, - - const Self = @This(); - - pub fn identify(allocator: std.mem.Allocator, path: []const u8) !std.json.Parsed(Self) { - var temp_dirs = TempDirs.init(allocator); - defer temp_dirs.deinit(); - - const pexcz_root = try cache.root(allocator, &temp_dirs, .{}); - defer pexcz_root.deinit(.{}); - - // TODO(John Sirois): Re-consider key hashing scheme - compare to Pex. - const Hasher = std.crypto.hash.sha2.Sha256; - var digest: [Hasher.digest_length]u8 = undefined; - Hasher.hash(path, &digest, .{}); - - const encoder = std.fs.base64_encoder; - // N.B.: This is the correct value for a 32 byte hash (sha256). - var key_buf: [43]u8 = undefined; - const key = encoder.encode(&key_buf, &digest); - const expected_size = encoder.calcSize(Hasher.digest_length); - std.debug.assert(expected_size == key.len); - - var interpeter_cache = try pexcz_root.join(&.{ "interpreters", "0", key }); - defer interpeter_cache.deinit(.{}); - - const Work = struct { - allocator: std.mem.Allocator, - python: []const u8, - - fn work(work_path: []const u8, work_dir: std.fs.Dir, context: @This()) !void { - var timer = try std.time.Timer.start(); - defer log.debug( - "interpreter identification took {d:.3}µs", - .{timer.read() / 1_000}, - ); - - const linux_info = res: { - defer log.debug("Linux libc detection took {d:.3}µs", .{timer.lap() / 1_000}); - const linux = try Linux.detect(context.allocator, context.python); - break :res linux; - }; +path: []const u8, +realpath: []const u8, +prefix: []const u8, +base_prefix: ?[]const u8, +version: VersionInfo, +marker_env: Marker.Env, +macos_framework_build: bool, +has_ensurepip: bool, + +// TODO: XXX: See if we can just keep tags as []const u8 opaque strings for set membership +// tests. +supported_tags: []const Tag, + +const Self = @This(); + +pub fn identify(allocator: std.mem.Allocator, path: []const u8) !std.json.Parsed(Self) { + var temp_dirs = TempDirs.init(allocator); + defer temp_dirs.deinit(); + + const pexcz_root = try cache.root(allocator, &temp_dirs, .{}); + defer pexcz_root.deinit(.{}); + + // TODO(John Sirois): Re-consider key hashing scheme - compare to Pex. + const Hasher = std.crypto.hash.sha2.Sha256; + var digest: [Hasher.digest_length]u8 = undefined; + Hasher.hash(path, &digest, .{}); + + const encoder = std.fs.base64_encoder; + // N.B.: This is the correct value for a 32 byte hash (sha256). + var key_buf: [43]u8 = undefined; + const key = encoder.encode(&key_buf, &digest); + const expected_size = encoder.calcSize(Hasher.digest_length); + std.debug.assert(expected_size == key.len); + + var interpeter_cache = try pexcz_root.join(&.{ "interpreters", "0", key }); + defer interpeter_cache.deinit(.{}); + + const Work = struct { + allocator: std.mem.Allocator, + python: []const u8, + + fn work(work_path: []const u8, work_dir: std.fs.Dir, context: @This()) !void { + var timer = try std.time.Timer.start(); + defer log.debug( + "interpreter identification took {d:.3}µs", + .{timer.read() / 1_000}, + ); - var argc: usize = 5; - var argv = [_][]const u8{ - context.python, - "-sE", - "-c", - interpreter_py, - "info.json", - "--linux-info", - "", - }; - if (linux_info) |linux| { - argv[argv.len - 1] = try std.json.stringifyAlloc( - context.allocator, - linux, - .{}, - ); - argc = argv.len; - log.debug( - "Detected Linux for {s}:\n{s}", - .{ context.python, argv[argv.len - 1] }, - ); - } - defer if (argc == argv.len) context.allocator.free(argv[argv.len - 1]); + const linux_info = res: { + defer log.debug("Linux libc detection took {d:.3}µs", .{timer.lap() / 1_000}); + const linux = try Linux.detect(context.allocator, context.python); + break :res linux; + }; - const CheckCall = struct { - pub fn printError(python: []const u8) void { - std.debug.print("Failed to identify interpreter at {s}.\n", .{python}); - } - }; - try subprocess.run( + var argc: usize = 5; + var argv = [_][]const u8{ + context.python, + "-sE", + "-c", + interpreter_py, + "info.json", + "--linux-info", + "", + }; + if (linux_info) |linux| { + argv[argv.len - 1] = try std.json.stringifyAlloc( context.allocator, - argv[0..argc], - subprocess.CheckCall(CheckCall.printError), - .{ - .print_error_args = context.python, - .extra_child_run_args = .{ - .cwd = work_path, - .cwd_dir = work_dir, - }, + linux, + .{}, + ); + argc = argv.len; + log.debug( + "Detected Linux for {s}:\n{s}", + .{ context.python, argv[argv.len - 1] }, + ); + } + defer if (argc == argv.len) context.allocator.free(argv[argv.len - 1]); + + const CheckCall = struct { + pub fn printError(python: []const u8) void { + std.debug.print("Failed to identify interpreter at {s}.\n", .{python}); + } + }; + try subprocess.run( + context.allocator, + argv[0..argc], + subprocess.CheckCall(CheckCall.printError), + .{ + .print_error_args = context.python, + .extra_child_run_args = .{ + .cwd = work_path, + .cwd_dir = work_dir, }, + }, + ); + } + }; + const work: Work = .{ .allocator = allocator, .python = path }; + var interpeter_cache_dir = try interpeter_cache.createAtomic(Work, Work.work, work, .{}); + defer interpeter_cache_dir.close(); + + const stat = try interpeter_cache_dir.statFile("info.json"); + const data = try interpeter_cache_dir.readFileAlloc( + allocator, + "info.json", + @intCast(stat.size), + ); + defer allocator.free(data); + + return try std.json.parseFromSlice( + Self, + allocator, + data, + .{ .allocate = .alloc_always }, + ); +} + +pub fn rankedTags(self: Self, allocator: std.mem.Allocator) !RankedTags { + return RankedTags.init(allocator, self.supported_tags); +} + +pub fn resolve_base_interpreter(self: Self, allocator: std.mem.Allocator) !?std.json.Parsed(Self) { + if (self.base_prefix) |base_prefix| { + if (std.mem.eql(u8, base_prefix, self.prefix)) { + return null; + } + const path = res: { + if (native_os == .windows) { + break :res try std.fs.path.join( + allocator, + &.{ base_prefix, std.fs.path.basename(self.path) }, + ); + } else { + break :res try std.fs.path.join( + allocator, + &.{ base_prefix, "bin", std.fs.path.basename(self.path) }, ); } }; - const work: Work = .{ .allocator = allocator, .python = path }; - var interpeter_cache_dir = try interpeter_cache.createAtomic(Work, Work.work, work, .{}); - defer interpeter_cache_dir.close(); - - const stat = try interpeter_cache_dir.statFile("info.json"); - const data = try interpeter_cache_dir.readFileAlloc( - allocator, - "info.json", - @intCast(stat.size), - ); - defer allocator.free(data); - - return try std.json.parseFromSlice( - Interpreter, - allocator, - data, - .{ .allocate = .alloc_always }, - ); + defer allocator.free(path); + std.fs.cwd().access(path, .{}) catch |err| { + log.debug( + "Failed to find base interpreter given base_prefix of {s} at {s}: {}", + .{ base_prefix, path, err }, + ); + return null; + }; + return try Self.identify(allocator, path); } + return null; +} - pub fn rankedTags(self: Self, allocator: std.mem.Allocator) !RankedTags { - return RankedTags.init(allocator, self.supported_tags); - } +pub const Iter = struct { + const Candidate = struct { + python_exe: []const u8, + allocator: ?std.mem.Allocator = null, - pub fn resolve_base_interpreter(self: Self, allocator: std.mem.Allocator) !?std.json.Parsed(Self) { - if (self.base_prefix) |base_prefix| { - if (std.mem.eql(u8, base_prefix, self.prefix)) { - return null; - } - const path = res: { - if (native_os == .windows) { - break :res try std.fs.path.join( - allocator, - &.{ base_prefix, std.fs.path.basename(self.path) }, - ); - } else { - break :res try std.fs.path.join( - allocator, - &.{ base_prefix, "bin", std.fs.path.basename(self.path) }, - ); - } - }; - defer allocator.free(path); - std.fs.cwd().access(path, .{}) catch |err| { - log.debug( - "Failed to find base interpreter given base_prefix of {s} at {s}: {}", - .{ base_prefix, path, err }, - ); - return null; - }; - return try Self.identify(allocator, path); + fn deinit(self: Candidate) void { + if (self.allocator) |allocator| allocator.free(self.python_exe); } - return null; - } -}; + }; -pub const InterpreterIter = struct { allocator: std.mem.Allocator, index: usize = 0, - candidates: []const []const u8, + candidates: []const Candidate, - const Self = @This(); - - fn fromSearchPath(allocator: std.mem.Allocator, search_path: ?[]const []const u8) !Self { - var path = search_path; + pub fn fromSearchPath(allocator: std.mem.Allocator, options: struct { search_path: ?[]const []const u8 = null }) !Iter { + var path = options.search_path; if (path == null) { - if (try getenv(allocator, "PATH")) |path_entries| { + if (try os.getEnv(allocator, "PATH")) |path_entries| { defer path_entries.deinit(); var buf = std.ArrayList([]const u8).init(allocator); @@ -391,27 +390,50 @@ pub const InterpreterIter = struct { } } defer { - if (search_path == null and path != null) { - for (path.?) |entry| { - allocator.free(entry); + if (options.search_path == null) { + if (path) |search_path| { + for (search_path) |entry| { + allocator.free(entry); + } + allocator.free(search_path); } - allocator.free(path.?); } } - if (path == null) { - return error.NoSearchPath; - } + const search_path = path orelse return error.NoSearchPath; - var candidates = std.ArrayList([]const u8).init(allocator); + var candidates = std.ArrayList(Candidate).init(allocator); errdefer { for (candidates.items) |candidate| { - allocator.free(candidate); + candidate.deinit(); } candidates.deinit(); } - for (path.?) |entry| { + for (search_path) |entry| { + if (std.fs.cwd().statFile(entry) catch null) |entry_stat| { + switch (entry_stat.kind) { + .file => { + try candidates.append(.{ .python_exe = entry }); + log.debug("... explicit candidate: {s}", .{entry}); + continue; + }, + .sym_link => { + var buffer: [std.fs.max_path_bytes]u8 = undefined; + if (std.fs.cwd().realpath(entry, &buffer) catch null) |realpath| { + if (std.fs.cwd().statFile(realpath) catch null) |stat| { + if (stat.kind == .file) { + try candidates.append(.{ .python_exe = entry }); + log.debug("... explicit candidate: {s}", .{entry}); + continue; + } + } + } + }, + else => {}, + } + } + var entry_dir = std.fs.cwd().openDir(entry, .{ .iterate = true }) catch |err| { log.debug("Cannot open PATH entry {s}, continuing: {}", .{ entry, err }); continue; @@ -419,7 +441,6 @@ pub const InterpreterIter = struct { defer entry_dir.close(); if (native_os == .windows) { - // TODO: XXX: What is pypy called on Windows? - double check. for ([_][]const u8{ "python.exe", "pythonw.exe", @@ -428,7 +449,7 @@ pub const InterpreterIter = struct { }) |exe_name| { if (entry_dir.access(exe_name, .{})) |_| { const candidate = try std.fs.path.join(allocator, &.{ entry, exe_name }); - try candidates.append(candidate); + try candidates.append(.{ .python_exe = candidate, .allocator = allocator }); } else |_| {} } } else { @@ -497,8 +518,9 @@ pub const InterpreterIter = struct { allocator, &.{ entry, dir_ent.name }, ); + errdefer allocator.free(candidate); + try candidates.append(.{ .python_exe = candidate, .allocator = allocator }); log.debug("... candidate: {s}", .{candidate}); - try candidates.append(candidate); } else |_| {} } }, @@ -511,23 +533,23 @@ pub const InterpreterIter = struct { return .{ .allocator = allocator, .candidates = try candidates.toOwnedSlice() }; } - pub fn next(self: *Self) ?std.json.Parsed(Interpreter) { + pub fn next(self: *Iter) ?std.json.Parsed(Self) { if (self.index >= self.candidates.len) { return null; } defer self.index += 1; const candidate = self.candidates[self.index]; - return Interpreter.identify(self.allocator, candidate) catch |err| { - log.debug("Candidate {s} failed identification: {}", .{ candidate, err }); + return Self.identify(self.allocator, candidate.python_exe) catch |err| { + log.debug("Candidate {s} failed identification: {}", .{ candidate.python_exe, err }); // TODO: XXX: Avoid recursion here - flatten with a loop. self.index += 1; return self.next(); }; } - pub fn deinit(self: Self) void { + pub fn deinit(self: Iter) void { for (self.candidates) |candidate| { - self.allocator.free(candidate); + candidate.deinit(); } self.allocator.free(self.candidates); } @@ -536,7 +558,7 @@ pub const InterpreterIter = struct { test "compare with packaging" { const Virtualenv = @import("Virtualenv.zig"); - var interpreters = try InterpreterIter.fromSearchPath(std.testing.allocator, null); + var interpreters = try Iter.fromSearchPath(std.testing.allocator, .{}); defer interpreters.deinit(); var seen = std.BufSet.init(std.testing.allocator); @@ -615,25 +637,13 @@ test "compare with packaging" { try seen.insert(interpreter.value.realpath); - const CheckInstall = struct { - pub fn printError() void { - std.debug.print("Failed to install packaging.\n", .{}); - } - }; - try subprocess.run( + try subprocess.checkCall( std.testing.allocator, &.{ venv.interpreter_relpath, "-m", "pip", "install", "packaging" }, - subprocess.CheckCall(CheckInstall.printError), .{ .extra_child_run_args = .{ .cwd = venv.path, .cwd_dir = venv.dir } }, ); - const CheckQuery = struct { - pub fn printError() void { - std.debug.print("Failed to query packaging for sys tags.\n", .{}); - } - }; - - const output = try subprocess.run( + const output = try subprocess.checkOutput( std.testing.allocator, &.{ venv.interpreter_relpath, @@ -647,7 +657,6 @@ test "compare with packaging" { \\json.dump(list(map(str, tags.sys_tags())), sys.stdout) \\ }, - subprocess.CheckOutput(CheckQuery.printError), .{ .extra_child_run_args = .{ .cwd = venv.path, .cwd_dir = venv.dir, .max_output_bytes = 1024 * 1024 } }, ); defer std.testing.allocator.free(output); @@ -667,3 +676,132 @@ test "compare with packaging" { // We should have found at least one Python interpreter to test against. try std.testing.expect(seen.count() > 0); } + +test "fromSearchPath" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + const tmp_dir_path = try tmp.dir.realpathAlloc(std.testing.allocator, "."); + defer std.testing.allocator.free(tmp_dir_path); + + var env = try std.process.getEnvMap(std.testing.allocator); + defer env.deinit(); + env.remove("UV_NO_MANAGED_PYTHON"); + try env.put("UV_PYTHON_INSTALL_DIR", "."); + + var pex_python_path = try std.ArrayList([]const u8).initCapacity(std.testing.allocator, 2); + defer { + for (pex_python_path.items) |python_exe| { + std.testing.allocator.free(python_exe); + } + pex_python_path.deinit(); + } + + for ([_][]const u8{ "3.12.11", "3.13.5" }, 0..) |version, index| { + const CheckInstall = struct { + fn printError(ver: []const u8) void { + std.debug.print( + "Failed to install a uv managed Python {s} to exercise PEX_PYTHON_PATH " ++ + "handling.\n", + .{ver}, + ); + } + }; + try subprocess.run( + std.testing.allocator, + &.{ "uv", "python", "install", "--managed-python", version }, + subprocess.CheckCall(CheckInstall.printError), + .{ + .extra_child_run_args = .{ + .env_map = &env, + .cwd = tmp_dir_path, + .cwd_dir = tmp.dir, + }, + .print_error_args = version, + }, + ); + + const CheckFind = struct { + fn printError(ver: []const u8) void { + std.debug.print( + "Failed to find a uv managed Python {s} to exercise PEX_PYTHON_PATH " ++ + "handling.\n", + .{ver}, + ); + } + }; + const output = try subprocess.run( + std.testing.allocator, + &.{ "uv", "python", "find", "--system" }, + subprocess.CheckOutput(CheckFind.printError), + .{ + .extra_child_run_args = .{ + .env_map = &env, + .cwd = tmp_dir_path, + .cwd_dir = tmp.dir, + }, + .print_error_args = version, + }, + ); + defer std.testing.allocator.free(output); + const python_exe = std.mem.trim(u8, output, "\r\n"); + try pex_python_path.append(try std.testing.allocator.dupe( + u8, + if (index % 2 == 0) python_exe else std.fs.path.dirname(python_exe).?, + )); + } + + var interpreter_iter = try Iter.fromSearchPath( + std.testing.allocator, + .{ .search_path = pex_python_path.items }, + ); + defer interpreter_iter.deinit(); + + var interpreters = try std.ArrayList(std.json.Parsed(Self)).initCapacity( + std.testing.allocator, + 2, + ); + defer { + for (interpreters.items) |interpreter| { + interpreter.deinit(); + } + interpreters.deinit(); + } + + while (interpreter_iter.next()) |interpreter| { + try interpreters.append(interpreter); + } + + const expected_python313_exe_names: []const []const u8 = res: { + if (native_os == .windows) { + break :res &.{ "python.exe", "pythonw.exe" }; + } else { + break :res &.{ "python", "python3", "python3.13" }; + } + }; + + try std.testing.expectEqual(expected_python313_exe_names.len + 1, interpreters.items.len); + + const python312 = interpreters.items[0]; + try std.testing.expectEqualDeep( + VersionInfo{ .major = 3, .minor = 12, .micro = 11 }, + python312.value.version, + ); + + var expected_python_exe_names = std.StringHashMap(void).init(std.testing.allocator); + defer expected_python_exe_names.deinit(); + for (expected_python313_exe_names) |python_exe| { + try expected_python_exe_names.put(python_exe, {}); + } + + for (interpreters.items[1..]) |python313| { + try std.testing.expectEqualDeep( + VersionInfo{ .major = 3, .minor = 13, .micro = 5 }, + python313.value.version, + ); + try std.testing.expect( + expected_python_exe_names.remove(std.fs.path.basename(python313.value.path)), + ); + } + try std.testing.expectEqual(0, expected_python_exe_names.count()); +} diff --git a/src/lib/InterpreterConstraints.zig b/src/lib/InterpreterConstraints.zig new file mode 100644 index 0000000..ce56565 --- /dev/null +++ b/src/lib/InterpreterConstraints.zig @@ -0,0 +1,643 @@ +const builtin = @import("builtin"); +const std = @import("std"); +const zeit = @import("zeit"); + +const Interpreter = @import("Interpreter.zig"); +const Release = Version.Release; +const PreRelease = Version.PreRelease; +const Specifier = @import("Specifier.zig"); +const Version = @import("Version.zig"); +const string = @import("string.zig"); + +const log = std.log.scoped(.ics); + +pub const PythonImplementation = enum { + CPython, + PyPy, + + pub fn binaryName(self: PythonImplementation) []const u8 { + switch (self) { + inline .CPython => return "python", + inline .PyPy => return "pypy", + } + } +}; + +const InterpreterConstraint = struct { + impl: ?PythonImplementation, + specifier: Specifier, + + fn parse(allocator: std.mem.Allocator, constraint: []const u8) !InterpreterConstraint { + const trimmed = string.trim_ascii_ws(constraint); + var index: usize = 0; + while (index < trimmed.len) { + switch (trimmed[index]) { + '!', '=', '<', '>', '~' => break, + else => |c| if (std.ascii.isWhitespace(c)) { + break; + } else { + index += 1; + }, + } + } + const impl: ?PythonImplementation = res: { + if (index == 0) { + break :res null; + } else if (std.meta.stringToEnum(PythonImplementation, trimmed[0..index])) |impl| { + break :res impl; + } else { + return error.InvalidPythonImpl; + } + }; + const specifier = try Specifier.parse(allocator, constraint[index..]); + for (specifier.clauses) |clause| { + switch (clause) { + .exact => return error.InvalidOperator, + else => {}, + } + } + return .{ .impl = impl, .specifier = specifier }; + } + + fn deinit(self: InterpreterConstraint) void { + self.specifier.deinit(); + } + + fn release_matches(self: InterpreterConstraint, release: Release) bool { + return self.specifier.matches(.{ .release = release }); + } + + pub fn matches(self: InterpreterConstraint, interp: Interpreter) bool { + if (self.impl) |impl| { + switch (impl) { + .CPython => if (!std.mem.eql( + u8, + "CPython", + interp.marker_env.platform_python_implementation, + )) { + return false; + }, + .PyPy => if (!std.mem.eql( + u8, + "PyPy", + interp.marker_env.platform_python_implementation, + )) { + return false; + }, + } + } + + const release: Release = .{ + .major = interp.version.major, + .minor = interp.version.minor, + .patch = interp.version.micro, + }; + + const pre_release: ?PreRelease = res: { + // C.F.: https://docs.python.org/3/library/sys.html#sys.version_info + if (std.mem.eql(u8, "alpha", interp.version.releaselevel)) { + break :res .{ .alpha = interp.version.serial }; + } else if (std.mem.eql(u8, "beta", interp.version.releaselevel)) { + break :res .{ .beta = interp.version.serial }; + } else if (std.mem.eql(u8, "candidate", interp.version.releaselevel)) { + break :res .{ .rc = interp.version.serial }; + } else { + if (!std.mem.eql(u8, "final", interp.version.releaselevel)) { + log.warn("Unrecognized interpreter release level: {s}{d}. " ++ + "Considering this a final release of {d}.{d}.{d}", .{ + interp.version.releaselevel, + interp.version.serial, + interp.version.major, + interp.version.minor, + interp.version.micro, + }); + } + break :res null; + } + }; + + return self.specifier.matches(.{ .release = release, .pre_release = pre_release }); + } +}; + +const Self = @This(); + +allocator: std.mem.Allocator, +constraints: []InterpreterConstraint, + +pub fn parse(allocator: std.mem.Allocator, interpreter_constraints: []const []const u8) !Self { + var constraints = try std.ArrayList(InterpreterConstraint).initCapacity( + allocator, + interpreter_constraints.len, + ); + errdefer constraints.deinit(); + for (interpreter_constraints) |interpreter_constraint| { + try constraints.append(try InterpreterConstraint.parse(allocator, interpreter_constraint)); + } + return .{ .allocator = allocator, .constraints = try constraints.toOwnedSlice() }; +} + +pub fn deinit(self: Self) void { + for (self.constraints) |constraint| { + constraint.deinit(); + } + self.allocator.free(self.constraints); +} + +pub fn matches(self: Self, interp: Interpreter) bool { + if (self.constraints.len == 0) return true; + for (self.constraints) |constraint| { + if (constraint.matches(interp)) return true; + } + return false; +} + +pub const PythonVersion = struct { + major: u8, + minor: u8, + impl: ?PythonImplementation = null, +}; + +// N.B.: This assumes there will never be a Python 4. +pub const CompatibleVersionsIter = struct { + constraints: []InterpreterConstraint, + max_minor: u8, + release: Release = .{ .major = 2, .minor = 7 }, + + fn incrementVersion(self: *CompatibleVersionsIter) void { + if (self.release.major == 2) { + self.release.major = 3; + self.release.minor = 5; + } else { + self.release.minor.? += 1; + } + } + + pub fn next(self: *CompatibleVersionsIter) ?PythonVersion { + while (self.release.major != 3 or self.release.minor.? <= self.max_minor) { + defer self.incrementVersion(); + for (self.constraints) |constraint| { + if (constraint.release_matches(self.release)) { + return PythonVersion{ + .major = @intCast(self.release.major), + .minor = self.release.minor.?, + .impl = constraint.impl, + }; + } + } + } + return null; + } +}; + +fn maxMinor() u8 { + // N.B.: This computes the maximum CPython minor version assuming CPython sticks to ~semver and + // does not switch to calver. + // + Release Schedule: https://peps.python.org/pep-0602/ + // + Rejected calver proposal: https://peps.python.org/pep-2026/ + // + // Given PyPy history and the structure of the project, this max should always be greater than + // the PyPy max minor. + const static = struct { + var max_minor: ?u8 = null; + }; + return static.max_minor orelse { + const current_production_release_minor: u8 = blk: { + // Calibration point: 3.14.0 release will be in 10 / 2025 and there are yearly releases. + const now = zeit.instant(.{ .source = .now }) catch { + // N.B.: There are never errors when the source is not a string that needs to be + // parsed. + unreachable; + }; + const time = now.time(); + // TODO(John Sirois): XXX: This goes wrong after 2266. + const fall_release: u8 = @intCast(14 + @max(0, time.year - 2025)); + if (@intFromEnum(time.month) >= @intFromEnum(zeit.Month.oct)) { + break :blk fall_release; + } else { + break :blk fall_release - 1; + } + }; + + // N.B.: The +1 accounts for dev / alpha / beta / rc of the next Python release being + // installed. + const value = current_production_release_minor + 1; + static.max_minor = value; + return value; + }; +} + +pub fn compatibleVersionsIter( + self: Self, + options: struct { max: ?union(enum) { minor: u8, years_ahead: u8 } = null }, +) CompatibleVersionsIter { + const max_minor = res: { + if (options.max) |max| { + switch (max) { + .minor => |val| break :res val, + .years_ahead => |val| break :res maxMinor() + val, + } + } else { + break :res maxMinor(); + } + }; + return .{ .constraints = self.constraints, .max_minor = max_minor }; +} + +pub fn forPexczRuntime( + allocator: std.mem.Allocator, + options: struct { years_ahead: u8 = 0 }, +) !Self { + const interpreter_constraint = try std.fmt.allocPrint( + allocator, + ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,<3.{d}", + .{maxMinor() + options.years_ahead}, + ); + defer allocator.free(interpreter_constraint); + return Self.parse(allocator, &.{interpreter_constraint}); +} + +pub const PythonBinarySpec = struct { + impl: PythonImplementation, + major: u8, + minor: u8, + + pub const name_buf_len = std.fs.max_name_bytes; + + inline fn binaryName(self: PythonBinarySpec) []const u8 { + return self.impl.binaryName(); + } + + pub fn versionedBinaryName( + self: PythonBinarySpec, + buf: *[name_buf_len]u8, + version_components: enum { one, two }, + ) ![]const u8 { + switch (version_components) { + .one => return try std.fmt.bufPrint( + buf, + "{s}{d}", + .{ self.impl.binaryName(), self.major }, + ), + .two => return try std.fmt.bufPrint( + buf, + "{s}{d}.{d}", + .{ self.impl.binaryName(), self.major, self.minor }, + ), + } + } +}; + +pub fn calculateApplicableBinarySpecs( + self: Self, + allocator: std.mem.Allocator, +) ![]const PythonBinarySpec { + var python_binary_specs = std.ArrayList(PythonBinarySpec).init(allocator); + errdefer python_binary_specs.deinit(); + + var compatible_versions_iter = self.compatibleVersionsIter(.{}); + while (compatible_versions_iter.next()) |python_version| { + if (python_version.impl) |impl| { + try python_binary_specs.append( + .{ + .impl = impl, + .major = python_version.major, + .minor = python_version.minor, + }, + ); + } else { + inline for ([_]PythonImplementation{ .CPython, .PyPy }) |impl| { + try python_binary_specs.append( + .{ + .impl = impl, + .major = python_version.major, + .minor = python_version.minor, + }, + ); + } + } + } + + const lessThan = struct { + fn lessThan(_: void, lhs: PythonBinarySpec, rhs: PythonBinarySpec) bool { + if (lhs.impl != rhs.impl) return lhs.impl == PythonImplementation.CPython; + if (lhs.major != rhs.major) return lhs.major > rhs.major; + return lhs.minor > rhs.minor; + } + }.lessThan; + std.mem.sort(PythonBinarySpec, python_binary_specs.items, {}, lessThan); + + return python_binary_specs.toOwnedSlice(); +} + +fn interpreter( + allocator: std.mem.Allocator, + spec: []const u8, + options: struct { install: bool = true }, +) !std.json.Parsed(Interpreter) { + const subprocess = @import("subprocess.zig"); + const CheckFind = struct { + python_spec: []const u8, + install: bool, + pub fn printError(self: @This()) void { + std.debug.print( + "Failed to find interpreter for {s}{s}\n", + .{ self.python_spec, if (self.install) "; attempting install..." else "." }, + ); + } + }; + const output = subprocess.run( + allocator, + &.{ "uv", "python", "find", spec }, + subprocess.CheckOutput(CheckFind.printError), + .{ + .extra_child_run_args = .{ .max_output_bytes = std.fs.max_path_bytes + 2 }, + .print_error_args = .{ .python_spec = spec, .install = options.install }, + }, + ) catch |err| { + if (options.install) { + const CheckInstall = struct { + pub fn printError(python_spec: []const u8) void { + std.debug.print("Failed to install interpreter for {s}.\n", .{python_spec}); + } + }; + try subprocess.run( + allocator, + &.{ "uv", "python", "install", spec }, + subprocess.CheckCall(CheckInstall.printError), + .{ .print_error_args = spec }, + ); + return interpreter(allocator, spec, .{ .install = false }); + } else return err; + }; + defer allocator.free(output); + return Interpreter.identify(allocator, std.mem.trim(u8, output, " \t\r\n")); +} + +const windows_arm = builtin.os.tag == .windows and builtin.cpu.arch == .aarch64; + +const Pythons = struct { + cpython38: std.json.Parsed(Interpreter), + cpython39: std.json.Parsed(Interpreter), + cpython312: std.json.Parsed(Interpreter), + pypy38: ?std.json.Parsed(Interpreter), + pypy310: ?std.json.Parsed(Interpreter), + pypy311: ?std.json.Parsed(Interpreter), + + fn init(allocator: std.mem.Allocator) !Pythons { + const cpython38 = try interpreter(allocator, "3.8", .{}); + errdefer cpython38.deinit(); + + const cpython39 = try interpreter(allocator, "3.9", .{}); + errdefer cpython39.deinit(); + + const cpython312 = try interpreter(allocator, "3.12", .{}); + errdefer cpython312.deinit(); + + var pypy38: ?std.json.Parsed(Interpreter) = null; + if (pypy38) |interp| interp.deinit(); + + var pypy310: ?std.json.Parsed(Interpreter) = null; + if (pypy310) |interp| interp.deinit(); + + var pypy311: ?std.json.Parsed(Interpreter) = null; + if (pypy311) |interp| interp.deinit(); + + // N.B.: There are currently no PyPy builds for Windows ARM. + if (!windows_arm) { + pypy38 = try interpreter(allocator, "pypy3.8", .{}); + pypy310 = try interpreter(allocator, "pypy3.10", .{}); + pypy311 = try interpreter(allocator, "pypy3.11", .{}); + } + + return .{ + .cpython38 = cpython38, + .cpython39 = cpython39, + .cpython312 = cpython312, + .pypy38 = pypy38, + .pypy310 = pypy310, + .pypy311 = pypy311, + }; + } + + fn deinit(self: Pythons) void { + self.cpython38.deinit(); + self.cpython39.deinit(); + self.cpython312.deinit(); + if (self.pypy38) |interp| interp.deinit(); + if (self.pypy310) |interp| interp.deinit(); + if (self.pypy311) |interp| interp.deinit(); + } +}; + +test "no impl" { + const ics = try Self.parse(std.testing.allocator, &.{">=3.9"}); + defer ics.deinit(); + + try std.testing.expectEqual(1, ics.constraints.len); + try std.testing.expect(ics.constraints[0].impl == null); + + const expected_specifier = try Specifier.parse(std.testing.allocator, ">=3.9"); + defer expected_specifier.deinit(); + try std.testing.expectEqualDeep(expected_specifier, ics.constraints[0].specifier); + + const pythons = try Pythons.init(std.testing.allocator); + defer pythons.deinit(); + + try std.testing.expect(!ics.matches(pythons.cpython38.value)); + try std.testing.expect(ics.matches(pythons.cpython39.value)); + try std.testing.expect(ics.matches(pythons.cpython312.value)); + if (pythons.pypy38) |pypy38| { + try std.testing.expect(!ics.matches(pypy38.value)); + } + if (pythons.pypy310) |pypy310| { + try std.testing.expect(ics.matches(pypy310.value)); + } + if (pythons.pypy311) |pypy311| { + try std.testing.expect(ics.matches(pypy311.value)); + } +} + +test "CPython" { + const ics = try Self.parse(std.testing.allocator, &.{"CPython >=3.11"}); + defer ics.deinit(); + + try std.testing.expectEqual(1, ics.constraints.len); + try std.testing.expectEqual(PythonImplementation.CPython, ics.constraints[0].impl); + + const expected_specifier = try Specifier.parse(std.testing.allocator, " >=3.11"); + defer expected_specifier.deinit(); + try std.testing.expectEqualDeep(expected_specifier, ics.constraints[0].specifier); + + const pythons = try Pythons.init(std.testing.allocator); + defer pythons.deinit(); + + try std.testing.expect(!ics.matches(pythons.cpython38.value)); + try std.testing.expect(!ics.matches(pythons.cpython39.value)); + try std.testing.expect(ics.matches(pythons.cpython312.value)); + if (pythons.pypy38) |pypy38| { + try std.testing.expect(!ics.matches(pypy38.value)); + } + if (pythons.pypy310) |pypy310| { + try std.testing.expect(!ics.matches(pypy310.value)); + } + if (pythons.pypy311) |pypy311| { + try std.testing.expect(!ics.matches(pypy311.value)); + } +} + +test "PyPy" { + const ics = try Self.parse(std.testing.allocator, &.{"PyPy==3.11.*"}); + defer ics.deinit(); + + try std.testing.expectEqual(1, ics.constraints.len); + try std.testing.expectEqual(PythonImplementation.PyPy, ics.constraints[0].impl); + + const expected_specifier = try Specifier.parse(std.testing.allocator, "==3.11.*"); + defer expected_specifier.deinit(); + try std.testing.expectEqualDeep(expected_specifier, ics.constraints[0].specifier); + + const pythons = try Pythons.init(std.testing.allocator); + defer pythons.deinit(); + + try std.testing.expect(!ics.matches(pythons.cpython38.value)); + try std.testing.expect(!ics.matches(pythons.cpython39.value)); + try std.testing.expect(!ics.matches(pythons.cpython312.value)); + if (pythons.pypy38) |pypy38| { + try std.testing.expect(!ics.matches(pypy38.value)); + } + if (pythons.pypy310) |pypy310| { + try std.testing.expect(!ics.matches(pypy310.value)); + } + if (pythons.pypy311) |pypy311| { + try std.testing.expect(ics.matches(pypy311.value)); + } +} + +test "ORed constraints" { + const ics = try Self.parse(std.testing.allocator, &.{ "PyPy==3.11.*", "==3.8.*" }); + defer ics.deinit(); + + try std.testing.expectEqualDeep(2, ics.constraints.len); + try std.testing.expectEqual(PythonImplementation.PyPy, ics.constraints[0].impl); + try std.testing.expectEqual(null, ics.constraints[1].impl); + + const expected_specifier1 = try Specifier.parse(std.testing.allocator, "==3.11.*"); + defer expected_specifier1.deinit(); + try std.testing.expectEqualDeep(expected_specifier1, ics.constraints[0].specifier); + + const expected_specifier2 = try Specifier.parse(std.testing.allocator, "==3.8.*"); + defer expected_specifier2.deinit(); + try std.testing.expectEqualDeep(expected_specifier2, ics.constraints[1].specifier); + + const pythons = try Pythons.init(std.testing.allocator); + defer pythons.deinit(); + + try std.testing.expect(ics.matches(pythons.cpython38.value)); + try std.testing.expect(!ics.matches(pythons.cpython39.value)); + try std.testing.expect(!ics.matches(pythons.cpython312.value)); + if (pythons.pypy38) |pypy38| { + try std.testing.expect(ics.matches(pypy38.value)); + } + if (pythons.pypy310) |pypy310| { + try std.testing.expect(!ics.matches(pypy310.value)); + } + if (pythons.pypy311) |pypy311| { + try std.testing.expect(ics.matches(pypy311.value)); + } +} + +test "Compatible versions simple" { + const ics = try Self.parse(std.testing.allocator, &.{">=2.7"}); + defer ics.deinit(); + + var actual_versions = std.ArrayList(PythonVersion).init(std.testing.allocator); + defer actual_versions.deinit(); + + var iter = ics.compatibleVersionsIter(.{ .max = .{ .minor = 6 } }); + while (iter.next()) |version| try actual_versions.append(version); + + try std.testing.expectEqualDeep( + &.{ + PythonVersion{ .major = 2, .minor = 7 }, + PythonVersion{ .major = 3, .minor = 5 }, + PythonVersion{ .major = 3, .minor = 6 }, + }, + actual_versions.items, + ); +} + +test "Compatible versions ORed" { + const ics = try Self.parse(std.testing.allocator, &.{ "PyPy~=3.11", "==3.8.*" }); + defer ics.deinit(); + + var actual_versions = std.ArrayList(PythonVersion).init(std.testing.allocator); + defer actual_versions.deinit(); + + var iter = ics.compatibleVersionsIter(.{ .max = .{ .minor = 14 } }); + while (iter.next()) |version| try actual_versions.append(version); + + try std.testing.expectEqualDeep( + &.{ + PythonVersion{ .major = 3, .minor = 8 }, + PythonVersion{ .major = 3, .minor = 11, .impl = .PyPy }, + PythonVersion{ .major = 3, .minor = 12, .impl = .PyPy }, + PythonVersion{ .major = 3, .minor = 13, .impl = .PyPy }, + PythonVersion{ .major = 3, .minor = 14, .impl = .PyPy }, + }, + actual_versions.items, + ); +} + +test "calculateApplicableBinarySpecs impl pinned" { + const interpreter_constraints = try Self.parse(std.testing.allocator, &.{"CPython~=3.9"}); + defer interpreter_constraints.deinit(); + + const actual_specs = try interpreter_constraints.calculateApplicableBinarySpecs( + std.testing.allocator, + ); + defer std.testing.allocator.free(actual_specs); + + const expected_specs: []const PythonBinarySpec = &.{ + .{ .impl = .CPython, .major = 3, .minor = 14 }, + .{ .impl = .CPython, .major = 3, .minor = 13 }, + .{ .impl = .CPython, .major = 3, .minor = 12 }, + .{ .impl = .CPython, .major = 3, .minor = 11 }, + .{ .impl = .CPython, .major = 3, .minor = 10 }, + .{ .impl = .CPython, .major = 3, .minor = 9 }, + }; + for (expected_specs, actual_specs) |expected, actual| { + try std.testing.expectEqualDeep(expected, actual); + } +} + +test "calculateApplicableBinarySpecs no impl" { + const interpreter_constraints = try Self.parse(std.testing.allocator, &.{"~=3.11"}); + defer interpreter_constraints.deinit(); + + const actual_specs = try interpreter_constraints.calculateApplicableBinarySpecs( + std.testing.allocator, + ); + defer std.testing.allocator.free(actual_specs); + + const expected_specs: []const PythonBinarySpec = &.{ + .{ .impl = .CPython, .major = 3, .minor = 14 }, + .{ .impl = .CPython, .major = 3, .minor = 13 }, + .{ .impl = .CPython, .major = 3, .minor = 12 }, + .{ .impl = .CPython, .major = 3, .minor = 11 }, + .{ .impl = .PyPy, .major = 3, .minor = 14 }, + .{ .impl = .PyPy, .major = 3, .minor = 13 }, + .{ .impl = .PyPy, .major = 3, .minor = 12 }, + .{ .impl = .PyPy, .major = 3, .minor = 11 }, + }; + for (expected_specs, actual_specs) |expected, actual| { + try std.testing.expectEqualDeep(expected, actual); + } +} + +test "parse empty" { + const interpreter_constraints = try Self.parse(std.testing.allocator, &.{}); + defer interpreter_constraints.deinit(); + + try std.testing.expectEqual(0, interpreter_constraints.constraints.len); +} diff --git a/src/lib/PexInfo.zig b/src/lib/PexInfo.zig index 6acec8f..997e05d 100644 --- a/src/lib/PexInfo.zig +++ b/src/lib/PexInfo.zig @@ -22,6 +22,7 @@ inject_env: std.json.ArrayHashMap([]const u8), entry_point: ?[]const u8 = null, script: ?[]const u8 = null, strip_pex_env: ?bool = null, +pex_root: ?[]const u8 = null, const Self = @This(); diff --git a/src/lib/PexPython.zig b/src/lib/PexPython.zig new file mode 100644 index 0000000..ad482b8 --- /dev/null +++ b/src/lib/PexPython.zig @@ -0,0 +1,44 @@ +const std = @import("std"); + +const Interpreter = @import("Interpreter.zig"); +const fs = @import("fs.zig"); +const os = @import("os.zig"); + +const Self = @This(); + +const log = std.log.scoped(.pex_python); + +value: ?os.Value = null, + +pub fn fromEnv(allocator: std.mem.Allocator) !Self { + const value = try os.getEnv(allocator, "PEX_PYTHON"); + if (value) |python| { + errdefer python.deinit(); + if (!std.fs.path.isAbsolute(python.value) and std.mem.indexOf(u8, python.value, std.fs.path.sep_str) != null) { + log.err("Invalid PEX_PYTHON: {s}. Must be either an absolute path to a Python " ++ + "binary or a Python binary name (like \"python3.12\")", .{python.value}); + return error.InvalidPexPython; + } + } + return .{ .value = value }; +} + +pub fn matches(self: Self, interpreter: Interpreter) !bool { + if (self.value) |python| { + if (std.fs.path.isAbsolute(python.value)) { + return try fs.contains(interpreter.prefix, python.value); + } else { + // TODO(John Sirois): Handle PEX_PYTHON=python3.8 vs a basename of python or python3 + const basename = std.fs.path.basename(interpreter.path); + return std.mem.eql(u8, python.value, basename); + } + } else { + return true; + } +} + +pub fn deinit(self: Self) void { + if (self.value) |value| { + value.deinit(); + } +} diff --git a/src/lib/PexPythonPath.zig b/src/lib/PexPythonPath.zig new file mode 100644 index 0000000..777b093 --- /dev/null +++ b/src/lib/PexPythonPath.zig @@ -0,0 +1,53 @@ +const std = @import("std"); + +const Interpreter = @import("Interpreter.zig"); +const fs = @import("fs.zig"); +const os = @import("os.zig"); + +const Self = @This(); + +entries: ?[]const []const u8 = null, + +pub fn fromEnv(allocator: std.mem.Allocator) !Self { + if (try os.getEnv(allocator, "PEX_PYTHON_PATH")) |pex_python_path| { + defer pex_python_path.deinit(); + + var path = std.ArrayList([]const u8).init(allocator); + errdefer { + for (path.items) |item| { + allocator.free(item); + } + path.deinit(); + } + + var path_iter = std.mem.splitScalar( + u8, + pex_python_path.value, + std.fs.path.delimiter, + ); + while (path_iter.next()) |entry| { + try path.append(try allocator.dupe(u8, entry)); + } + return .{ .entries = try path.toOwnedSlice() }; + } else { + return .{}; + } +} + +pub fn contains(self: Self, interpreter: Interpreter) !bool { + if (self.entries) |entries| { + for (entries) |entry| { + if (try fs.contains(entry, interpreter.path)) return true; + } + } + return false; +} + +pub fn deinit(self: Self, allocator: std.mem.Allocator) void { + if (self.entries) |entries| { + for (entries) |entry| { + allocator.free(entry); + } + allocator.free(entries); + } +} diff --git a/src/lib/Specifier.zig b/src/lib/Specifier.zig new file mode 100644 index 0000000..7aa76b6 --- /dev/null +++ b/src/lib/Specifier.zig @@ -0,0 +1,512 @@ +const std = @import("std"); + +const PreRelease = Version.PreRelease; +const Release = Version.Release; +const Version = @import("Version.zig"); +const string = @import("string.zig"); + +const Clause = union(enum) { + exact: []const u8, + compatible: Version, + lte: Version, + gte: Version, + lt: Version, + gt: Version, + eq: Version, + ne: Version, +}; + +allocator: std.mem.Allocator, +raw: []const u8, +clauses: []Clause, + +const Self = @This(); + +pub fn parse(allocator: std.mem.Allocator, value: []const u8) !Self { + var clauses = std.ArrayList(Clause).init(allocator); + var clause_iter = std.mem.splitScalar(u8, value, ','); + while (clause_iter.next()) |clause| { + const trimmed_clause = string.trim_ascii_ws(clause); + if (trimmed_clause.len == 0) { + return error.InvalidSpecifierClause; + } + switch (trimmed_clause[0]) { + '=' => { + if (trimmed_clause.len >= 3 and std.mem.eql(u8, "===", trimmed_clause[0..3])) { + const exact_version = string.trim_ascii_ws(trimmed_clause[3..]); + if (exact_version.len == 0) { + return error.InvalidVersion; + } + try clauses.append(.{ .exact = exact_version }); + } else if (trimmed_clause.len >= 2 and std.mem.eql( + u8, + "==", + trimmed_clause[0..2], + )) { + try clauses.append(.{ .eq = try Version.parse( + allocator, + trimmed_clause[2..], + .{ .wildcard_allowed = true }, + ) }); + } else { + return error.InvalidOperator; + } + }, + '!' => { + if (trimmed_clause.len == 1 or trimmed_clause[1] != '=') { + return error.InvalidOperator; + } + try clauses.append(.{ .ne = try Version.parse( + allocator, + trimmed_clause[2..], + .{ .wildcard_allowed = true }, + ) }); + }, + '>' => { + if (trimmed_clause.len > 1 and trimmed_clause[1] == '=') { + try clauses.append(.{ .gte = try Version.parse( + allocator, + trimmed_clause[2..], + .{}, + ) }); + } else { + try clauses.append(.{ .gt = try Version.parse( + allocator, + trimmed_clause[1..], + .{}, + ) }); + } + }, + '<' => { + if (trimmed_clause.len > 1 and trimmed_clause[1] == '=') { + try clauses.append(.{ .lte = try Version.parse( + allocator, + trimmed_clause[2..], + .{}, + ) }); + } else { + try clauses.append(.{ .lt = try Version.parse( + allocator, + trimmed_clause[1..], + .{}, + ) }); + } + }, + '~' => { + if (trimmed_clause.len == 1 or trimmed_clause[1] != '=') { + return error.InvalidOperator; + } + const version = try Version.parse( + allocator, + trimmed_clause[2..], + .{}, + ); + if (version.release.minor == null) return error.InvalidVersion; + try clauses.append(.{ .compatible = version }); + }, + else => return error.InvalidSpecifierClause, + } + } + return Self{ .allocator = allocator, .raw = value, .clauses = try clauses.toOwnedSlice() }; +} + +pub fn deinit(self: Self) void { + for (self.clauses) |clause| { + switch (clause) { + .compatible, .lte, .gte, .lt, .gt, .eq, .ne => |ver| ver.deinit(self.allocator), + else => {}, + } + } + self.allocator.free(self.clauses); +} + +pub fn matches(self: Self, version: Version) bool { + for (self.clauses) |clause| { + switch (clause) { + .exact => |val| { + if (version.raw) |raw_version| { + if (!std.mem.eql(u8, val, raw_version)) return false; + } else { + // N.B.: A hand-constructed version with no raw text source should never + // respond positively to === legacy parsing arbitrary equality checks. + return false; + } + }, + .compatible => |ver| if (!ver.compatible(version)) return false, + .lte => |ver| if (!ver.lte(version)) return false, + .gte => |ver| if (!ver.gte(version)) return false, + .lt => |ver| if (!ver.lt(version)) return false, + .gt => |ver| if (!ver.gt(version)) return false, + .eq => |ver| if (!ver.eq(version)) return false, + .ne => |ver| if (!ver.ne(version)) return false, + } + } + return true; +} + +pub fn matches_parse(self: Self, allocator: std.mem.Allocator, value: []const u8) !bool { + const Parsed = struct { + allocator: std.mem.Allocator, + raw: []const u8, + version: ?Version = null, + + fn parsedVersion(this: *@This()) !Version { + return this.version orelse { + const version = try Version.parse(this.allocator, this.raw, .{}); + this.version = version; + return version; + }; + } + + fn deinit(this: @This()) void { + if (this.version) |ver| { + ver.deinit(this.allocator); + } + } + }; + var parsed = Parsed{ .allocator = allocator, .raw = value }; + defer parsed.deinit(); + + for (self.clauses) |clause| { + switch (clause) { + .exact => |val| if (!std.mem.eql(u8, val, parsed.raw)) return false, + .compatible => |ver| if (!ver.compatible(try parsed.parsedVersion())) return false, + .lte => |ver| if (!ver.lte(try parsed.parsedVersion())) return false, + .gte => |ver| if (!ver.gte(try parsed.parsedVersion())) return false, + .lt => |ver| if (!ver.lt(try parsed.parsedVersion())) return false, + .gt => |ver| if (!ver.gt(try parsed.parsedVersion())) return false, + .eq => |ver| if (!ver.eq(try parsed.parsedVersion())) return false, + .ne => |ver| if (!ver.ne(try parsed.parsedVersion())) return false, + } + } + return true; +} + +test "Arbitrary equality nominal" { + const specifier = try Self.parse(std.testing.allocator, "===bob"); + defer specifier.deinit(); + + try std.testing.expect(try specifier.matches_parse(std.testing.allocator, "bob")); + try std.testing.expect(!try specifier.matches_parse(std.testing.allocator, "bill")); + + const specifier_crossover = try Self.parse(std.testing.allocator, "===1.0.0"); + defer specifier_crossover.deinit(); + + try std.testing.expect(!specifier_crossover.matches(.{ .release = .{ .major = 1 } })); + try std.testing.expect(specifier_crossover.matches(.{ + .raw = "1.0.0", + .release = .{ .major = 1 }, + })); + try std.testing.expect(!specifier_crossover.matches(.{ .release = .{ + .major = 1, + .minor = 0, + .patch = 0, + } })); +} + +test "Arbitrary equality whitespace" { + const specifier = try Self.parse(std.testing.allocator, "===\tbob "); + defer specifier.deinit(); + + try std.testing.expect(try specifier.matches_parse(std.testing.allocator, "bob")); + try std.testing.expect(!try specifier.matches_parse(std.testing.allocator, "bill")); +} + +test "GTE" { + try std.testing.expectEqual( + error.InvalidVersion, + Self.parse(std.testing.allocator, ">=3.9.*"), + ); + + const specifier = try Self.parse(std.testing.allocator, ">=3.9"); + defer specifier.deinit(); + + try std.testing.expect(try specifier.matches_parse(std.testing.allocator, "3.9")); + try std.testing.expect(try specifier.matches_parse(std.testing.allocator, "3.9.0")); + try std.testing.expect(try specifier.matches_parse(std.testing.allocator, "3.9.23")); + try std.testing.expect(try specifier.matches_parse(std.testing.allocator, "3.13")); + try std.testing.expect(try specifier.matches_parse(std.testing.allocator, "3.13.5")); + + try std.testing.expect(!try specifier.matches_parse(std.testing.allocator, "3")); + try std.testing.expect(!try specifier.matches_parse(std.testing.allocator, "3.8")); + try std.testing.expect(!try specifier.matches_parse(std.testing.allocator, "3.8.20")); + + try std.testing.expect(try specifier.matches_parse(std.testing.allocator, "0!3.9")); + try std.testing.expect(try specifier.matches_parse(std.testing.allocator, "1!3.9")); + + const epoch_specifier = try Self.parse(std.testing.allocator, ">=1!3.9"); + defer epoch_specifier.deinit(); + + try std.testing.expect(!try epoch_specifier.matches_parse(std.testing.allocator, "0!3.9")); + try std.testing.expect(try epoch_specifier.matches_parse(std.testing.allocator, "1!3.9")); + try std.testing.expect(try epoch_specifier.matches_parse(std.testing.allocator, "2!3.9")); +} + +test "GT" { + try std.testing.expectEqual(error.InvalidVersion, Self.parse(std.testing.allocator, ">3.9.*")); + + const specifier = try Self.parse(std.testing.allocator, ">3.9"); + defer specifier.deinit(); + + try std.testing.expect(!try specifier.matches_parse(std.testing.allocator, "3.9")); + try std.testing.expect(!try specifier.matches_parse(std.testing.allocator, "3.9.0")); + try std.testing.expect(try specifier.matches_parse(std.testing.allocator, "3.9.23")); + try std.testing.expect(try specifier.matches_parse(std.testing.allocator, "3.13")); + try std.testing.expect(try specifier.matches_parse(std.testing.allocator, "3.13.5")); + + try std.testing.expect(!try specifier.matches_parse(std.testing.allocator, "3")); + try std.testing.expect(!try specifier.matches_parse(std.testing.allocator, "3.8")); + try std.testing.expect(!try specifier.matches_parse(std.testing.allocator, "3.8.20")); + + try std.testing.expect(try specifier.matches_parse(std.testing.allocator, "0!3.9.1")); + try std.testing.expect(try specifier.matches_parse(std.testing.allocator, "1!3.9")); + try std.testing.expect(try specifier.matches_parse(std.testing.allocator, "1!3.9.1")); + + const epoch_specifier = try Self.parse(std.testing.allocator, ">1!3.9"); + defer epoch_specifier.deinit(); + + try std.testing.expect(!try epoch_specifier.matches_parse(std.testing.allocator, "0!3.9")); + try std.testing.expect(!try epoch_specifier.matches_parse(std.testing.allocator, "1!3.9")); + try std.testing.expect(try epoch_specifier.matches_parse(std.testing.allocator, "1!3.9.1")); + try std.testing.expect(try epoch_specifier.matches_parse(std.testing.allocator, "2!3.9")); + try std.testing.expect(try epoch_specifier.matches_parse(std.testing.allocator, "2!3.9.1")); +} + +test "LTE" { + try std.testing.expectEqual( + error.InvalidVersion, + Self.parse(std.testing.allocator, "<=3.9.*"), + ); + + const specifier = try Self.parse(std.testing.allocator, "<=3.9"); + defer specifier.deinit(); + + try std.testing.expect(try specifier.matches_parse(std.testing.allocator, "3")); + try std.testing.expect(try specifier.matches_parse(std.testing.allocator, "3.9")); + try std.testing.expect(try specifier.matches_parse(std.testing.allocator, "3.9.0")); +} + +test "LT" { + try std.testing.expectEqual(error.InvalidVersion, Self.parse(std.testing.allocator, "<3.9.*")); + + const specifier = try Self.parse(std.testing.allocator, "<3.9"); + defer specifier.deinit(); + + try std.testing.expect(try specifier.matches_parse(std.testing.allocator, "3")); + try std.testing.expect(!try specifier.matches_parse(std.testing.allocator, "3.9")); + try std.testing.expect(!try specifier.matches_parse(std.testing.allocator, "3.9.0")); +} + +test "Compatible" { + try std.testing.expectEqual( + error.InvalidVersion, + Self.parse(std.testing.allocator, "~=3"), + ); + + try std.testing.expectEqual( + error.InvalidVersion, + Self.parse(std.testing.allocator, "~=3.9.*"), + ); + + const specifier = try Self.parse(std.testing.allocator, "~=3.9"); + defer specifier.deinit(); + + try std.testing.expect(!try specifier.matches_parse(std.testing.allocator, "2")); + try std.testing.expect(!try specifier.matches_parse(std.testing.allocator, "2.7")); + try std.testing.expect(!try specifier.matches_parse(std.testing.allocator, "2.7.18")); + + try std.testing.expect(!try specifier.matches_parse(std.testing.allocator, "3")); + try std.testing.expect(try specifier.matches_parse(std.testing.allocator, "3.9")); + try std.testing.expect(try specifier.matches_parse(std.testing.allocator, "3.9.0")); + try std.testing.expect(try specifier.matches_parse(std.testing.allocator, "3.9.1")); + try std.testing.expect(try specifier.matches_parse(std.testing.allocator, "3.9.23")); + try std.testing.expect(try specifier.matches_parse(std.testing.allocator, "3.10")); + try std.testing.expect(try specifier.matches_parse(std.testing.allocator, "3.13")); + + try std.testing.expect(!try specifier.matches_parse(std.testing.allocator, "4")); + try std.testing.expect(!try specifier.matches_parse(std.testing.allocator, "4.0")); + + const specifier_micro = try Self.parse(std.testing.allocator, "~=3.9.5.2"); + defer specifier_micro.deinit(); + + try std.testing.expect(!try specifier_micro.matches_parse(std.testing.allocator, "3")); + try std.testing.expect(!try specifier_micro.matches_parse(std.testing.allocator, "3.9")); + try std.testing.expect(!try specifier_micro.matches_parse(std.testing.allocator, "3.9.5")); + try std.testing.expect(!try specifier_micro.matches_parse(std.testing.allocator, "3.9.5.0")); + try std.testing.expect(!try specifier_micro.matches_parse(std.testing.allocator, "3.9.5.1")); + try std.testing.expect(try specifier_micro.matches_parse(std.testing.allocator, "3.9.5.2")); + try std.testing.expect(try specifier_micro.matches_parse(std.testing.allocator, "3.9.5.3")); + try std.testing.expect(try specifier_micro.matches_parse(std.testing.allocator, "3.9.5.99")); + try std.testing.expect(try specifier_micro.matches_parse(std.testing.allocator, "3.9.5.99.1")); + try std.testing.expect(!try specifier_micro.matches_parse(std.testing.allocator, "3.9.6")); + try std.testing.expect(!try specifier_micro.matches_parse(std.testing.allocator, "4")); +} + +test "EQ" { + const specifier = try Self.parse(std.testing.allocator, "==3.9"); + defer specifier.deinit(); + + try std.testing.expect(!try specifier.matches_parse(std.testing.allocator, "3")); + try std.testing.expect(try specifier.matches_parse(std.testing.allocator, "3.9")); + try std.testing.expect(try specifier.matches_parse(std.testing.allocator, "3.9.0")); + try std.testing.expect(try specifier.matches_parse(std.testing.allocator, "3.9.0.0")); + try std.testing.expect(!try specifier.matches_parse(std.testing.allocator, "3.9.1")); + try std.testing.expect(!try specifier.matches_parse(std.testing.allocator, "3.8")); + + try std.testing.expectEqual( + error.InvalidVersion, + Self.parse(std.testing.allocator, "==*"), + ); + + const wildcard_minor = try Self.parse(std.testing.allocator, "==3.*"); + defer wildcard_minor.deinit(); + + try std.testing.expect(!try wildcard_minor.matches_parse(std.testing.allocator, "2")); + try std.testing.expect(!try wildcard_minor.matches_parse(std.testing.allocator, "2.7")); + try std.testing.expect(!try wildcard_minor.matches_parse(std.testing.allocator, "2.7.18")); + try std.testing.expect(try wildcard_minor.matches_parse(std.testing.allocator, "3")); + try std.testing.expect(try wildcard_minor.matches_parse(std.testing.allocator, "3.0")); + try std.testing.expect(try wildcard_minor.matches_parse(std.testing.allocator, "3.1")); + try std.testing.expect(try wildcard_minor.matches_parse(std.testing.allocator, "3.99")); + try std.testing.expect(try wildcard_minor.matches_parse(std.testing.allocator, "3.99.1")); + try std.testing.expect(!try wildcard_minor.matches_parse(std.testing.allocator, "4")); + + const wildcard_patch = try Self.parse(std.testing.allocator, "==3.9.*"); + defer wildcard_patch.deinit(); + + try std.testing.expect(!try wildcard_patch.matches_parse(std.testing.allocator, "3")); + try std.testing.expect(try wildcard_patch.matches_parse(std.testing.allocator, "3.9")); + try std.testing.expect(try wildcard_patch.matches_parse(std.testing.allocator, "3.9.0")); + try std.testing.expect(try wildcard_patch.matches_parse(std.testing.allocator, "3.9.0.0")); + try std.testing.expect(try wildcard_patch.matches_parse(std.testing.allocator, "3.9.1")); + try std.testing.expect(try wildcard_patch.matches_parse(std.testing.allocator, "3.9.23")); + try std.testing.expect(!try wildcard_patch.matches_parse(std.testing.allocator, "3.8")); + try std.testing.expect(!try wildcard_patch.matches_parse(std.testing.allocator, "3.10")); + + const wildcard_additional_segment = try Self.parse(std.testing.allocator, "==3.9.2.*"); + defer wildcard_additional_segment.deinit(); + + try std.testing.expect(!try wildcard_additional_segment.matches_parse( + std.testing.allocator, + "3", + )); + try std.testing.expect(!try wildcard_additional_segment.matches_parse( + std.testing.allocator, + "3.9", + )); + try std.testing.expect(try wildcard_additional_segment.matches_parse( + std.testing.allocator, + "3.9.2", + )); + try std.testing.expect(try wildcard_additional_segment.matches_parse( + std.testing.allocator, + "3.9.2.0", + )); + try std.testing.expect(try wildcard_additional_segment.matches_parse( + std.testing.allocator, + "3.9.2.1", + )); + try std.testing.expect(try wildcard_additional_segment.matches_parse( + std.testing.allocator, + "3.9.2.99", + )); + try std.testing.expect(try wildcard_additional_segment.matches_parse( + std.testing.allocator, + "3.9.2.99.1", + )); + try std.testing.expect(!try wildcard_additional_segment.matches_parse( + std.testing.allocator, + "3.9.3", + )); + try std.testing.expect(!try wildcard_additional_segment.matches_parse( + std.testing.allocator, + "4", + )); +} + +test "NE" { + const specifier = try Self.parse(std.testing.allocator, "!=3.9"); + defer specifier.deinit(); + + try std.testing.expect(try specifier.matches_parse(std.testing.allocator, "3")); + try std.testing.expect(!try specifier.matches_parse(std.testing.allocator, "3.9")); + try std.testing.expect(!try specifier.matches_parse(std.testing.allocator, "3.9.0")); + try std.testing.expect(!try specifier.matches_parse(std.testing.allocator, "3.9.0.0")); + try std.testing.expect(try specifier.matches_parse(std.testing.allocator, "3.9.1")); + try std.testing.expect(try specifier.matches_parse(std.testing.allocator, "3.8")); + + const wildcard_specifier = try Self.parse(std.testing.allocator, "!=3.9.*"); + defer wildcard_specifier.deinit(); + + try std.testing.expect(try wildcard_specifier.matches_parse(std.testing.allocator, "3")); + try std.testing.expect(!try wildcard_specifier.matches_parse(std.testing.allocator, "3.9")); + try std.testing.expect(!try wildcard_specifier.matches_parse(std.testing.allocator, "3.9.0")); + try std.testing.expect(!try wildcard_specifier.matches_parse( + std.testing.allocator, + "3.9.0.0", + )); + try std.testing.expect(!try wildcard_specifier.matches_parse(std.testing.allocator, "3.9.1")); + try std.testing.expect(!try wildcard_specifier.matches_parse(std.testing.allocator, "3.9.23")); + try std.testing.expect(try wildcard_specifier.matches_parse(std.testing.allocator, "3.8")); + try std.testing.expect(try wildcard_specifier.matches_parse(std.testing.allocator, "3.10")); +} + +test "Compound" { + const specifier = try Self.parse(std.testing.allocator, "~=3.9.2,<3.9.20"); + defer specifier.deinit(); + + try std.testing.expect(!try specifier.matches_parse(std.testing.allocator, "3")); + try std.testing.expect(!try specifier.matches_parse(std.testing.allocator, "3.9")); + try std.testing.expect(!try specifier.matches_parse(std.testing.allocator, "3.9.1")); + + try std.testing.expect(try specifier.matches_parse(std.testing.allocator, "3.9.2")); + try std.testing.expect(try specifier.matches_parse(std.testing.allocator, "3.9.2.0")); + try std.testing.expect(try specifier.matches_parse(std.testing.allocator, "3.9.3")); + try std.testing.expect(try specifier.matches_parse(std.testing.allocator, "3.9.19")); + try std.testing.expect(try specifier.matches_parse(std.testing.allocator, "3.9.19.99")); + + try std.testing.expect(!try specifier.matches_parse(std.testing.allocator, "3.9.20")); + try std.testing.expect(!try specifier.matches_parse(std.testing.allocator, "3.9.20.0")); + + const subtractive_specifier = try Self.parse( + std.testing.allocator, + ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,<3.14", + ); + defer subtractive_specifier.deinit(); + + try std.testing.expect(try subtractive_specifier.matches_parse( + std.testing.allocator, + "2.7.18", + )); + try std.testing.expect(try subtractive_specifier.matches_parse(std.testing.allocator, "3.5")); + try std.testing.expect(try subtractive_specifier.matches_parse( + std.testing.allocator, + "3.5.0", + )); + try std.testing.expect(try subtractive_specifier.matches_parse( + std.testing.allocator, + "3.13.5", + )); + try std.testing.expect(try subtractive_specifier.matches_parse( + std.testing.allocator, + "3.13.5", + )); + try std.testing.expect(!try subtractive_specifier.matches_parse(std.testing.allocator, "3.0")); + try std.testing.expect(!try subtractive_specifier.matches_parse( + std.testing.allocator, + "3.1.1", + )); + try std.testing.expect(!try subtractive_specifier.matches_parse( + std.testing.allocator, + "3.2.2", + )); + try std.testing.expect(!try subtractive_specifier.matches_parse( + std.testing.allocator, + "3.3.3", + )); + try std.testing.expect(!try subtractive_specifier.matches_parse(std.testing.allocator, "3.4")); + try std.testing.expect(!try subtractive_specifier.matches_parse( + std.testing.allocator, + "3.14", + )); +} diff --git a/src/lib/Tag.zig b/src/lib/Tag.zig index 9c35aa3..f0c2b0e 100644 --- a/src/lib/Tag.zig +++ b/src/lib/Tag.zig @@ -6,6 +6,19 @@ platform: []const u8, const Self = @This(); +pub fn eql(self: Self, other: Self) bool { + inline for (std.meta.fields(Self)) |field_info| { + if (field_info.type != []const u8) { + @compileError( + "The eql method needs to be adjusted to take account of Tag struct changes.", + ); + } + const field_name = field_info.name; + if (!std.mem.eql(u8, @field(self, field_name), @field(other, field_name))) return false; + } + return true; +} + pub fn format( self: Self, fmt: []const u8, @@ -46,3 +59,20 @@ pub fn jsonParse( else => return error.UnexpectedToken, }; } + +test "eql" { + const first = Self{ .python = "", .abi = "", .platform = "" }; + try std.testing.expect(std.meta.eql(first, first)); + try std.testing.expect(first.eql(first)); + + const second = Self{ .python = "", .abi = "", .platform = "" }; + try std.testing.expect(std.meta.eql(first, second)); + try std.testing.expect(first.eql(second)); + + const python = try std.testing.allocator.dupe(u8, ""); + defer std.testing.allocator.free(python); + + const third = Self{ .python = python, .abi = "", .platform = "" }; + try std.testing.expect(!std.meta.eql(first, third)); + try std.testing.expect(first.eql(third)); +} diff --git a/src/lib/VenvPex.zig b/src/lib/VenvPex.zig index f4923b1..21855b0 100644 --- a/src/lib/VenvPex.zig +++ b/src/lib/VenvPex.zig @@ -1,7 +1,7 @@ const native_os = @import("builtin").target.os.tag; const std = @import("std"); -const Interpreter = @import("interpreter.zig").Interpreter; +const Interpreter = @import("Interpreter.zig"); const PexInfo = @import("PexInfo.zig"); const Tag = @import("Tag.zig"); const VENV_PEX_PY = @embedFile("venv_pex.py"); @@ -11,6 +11,34 @@ const WheelInfo = @import("WheelInfo.zig"); const Zip = @import("Zip.zig"); const installed_wheel = @import("installed_wheel.zig"); +pub fn calculateVenvRelpath( + allocator: std.mem.Allocator, + pex_info: PexInfo, + options: struct { dir_sep: []const u8 = std.fs.path.sep_str, interpreter: ?Interpreter = null }, +) ![]const u8 { + // TODO: XXX: Account for PEX_PATH + const encoder = std.fs.base64_encoder; + const pex_hash_bytes = @as( + [20]u8, + @bitCast(try std.fmt.parseUnsigned(u160, pex_info.pex_hash, 16)), + ); + + var venv_digest = std.crypto.hash.Sha1.init(.{}); + venv_digest.update(&pex_hash_bytes); + if (options.interpreter) |interp| { + const tag = interp.supported_tags[0]; + venv_digest.update(tag.python); + venv_digest.update(tag.abi); + venv_digest.update(tag.platform); + } + const venv_hash_bytes = venv_digest.finalResult(); + + var encoded_venv_hash_buf: [27]u8 = undefined; + std.debug.assert(encoded_venv_hash_buf.len == encoder.calcSize(venv_hash_bytes.len)); + const pex_hash = encoder.encode(&encoded_venv_hash_buf, &venv_hash_bytes); + return try std.mem.join(allocator, options.dir_sep, &.{ "venvs", "0", pex_hash }); +} + pex_path: [*c]const u8, pex_info: PexInfo, pex_info_data: []const u8, diff --git a/src/lib/Version.zig b/src/lib/Version.zig new file mode 100644 index 0000000..31934c8 --- /dev/null +++ b/src/lib/Version.zig @@ -0,0 +1,621 @@ +const std = @import("std"); + +const string = @import("string.zig"); + +fn trim_leading_v(value: []const u8) []const u8 { + return if (value.len > 0 and value[0] == 'v') value[1..] else value; +} + +pub const Release = struct { + major: u16, + minor: ?u8 = null, + patch: ?u8 = null, + additional_segments: ?[]const u8 = null, + wildcard: bool = false, + + fn eq(self: Release, other: Release) bool { + if (self.major != other.major) return false; + + if (self.minor == null and self.wildcard) return true; + if ((self.minor orelse 0) != (other.minor orelse 0)) return false; + + if (self.patch == null and self.wildcard) return true; + if ((self.patch orelse 0) != (other.patch orelse 0)) return false; + + const segment_count = if (self.additional_segments) |segs| segs.len else 0; + const other_segment_count = if (other.additional_segments) |segs| segs.len else 0; + const release_segments = @max(segment_count, other_segment_count); + for (0..release_segments) |index| { + if (self.wildcard and index >= segment_count) break; + const my_segment = if (index < segment_count) self.additional_segments.?[index] else 0; + const other_segment = if (index < other_segment_count) other.additional_segments.?[index] else 0; + if (my_segment != other_segment) return false; + } + + return true; + } + + fn ne(self: Release, other: Release) bool { + return !self.eq(other); + } + + fn gte(self: Release, other: Release) bool { + if (other.major > self.major) return true; + if (other.major < self.major) return false; + + if ((other.minor orelse 0) > (self.minor orelse 0)) return true; + if ((other.minor orelse 0) < (self.minor orelse 0)) return false; + + if ((other.patch orelse 0) > (self.patch orelse 0)) return true; + if ((other.patch orelse 0) < (self.patch orelse 0)) return false; + + const segment_count = if (self.additional_segments) |segs| segs.len else 0; + const other_segment_count = if (other.additional_segments) |segs| segs.len else 0; + const release_segments = @max(segment_count, other_segment_count); + for (0..release_segments) |index| { + const my_segment = if (index < segment_count) self.additional_segments.?[index] else 0; + const other_segment = if (index < other_segment_count) other.additional_segments.?[index] else 0; + if (other_segment > my_segment) return true; + if (other_segment < my_segment) return false; + } + + return true; + } + + fn gt(self: Release, other: Release) bool { + return self.ne(other) and self.gte(other); + } + + fn lt(self: Release, other: Release) bool { + return !self.gte(other); + } + + fn lte(self: Release, other: Release) bool { + return !self.gt(other); + } + + fn compatible(self: Release, other: Release) bool { + if (self.major != other.major) return false; + + if (self.patch != null) { + if (self.minor) |val| { + if ((other.minor orelse 0) != val) return false; + } + } + + if (self.additional_segments != null) { + if (self.patch) |val| { + if ((other.patch orelse 0) != val) return false; + } + } + + if (self.additional_segments) |additional_segments| { + const other_segment_count = if (other.additional_segments) |segs| segs.len else 0; + const leading_components = additional_segments.len - 1; + for (0..leading_components) |index| { + const my_component = additional_segments[index]; + const other_component = if (other_segment_count < index) other.additional_segments.?[index] else 0; + if (other_component < my_component) return false; + } + } + + return self.gte(other); + } +}; + +pub const PreRelease = union(enum) { alpha: u8, beta: u8, rc: u8 }; + +fn NonReleaseSegmentParser(comptime T: type, comptime prefixes: []const []const u8) type { + const leaders = [_][]const u8{ "", ".", "-", "_" }; + comptime var all_prefixes: [prefixes.len * leaders.len][]const u8 = undefined; + comptime var index = 0; + + inline for (prefixes) |prefix| { + inline for (leaders) |leader| { + all_prefixes[index] = leader ++ prefix; + index += 1; + } + } + + return struct { + fn parse(text: []const u8) !?std.meta.Tuple(&.{ T, usize }) { + for (all_prefixes) |prefix| { + if (!std.mem.startsWith(u8, text, prefix)) { + continue; + } + const start_index = prefix.len; + var end_index = start_index; + for (text[start_index..]) |ch| { + if (!std.ascii.isDigit(ch)) { + break; + } else { + end_index += 1; + } + } + const suffix = text[start_index..end_index]; + const segment_value = if (suffix.len == 0) 0 else try std.fmt.parseUnsigned( + T, + suffix, + 10, + ); + return .{ segment_value, end_index }; + } + return null; + } + }; +} + +const Alpha = NonReleaseSegmentParser(u8, &.{ "alpha", "a" }); +const Beta = NonReleaseSegmentParser(u8, &.{ "beta", "b" }); +const RC = NonReleaseSegmentParser(u8, &.{ "preview", "pre", "rc", "c" }); +const Post = NonReleaseSegmentParser(u8, &.{ "post", "rev", "r" }); +const Dev = NonReleaseSegmentParser(u8, &.{"dev"}); + +raw: ?[]const u8 = null, +epoch: ?u8 = null, +release: Release, +pre_release: ?PreRelease = null, +post_release: ?u8 = null, +dev_release: ?u8 = null, +local_version: ?[]const u8 = null, + +const Self = @This(); + +pub fn parse( + allocator: std.mem.Allocator, + value: []const u8, + options: struct { wildcard_allowed: bool = false }, +) !Self { + const trimmed_value = trim_leading_v(string.trim_ascii_ws(value)); + const epoch, const rest = res: { + if (std.mem.indexOfScalar(u8, trimmed_value, '!')) |index| { + if (index == trimmed_value.len - 1) { + return error.InvalidVersion; + } + break :res .{ + try std.fmt.parseUnsigned(u8, trimmed_value[0..index], 10), + trimmed_value[index + 1 ..], + }; + } else { + break :res .{ null, trimmed_value }; + } + }; + + var major_value: ?u16 = null; + var minor_value: ?u8 = null; + var patch_value: ?u8 = null; + + var additional_segments = std.ArrayList(u8).init(allocator); + errdefer additional_segments.deinit(); + + var release_segment: [5]u8 = undefined; + var release_digits: u8 = 0; + var wildcard = false; + + var pre_release: ?PreRelease = null; + var post_release: ?u8 = null; + var dev_release: ?u8 = null; + var local_version: ?[]const u8 = null; + + var index: usize = 0; + while (index < rest.len) { + const char = rest[index]; + if (!std.ascii.isDigit(char)) { + if (pre_release == null and + post_release == null and + dev_release == null and + local_version == null) + { + if (release_digits == 0) { + return error.InvalidVersion; + } + const release_val = release_segment[0..release_digits]; + if (major_value == null) { + major_value = try std.fmt.parseUnsigned(u16, release_val, 10); + } else if (minor_value == null) { + minor_value = try std.fmt.parseUnsigned(u8, release_val, 10); + } else if (patch_value == null) { + patch_value = try std.fmt.parseUnsigned(u8, release_val, 10); + } else { + try additional_segments.append(try std.fmt.parseUnsigned(u8, release_val, 10)); + } + release_digits = 0; + } + + if (char == '.') { + if (index + 1 >= rest.len) { + return error.InvalidVersion; + } + const next_char = rest[index + 1]; + if (next_char == '*') { + if (!options.wildcard_allowed) { + return error.InvalidVersion; + } + wildcard = true; + index += 2; + break; + } else if (std.ascii.isDigit(next_char)) { + index += 1; + continue; + } + } else if (char == '+') { + if (index + 1 >= rest.len) { + return error.InvalidVersion; + } + const local_version_value = rest[index + 1 ..]; + for (local_version_value, 0..) |ch, idx| { + if (ch != '.' and !std.ascii.isAlphanumeric(ch)) { + return error.InvalidVersion; + } + if (ch == '.' and (idx == 0 or idx == local_version_value.len - 1)) { + return error.InvalidVersion; + } + } + local_version = local_version_value; + index += local_version_value.len + 1; + break; + } + + if (index >= rest.len) break; + const tail = rest[index..]; + if (try Alpha.parse(tail)) |result| { + const alpha, const end_index = result; + pre_release = .{ .alpha = alpha }; + index += end_index; + continue; + } else if (try Beta.parse(tail)) |result| { + const beta, const end_index = result; + pre_release = .{ .beta = beta }; + index += end_index; + continue; + } else if (try RC.parse(tail)) |result| { + const rc, const end_index = result; + pre_release = .{ .rc = rc }; + index += end_index; + continue; + } else if (try Post.parse(tail)) |result| { + post_release, const end_index = result; + index += end_index; + continue; + } else if (try Dev.parse(tail)) |result| { + dev_release, const end_index = result; + index += end_index; + continue; + } else return error.InvalidVersion; + } else { + if (release_digits >= release_segment.len) { + return error.UnexpectedSelf; + } + release_segment[release_digits] = char; + release_digits += 1; + } + index += 1; + } + if (release_digits > 0) { + const release_val = release_segment[0..release_digits]; + if (major_value == null) { + major_value = try std.fmt.parseUnsigned(u16, release_val, 10); + } else if (minor_value == null) { + minor_value = try std.fmt.parseUnsigned(u8, release_val, 10); + } else if (patch_value == null) { + patch_value = try std.fmt.parseUnsigned(u8, release_val, 10); + } else { + try additional_segments.append(try std.fmt.parseUnsigned(u8, release_val, 10)); + } + } + + const major_version = if (major_value) |val| val else return error.InvalidVersion; + if (index < rest.len) return error.InvalidVersion; + + const segments: ?[]const u8 = if (additional_segments.items.len == 0) null else try additional_segments.toOwnedSlice(); + return .{ + .raw = trimmed_value, + .epoch = epoch, + .release = .{ + .major = major_version, + .minor = minor_value, + .patch = patch_value, + .additional_segments = segments, + .wildcard = wildcard, + }, + .pre_release = pre_release, + .post_release = post_release, + .dev_release = dev_release, + .local_version = local_version, + }; +} + +pub fn deinit(self: Self, allocator: std.mem.Allocator) void { + if (self.release.additional_segments) |segments| { + allocator.free(segments); + } +} + +pub fn format( + self: Self, + fmt: []const u8, + _: std.fmt.FormatOptions, + writer: anytype, +) !void { + _ = fmt; + if (self.epoch) |epoch| { + try std.fmt.format(writer, "{d}!", .{epoch}); + } + try std.fmt.format(writer, "{d}", .{self.release.major}); + if (self.release.minor) |val| { + try std.fmt.format(writer, ".{d}", .{val}); + } + if (self.release.patch) |val| { + try std.fmt.format(writer, ".{d}", .{val}); + } + if (self.release.additional_segments) |segments| { + for (segments) |segment| { + try std.fmt.format(writer, ".{d}", .{segment}); + } + } + if (self.pre_release) |pre_release| { + const label, const val = res: switch (pre_release) { + .rc => |val| break :res .{ "rc", val }, + .beta => |val| break :res .{ "b", val }, + .alpha => |val| break :res .{ "a", val }, + }; + try std.fmt.format(writer, "{s}{d}", .{ label, val }); + } + if (self.post_release) |post_release| { + try std.fmt.format(writer, ".post{d}", .{post_release}); + } + if (self.dev_release) |dev_release| { + try std.fmt.format(writer, ".dev{d}", .{dev_release}); + } + if (self.local_version) |local_version| { + try writer.writeByte('+'); + try writer.writeAll(local_version); + } +} + +pub fn major(self: Self) u16 { + return self.release.major; +} + +pub fn minor(self: Self) u8 { + return self.release.minor orelse 0; +} + +pub fn patch(self: Self) u8 { + return self.release.patch orelse 0; +} + +pub fn compatible(self: Self, other: Self) bool { + const my_epoch: u8 = self.epoch orelse 0; + const other_epoch: u8 = other.epoch orelse 0; + if (other_epoch != my_epoch) return false; + + // TODO: Handle pre/post/dev/local + return self.release.compatible(other.release); +} + +pub fn lte(self: Self, other: Self) bool { + return !self.gt(other); +} + +pub fn gte(self: Self, other: Self) bool { + const my_epoch: u8 = self.epoch orelse 0; + const other_epoch: u8 = other.epoch orelse 0; + if (other_epoch < my_epoch) return false; + if (other_epoch > my_epoch) return true; + + // TODO: Handle pre/post/dev/local + return self.release.gte(other.release); +} + +pub fn lt(self: Self, other: Self) bool { + return !self.gte(other); +} + +pub fn gt(self: Self, other: Self) bool { + return self.ne(other) and self.gte(other); +} + +pub fn eq(self: Self, other: Self) bool { + const my_epoch: u8 = self.epoch orelse 0; + const other_epoch: u8 = other.epoch orelse 0; + if (other_epoch != my_epoch) return false; + + // TODO: Handle pre/post/dev/local + return self.release.eq(other.release); +} + +pub fn ne(self: Self, other: Self) bool { + return !self.eq(other); +} + +test "alpha" { + const expectAlpha = struct { + fn expectAlpha(expected: u8, version: []const u8) !void { + const ver = try Self.parse(std.testing.allocator, version, .{}); + defer ver.deinit(std.testing.allocator); + try std.testing.expectEqualDeep(PreRelease{ .alpha = expected }, ver.pre_release); + } + }.expectAlpha; + + try expectAlpha(0, "3.9.2a"); + try expectAlpha(0, "3.9.2.a"); + try expectAlpha(0, "3.9.2-a"); + try expectAlpha(0, "3.9.2_a"); + + try expectAlpha(0, "3.9.2alpha"); + try expectAlpha(0, "3.9.2.alpha"); + try expectAlpha(0, "3.9.2-alpha"); + try expectAlpha(0, "3.9.2_alpha"); + + try expectAlpha(1, "3.9.2a1"); + try expectAlpha(12, "3.9.2alpha12"); +} + +test "beta" { + const expectBeta = struct { + fn expectBeta(expected: u8, version: []const u8) !void { + const ver = try Self.parse(std.testing.allocator, version, .{}); + defer ver.deinit(std.testing.allocator); + try std.testing.expectEqualDeep(PreRelease{ .beta = expected }, ver.pre_release); + } + }.expectBeta; + + try expectBeta(0, "3.9.2b"); + try expectBeta(0, "3.9.2.b"); + try expectBeta(0, "3.9.2-b"); + try expectBeta(0, "3.9.2_b"); + + try expectBeta(0, "3.9.2beta"); + try expectBeta(0, "3.9.2.beta"); + try expectBeta(0, "3.9.2-beta"); + try expectBeta(0, "3.9.2_beta"); + + try expectBeta(1, "3.9.2b1"); + try expectBeta(12, "3.9.2beta12"); +} + +test "rc" { + const expectRc = struct { + fn expectRc(expected: u8, version: []const u8) !void { + const ver = try Self.parse(std.testing.allocator, version, .{}); + defer ver.deinit(std.testing.allocator); + try std.testing.expectEqualDeep(PreRelease{ .rc = expected }, ver.pre_release); + } + }.expectRc; + + try expectRc(0, "3.9.2c"); + try expectRc(0, "3.9.2.c"); + try expectRc(0, "3.9.2-c"); + try expectRc(0, "3.9.2_c"); + + try expectRc(0, "3.9.2rc"); + try expectRc(0, "3.9.2.rc"); + try expectRc(0, "3.9.2-rc"); + try expectRc(0, "3.9.2_rc"); + + try expectRc(1, "3.9.2c1"); + try expectRc(2, "3.9.2pre2"); + try expectRc(3, "3.9.2preview3"); + try expectRc(12, "3.9.2rc12"); +} + +test "post" { + const expectPost = struct { + fn expectPost(expected: u8, version: []const u8) !void { + const ver = try Self.parse(std.testing.allocator, version, .{}); + defer ver.deinit(std.testing.allocator); + try std.testing.expectEqual(expected, ver.post_release); + } + }.expectPost; + + try expectPost(0, "3.9.2r"); + try expectPost(0, "3.9.2.r"); + try expectPost(0, "3.9.2-r"); + try expectPost(0, "3.9.2_r"); + + try expectPost(0, "3.9.2post"); + try expectPost(0, "3.9.2.post"); + try expectPost(0, "3.9.2-post"); + try expectPost(0, "3.9.2_post"); + + try expectPost(1, "3.9.2r1"); + try expectPost(2, "3.9.2rev2"); + try expectPost(12, "3.9.2post12"); +} + +test "dev" { + const expectDev = struct { + fn expectDev(expected: u8, version: []const u8) !void { + const ver = try Self.parse(std.testing.allocator, version, .{}); + defer ver.deinit(std.testing.allocator); + try std.testing.expectEqual(expected, ver.dev_release); + } + }.expectDev; + + try expectDev(0, "3.9.2dev"); + try expectDev(0, "3.9.2.dev"); + try expectDev(0, "3.9.2-dev"); + try expectDev(0, "3.9.2_dev"); + + try expectDev(1, "3.9.2dev1"); + try expectDev(2, "3.9.2-dev2"); + try expectDev(12, "3.9.2.dev12"); +} + +test "local" { + const expectLocal = struct { + fn expectLocal(expected: []const u8, version: []const u8) !void { + const ver = try Self.parse(std.testing.allocator, version, .{}); + defer ver.deinit(std.testing.allocator); + try std.testing.expect(ver.local_version != null); + try std.testing.expectEqualStrings(expected, ver.local_version.?); + } + }.expectLocal; + + try expectLocal("foo", "3.9.2+foo"); + try expectLocal("foo.bar", "3.9.2+foo.bar"); + try expectLocal("foo.123", "3.9.2+foo.123"); +} + +test "complex version" { + const ver = try Self.parse(std.testing.allocator, "3.9rc1.post2.dev3+baz4", .{}); + defer ver.deinit(std.testing.allocator); + + try std.testing.expectEqual(3, ver.major()); + try std.testing.expectEqual(9, ver.minor()); + try std.testing.expectEqual(0, ver.patch()); + try std.testing.expectEqualDeep(PreRelease{ .rc = 1 }, ver.pre_release); + try std.testing.expectEqualDeep(2, ver.post_release); + try std.testing.expectEqualDeep(3, ver.dev_release); + try std.testing.expectEqualDeep("baz4", ver.local_version); +} + +test "invalid versions" { + const expectInvalidVersion = struct { + fn expectInvalidVersion(text: []const u8) !void { + const version = Self.parse(std.testing.allocator, text, .{}) catch |err| { + try std.testing.expectEqual(error.InvalidVersion, err); + return; + }; + defer version.deinit(std.testing.allocator); + std.debug.print( + "Expected {s} to parse as an invalid version, but got: {s}\n", + .{ text, version }, + ); + try std.testing.expect(false); + } + }.expectInvalidVersion; + + try expectInvalidVersion(""); + try expectInvalidVersion("v"); + try expectInvalidVersion("0!"); + try expectInvalidVersion("1d"); + try expectInvalidVersion("1bob"); + try expectInvalidVersion("1+"); + try expectInvalidVersion("1+!"); + try expectInvalidVersion("1+#"); + try expectInvalidVersion("1+.local"); + try expectInvalidVersion("1+local."); +} + +test "version format" { + const expectFormat = struct { + fn expectFormat(text: []const u8, expected_format: []const u8) !void { + const version = try Self.parse(std.testing.allocator, text, .{}); + defer version.deinit(std.testing.allocator); + const actual_format = try std.fmt.allocPrint(std.testing.allocator, "{s}", .{version}); + defer std.testing.allocator.free(actual_format); + try std.testing.expectEqualStrings(expected_format, actual_format); + } + }.expectFormat; + + try expectFormat("1.2.3", "1.2.3"); + try expectFormat("v0!1.2.3", "0!1.2.3"); + try expectFormat("v1.2.3", "1.2.3"); + try expectFormat("1.2.3.rc0", "1.2.3rc0"); + try expectFormat("1.2.3.beta1", "1.2.3b1"); + try expectFormat("1.2.3-a2", "1.2.3a2"); + try expectFormat("1.2.3-r3", "1.2.3.post3"); + try expectFormat("1.2.3dev4", "1.2.3.dev4"); +} diff --git a/src/lib/Virtualenv.zig b/src/lib/Virtualenv.zig index 627e0db..1205f07 100644 --- a/src/lib/Virtualenv.zig +++ b/src/lib/Virtualenv.zig @@ -1,7 +1,7 @@ const native_os = @import("builtin").target.os.tag; const std = @import("std"); -const Interpreter = @import("interpreter.zig").Interpreter; +const Interpreter = @import("Interpreter.zig"); const subprocess = @import("subprocess.zig"); pub const VIRTUALENV_PY = @embedFile("virtualenv.py"); @@ -127,8 +127,8 @@ pub fn load(allocator: std.mem.Allocator, venv_path: []const u8, venv_dir: std.f } if (site_packages_relpath == null) { - const interpreter_path = try venv_dir.realpathAlloc(allocator, interpreter_relpath.?); - defer allocator.free(interpreter_path); + var buffer: [std.fs.max_path_bytes]u8 = undefined; + const interpreter_path = try venv_dir.realpath(interpreter_relpath.?, &buffer); const interpreter = try Interpreter.identify(allocator, interpreter_path); defer interpreter.deinit(); diff --git a/src/lib/boot.zig b/src/lib/boot.zig index da0c67a..5510983 100644 --- a/src/lib/boot.zig +++ b/src/lib/boot.zig @@ -2,13 +2,17 @@ const native_os = @import("builtin").target.os.tag; const std = @import("std"); const Environ = @import("process.zig").Environ; -const Interpreter = @import("interpreter.zig").Interpreter; +const Interpreter = @import("Interpreter.zig"); +const InterpreterContraints = @import("InterpreterConstraints.zig"); +const PexInfo = @import("PexInfo.zig"); +const PexPython = @import("PexPython.zig"); +const PexPythonPath = @import("PexPythonPath.zig"); const VenvPex = @import("VenvPex.zig"); const Virtualenv = @import("Virtualenv.zig"); const Zip = @import("Zip.zig"); const cache = @import("cache.zig"); const fs = @import("fs.zig"); -const PexInfo = @import("PexInfo.zig"); +const os = @import("os.zig"); const log = std.log.scoped(.boot); @@ -167,10 +171,6 @@ fn setupBoot( var timer = try std.time.Timer.start(); - const interpreter = try Interpreter.identify(allocator, std.mem.span(python_exe_path)); - defer interpreter.deinit(); - log.debug("Identify interpreter took {d:.3}µs.", .{timer.lap() / 1_000}); - var temp_dirs = fs.TempDirs.init(allocator); defer temp_dirs.deinit(); @@ -192,29 +192,68 @@ fn setupBoot( defer pex_info.deinit(); log.debug("Parse PEX-INFO took {d:.3}µs.", .{timer.lap() / 1_000}); - const encoder = std.fs.base64_encoder; - const pex_hash_bytes = @as( - [20]u8, - @bitCast(try std.fmt.parseUnsigned(u160, pex_info.value.pex_hash, 16)), - ); + var interp = try Interpreter.identify(allocator, std.mem.span(python_exe_path)); + defer interp.deinit(); + log.debug("Identify interpreter took {d:.3}µs.", .{timer.lap() / 1_000}); - // TODO: XXX: Account for PEX_PATH - var venv_digest = std.crypto.hash.Sha1.init(.{}); - venv_digest.update(&pex_hash_bytes); - const tag = interpreter.value.supported_tags[0]; - venv_digest.update(tag.python); - venv_digest.update(tag.abi); - venv_digest.update(tag.platform); - const venv_hash_bytes = venv_digest.finalResult(); + var using_ambient_interpreter = true; + + // TODO(John Sirois): XXX: In addition, test the interpreter can be used to resolve a full dep + // set from the PEX. + const ics = try InterpreterContraints.parse( + allocator, + pex_info.value.interpreter_constraints, + ); + defer ics.deinit(); + + const pex_python = try PexPython.fromEnv(allocator); + defer pex_python.deinit(); + + const pex_python_path = try PexPythonPath.fromEnv(allocator); + defer pex_python_path.deinit(allocator); + + if (!ics.matches(interp.value) or + !try pex_python.matches(interp.value) or + !try pex_python_path.contains(interp.value)) + { + defer log.debug("Finding a compatible interpreter took {d:.3}µs.", .{timer.lap() / 1_000}); + + var matching_interp: ?std.json.Parsed(Interpreter) = null; + var interpreter_iter = try Interpreter.Iter.fromSearchPath( + allocator, + .{ .search_path = pex_python_path.entries }, + ); + defer interpreter_iter.deinit(); + while (interpreter_iter.next()) |python_interp| { + if (!std.mem.eql(u8, python_interp.value.prefix, interp.value.prefix) and + ics.matches(python_interp.value) and try pex_python.matches(interp.value)) + { + matching_interp = python_interp; + break; + } else { + python_interp.deinit(); + } + } + if (matching_interp) |matching| { + using_ambient_interpreter = interp.value.supported_tags[0].eql(matching.value.supported_tags[0]); + interp.deinit(); + interp = matching; + } else { + return error.CompatibleInterpreterNotFound; + } + } - var encoded_venv_hash_buf: [27]u8 = undefined; - std.debug.assert(encoded_venv_hash_buf.len == encoder.calcSize(venv_hash_bytes.len)); - const pex_hash = encoder.encode(&encoded_venv_hash_buf, &venv_hash_bytes); + const venv_relpath = try VenvPex.calculateVenvRelpath( + allocator, + pex_info.value, + .{ .interpreter = if (!using_ambient_interpreter) interp.value else null }, + ); + defer allocator.free(venv_relpath); const pexcz_root = try cache.root(allocator, &temp_dirs, .{}); defer pexcz_root.deinit(.{}); - var venv_cache_dir = try pexcz_root.join(&.{ "venvs", "0", pex_hash }); + var venv_cache_dir = try pexcz_root.join(&.{venv_relpath}); defer venv_cache_dir.deinit(.{}); const venv_pex: VenvPex = try .init(pex_path, pex_info.value, pex_info_data); @@ -244,7 +283,7 @@ fn setupBoot( .allocator = allocator, .venv_pex = venv_pex, .dest_path = venv_cache_dir.path, - .interpreter = interpreter.value, + .interpreter = interp.value, }; var dir = try venv_cache_dir.createAtomic(Fn, Fn.install, func, .{}); defer dir.close(); diff --git a/src/lib/fs.zig b/src/lib/fs.zig index d46059f..03edcd9 100644 --- a/src/lib/fs.zig +++ b/src/lib/fs.zig @@ -1,10 +1,78 @@ const native_os = @import("builtin").target.os.tag; const std = @import("std"); -const getenv = @import("os.zig").getenv; +const os = @import("os.zig"); const log = std.log.scoped(.fs); +pub fn isDir( + dir: std.fs.Dir, + sub_path: []const u8, + options: struct { follow_symlinks: bool = true }, +) !bool { + const stat = dir.statFile(sub_path) catch |err| { + return native_os == .windows and err == error.IsDir; + }; + if (stat.kind == .sym_link) { + if (options.follow_symlinks) { + var buffer: [std.fs.max_path_bytes]u8 = undefined; + const realpath = try std.fs.realpath(sub_path, &buffer); + return try isDir(dir, realpath, options); + } else { + return false; + } + } + return stat.kind == .directory; +} + +pub fn contains(dir: []const u8, path: []const u8) !bool { + if (!try isDir(std.fs.cwd(), dir, .{})) return false; + var dir_iter = try std.fs.path.componentIterator(dir); + var path_iter = try std.fs.path.componentIterator(path); + if (dir_iter.root()) |dir_root| { + if (path_iter.root()) |path_root| { + if (!std.mem.eql(u8, dir_root, path_root)) return false; + } else return false; + } + while (dir_iter.next()) |dir_component| { + if (path_iter.next()) |path_component| { + if (!std.mem.eql(u8, dir_component.name, path_component.name)) return false; + } else return false; + } + return path_iter.next() != null; +} + +test "contains" { + const dir1 = "dir1"; + try std.testing.expect(!try contains(dir1, dir1)); + try std.testing.expect(!try contains(dir1 ++ std.fs.path.sep_str, dir1)); + try std.testing.expect(!try contains(dir1, dir1 ++ std.fs.path.sep_str)); + try std.testing.expect( + !try contains(dir1 ++ std.fs.path.sep_str, dir1 ++ std.fs.path.sep_str), + ); + + // N.B.: This should fail because "dir1" is not a directory. + try std.testing.expect(!try contains(dir1, dir1 ++ std.fs.path.sep_str ++ "path")); + + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + const tmp_dir_path = try tmp.dir.realpathAlloc(std.testing.allocator, "."); + defer std.testing.allocator.free(tmp_dir_path); + + const sub_path1 = try std.fs.path.join(std.testing.allocator, &.{ tmp_dir_path, "path" }); + defer std.testing.allocator.free(sub_path1); + try std.testing.expect(try contains(tmp_dir_path, sub_path1)); + + const tmp_dir_path_trailing_sep = try std.mem.concat( + std.testing.allocator, + u8, + &.{ tmp_dir_path, std.fs.path.sep_str }, + ); + defer std.testing.allocator.free(tmp_dir_path_trailing_sep); + try std.testing.expect(try contains(tmp_dir_path_trailing_sep, sub_path1)); +} + const TempDirRoot = struct { path: []const u8, allocator: ?std.mem.Allocator = null, @@ -41,7 +109,7 @@ fn tempDirRoot(allocator: std.mem.Allocator) !TempDirRoot { // The result of this search is cached. // for ([_][]const u8{ "TMPDIR", "TEMP", "TMP" }) |key| { - if (try getenv(allocator, key)) |tmp| { + if (try os.getEnv(allocator, key)) |tmp| { return .{ .path = tmp.value, .allocator = tmp.allocator }; } } diff --git a/src/lib/layout.zig b/src/lib/layout.zig new file mode 100644 index 0000000..f82b61d --- /dev/null +++ b/src/lib/layout.zig @@ -0,0 +1,114 @@ +const std = @import("std"); + +const fs = @import("fs.zig"); + +const log = std.log.scoped(.layout); + +const Layout = enum { + zipapp, + @"packed", + loose, + + pub fn identify(pex: []const u8) !Layout { + const cwd = std.fs.cwd(); + if (try fs.isDir(cwd, pex, .{})) { + var pex_dir = try cwd.openDir(pex, .{}); + defer pex_dir.close(); + + if (try fs.isDir(pex_dir, ".bootstrap", .{})) { + return .loose; + } else { + return .@"packed"; + } + } else { + return .zipapp; + } + } +}; + +fn createPex(tmp_dir: std.testing.TmpDir, layout: Layout) ![]const u8 { + const Interpreter = @import("Interpreter.zig"); + const Virtualenv = @import("Virtualenv.zig"); + const subprocess = @import("subprocess.zig"); + + const interpreter = try struct { + fn findInterpreter() !std.json.Parsed(Interpreter) { + var interpreter_iter = try Interpreter.Iter.fromSearchPath(std.testing.allocator, .{}); + defer interpreter_iter.deinit(); + + while (interpreter_iter.next()) |candidate| { + if (candidate.value.has_ensurepip) { + return candidate; + } else { + candidate.deinit(); + } + } + return error.InterpreterNotFound; + } + }.findInterpreter(); + defer interpreter.deinit(); + + const tmp_dir_path = try tmp_dir.dir.realpathAlloc(std.testing.allocator, "."); + defer std.testing.allocator.free(tmp_dir_path); + + const venv = try Virtualenv.create( + std.testing.allocator, + interpreter.value, + tmp_dir_path, + tmp_dir.dir, + .{ .include_pip = true }, + ); + defer venv.deinit(); + + const python_exe = try std.fs.path.join( + std.testing.allocator, + &.{ venv.path, venv.interpreter_relpath }, + ); + defer std.testing.allocator.free(python_exe); + + try subprocess.checkCall( + std.testing.allocator, + &.{ python_exe, "-m", "pip", "install", "pex" }, + .{}, + ); + + const pex = try std.fs.path.join(std.testing.allocator, &.{ tmp_dir_path, "empty.pex" }); + errdefer std.testing.allocator.free(pex); + + try subprocess.checkCall( + std.testing.allocator, + &.{ python_exe, "-m", "pex", "--layout", @tagName(layout), "-o", pex }, + .{}, + ); + return pex; +} + +test "identify zipapp" { + var tmp_dir = std.testing.tmpDir(.{}); + defer tmp_dir.cleanup(); + + const pex = try createPex(tmp_dir, .zipapp); + defer std.testing.allocator.free(pex); + + try std.testing.expectEqual(.zipapp, Layout.identify(pex)); +} + +test "identify packed" { + var tmp_dir = std.testing.tmpDir(.{}); + defer tmp_dir.cleanup(); + + const pex = try createPex(tmp_dir, .@"packed"); + defer std.testing.allocator.free(pex); + + try std.testing.expectEqual(.@"packed", Layout.identify(pex)); +} + +test "identify loose" { + var tmp_dir = std.testing.tmpDir(.{}); + defer tmp_dir.cleanup(); + + const pex = try createPex(tmp_dir, .loose); + defer std.testing.allocator.free(pex); + + try std.testing.expectEqual(.loose, Layout.identify(pex)); +} diff --git a/src/lib/os.zig b/src/lib/os.zig index f33d22d..9d7d408 100644 --- a/src/lib/os.zig +++ b/src/lib/os.zig @@ -14,7 +14,7 @@ pub const Value = struct { } }; -pub fn getenv(allocator: std.mem.Allocator, name: []const u8) !?Value { +pub fn getEnv(allocator: std.mem.Allocator, name: []const u8) !?Value { if (native_os == .windows) { const w_key = try std.unicode.wtf8ToWtf16LeAllocZ(allocator, name); defer allocator.free(w_key); diff --git a/src/lib/string.zig b/src/lib/string.zig new file mode 100644 index 0000000..41fd609 --- /dev/null +++ b/src/lib/string.zig @@ -0,0 +1,114 @@ +const std = @import("std"); + +pub fn trim_ascii_ws(value: []const u8) []const u8 { + if (value.len == 0) return ""; + + var start_index: usize = 0; + var end_index: usize = value.len - 1; + while (start_index <= end_index and std.ascii.isWhitespace(value[start_index])) { + start_index += 1; + } + while (end_index > start_index and std.ascii.isWhitespace(value[end_index])) end_index -= 1; + + return value[start_index .. end_index + 1]; +} + +test "empty" { + try std.testing.expectEqualStrings("", trim_ascii_ws("")); + try std.testing.expectEqualStrings("", trim_ascii_ws(" ")); + try std.testing.expectEqualStrings("", trim_ascii_ws(" \n")); + try std.testing.expectEqualStrings("", trim_ascii_ws(" \r\n\t")); +} + +test "non-empty" { + try std.testing.expectEqualStrings("bob", trim_ascii_ws("bob")); + try std.testing.expectEqualStrings("bob", trim_ascii_ws(" bob")); + try std.testing.expectEqualStrings("bob", trim_ascii_ws("bob ")); + try std.testing.expectEqualStrings("bob", trim_ascii_ws(" bob ")); + try std.testing.expectEqualStrings("bob", trim_ascii_ws("\nbob ")); + try std.testing.expectEqualStrings("bob", trim_ascii_ws("\nbob\t")); + try std.testing.expectEqualStrings("bob", trim_ascii_ws("\n bob\t\r")); +} + +pub const ShSafeString = struct { + value: []const u8, + allocator: ?std.mem.Allocator = null, + + pub fn deinit(self: ShSafeString) void { + if (self.allocator) |alloc| { + alloc.free(self.value); + } + } +}; + +pub fn sh_quote(allocator: std.mem.Allocator, value: []const u8) !ShSafeString { + if (value.len == 0) return .{ .value = "''" }; + var quoted: ?std.ArrayList(u8) = null; + for (value, 0..) |char, index| { + const needs_quote = switch (char) { + // N.B.: Taken from Python shlex.quote which uses the regex `[^\w@%+=:,./-]` to + // identify _unsafe_ characters that require escaping. + 'a'...'z', 'A'...'Z', '0'...'9', '_' => false, // This is Python's `\w` character class. + '@', '%', '+', '=', ':', ',', '.', '/', '-' => false, + else => true, + }; + if (needs_quote and quoted == null) { + quoted = try std.ArrayList(u8).initCapacity(allocator, value.len * 2); + // Backfill. + try quoted.?.append('\''); + try quoted.?.appendSlice(value[0..index]); + } + if (quoted) |*sh_quoted| { + if (char == '\'') { + // N.B.: Once inside a single-quoted string, we need only quote the single quote + // itself. + try sh_quoted.appendSlice("'\"'\"'"); + } else { + try sh_quoted.append(char); + } + } + } + if (quoted) |*sh_quoted| { + try sh_quoted.append('\''); + return .{ .value = try sh_quoted.toOwnedSlice(), .allocator = allocator }; + } else { + return .{ .value = value }; + } +} + +test "sh_quote empty" { + const empty = try sh_quote(std.testing.allocator, ""); + try std.testing.expectEqual(null, empty.allocator); + try std.testing.expectEqual("''", empty.value); +} + +test "sh_quote noop" { + const expectNotQuoted = struct { + fn expectNotQuoted(value: []const u8) !void { + const not_quoted = try sh_quote(std.testing.allocator, value); + + try std.testing.expectEqual(null, not_quoted.allocator); + try std.testing.expectEqualStrings(value, not_quoted.value); + } + }.expectNotQuoted; + + try expectNotQuoted("BobTheBuilder"); + try expectNotQuoted("Bob/The/Builder"); +} + +test "sh_quote quoted" { + const expectQuoted = struct { + fn expectQuoted(expected: []const u8, value: []const u8) !void { + const quoted = try sh_quote(std.testing.allocator, value); + defer quoted.deinit(); + + try std.testing.expectEqual(std.testing.allocator, quoted.allocator); + try std.testing.expectEqualStrings(expected, quoted.value); + } + }.expectQuoted; + + try expectQuoted("' '", " "); + try expectQuoted("'$PATH'", "$PATH"); + try expectQuoted("'!'", "!"); + try expectQuoted("'Bob The Builder'", "Bob The Builder"); +} diff --git a/src/lib/subprocess.zig b/src/lib/subprocess.zig index 64ae523..1423c54 100644 --- a/src/lib/subprocess.zig +++ b/src/lib/subprocess.zig @@ -189,7 +189,7 @@ pub fn run( } } -pub fn checkCall(result: RunResult) !void { +fn checkResult(result: RunResult) !void { switch (result.term) { .Exited => |code| { if (code != 0) return error.CalledProcessError; @@ -199,11 +199,19 @@ pub fn checkCall(result: RunResult) !void { } pub fn CheckCall(printErrorFn: anytype) type { + if (@TypeOf(printErrorFn) == void) { + return struct { + pub fn parse(result: RunResult) !void { + return checkResult(result); + } + pub fn printError() void {} + }; + } const ErrorFnParamType = FuncParamType(printErrorFn, 0, void); if (ErrorFnParamType == void) { return struct { pub fn parse(result: RunResult) !void { - return checkCall(result); + return checkResult(result); } pub fn printError() void { printErrorFn(); @@ -212,7 +220,7 @@ pub fn CheckCall(printErrorFn: anytype) type { } else { return struct { pub fn parse(result: RunResult) !void { - return checkCall(result); + return checkResult(result); } pub fn printError(args: ErrorFnParamType) void { printErrorFn(args); @@ -221,14 +229,33 @@ pub fn CheckCall(printErrorFn: anytype) type { } } +pub fn checkCall( + allocator: std.mem.Allocator, + argv: []const []const u8, + args: Args(CheckCall({})), +) !void { + return run(allocator, argv, CheckCall({}), args); +} + pub fn CheckOutput(printErrorFn: anytype) type { + if (@TypeOf(printErrorFn) == void) { + return struct { + pub const owns_stdout = true; + pub const owns_stderr = false; + pub fn parse(result: RunResult) ![]const u8 { + try checkResult(result); + return result.stdout; + } + pub fn printError() void {} + }; + } const ErrorFnParamType = FuncParamType(printErrorFn, 0, void); if (ErrorFnParamType == void) { return struct { pub const owns_stdout = true; pub const owns_stderr = false; pub fn parse(result: RunResult) ![]const u8 { - try checkCall(result); + try checkResult(result); return result.stdout; } pub fn printError() void { @@ -240,7 +267,7 @@ pub fn CheckOutput(printErrorFn: anytype) type { pub const owns_stdout = true; pub const owns_stderr = false; pub fn parse(result: RunResult) ![]const u8 { - try checkCall(result); + try checkResult(result); return result.stdout; } pub fn printError(args: ErrorFnParamType) void { @@ -249,3 +276,26 @@ pub fn CheckOutput(printErrorFn: anytype) type { }; } } + +pub fn checkOutput( + allocator: std.mem.Allocator, + argv: []const []const u8, + args: Args(CheckOutput({})), +) ![]const u8 { + return run(allocator, argv, CheckOutput({}), args); +} + +test "checkCall" { + try run(std.testing.allocator, &.{ "uv", "--version" }, CheckCall({}), .{}); + try checkCall(std.testing.allocator, &.{ "uv", "--version" }, .{}); +} + +test "checkOutput" { + const version1 = try run(std.testing.allocator, &.{ "uv", "--version" }, CheckOutput({}), .{}); + defer std.testing.allocator.free(version1); + + const version2 = try checkOutput(std.testing.allocator, &.{ "uv", "--version" }, .{}); + defer std.testing.allocator.free(version2); + + try std.testing.expectEqualStrings(version1, version2); +} diff --git a/src/main.zig b/src/main.zig index 263e5f3..2fdb3ef 100644 --- a/src/main.zig +++ b/src/main.zig @@ -1,12 +1,20 @@ const builtin = @import("builtin"); -const std = @import("std"); const native_os = builtin.target.os.tag; +const std = @import("std"); -const pexcz = @import("pexcz"); const Allocator = pexcz.Allocator; +const Interpreter = pexcz.Interpreter; +const InterpreterConstraints = pexcz.InterpreterConstraints; +const PexInfo = pexcz.PexInfo; +const PythonBinarySpec = InterpreterConstraints.PythonBinarySpec; +const VenvPex = pexcz.VenvPex; const Zip = pexcz.Zip; const c = Zip.c; +const cache = pexcz.cache; const config = @import("config"); +const fs = pexcz.fs; +const pexcz = @import("pexcz"); +const string = pexcz.string; const EmbeddedLib = struct { []const u8, @@ -62,7 +70,293 @@ const CompressionOptions = struct { level: i8 = 0, }; -fn setZipPrefix(allocator: std.mem.Allocator, pex: *Zip, czex: *Zip) !?[]const u8 { +fn join_quote( + allocator: std.mem.Allocator, + args: []const []const u8, + separator: []const u8, +) ![]const u8 { + var estimated_length: usize = 0; + for (args) |arg| { + estimated_length += arg.len + 1; + } + var buffer = try std.ArrayList(u8).initCapacity(allocator, estimated_length * 2); + errdefer buffer.deinit(); + + for (args) |arg| { + const quoted = try string.sh_quote(allocator, arg); + defer quoted.deinit(); + + if (buffer.items.len > 0) { + try buffer.appendSlice(separator); + } + try buffer.appendSlice(quoted.value); + } + return try buffer.toOwnedSlice(); +} + +fn createShBootPrefix(allocator: std.mem.Allocator, pex: *Zip) ![]const u8 { + // TODO(John Sirois): Handle packed and loose layouts: + // + https://github.com/pex-tool/pexcz/issues/30 + // + https://github.com/pex-tool/pexcz/issues/31 + // + // In particular, `export PEX="$0"` goes to `export PEX=$(dirname "$0")` in those cases. + + var temp_dirs = fs.TempDirs.init(allocator); + defer temp_dirs.deinit(); + + const pexcz_root = try cache.root(allocator, &temp_dirs, .{}); + defer pexcz_root.deinit(.{}); + + const pex_info_data = try pex.extractToSlice(allocator, "PEX-INFO") orelse { + log.err("The PEX at {s} does not contain a PEX-INFO zip entry.", .{pex.path}); + return error.PexInfoNotFound; + }; + defer allocator.free(pex_info_data); + + const parsed_pex_info = try PexInfo.parse(allocator, pex_info_data); + defer parsed_pex_info.deinit(); + + const pex_info = parsed_pex_info.value; + + const python = ""; // TODO: XXX + const python_args = pex_info.inject_python_args; + const venv_python_args = res: { + if (pex_info.venv_hermetic_scripts) { + var args = try std.ArrayList([]const u8).initCapacity( + allocator, + pex_info.inject_python_args.len + 1, + ); + try args.append("-sE"); + break :res try args.toOwnedSlice(); + } else { + break :res pex_info.inject_python_args; + } + }; + defer if (pex_info.venv_hermetic_scripts) allocator.free(venv_python_args); + + const joined_python_args = try join_quote(allocator, python_args, " "); + defer allocator.free(joined_python_args); + + const joined_venv_python_args = try join_quote(allocator, venv_python_args, " "); + defer allocator.free(joined_venv_python_args); + + // TODO: XXX: Need python names as follows: + // 1.) The shebang python, if any. + // 2.) The pythons implied by PEX-INFO ICs, if any. + // 3.) The pythons implied by pexcz IC == Pex IC == >=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,<[current stable + 1?]. + var python_binary_names = std.StringArrayHashMap(void).init(allocator); + defer { + for (python_binary_names.keys()) |name| { + allocator.free(name); + } + python_binary_names.deinit(); + } + + const pex_info_ics = try InterpreterConstraints.parse( + allocator, + pex_info.interpreter_constraints, + ); + defer pex_info_ics.deinit(); + const pex_info_ics_specs = try pex_info_ics.calculateApplicableBinarySpecs(allocator); + defer allocator.free(pex_info_ics_specs); + + const pexcz_ics = try InterpreterConstraints.forPexczRuntime(allocator, .{ .years_ahead = 1 }); + defer pexcz_ics.deinit(); + const pexcz_ics_specs = try pexcz_ics.calculateApplicableBinarySpecs(allocator); + defer allocator.free(pexcz_ics_specs); + + var interp_iter = try Interpreter.Iter.fromSearchPath(allocator, .{}); + const current_interpreter_specs: []const PythonBinarySpec = res: { + while (interp_iter.next()) |interpreter| { + if (pex_info_ics.matches(interpreter.value) and pexcz_ics.matches(interpreter.value)) { + if (std.meta.stringToEnum( + InterpreterConstraints.PythonImplementation, + interpreter.value.marker_env.platform_python_implementation, + )) |impl| { + break :res &.{ + .{ + .impl = impl, + .major = interpreter.value.version.major, + .minor = interpreter.value.version.minor, + }, + }; + } + } + } + break :res &.{}; + }; + + const all_specs: []const []const PythonBinarySpec = &.{ + current_interpreter_specs, + pex_info_ics_specs, + pexcz_ics_specs, + }; + try python_binary_names.ensureTotalCapacity( + current_interpreter_specs.len + pex_info_ics_specs.len + pexcz_ics_specs.len, + ); + var buf: [PythonBinarySpec.name_buf_len]u8 = undefined; + inline for (&.{ .two, .one }) |components| { + for (all_specs) |specs| { + for (specs) |python_binary_spec| { + const name = try python_binary_spec.versionedBinaryName(&buf, components); + if (!python_binary_names.contains(name)) { + try python_binary_names.put(try allocator.dupe(u8, name), {}); + } + } + } + } + for (all_specs) |specs| { + for (specs) |python_binary_spec| { + const name = python_binary_spec.impl.binaryName(); + if (!python_binary_names.contains(name)) { + try python_binary_names.put(try allocator.dupe(u8, name), {}); + } + } + } + + var joined_python_names = std.ArrayList(u8).init(allocator); + defer joined_python_names.deinit(); + + for (python_binary_names.keys(), 0..) |python_binary_name, index| { + if (index > 0) { + try joined_python_names.appendSlice(" \\\n"); + } + try joined_python_names.append('"'); + try joined_python_names.appendSlice(python_binary_name); + try joined_python_names.append('"'); + } + + const czex_installed_relpath = try VenvPex.calculateVenvRelpath( + allocator, + pex_info, + .{ .dir_sep = std.fs.path.sep_str_posix }, + ); + defer allocator.free(czex_installed_relpath); + + // python = "" # type: str + // python_args = list(pex_info.inject_python_args) # type: List[str] + // if python_shebang: + // shebang = python_shebang[2:] if python_shebang.startswith("#!") else python_shebang + // # Drop leading `/usr/bin/env [args]?`. + // args = list( + // itertools.dropwhile( + // lambda word: not PythonInterpreter.matches_binary_name(word), + // shlex.split(shebang, posix=not WINDOWS), + // ) + // ) + // python = args[0] + // python_args.extend(args[1:]) + // venv_python_args = python_args[:] + // if pex_info.venv_hermetic_scripts: + // venv_python_args.append("-sE") + // + // python_names = tuple( + // _calculate_applicable_binary_names( + // targets=targets, + // interpreter_constraints=pex_info.interpreter_constraints, + // ) + // ) + // + // venv_dir = pex_info.raw_venv_dir(pex_file=pex_name, interpreter=interpreter) + // if venv_dir: + // pex_installed_path = venv_dir.path + // else: + // pex_hash = pex_info.pex_hash + // if pex_hash is None: + // raise ValueError("Expected pex_hash to be set already in PEX-INFO.") + // pex_installed_path = variables.unzip_dir( + // pex_info.raw_pex_root, pex_hash, expand_pex_root=False + // ) + + return try std.fmt.allocPrint( + allocator, + \\#!/bin/sh + \\# N.B.: This script should stick to syntax defined for POSIX `sh` and avoid non-builtins. + \\# See: https://pubs.opengroup.org/onlinepubs/9699919799/idx/shell.html + \\set -eu + \\ + \\VENV_PYTHON_ARGS="{[venv_python_args]s}" + \\ + \\# N.B.: This ensures tilde-expansion of the DEFAULT_CZEX_ROOT value. + \\DEFAULT_CZEX_ROOT="$(echo {[czex_root]s})" + \\if [ -z "${{DEFAULT_CZEX_ROOT}}" ]; then + \\ if uname -s | grep -iE 'mac|darwin' > /dev/null; then + \\ DEFAULT_CZEX_ROOT="$(echo ~/Library/Caches/pexcz)" + \\ else + \\ DEFAULT_CZEX_ROOT="$(echo ~/.cache/pexcz)" + \\ fi + \\fi + \\ + \\DEFAULT_PYTHON="{[python]s}" + \\PYTHON_ARGS="{[python_args]s}" + \\ + \\CZEX_ROOT="${{CZEX_ROOT:-${{DEFAULT_CZEX_ROOT}}}}" + \\INSTALLED_CZEX="${{CZEX_ROOT}}/{[czex_installed_relpath]s}" + \\ + \\if [ -x "${{INSTALLED_CZEX}}/pex" -a -z "${{PEX_TOOLS:-}}" ]; then + \\ # We're a --venv execution mode PEX installed under the PEX_ROOT and the venv + \\ # interpreter to use is embedded in the shebang of our venv pex script; so just + \\ # execute that script directly... except if we're needing to execute PEX code, in + \\ # the form of the tools. + \\ export PEX="$0" + \\ exec "${{INSTALLED_CZEX}}/bin/python" ${{VENV_PYTHON_ARGS}} \ + \\ "${{INSTALLED_CZEX}}/pex" \ + \\ "$@" + \\fi + \\ + \\find_python() {{ + \\ for python in \ + \\{[pythons]s} \ + \\ ; do + \\ if command -v "${{python}}" 2>/dev/null; then + \\ return + \\ fi + \\ done + \\}} + \\ + \\if [ -x "${{DEFAULT_PYTHON}}" ]; then + \\ python_exe="${{DEFAULT_PYTHON}}" + \\else + \\ python_exe="$(find_python)" + \\fi + \\if [ -n "${{python_exe}}" ]; then + \\ if [ -n "${{PEX_VERBOSE:-}}" ]; then + \\ echo >&2 "$0 used /bin/sh boot to select python: ${{python_exe}} for re-exec..." + \\ fi + \\ # The slow path: this CZEX is not installed yet. Run the CZEX so it can install + \\ # itself, rebuilding its fast path venv under the PEX_ROOT. + \\ if [ -n "${{PEX_VERBOSE:-}}" ]; then + \\ echo >&2 "Running CZEX to to create its venv under the PEX_ROOT." + \\ fi + \\ exec "${{python_exe}}" ${{PYTHON_ARGS}} "$0" "$@" + \\fi + \\ + \\echo >&2 "Failed to find any of these python binaries on the PATH:" + \\for python in \ + \\{[pythons]s} \ + \\; do + \\ echo >&2 "${{python}}" + \\done + \\echo >&2 'Either adjust your $PATH which is currently:' + \\echo >&2 "${{PATH}}" + \\echo >&2 -n "Or else install an appropriate Python that provides one of the binaries in " + \\echo >&2 "this list." + \\exit 1 + , + .{ + .czex_installed_relpath = czex_installed_relpath, + .czex_root = pex_info.pex_root orelse "", + .python = python, + .python_args = joined_python_args, + .pythons = joined_python_names.items, + .venv_python_args = joined_venv_python_args, + }, + ); +} + +const sh_boot_shebang = "#!/bin/sh\n"; + +fn getZipPrefix(allocator: std.mem.Allocator, pex: *Zip) !?[]const u8 { const prefix = c.zip_get_archive_prefix(pex.handle); if (prefix == 0) { return null; @@ -71,28 +365,47 @@ fn setZipPrefix(allocator: std.mem.Allocator, pex: *Zip, czex: *Zip) !?[]const u log.err( "The zip prefix for {s} is {d} bytes which is too large for this system " ++ "to process: {s}", - .{ czex.path, prefix, c.zip_strerror(czex.handle) }, + .{ pex.path, prefix, c.zip_strerror(pex.handle) }, ); return error.ZipPrefixTooBig; } - const buffer = try allocator.alloc(u8, @intCast(prefix)); - errdefer allocator.free(buffer); var source_pex_file = try std.fs.cwd().openFileZ(pex.path, .{}); defer source_pex_file.close(); var source_pex_fp = std.io.bufferedReader(source_pex_file.reader()); - const read_amount = try source_pex_fp.reader().readAll(buffer); + var source_pex_reader = source_pex_fp.reader(); + + if (prefix >= sh_boot_shebang.len) { + var buffer: [sh_boot_shebang.len]u8 = undefined; + const read_amount = try source_pex_reader.readAll(&buffer); + std.debug.assert(read_amount == buffer.len); + if (std.mem.eql(u8, sh_boot_shebang, &buffer)) { + return try createShBootPrefix(allocator, pex); + } + } + + const buffer = try allocator.alloc(u8, @intCast(prefix)); + errdefer allocator.free(buffer); + + try source_pex_file.seekTo(0); + const read_amount = try source_pex_reader.readAll(buffer); std.debug.assert(read_amount == buffer.len); + return buffer; +} - if (c.zip_set_archive_prefix(czex.handle, buffer.ptr, buffer.len) != 0) { - log.err( - "Failed to set Pex shebang prefix on {s}: {s}", - .{ czex.path, c.zip_strerror(czex.handle) }, - ); - return error.ZipAddPrefixError; +fn setZipPrefix(allocator: std.mem.Allocator, pex: *Zip, czex: *Zip) !?[]const u8 { + if (try getZipPrefix(allocator, pex)) |prefix| { + if (c.zip_set_archive_prefix(czex.handle, prefix.ptr, prefix.len) != 0) { + log.err( + "Failed to set Pex shebang prefix on {s}: {s}", + .{ czex.path, c.zip_strerror(czex.handle) }, + ); + return error.ZipAddPrefixError; + } + return prefix; } - return buffer; + return null; } fn setEntryMtime(pex: *Zip, entry_index: c.zip_uint64_t, entry_name: []const u8) !void { @@ -338,7 +651,7 @@ pub fn main() !u8 { const allocator = alloc.allocator(); const args = try std.process.argsAlloc(allocator); - defer std.process.argsFree(allocator, args); + errdefer std.process.argsFree(allocator, args); const prog = args[0]; var result: u8 = 0; @@ -365,7 +678,7 @@ pub fn main() !u8 { } try inject(allocator, args[i + 1]); - std.process.exit(0); + break; } else { result = usage( prog, @@ -376,6 +689,8 @@ pub fn main() !u8 { } else { help(prog); } + + std.process.argsFree(allocator, args); if (alloc.deinit() != .ok) { return @intFromEnum(BootResult.boot_error); } else { @@ -463,7 +778,7 @@ test "Export PEX env var" { .argv = &.{ "uv", "run", "python", "test.czex" }, .cwd = tmp_dir_path, .cwd_dir = tmp_dir.dir, - .max_output_bytes = 1024 * 1024, + .max_output_bytes = 10 * 1024 * 1024, }); defer std.testing.allocator.free(execute_czex_result.stdout); defer std.testing.allocator.free(execute_czex_result.stderr); diff --git a/src/python/pexcz/__init__.py b/src/python/pexcz/__init__.py index 48f546d..7e3c0b6 100644 --- a/src/python/pexcz/__init__.py +++ b/src/python/pexcz/__init__.py @@ -622,6 +622,8 @@ def mount( pex, # type: str python=None, # type: Optional[str] ): + # type: (...) -> None + pex_file = to_cstr(pex) boot_python = python or sys.executable diff --git a/uv.lock b/uv.lock index adc016e..627b4cc 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=2.7" resolution-markers = [ "python_full_version >= '3.13'", @@ -480,7 +480,7 @@ wheels = [ [[package]] name = "pexcz" -version = "0.0.2" +version = "0.0.3" source = { editable = "." } [package.dev-dependencies] @@ -1073,6 +1073,8 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/94/84/2ca431d4f7984a260b5115a5ab130c1459d0b0ed08c5ae7d4093e52cb4a3/ziglang-0.14.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:7994b27f3cbfcedea43f9b7552e38b45857bdf0e9a45065474092dd74e7048cf", size = 80233732, upload-time = "2025-05-26T11:45:07.741Z" }, { url = "https://files.pythonhosted.org/packages/74/ea/c59e5a0368bb85eded8df8893f6e7a72d20293268f4ad5b2ce6aabe8cf8e/ziglang-0.14.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:2c9dbee405ad83a062df3569949f24b59e938c1b85aa26674e30b515e654fef4", size = 81624139, upload-time = "2025-05-26T11:45:19.882Z" }, { url = "https://files.pythonhosted.org/packages/cf/b6/2e9673067d0e25a4c0681e33f1c6213f36e9b1d3ad327ea58f061765a6ef/ziglang-0.14.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.musllinux_1_1_ppc64le.whl", hash = "sha256:ad2c7c3a77cff522971fc303da2b656c4ed675fe17cdf5ff74ba7e1b594a4780", size = 89083283, upload-time = "2025-05-26T11:45:31.61Z" }, + { url = "https://files.pythonhosted.org/packages/d9/6c/628f046d91e8f0b8b65fa268c6a8b60d7e52f5ea8d1c1ed07b3a28a2d1aa/ziglang-0.14.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.musllinux_1_1_s390x.whl", hash = "sha256:f566222095390406ededf87bff1aa3df00eaf7d9ddc2ddd182b214f1c16f2904", size = 101514887, upload-time = "2025-08-04T15:52:20.596Z" }, + { url = "https://files.pythonhosted.org/packages/be/2c/ab9d28df1f6bce523397d5e39add3bf12737487dfd6d4ae56f5d40bb3656/ziglang-0.14.1-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:75f879ea9678eeba5fe7909a4cf4e67324eb761d68f416a72d5c3a130361d9fa", size = 82279115, upload-time = "2025-08-04T15:52:32.137Z" }, { url = "https://files.pythonhosted.org/packages/8b/2e/a66ac42e58db4349d75e77644f2717aecccade7bc4f2f924cd114a05ef2e/ziglang-0.14.1-py3-none-win32.whl", hash = "sha256:3d68104704f850c52e2baec1e12caf58e097e954a40880b247da8fb4fa500550", size = 85479685, upload-time = "2025-05-26T11:45:43.203Z" }, { url = "https://files.pythonhosted.org/packages/9d/53/b15661c6f4442c0e1ec1223c17f7a47e5cc108cb171bbdadbc675479582d/ziglang-0.14.1-py3-none-win_amd64.whl", hash = "sha256:e4f7e089a44d5ce34181853a90cdb8456e63c6640f5d44b844a117055326c375", size = 83574233, upload-time = "2025-05-26T11:45:55.268Z" }, { url = "https://files.pythonhosted.org/packages/83/55/ba6235dfcaf5c64524615786031b69af2323ac55242433fc01089badf7bf/ziglang-0.14.1-py3-none-win_arm64.whl", hash = "sha256:f9e13d3e3778a850acf20115b0193a9ae0569c5ca2f71c8e690c4110746b6993", size = 79290101, upload-time = "2025-05-26T11:46:06.54Z" },