Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 52 additions & 6 deletions hatch_rs/structs.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
from tempfile import TemporaryDirectory
from typing import Any, Literal

from packaging.tags import cpython_tags, mac_platforms
from packaging.tags import cpython_tags, mac_platforms, platform_tags
from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, field_validator

__all__ = (
Expand Down Expand Up @@ -102,13 +102,18 @@ class CopiedArtifact:
"i686": "i686-unknown-linux-musl",
"aarch64": "aarch64-unknown-linux-musl",
"armv7": "armv7-unknown-linux-musleabihf",
"loongarch64": "loongarch64-unknown-linux-musl",
"ppc64le": "powerpc64le-unknown-linux-musl",
"s390x": "s390x-unknown-linux-musl",
"riscv64": "riscv64gc-unknown-linux-musl",
}

WHEEL_ARCHES = {
"x86_64": "x86_64",
"i686": "i686",
"aarch64": "aarch64",
"armv7": "armv7l",
"loongarch64": "loongarch64",
"ppc64le": "ppc64le",
"s390x": "s390x",
"riscv64": "riscv64",
Expand All @@ -122,6 +127,16 @@ def _env_truthy(value: str | None) -> bool:
return value.strip().lower() not in ("", "0", "false", "no", "off")


def _is_musl_linux() -> bool:
"""Return True if running in a musl Linux environment or container."""
auditwheel = environ.get("AUDITWHEEL_PLAT", "")
if auditwheel.startswith("musllinux"):
return True
if sys_platform == "linux":
return any(tag.startswith("musllinux") for tag in platform_tags())
return False


def _normalize_machine(machine: str) -> str:
normalized = machine.lower().replace("-", "_")
aliases = {
Expand All @@ -147,15 +162,15 @@ def _normalize_platform(platform: str) -> str:
return "win32"
if normalized.startswith("macosx") or normalized == "darwin":
return "darwin"
if normalized.startswith(("linux", "manylinux", "musllinux")):
if normalized.startswith(("linux", "manylinux", "musl")):
return "linux"
if normalized.startswith(("emscripten", "pyemscripten")):
return "emscripten"
return normalized


def _linux_targets_for_platform(platform: str) -> dict[str, str]:
if platform.lower().startswith("musllinux"):
if platform.lower().startswith("musl"):
return LINUX_MUSL_TARGETS
return LINUX_GNU_TARGETS

Expand Down Expand Up @@ -236,7 +251,9 @@ def python_extension_name(


def _resolve_target(target: str | None = None, *, platform: str | None = None, machine: str | None = None) -> ResolvedTarget:
raw_platform = platform or environ.get("HATCH_RUST_PLATFORM", sys_platform)
raw_platform = platform or environ.get("HATCH_RUST_PLATFORM")
if raw_platform is None:
raw_platform = "musllinux" if _is_musl_linux() else sys_platform
platform = _normalize_platform(raw_platform)
machine = _normalize_machine(machine or environ.get("HATCH_RUST_MACHINE", platform_machine()))
target = target or environ.get("CARGO_BUILD_TARGET")
Expand Down Expand Up @@ -283,6 +300,12 @@ def _linux_wheel_platform(resolved_target: ResolvedTarget, platform_tag: str | N
if arch is None:
raise _unsupported_machine("Linux wheel", resolved_target.machine, WHEEL_ARCHES)
if "musl" in resolved_target.triple:
detected_platform = next(
(tag for tag in platform_tags() if tag.startswith("musllinux_") and tag.endswith(f"_{arch}")),
None,
)
if detected_platform is not None:
return detected_platform
return f"musllinux_1_2_{arch}"
return f"linux_{arch}"

Expand Down Expand Up @@ -857,13 +880,36 @@ def _build_artifact_plan(self, artifact: RustArtifactConfig, *, global_target: s
build_command.append("--frozen")
build_command.extend(self._artifact_cargo_args(artifact))

user_rustc_args = self._artifact_rustc_args(artifact)
user_crate_types = []
for index, argument in enumerate(user_rustc_args):
if argument == "--crate-type" and index + 1 < len(user_rustc_args):
user_crate_types.extend(user_rustc_args[index + 1].split(","))
elif argument.startswith("--crate-type="):
user_crate_types.extend(argument.split("=", 1)[1].split(","))
crate_types = {crate_type.strip() for crate_type in (user_crate_types or artifact.crate_type.split(","))}

rustc_args = []
if self._is_python_extension_artifact(artifact) and "apple" in resolved_target.triple:
rustc_args.extend(("-C", "link-arg=-undefined", "-C", "link-arg=dynamic_lookup"))
rustc_args.extend(self._artifact_rustc_args(artifact))
if (
"musl" in resolved_target.triple
and not self._is_executable_artifact(artifact)
and (not crate_types.isdisjoint(("cdylib", "dylib", "proc-macro")) or self._is_python_extension_artifact(artifact))
):
target_features = (
feature.strip().lstrip("+-")
for argument in user_rustc_args
if argument.startswith(("target-feature=", "-Ctarget-feature=", "--codegen=target-feature="))
for feature in argument.split("target-feature=", 1)[1].split(",")
)
if "crt-static" not in target_features:
rustc_args.extend(("-C", "target-feature=-crt-static"))
rustc_args.extend(user_rustc_args)
# Executables (bin/example) are not crate-type artifacts; injecting
# --crate-type would build them as a library instead of a binary.
if "--crate-type" not in rustc_args and not self._is_executable_artifact(artifact):
has_crate_type_arg = any(argument == "--crate-type" or argument.startswith("--crate-type=") for argument in user_rustc_args)
if not has_crate_type_arg and not self._is_executable_artifact(artifact):
rustc_args.extend(("--crate-type", artifact.crate_type))
if rustc_args:
build_command.append("--")
Expand Down
123 changes: 122 additions & 1 deletion hatch_rs/tests/test_structs.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,23 @@

import pytest

from hatch_rs.structs import HatchRustBuildPlan, executable_name, python_extension_name, resolve_target_triple, shared_library_name, wheel_tag
from hatch_rs.structs import (
HatchRustBuildPlan,
RustArtifactConfig,
executable_name,
python_extension_name,
resolve_target_triple,
shared_library_name,
wheel_tag,
)


@pytest.fixture(autouse=True)
def clear_cargo_target_dir(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("CARGO_TARGET_DIR", raising=False)
monkeypatch.delenv("CARGO_BUILD_TARGET", raising=False)
monkeypatch.delenv("PYODIDE_ABI_VERSION", raising=False)
monkeypatch.delenv("AUDITWHEEL_PLAT", raising=False)


@pytest.mark.parametrize(
Expand Down Expand Up @@ -53,6 +62,23 @@ def test_resolve_target_triple_uses_cargo_build_target(monkeypatch: pytest.Monke
assert resolve_target_triple(platform="linux", machine="x86_64") == "wasm32-unknown-emscripten"


@pytest.mark.parametrize(
("machine", "expected"),
[
("x86_64", "x86_64-unknown-linux-musl"),
("aarch64", "aarch64-unknown-linux-musl"),
("loongarch64", "loongarch64-unknown-linux-musl"),
("ppc64le", "powerpc64le-unknown-linux-musl"),
("s390x", "s390x-unknown-linux-musl"),
("riscv64", "riscv64gc-unknown-linux-musl"),
],
)
def test_resolve_target_triple_uses_musllinux_auditwheel_plat(monkeypatch: pytest.MonkeyPatch, machine: str, expected: str):
monkeypatch.setenv("AUDITWHEEL_PLAT", f"musllinux_1_2_{machine}")

assert resolve_target_triple(machine=machine) == expected


def test_resolve_target_triple_rejects_wheel_platform_tag_as_rust_target():
with pytest.raises(ValueError, match="manylinux.*wheel platform tags"):
resolve_target_triple("x86_64-manylinux_2_28")
Expand Down Expand Up @@ -84,6 +110,12 @@ def test_wheel_tag_uses_auditwheel_platform(monkeypatch: pytest.MonkeyPatch):
assert wheel_tag(target="x86_64-unknown-linux-gnu", python_version=(3, 11)) == "cp311-cp311-manylinux_2_28_x86_64"


def test_wheel_tag_uses_detected_musllinux_version(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr("hatch_rs.structs.platform_tags", lambda: iter(["linux_x86_64", "musllinux_1_1_x86_64"]))

assert wheel_tag(target="x86_64-unknown-linux-musl", python_version=(3, 11)) == "cp311-cp311-musllinux_1_1_x86_64"


def test_wheel_tag_uses_pyodide_abi(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setenv("PYODIDE_ABI_VERSION", "2026_0")

Expand Down Expand Up @@ -192,6 +224,95 @@ def test_build_plan_generates_cargo_invocation(tmp_path):
]


def test_build_plan_generates_musl_cdylib_flags(tmp_path):
plan = HatchRustBuildPlan(module="project", path=tmp_path, target="x86_64-unknown-linux-musl")

assert plan.generate() == ["cargo rustc --release --target x86_64-unknown-linux-musl -- -C target-feature=-crt-static --crate-type cdylib"]


def test_build_plan_preserves_explicit_crt_static_feature_for_musl(tmp_path):
plan = HatchRustBuildPlan(
module="project",
path=tmp_path,
target="x86_64-unknown-linux-musl",
rustc_args=["-C", "target-feature=+crt-static"],
)

assert plan.generate() == ["cargo rustc --release --target x86_64-unknown-linux-musl -- -C target-feature=+crt-static --crate-type cdylib"]


def test_build_plan_combines_musl_crt_flag_with_custom_target_features(tmp_path):
plan = HatchRustBuildPlan(
module="project",
path=tmp_path,
target="x86_64-unknown-linux-musl",
rustc_args=["-C", "target-feature=+sse2"],
)

assert plan.generate() == [
"cargo rustc --release --target x86_64-unknown-linux-musl -- -C target-feature=-crt-static -C target-feature=+sse2 --crate-type cdylib"
]


def test_build_plan_detects_cdylib_in_multiple_crate_types(tmp_path):
plan = HatchRustBuildPlan(
module="project",
path=tmp_path,
target="x86_64-unknown-linux-musl",
artifacts=[{"name": "project", "crate-type": "staticlib,cdylib"}],
)

assert plan.generate() == [
"cargo rustc --release --target x86_64-unknown-linux-musl -- -C target-feature=-crt-static --crate-type staticlib,cdylib"
]


@pytest.mark.parametrize("crate_type", ["dylib", "proc-macro"])
def test_build_plan_disables_static_crt_for_other_dynamic_crate_types(tmp_path, crate_type: str):
plan = HatchRustBuildPlan(
module="project",
path=tmp_path,
target="x86_64-unknown-linux-musl",
artifacts=[{"name": "project", "crate-type": crate_type}],
)

assert "target-feature=-crt-static" in plan.generate()[0]


@pytest.mark.parametrize("rustc_args", [["--crate-type", "staticlib"], ["--crate-type=staticlib"]])
def test_build_plan_preserves_static_crt_for_explicit_staticlib(tmp_path, rustc_args: list[str]):
plan = HatchRustBuildPlan(
module="project",
path=tmp_path,
target="x86_64-unknown-linux-musl",
artifacts=[{"name": "project", "rustc-args": rustc_args}],
)

command = plan.generate()[0]
assert "target-feature=-crt-static" not in command
assert command.count("--crate-type") == 1


def test_build_plan_does_not_disable_crt_static_for_musl_executable(tmp_path):
plan = HatchRustBuildPlan(
module="project",
path=tmp_path,
target="x86_64-unknown-linux-musl",
artifacts=[
RustArtifactConfig(
name="mycli",
cargo_target="mycli",
cargo_target_kind="bin",
install_scheme="shared-scripts",
)
],
)

assert plan.generate() == ["cargo rustc --bin mycli --release --target x86_64-unknown-linux-musl"]
invocation = plan.cargo_invocations[0]
assert "target-feature=-crt-static" not in invocation.env.get("RUSTFLAGS", "")


def test_build_plan_uses_debug_profile_for_editable_install(tmp_path):
plan = HatchRustBuildPlan(
module="project",
Expand Down
Loading