diff --git a/hatch_rs/structs.py b/hatch_rs/structs.py index f135a56..e331240 100644 --- a/hatch_rs/structs.py +++ b/hatch_rs/structs.py @@ -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__ = ( @@ -102,6 +102,10 @@ 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 = { @@ -109,6 +113,7 @@ class CopiedArtifact: "i686": "i686", "aarch64": "aarch64", "armv7": "armv7l", + "loongarch64": "loongarch64", "ppc64le": "ppc64le", "s390x": "s390x", "riscv64": "riscv64", @@ -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 = { @@ -147,7 +162,7 @@ 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" @@ -155,7 +170,7 @@ def _normalize_platform(platform: str) -> str: 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 @@ -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") @@ -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}" @@ -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("--") diff --git a/hatch_rs/tests/test_structs.py b/hatch_rs/tests/test_structs.py index d97f742..e3fb4a8 100644 --- a/hatch_rs/tests/test_structs.py +++ b/hatch_rs/tests/test_structs.py @@ -6,7 +6,15 @@ 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) @@ -14,6 +22,7 @@ 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( @@ -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") @@ -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") @@ -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",