From 38664a0ebbe4e36bcf39c8fff78380daf2753254 Mon Sep 17 00:00:00 2001 From: Tim Paine <3105306+timkpaine@users.noreply.github.com> Date: Tue, 29 Sep 2026 13:09:45 -0400 Subject: [PATCH] Fix ABI tags and executable artifact handling Signed-off-by: Tim Paine <3105306+timkpaine@users.noreply.github.com> --- hatch_rs/structs.py | 30 +++++++++------- hatch_rs/tests/test_structs.py | 63 +++++++++++++++++++++++++++++----- 2 files changed, 72 insertions(+), 21 deletions(-) diff --git a/hatch_rs/structs.py b/hatch_rs/structs.py index 9bb266c..e65f2c1 100644 --- a/hatch_rs/structs.py +++ b/hatch_rs/structs.py @@ -280,16 +280,15 @@ def python_extension_name( platform = _normalize_platform(platform or environ.get("HATCH_RUST_PLATFORM", sys_platform)) module_name = source_stem.removeprefix("lib") major, minor = python_version or (version_info.major, version_info.minor) + is_freethreaded = _resolve_free_threaded(free_threaded, python_version=(major, minor)) if abi3 else False + if abi3 and is_freethreaded and (major, minor) < (3, 15): + raise ValueError(f"ABI3 free-threaded builds require Python 3.15 or newer, got {major}.{minor}.") if platform == "win32": return f"{module_name}.pyd" if platform == "emscripten": return f"{module_name}.cpython-{major}{minor}-wasm32-emscripten.so" if abi3: - return ( - f"{module_name}.abi3t.so" - if _resolve_free_threaded(free_threaded, python_version=(major, minor)) and (major, minor) >= (3, 15) - else f"{module_name}.abi3.so" - ) + return f"{module_name}.abi3t.so" if is_freethreaded else f"{module_name}.abi3.so" return f"{module_name}.so" @@ -399,13 +398,12 @@ def wheel_tag( resolved = resolved_target or _resolve_target(target, platform=platform, machine=machine) version = python_version or (version_info.major, version_info.minor) is_freethreaded = _resolve_free_threaded(free_threaded, python_version=version) + if abi3 and is_freethreaded and version < (3, 15): + raise ValueError(f"ABI3 free-threaded builds require Python 3.15 or newer, got {version[0]}.{version[1]}.") if resolved.platform == "emscripten": abis = [f"cp{version[0]}{version[1]}"] elif abi3: - if is_freethreaded: - abis = ["abi3t"] if version >= (3, 15) else [f"cp{version[0]}{version[1]}t"] - else: - abis = ["abi3"] + abis = ["abi3.abi3t"] if is_freethreaded else ["abi3"] elif is_freethreaded: abis = [f"cp{version[0]}{version[1]}t"] else: @@ -831,7 +829,7 @@ def _is_python_extension_artifact(self, artifact: RustArtifactConfig) -> bool: return artifact.destination is not None and "{python_extension_name}" in artifact.destination def _is_executable_artifact(self, artifact: RustArtifactConfig) -> bool: - return artifact.cargo_target_kind in ("bin", "example") + return artifact.cargo_target_kind in ("bin", "example", "test", "bench") def _artifact_role(self, artifact: RustArtifactConfig) -> str: if self._is_generated_artifact(artifact): @@ -1116,7 +1114,7 @@ def _find_exact_artifact(self, target_path: Path, expected_name: str, *, search_ deps_dir = target_path / "deps" if deps_dir.is_dir() and (search_deps or not candidates): for candidate in deps_dir.glob(f"*{expected_path.suffix}"): - if candidate.is_file() and _cargo_artifact_stem(candidate) == expected_path.stem: + if candidate.is_file() and candidate.suffix == expected_path.suffix and _cargo_artifact_stem(candidate) == expected_path.stem: candidates.append(candidate) if not candidates: @@ -1138,6 +1136,7 @@ def _format_destination( source: Path, shared_library: str = "", import_library: str = "", + executable: str = "", ) -> Path: artifact = planned_artifact.artifact extension_stem = artifact.python_extension_name or _cargo_artifact_stem(source) @@ -1155,7 +1154,7 @@ def _format_destination( "shared_library": shared_library, "import_library": import_library, "python_extension_name": python_extension, - "executable": source.name, + "executable": executable or source.name, } try: rendered = template.format(**values) @@ -1423,7 +1422,12 @@ def _copy_executable(self, planned_artifact: PlannedArtifact, *, build_root: Pat executable = executable_name(name, platform=planned_artifact.resolved_target.platform) source = self._find_exact_artifact(target_path, executable, search_deps=artifact.search_deps, artifact=artifact) template = artifact.destination or self._default_executable_destination(artifact) - distribution_path = self._format_destination(template, planned_artifact=planned_artifact, source=source) + distribution_path = self._format_destination( + template, + planned_artifact=planned_artifact, + source=source, + executable=executable, + ) return self._place_artifact( source, distribution_path, diff --git a/hatch_rs/tests/test_structs.py b/hatch_rs/tests/test_structs.py index 234346e..b3e2570 100644 --- a/hatch_rs/tests/test_structs.py +++ b/hatch_rs/tests/test_structs.py @@ -103,19 +103,15 @@ def test_resolve_target_triple_rejects_macos_universal2_as_rust_target(): ), ( {"abi3": True, "free_threaded": True, "target": "x86_64-unknown-linux-gnu", "python_version": (3, 15)}, - "cp315-abi3t-linux_x86_64", + "cp315-abi3.abi3t-linux_x86_64", ), ( {"abi3": True, "free_threaded": True, "target": "aarch64-apple-darwin", "python_version": (3, 15)}, - "cp315-abi3t-macosx_11_0_arm64", + "cp315-abi3.abi3t-macosx_11_0_arm64", ), ( {"abi3": True, "free_threaded": True, "target": "x86_64-pc-windows-msvc", "python_version": (3, 15)}, - "cp315-abi3t-win_amd64", - ), - ( - {"abi3": True, "free_threaded": True, "target": "x86_64-unknown-linux-gnu", "python_version": (3, 13)}, - "cp313-cp313t-linux_x86_64", + "cp315-abi3.abi3t-win_amd64", ), ( {"free_threaded": True, "target": "x86_64-unknown-linux-gnu", "python_version": (3, 15)}, @@ -132,7 +128,18 @@ def test_wheel_tag_uses_packaging_tags(monkeypatch: pytest.MonkeyPatch, kwargs: def test_wheel_tag_uses_free_threaded_env(monkeypatch: pytest.MonkeyPatch): monkeypatch.setenv("HATCH_RUST_FREE_THREADED", "1") - assert wheel_tag(abi3=True, target="aarch64-apple-darwin", python_version=(3, 15)) == "cp315-abi3t-macosx_11_0_arm64" + assert wheel_tag(abi3=True, target="aarch64-apple-darwin", python_version=(3, 15)) == "cp315-abi3.abi3t-macosx_11_0_arm64" + + +@pytest.mark.parametrize("python_version", [(3, 13), (3, 14)]) +def test_wheel_tag_rejects_abi3_free_threaded_before_python_315(python_version: tuple[int, int]): + with pytest.raises(ValueError, match="ABI3 free-threaded builds require Python 3.15 or newer"): + wheel_tag( + abi3=True, + free_threaded=True, + target="x86_64-unknown-linux-gnu", + python_version=python_version, + ) def test_wheel_tag_rejects_free_threaded_for_python_under_313(): @@ -209,6 +216,18 @@ def test_python_extension_name_rejects_free_threaded_for_python_under_313(): python_extension_name("libproject", abi3=True, free_threaded=True, platform="linux", python_version=(3, 12)) +@pytest.mark.parametrize("python_version", [(3, 13), (3, 14)]) +def test_python_extension_name_rejects_abi3_free_threaded_before_python_315(python_version: tuple[int, int]): + with pytest.raises(ValueError, match="ABI3 free-threaded builds require Python 3.15 or newer"): + python_extension_name( + "libproject", + abi3=True, + free_threaded=True, + platform="linux", + python_version=python_version, + ) + + def test_build_plan_copies_emscripten_python_extension(tmp_path): plan = HatchRustBuildPlan( module="project", @@ -366,6 +385,34 @@ def test_build_plan_does_not_disable_crt_static_for_musl_executable(tmp_path): assert "target-feature=-crt-static" not in invocation.env.get("RUSTFLAGS", "") +@pytest.mark.parametrize("cargo_target_kind", ["test", "bench"]) +def test_build_plan_handles_test_and_bench_as_executables(tmp_path, cargo_target_kind: str): + plan = HatchRustBuildPlan( + module="project", + path=tmp_path, + target="x86_64-unknown-linux-musl", + artifacts=[ + RustArtifactConfig( + name="check", + cargo_target="check", + cargo_target_kind=cargo_target_kind, + ) + ], + ) + + assert plan.generate() == [f"cargo rustc --{cargo_target_kind} check --release --target x86_64-unknown-linux-musl"] + planned_artifact = plan._artifact_plans[0] + deps = tmp_path / "target" / "x86_64-unknown-linux-musl" / "release" / "deps" + deps.mkdir(parents=True) + source = deps / "check-0123456789abcdef" + source.write_bytes(b"executable") + source.with_suffix(".d").write_text("dependency metadata") + + plan._copy_outputs(planned_artifact, build_root=tmp_path) + + assert (tmp_path / "project" / "check").read_bytes() == b"executable" + + def test_build_plan_uses_debug_profile_for_editable_install(tmp_path): plan = HatchRustBuildPlan( module="project",