From c739d1f4da800180d07c256a79fc0f47abe70399 Mon Sep 17 00:00:00 2001 From: Tim Paine <3105306+timkpaine@users.noreply.github.com> Date: Fri, 21 Aug 2026 16:24:57 -0400 Subject: [PATCH] Let an extension be named apart from its Cargo artifact The Python extension filename was always derived from the Cargo artifact stem, so the cdylib had to be named for the module it becomes. A project whose extension links a core library of the same name then emits two cdylibs called the same thing into target/, which collide on Windows. Add an optional python-extension-name on an artifact to supply the module stem directly, freeing the Cargo lib name to be unique. --- hatch_rs/structs.py | 8 +++++++- hatch_rs/tests/test_structs.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/hatch_rs/structs.py b/hatch_rs/structs.py index 023b92d..f135a56 100644 --- a/hatch_rs/structs.py +++ b/hatch_rs/structs.py @@ -451,6 +451,11 @@ class RustArtifactConfig(BaseModel): cargo_target_kind: CargoTargetKind | None = Field(default=None, alias="cargo-target-kind", description="Cargo target selector kind.") cargo_target: str | None = Field(default=None, alias="cargo-target", description="Cargo target selector name.") crate_type: str = Field(default="cdylib", alias="crate-type", description="Rust crate type to pass through to rustc.") + python_extension_name: str | None = Field( + default=None, + alias="python-extension-name", + description="Module stem for {python_extension_name}, when the importable name differs from the Cargo artifact name.", + ) install_scheme: InstallScheme = Field( default="package", alias="install-scheme", @@ -1029,7 +1034,8 @@ def _format_destination( import_library: str = "", ) -> Path: artifact = planned_artifact.artifact - python_extension = python_extension_name(_cargo_artifact_stem(source), abi3=self.abi3, platform=planned_artifact.resolved_target.platform) + extension_stem = artifact.python_extension_name or _cargo_artifact_stem(source) + python_extension = python_extension_name(extension_stem, abi3=self.abi3, platform=planned_artifact.resolved_target.platform) values = { "module": self.module, "target": planned_artifact.resolved_target.triple, diff --git a/hatch_rs/tests/test_structs.py b/hatch_rs/tests/test_structs.py index 0c065e1..d97f742 100644 --- a/hatch_rs/tests/test_structs.py +++ b/hatch_rs/tests/test_structs.py @@ -156,6 +156,34 @@ def test_executable_name(platform: str, expected: str): assert executable_name("mycli", platform=platform) == expected +def test_python_extension_name_overrides_cargo_artifact_stem(tmp_path): + # The cdylib is named apart from the core library it links, so the two do + # not collide in target/, but it still has to import under the module name. + plan = HatchRustBuildPlan( + module="project", + path=tmp_path, + target="x86_64-unknown-linux-gnu", + artifacts=[ + { + "name": "project_ext", + "python-extension-name": "project", + "destination": "{module}/{python_extension_name}", + } + ], + ) + plan.generate() + planned_artifact = plan._artifact_plans[0] + source = tmp_path / "target" / "x86_64-unknown-linux-gnu" / "release" / "libproject_ext.so" + source.parent.mkdir(parents=True) + source.write_bytes(b"extension") + + plan._copy_outputs(planned_artifact, build_root=tmp_path) + + copied = tmp_path / "project" / "project.so" + assert copied.read_bytes() == b"extension" + assert plan.libraries == ["project/project.so"] + + def test_build_plan_generates_cargo_invocation(tmp_path): plan = HatchRustBuildPlan(module="project", path=tmp_path, target="x86_64-apple-darwin")