Skip to content

Commit 89f5679

Browse files
committed
Better handling of cross platform and other platform targets
1 parent 48343e5 commit 89f5679

5 files changed

Lines changed: 283 additions & 56 deletions

File tree

README.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,5 +112,44 @@ command = ["python", "scripts/validate_abi.py", "{destination}", "{header}"]
112112
`include-import-lib` only packages an import library on Windows targets, where
113113
Cargo emits `.dll.lib` or `.dll.a` files for downstream native linkers.
114114

115+
### Platform tags and cibuildwheel
116+
117+
Binary wheel tags are generated with `packaging.tags` from the resolved Rust
118+
target. Linux builds default to `linux_<arch>` unless `AUDITWHEEL_PLAT` is set
119+
by auditwheel/cibuildwheel or `wheel-platform-tag` is configured explicitly.
120+
Rust targets should use concrete triples such as `x86_64-unknown-linux-gnu` or
121+
`x86_64-unknown-linux-musl`; manylinux and musllinux are wheel platform tags,
122+
not Rust target triples.
123+
124+
For cibuildwheel, keep Cargo outputs isolated so repeated platform builds do not
125+
reuse stale artifacts from another target:
126+
127+
```toml
128+
[tool.hatch.build.hooks.hatch-rs]
129+
module = "project"
130+
target-dir = "isolated"
131+
132+
[[tool.hatch.build.hooks.hatch-rs.artifacts]]
133+
name = "python-extension"
134+
kind = "python-extension"
135+
manifest = "Cargo.toml"
136+
library = "project"
137+
138+
[[tool.hatch.build.hooks.hatch-rs.artifacts]]
139+
name = "c-abi"
140+
kind = "shared-library"
141+
manifest = "rust/Cargo.toml"
142+
library = "project_ffi"
143+
crate-type = "cdylib"
144+
destination = "project/lib/{shared_library}"
145+
146+
[tool.cibuildwheel]
147+
build = "cp311-*"
148+
test-command = "python -c \"import project\""
149+
```
150+
151+
When cross-building outside cibuildwheel, set `wheel-platform-tag` only if the
152+
final wheel platform tag is known, for example `manylinux_2_28_x86_64`.
153+
115154
> [!NOTE]
116155
> This library was generated using [copier](https://copier.readthedocs.io/en/stable/) from the [Base Python Project Template repository](https://github.com/python-project-templates/base).

hatch_rs/plugin.py

Lines changed: 6 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,11 @@
22

33
from logging import getLogger
44
from os import getenv
5-
from platform import machine as platform_machine
6-
from sys import platform as sys_platform, version_info
75
from typing import Any
86

97
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
108

11-
from .structs import HatchRustBuildConfig, HatchRustBuildPlan
9+
from .structs import HatchRustBuildConfig, HatchRustBuildPlan, wheel_tag
1210
from .utils import import_string
1311

1412
__all__ = ("HatchRustBuildHook",)
@@ -88,21 +86,11 @@ def initialize(self, version: str, build_data: dict[str, Any]) -> None:
8886
# build_data["tag"] = f"cp{version_major}{version_minor}-cp{version_major}{version_minor}-{os_name}_{machine}"
8987
if build_plan.libraries:
9088
build_data["pure_python"] = False
91-
machine = platform_machine().lower()
92-
version_major = version_info.major
93-
version_minor = version_info.minor
94-
95-
# TODO abi3
96-
if "darwin" in sys_platform:
97-
os_name = "macosx_11_0"
98-
elif "linux" in sys_platform:
99-
os_name = "linux"
100-
else:
101-
os_name = "win"
102-
if config.abi3:
103-
build_data["tag"] = f"cp{version_major}{version_minor}-abi3-{os_name}_{machine}"
104-
else:
105-
build_data["tag"] = f"cp{version_major}{version_minor}-cp{version_major}{version_minor}-{os_name}_{machine}"
89+
build_data["tag"] = wheel_tag(
90+
abi3=config.abi3,
91+
resolved_target=build_plan.resolved_target,
92+
platform_tag=config.wheel_platform_tag,
93+
)
10694

10795
# force include libraries
10896
force_include = build_data.setdefault("force_include", {})

hatch_rs/structs.py

Lines changed: 194 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,11 @@
1111
from shlex import join as shell_join
1212
from shutil import copy2
1313
from subprocess import run
14-
from sys import platform as sys_platform
14+
from sys import platform as sys_platform, version_info
1515
from tempfile import TemporaryDirectory
1616
from typing import Any, List, Literal, Optional
1717

18+
from packaging.tags import cpython_tags, mac_platforms
1819
from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, field_validator
1920

2021
__all__ = (
@@ -29,6 +30,7 @@
2930
"python_extension_name",
3031
"resolve_target_triple",
3132
"shared_library_name",
33+
"wheel_tag",
3234
)
3335

3436
ArtifactKind = Literal["python-extension", "shared-library", "command", "header"]
@@ -74,14 +76,115 @@ class CopiedArtifact:
7476
distribution_path: str
7577

7678

79+
WINDOWS_TARGETS = {
80+
"x86_64": "x86_64-pc-windows-msvc",
81+
"i686": "i686-pc-windows-msvc",
82+
"aarch64": "aarch64-pc-windows-msvc",
83+
}
84+
85+
DARWIN_TARGETS = {
86+
"x86_64": "x86_64-apple-darwin",
87+
"aarch64": "aarch64-apple-darwin",
88+
}
89+
90+
LINUX_GNU_TARGETS = {
91+
"x86_64": "x86_64-unknown-linux-gnu",
92+
"i686": "i686-unknown-linux-gnu",
93+
"aarch64": "aarch64-unknown-linux-gnu",
94+
"armv7": "armv7-unknown-linux-gnueabihf",
95+
"ppc64le": "powerpc64le-unknown-linux-gnu",
96+
"s390x": "s390x-unknown-linux-gnu",
97+
"riscv64": "riscv64gc-unknown-linux-gnu",
98+
}
99+
100+
LINUX_MUSL_TARGETS = {
101+
"x86_64": "x86_64-unknown-linux-musl",
102+
"i686": "i686-unknown-linux-musl",
103+
"aarch64": "aarch64-unknown-linux-musl",
104+
"armv7": "armv7-unknown-linux-musleabihf",
105+
}
106+
107+
WHEEL_ARCHES = {
108+
"x86_64": "x86_64",
109+
"i686": "i686",
110+
"aarch64": "aarch64",
111+
"armv7": "armv7l",
112+
"ppc64le": "ppc64le",
113+
"s390x": "s390x",
114+
"riscv64": "riscv64",
115+
}
116+
117+
118+
def _normalize_machine(machine: str) -> str:
119+
normalized = machine.lower().replace("-", "_")
120+
aliases = {
121+
"amd64": "x86_64",
122+
"x64": "x86_64",
123+
"x86": "i686",
124+
"i386": "i686",
125+
"arm64": "aarch64",
126+
"armv7l": "armv7",
127+
"powerpc64le": "ppc64le",
128+
"riscv64gc": "riscv64",
129+
}
130+
return aliases.get(normalized, normalized)
131+
132+
133+
def _target_machine(target: str) -> str:
134+
return _normalize_machine(target.split("-", 1)[0])
135+
136+
137+
def _normalize_platform(platform: str) -> str:
138+
normalized = platform.lower()
139+
if normalized.startswith("win"):
140+
return "win32"
141+
if normalized.startswith("macosx") or normalized == "darwin":
142+
return "darwin"
143+
if normalized.startswith(("linux", "manylinux", "musllinux")):
144+
return "linux"
145+
return normalized
146+
147+
148+
def _linux_targets_for_platform(platform: str) -> dict[str, str]:
149+
if platform.lower().startswith("musllinux"):
150+
return LINUX_MUSL_TARGETS
151+
return LINUX_GNU_TARGETS
152+
153+
154+
def _unsupported_machine(platform: str, machine: str, supported: dict[str, str]) -> ValueError:
155+
supported_machines = ", ".join(sorted(supported))
156+
return ValueError(f"Unsupported machine type: {machine} for {platform} platform. Supported machines: {supported_machines}.")
157+
158+
159+
def _explicit_target(target: str, *, platform: str, machine: str) -> ResolvedTarget:
160+
if "manylinux" in target or "musllinux" in target:
161+
raise ValueError(
162+
"Rust target triples use linux-gnu or linux-musl, not manylinux or musllinux wheel platform tags. "
163+
"Use a Rust target such as x86_64-unknown-linux-gnu or x86_64-unknown-linux-musl."
164+
)
165+
if "universal2" in target:
166+
raise ValueError(
167+
"macOS universal2 is a wheel strategy, not a Rust target triple. Build x86_64-apple-darwin and "
168+
"aarch64-apple-darwin artifacts separately, then combine them with a project-specific step if needed."
169+
)
170+
171+
if target.endswith("-pc-windows-msvc"):
172+
return ResolvedTarget(platform="win32", machine=_target_machine(target), triple=target)
173+
if target.endswith("-apple-darwin"):
174+
return ResolvedTarget(platform="darwin", machine=_target_machine(target), triple=target)
175+
if "-linux-" in target:
176+
return ResolvedTarget(platform="linux", machine=_target_machine(target), triple=target)
177+
return ResolvedTarget(platform=platform, machine=machine, triple=target)
178+
179+
77180
def resolve_target_triple(target: Optional[str] = None, *, platform: Optional[str] = None, machine: Optional[str] = None) -> str:
78181
"""Resolve a Rust target triple from explicit config or host platform details."""
79182
return _resolve_target(target, platform=platform, machine=machine).triple
80183

81184

82185
def shared_library_name(library: str, *, platform: Optional[str] = None) -> str:
83186
"""Render a platform-specific standalone shared-library filename."""
84-
platform = platform or environ.get("HATCH_RUST_PLATFORM", sys_platform)
187+
platform = _normalize_platform(platform or environ.get("HATCH_RUST_PLATFORM", sys_platform))
85188
if platform == "win32":
86189
return f"{library}.dll"
87190
if platform == "darwin":
@@ -93,7 +196,7 @@ def shared_library_name(library: str, *, platform: Optional[str] = None) -> str:
93196

94197
def python_extension_name(source_stem: str, *, abi3: bool = False, platform: Optional[str] = None) -> str:
95198
"""Render the Python extension filename for a Cargo cdylib artifact stem."""
96-
platform = platform or environ.get("HATCH_RUST_PLATFORM", sys_platform)
199+
platform = _normalize_platform(platform or environ.get("HATCH_RUST_PLATFORM", sys_platform))
97200
module_name = source_stem.removeprefix("lib")
98201
if platform == "win32":
99202
return f"{module_name}.pyd"
@@ -103,52 +206,97 @@ def python_extension_name(source_stem: str, *, abi3: bool = False, platform: Opt
103206

104207

105208
def _resolve_target(target: Optional[str] = None, *, platform: Optional[str] = None, machine: Optional[str] = None) -> ResolvedTarget:
106-
platform = platform or environ.get("HATCH_RUST_PLATFORM", sys_platform)
107-
machine = machine or environ.get("HATCH_RUST_MACHINE", platform_machine())
209+
raw_platform = platform or environ.get("HATCH_RUST_PLATFORM", sys_platform)
210+
platform = _normalize_platform(raw_platform)
211+
machine = _normalize_machine(machine or environ.get("HATCH_RUST_MACHINE", platform_machine()))
108212

109213
if target:
110-
if target.endswith("-pc-windows-msvc"):
111-
platform = "win32"
112-
machine = target.split("-", 1)[0]
113-
elif target.endswith("-apple-darwin"):
114-
platform = "darwin"
115-
machine = target.split("-", 1)[0]
116-
elif "-unknown-linux-" in target:
117-
platform = "linux"
118-
machine = target.split("-", 1)[0]
119-
return ResolvedTarget(platform=platform, machine=machine, triple=target)
214+
return _explicit_target(target, platform=platform, machine=machine)
120215

121216
if platform == "win32":
122-
if machine in ("x86_64", "AMD64"):
123-
triple = "x86_64-pc-windows-msvc"
124-
elif machine == "i686":
125-
triple = "i686-pc-windows-msvc"
126-
elif machine in ("arm64", "aarch64"):
127-
triple = "aarch64-pc-windows-msvc"
128-
else:
129-
raise ValueError(f"Unsupported machine type: {machine} for Windows platform")
217+
try:
218+
triple = WINDOWS_TARGETS[machine]
219+
except KeyError as error:
220+
raise _unsupported_machine("Windows", machine, WINDOWS_TARGETS) from error
130221
elif platform == "darwin":
131-
if machine == "x86_64":
132-
triple = "x86_64-apple-darwin"
133-
elif machine in ("arm64", "aarch64"):
134-
triple = "aarch64-apple-darwin"
135-
else:
136-
raise ValueError(f"Unsupported machine type: {machine} for macOS platform")
222+
if machine == "universal2":
223+
raise ValueError(
224+
"macOS universal2 wheels require separate concrete Rust targets, x86_64-apple-darwin and aarch64-apple-darwin, "
225+
"plus a project-specific combine step."
226+
)
227+
try:
228+
triple = DARWIN_TARGETS[machine]
229+
except KeyError as error:
230+
raise _unsupported_machine("macOS", machine, DARWIN_TARGETS) from error
137231
elif platform == "linux":
138-
if machine == "x86_64":
139-
triple = "x86_64-unknown-linux-gnu"
140-
elif machine == "i686":
141-
triple = "i686-unknown-linux-gnu"
142-
elif machine in ("arm64", "aarch64"):
143-
triple = "aarch64-unknown-linux-gnu"
144-
else:
145-
raise ValueError(f"Unsupported machine type: {machine} for Linux platform")
232+
linux_targets = _linux_targets_for_platform(raw_platform)
233+
try:
234+
triple = linux_targets[machine]
235+
except KeyError as error:
236+
raise _unsupported_machine("Linux", machine, linux_targets) from error
146237
else:
147238
raise ValueError(f"Unsupported platform: {platform}")
148239

149240
return ResolvedTarget(platform=platform, machine=machine, triple=triple)
150241

151242

243+
def _linux_wheel_platform(resolved_target: ResolvedTarget, platform_tag: Optional[str]) -> str:
244+
if platform_tag:
245+
return platform_tag
246+
247+
auditwheel_platform = environ.get("AUDITWHEEL_PLAT")
248+
if auditwheel_platform:
249+
return auditwheel_platform
250+
251+
arch = WHEEL_ARCHES.get(resolved_target.machine)
252+
if arch is None:
253+
raise _unsupported_machine("Linux wheel", resolved_target.machine, WHEEL_ARCHES)
254+
if "musl" in resolved_target.triple:
255+
return f"musllinux_1_2_{arch}"
256+
return f"linux_{arch}"
257+
258+
259+
def _wheel_platform(resolved_target: ResolvedTarget, platform_tag: Optional[str]) -> str:
260+
if resolved_target.platform == "win32":
261+
windows_platforms = {
262+
"x86_64": "win_amd64",
263+
"i686": "win32",
264+
"aarch64": "win_arm64",
265+
}
266+
try:
267+
return windows_platforms[resolved_target.machine]
268+
except KeyError as error:
269+
raise _unsupported_machine("Windows wheel", resolved_target.machine, windows_platforms) from error
270+
if resolved_target.platform == "darwin":
271+
if platform_tag:
272+
return platform_tag
273+
darwin_arches = {"x86_64": "x86_64", "aarch64": "arm64"}
274+
try:
275+
return next(mac_platforms((11, 0), darwin_arches[resolved_target.machine]))
276+
except KeyError as error:
277+
raise _unsupported_machine("macOS wheel", resolved_target.machine, darwin_arches) from error
278+
if resolved_target.platform == "linux":
279+
return _linux_wheel_platform(resolved_target, platform_tag)
280+
raise ValueError(f"Unsupported platform for wheel tag: {resolved_target.platform}")
281+
282+
283+
def wheel_tag(
284+
*,
285+
abi3: bool = False,
286+
target: Optional[str] = None,
287+
platform: Optional[str] = None,
288+
machine: Optional[str] = None,
289+
resolved_target: Optional[ResolvedTarget] = None,
290+
platform_tag: Optional[str] = None,
291+
python_version: Optional[tuple[int, int]] = None,
292+
) -> str:
293+
"""Render a wheel tag for the resolved Rust target using packaging.tags."""
294+
resolved = resolved_target or _resolve_target(target, platform=platform, machine=machine)
295+
version = python_version or (version_info.major, version_info.minor)
296+
abis = ["abi3"] if abi3 else None
297+
return str(next(cpython_tags(python_version=version, abis=abis, platforms=[_wheel_platform(resolved, platform_tag)])))
298+
299+
152300
def _artifact_patterns(platform: str) -> tuple[str, ...]:
153301
if platform == "win32":
154302
return ("*.dll", "*.pyd")
@@ -429,6 +577,11 @@ class HatchRustBuildConfig(BaseModel):
429577
alias="artifact-manifest-destination",
430578
description="Wheel-relative destination template for the artifact metadata manifest.",
431579
)
580+
wheel_platform_tag: Optional[str] = Field(
581+
default=None,
582+
alias="wheel-platform-tag",
583+
description="Override the wheel platform tag, such as manylinux_2_28_x86_64 or musllinux_1_2_x86_64.",
584+
)
432585

433586
abi3: bool = Field(
434587
default=False,
@@ -501,6 +654,10 @@ def copied_artifacts(self) -> List[CopiedArtifact]:
501654
def shared_data(self) -> dict[str, str]:
502655
return dict(self._shared_data)
503656

657+
@property
658+
def resolved_target(self) -> Optional[ResolvedTarget]:
659+
return self._resolved_target
660+
504661
def _configured_artifacts(self) -> list[RustArtifactConfig]:
505662
if self.artifacts:
506663
return list(self.artifacts)

0 commit comments

Comments
 (0)