From d8c8ba65dcd2fcc2b1352af1da6945efea70e9a6 Mon Sep 17 00:00:00 2001 From: Tim Paine <3105306+timkpaine@users.noreply.github.com> Date: Sun, 26 Jul 2026 21:58:13 -0500 Subject: [PATCH] Preserve split package metadata in sdists Signed-off-by: Tim Paine <3105306+timkpaine@users.noreply.github.com> --- README.md | 5 +- hatch_multi/__init__.py | 4 +- hatch_multi/hooks.py | 7 ++- hatch_multi/plugin.py | 50 ++++++++++++++++++- .../tests/test_project_basic/pyproject.toml | 2 + .../pyproject.toml | 2 + hatch_multi/tests/test_projects_basic.py | 46 +++++++++++++++++ 7 files changed, 110 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index fc97e87..115f130 100644 --- a/README.md +++ b/README.md @@ -23,8 +23,11 @@ dynamic = ["dependencies"] main = [...] other = [...] -[tool.hatch.metadata.hooks.multi] +[tool.hatch.metadata.hooks.hatch-multi] primary = "main" + +# Required for split sdists to retain their package name when installed. +[tool.hatch.build.targets.sdist.hooks.hatch-multi] ``` ```bash diff --git a/hatch_multi/__init__.py b/hatch_multi/__init__.py index 335db1e..2f73f68 100644 --- a/hatch_multi/__init__.py +++ b/hatch_multi/__init__.py @@ -1,5 +1,5 @@ __version__ = "1.0.0" -from .hooks import hatch_register_metadata_hook -from .plugin import HatchMultiMetadataHook +from .hooks import hatch_register_build_hook, hatch_register_metadata_hook +from .plugin import HatchMultiBuildHook, HatchMultiMetadataHook from .structs import * diff --git a/hatch_multi/hooks.py b/hatch_multi/hooks.py index 3cdae7b..ca4091d 100644 --- a/hatch_multi/hooks.py +++ b/hatch_multi/hooks.py @@ -1,6 +1,11 @@ from hatchling.plugin import hookimpl -from .plugin import HatchMultiMetadataHook +from .plugin import HatchMultiBuildHook, HatchMultiMetadataHook + + +@hookimpl +def hatch_register_build_hook() -> type[HatchMultiBuildHook]: + return HatchMultiBuildHook @hookimpl diff --git a/hatch_multi/plugin.py b/hatch_multi/plugin.py index e472011..d0f0103 100644 --- a/hatch_multi/plugin.py +++ b/hatch_multi/plugin.py @@ -1,18 +1,64 @@ from __future__ import annotations +from json import dumps from logging import getLogger from os import getenv +from pathlib import Path +from tempfile import NamedTemporaryFile +from hatchling.builders.hooks.plugin.interface import BuildHookInterface from hatchling.metadata.plugin.interface import MetadataHookInterface from .structs import HatchMultiConfig -__all__ = ("HatchMultiMetadataHook",) +__all__ = ("HatchMultiBuildHook", "HatchMultiMetadataHook") -class HatchMultiMetadataHook(MetadataHookInterface): +class HatchMultiBuildHook(BuildHookInterface): """The hatch-multi build hook.""" + PLUGIN_NAME = "hatch-multi" + _temporary_project_file: Path | None = None + + def initialize(self, version: str, build_data: dict) -> None: + if self.target_name != "sdist": + return + + configured_name = self.metadata.config["project"]["name"] + package_name = self.metadata.core.raw_name + if package_name == configured_name: + return + + project_file = Path(self.root, "pyproject.toml") + lines = project_file.read_text(encoding="utf-8").splitlines(keepends=True) + in_project_table = False + for index, line in enumerate(lines): + stripped_line = line.strip() + if stripped_line.startswith("[") and stripped_line.endswith("]"): + in_project_table = stripped_line == "[project]" + elif in_project_table: + key, separator, _value = line.partition("=") + if separator and key.strip() == "name": + newline = "\n" if line.endswith("\n") else "" + lines[index] = f"{key}= {dumps(package_name)}{newline}" + break + + with NamedTemporaryFile(mode="w", encoding="utf-8", suffix=".toml", delete=False) as temporary_project_file: + temporary_project_file.writelines(lines) + self._temporary_project_file = Path(temporary_project_file.name) + + build_data["force_include"].pop(str(project_file), None) + build_data["force_include"][str(self._temporary_project_file)] = "pyproject.toml" + + def finalize(self, version: str, build_data: dict, artifact_path: str) -> None: + if self._temporary_project_file is not None: + self._temporary_project_file.unlink() + self._temporary_project_file = None + + +class HatchMultiMetadataHook(MetadataHookInterface): + """The hatch-multi metadata hook.""" + PLUGIN_NAME = "hatch-multi" _logger = getLogger(__name__) diff --git a/hatch_multi/tests/test_project_basic/pyproject.toml b/hatch_multi/tests/test_project_basic/pyproject.toml index d4a24c9..b73b70f 100644 --- a/hatch_multi/tests/test_project_basic/pyproject.toml +++ b/hatch_multi/tests/test_project_basic/pyproject.toml @@ -24,3 +24,5 @@ packages = ["project"] [tool.hatch.metadata.hooks.hatch-multi] primary = "main" + +[tool.hatch.build.targets.sdist.hooks.hatch-multi] diff --git a/hatch_multi/tests/test_project_multiple_primary/pyproject.toml b/hatch_multi/tests/test_project_multiple_primary/pyproject.toml index 9616649..d6106ac 100644 --- a/hatch_multi/tests/test_project_multiple_primary/pyproject.toml +++ b/hatch_multi/tests/test_project_multiple_primary/pyproject.toml @@ -24,3 +24,5 @@ packages = ["project"] [tool.hatch.metadata.hooks.hatch-multi] default = ["main", "other"] + +[tool.hatch.build.targets.sdist.hooks.hatch-multi] diff --git a/hatch_multi/tests/test_projects_basic.py b/hatch_multi/tests/test_projects_basic.py index b0844b9..e5ace88 100644 --- a/hatch_multi/tests/test_projects_basic.py +++ b/hatch_multi/tests/test_projects_basic.py @@ -3,6 +3,7 @@ from shutil import rmtree from subprocess import check_call from sys import executable +from tarfile import TarFile from zipfile import ZipFile @@ -105,3 +106,48 @@ def test_basic(): """ ) rmtree(f"hatch_multi/tests/{project}/dist") + + +def test_sdist_preserves_extra(): + project = "test_project_basic" + project_root = Path(f"hatch_multi/tests/{project}") + rmtree(project_root / "dist", ignore_errors=True) + + check_call( + [ + executable, + "-m", + "build", + "-n", + "-s", + ], + cwd=project_root, + env={"HATCH_MULTI_BUILD": "other"}, + ) + + sdist_name = "hatch_cpp_test_project_basic_other-0.1.0.tar.gz" + assert listdir(project_root / "dist") == [sdist_name] + with TarFile.open(project_root / "dist" / sdist_name, "r:gz") as tar_file: + tar_file.extractall(project_root / "dist" / "extracted", filter="data") + + source_root = project_root / "dist" / "extracted" / "hatch_cpp_test_project_basic_other-0.1.0" + check_call( + [ + executable, + "-m", + "build", + "-n", + "-w", + ], + cwd=source_root, + env={}, + ) + + wheel_name = "hatch_cpp_test_project_basic_other-0.1.0-py3-none-any.whl" + assert listdir(source_root / "dist") == [wheel_name] + with ZipFile(source_root / "dist" / wheel_name) as zip_file: + metadata = zip_file.read("hatch_cpp_test_project_basic_other-0.1.0.dist-info/METADATA").decode() + + assert "Name: hatch-cpp-test-project-basic-other\n" in metadata + assert "Requires-Dist: organizeit2\n" in metadata + rmtree(project_root / "dist")