diff --git a/backend/src/hatchling/builders/sdist.py b/backend/src/hatchling/builders/sdist.py index d92371bc7..325e3a825 100644 --- a/backend/src/hatchling/builders/sdist.py +++ b/backend/src/hatchling/builders/sdist.py @@ -11,6 +11,7 @@ from typing import TYPE_CHECKING, Any from hatchling.builders.config import BuilderConfig +from hatchling.builders.constants import EXCLUDED_DIRECTORIES, EXCLUDED_FILES from hatchling.builders.plugin.interface import BuilderInterface from hatchling.builders.utils import ( get_reproducible_timestamp, @@ -19,7 +20,9 @@ normalize_file_permissions, normalize_relative_path, replace_file, + safe_walk, ) +from hatchling.builders.wheel import WheelBuilder from hatchling.metadata.spec import DEFAULT_METADATA_VERSION, get_core_metadata_constructors from hatchling.utils.constants import DEFAULT_BUILD_SCRIPT, DEFAULT_CONFIG_FILE @@ -327,6 +330,78 @@ def construct_setup_py_file(self, packages: list[str], extra_dependencies: tuple return contents + def _add_in_tree_source(self, force_include: dict[str, str], path: str) -> None: + """Record a project-tree file so PEP 517 wheel-from-sdist builds can see it.""" + if not path.startswith(self.root) or not os.path.isfile(path): + return + + relative_path = os.path.relpath(path, self.root) + escaped = relative_path == ".." or relative_path.startswith(f"..{os.sep}") + if escaped or self.config.path_is_excluded(relative_path): + return + + force_include[path] = relative_path + + def _add_in_tree_sources(self, force_include: dict[str, str], source: str) -> None: + if os.path.isfile(source): + self._add_in_tree_source(force_include, source) + return + + if not os.path.isdir(source) or not source.startswith(self.root): + return + + for root, dirs, files in safe_walk(source): + dirs[:] = sorted(d for d in dirs if d not in EXCLUDED_DIRECTORIES) + files.sort() + for filename in files: + if filename in EXCLUDED_FILES: + continue + self._add_in_tree_source(force_include, os.path.join(root, filename)) + + def get_wheel_source_force_include(self) -> dict[str, str]: + """Collect in-tree wheel sources that isolated sdist builds must keep. + + Frontends such as `python -m build` and `uv build` unpack the sdist and + build the wheel from that tree. Files listed only on the wheel target + were previously omitted from the sdist, so those wheels silently dropped + them. Explicit sdist `exclude` entries still win, including VCS ignore + rules. + + Returns: + Mapping of absolute source paths to project-relative archive paths. + """ + force_include: dict[str, str] = {} + try: + wheel_builder = WheelBuilder( + self.root, + plugin_manager=self.plugin_manager, + config=self.raw_config, + metadata=self.metadata, + app=self.app, + ) + selected_files = list(wheel_builder.recurse_selected_project_files()) + force_sources = list(wheel_builder.config.force_include) + extra_sources = [ + *wheel_builder.config.shared_data, + *wheel_builder.config.shared_scripts, + *wheel_builder.config.extra_metadata, + ] + sbom_files = list(wheel_builder.config.sbom_files) + except (TypeError, ValueError) as error: + self.app.display_debug(f"Skipping wheel source inclusion for sdist: {error}") + return force_include + + for included_file in selected_files: + self._add_in_tree_source(force_include, included_file.path) + + for source in (*force_sources, *extra_sources): + self._add_in_tree_sources(force_include, source) + + for sbom_file in sbom_files: + self._add_in_tree_source(force_include, os.path.join(self.root, sbom_file)) + + return force_include + def get_default_build_data(self) -> dict[str, Any]: force_include = {} for filename in ["pyproject.toml", DEFAULT_CONFIG_FILE, DEFAULT_BUILD_SCRIPT]: @@ -350,6 +425,8 @@ def get_default_build_data(self) -> dict[str, Any]: relative_path = normalize_relative_path(license_file) force_include[os.path.join(self.root, relative_path)] = relative_path + force_include.update(self.get_wheel_source_force_include()) + return build_data @classmethod diff --git a/docs/config/build.md b/docs/config/build.md index 1717c4a51..1f738795d 100644 --- a/docs/config/build.md +++ b/docs/config/build.md @@ -59,6 +59,8 @@ exclude = [ will exclude every file with a `.json` extension, and will include everything under a `tests` directory located at the root and every file with a `.py` extension that is directly under a `pkg` directory located at the root except for `_compat.py`. +When the sdist `include` list is narrower than the wheel target, Hatchling still adds the wheel's in-tree sources to the sdist so isolated builds (`python -m build`, `uv build`) do not drop them. Use sdist `exclude` if a wheel source must stay out of the archive. + ### Artifacts If you want to include files that are [ignored by your VCS](#vcs), such as those that might be created by [build hooks](#build-hooks), you can use the `artifacts` option. This option is semantically equivalent to `include`. diff --git a/docs/history/hatchling.md b/docs/history/hatchling.md index 7be72c307..acc4a73ff 100644 --- a/docs/history/hatchling.md +++ b/docs/history/hatchling.md @@ -8,6 +8,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## Unreleased +***Fixed:*** + +- Source distributions now include in-tree files selected by the wheel target, so `python -m build` and `uv build` ship the same wheel contents as `hatch build -t wheel`. Files explicitly excluded from the sdist are still omitted. + ## [1.32.0](https://github.com/pypa/hatch/releases/tag/hatchling-v1.32.0) - 2026-08-11 ## {: #hatchling-v1.32.0 } ***Changed:*** diff --git a/docs/plugins/builder/sdist.md b/docs/plugins/builder/sdist.md index 13db3f657..c1a7ede30 100644 --- a/docs/plugins/builder/sdist.md +++ b/docs/plugins/builder/sdist.md @@ -40,6 +40,8 @@ When the user has not set any [file selection](../../config/build.md#file-select - Any defined [`readme`](../../config/metadata.md#readme) file - All defined [`license-files`](../../config/metadata.md#license) + In-tree files selected by the [wheel](wheel.md) target are also included, so frontends that build the wheel from the sdist (`python -m build`, `uv build`) ship the same contents as `hatch build -t wheel`. Paths listed in the sdist [`exclude`](../../config/build.md#patterns) option are still omitted. Generated or out-of-tree wheel sources are not copied; those still need a [build hook](../build-hook/reference.md) or an explicit sdist [`artifacts`](../../config/build.md#artifacts) / [`force-include`](../../config/build.md#forced-inclusion) entry. + ## Reproducibility [Reproducible builds](../../config/build.md#reproducible-builds) are supported. diff --git a/docs/plugins/builder/wheel.md b/docs/plugins/builder/wheel.md index 946df8f89..8b583fe0e 100644 --- a/docs/plugins/builder/wheel.md +++ b/docs/plugins/builder/wheel.md @@ -26,7 +26,7 @@ The builder plugin name is `wheel`. | `sbom-files` | | A list of paths to [Software Bill of Materials](https://peps.python.org/pep-0770/) files that will be included in the `.dist-info/sboms/` directory of the wheel | !!! note - Many build frontends will build the wheel from a source distribution. This is the recommended approach, but it means you need to ensure the SBOM files passed to `sbom-files` are also [included in the source distribution](https://hatch.pypa.io/latest/config/build/#file-selection). + Many build frontends will build the wheel from a source distribution. Hatchling includes in-tree wheel sources (including `sbom-files` that already exist in the project) in the sdist automatically. Generated or out-of-tree paths still need to be produced by a [build hook](../build-hook/reference.md) or copied into the project before the sdist is built. ## Versions diff --git a/tests/backend/builders/test_sdist.py b/tests/backend/builders/test_sdist.py index 5941b217c..e06a33524 100644 --- a/tests/backend/builders/test_sdist.py +++ b/tests/backend/builders/test_sdist.py @@ -1,11 +1,13 @@ import os import tarfile +import zipfile import pytest from hatchling.builders.plugin.interface import BuilderInterface from hatchling.builders.sdist import SdistBuilder from hatchling.builders.utils import get_reproducible_timestamp +from hatchling.builders.wheel import WheelBuilder from hatchling.metadata.spec import DEFAULT_METADATA_VERSION, get_core_metadata_constructors from hatchling.utils.constants import DEFAULT_BUILD_SCRIPT, DEFAULT_CONFIG_FILE @@ -1542,3 +1544,133 @@ def test_file_permissions_normalized(self, hatch, temp_dir, config_file): # we assert that at minimum 644 is set, based on the platform (e.g.) # windows it may be higher assert file_stat.st_mode & 0o644 + + def test_wheel_include_files_added_to_sdist(self, helpers, temp_dir): + """Wheel-only includes must ship in the sdist so PEP 517 frontends keep them. + + https://github.com/pypa/hatch/issues/1874 + """ + project_path = temp_dir / "foo" + project_path.mkdir() + (project_path / "sdist.py").touch() + (project_path / "sdist_wheel.py").touch() + (project_path / "wheel.py").touch() + + config = { + "project": {"name": "foo", "version": "0.0.1"}, + "tool": { + "hatch": { + "build": { + "targets": { + "sdist": {"include": ["sdist.py", "sdist_wheel.py"]}, + "wheel": {"include": ["sdist_wheel.py", "wheel.py"]}, + } + } + } + }, + } + builder = SdistBuilder(str(project_path), config=config) + artifacts = list(builder.build(directory=str(project_path / "dist"))) + assert len(artifacts) == 1 + + with tarfile.open(artifacts[0], "r:gz") as archive: + names = archive.getnames() + + prefix = builder.project_id + assert f"{prefix}/sdist.py" in names + assert f"{prefix}/sdist_wheel.py" in names + assert f"{prefix}/wheel.py" in names + + extraction_directory = temp_dir / "_archive" + extraction_directory.mkdir() + with tarfile.open(artifacts[0], "r:gz") as archive: + archive.extractall(str(extraction_directory), **helpers.tarfile_extraction_compat_options()) + + unpacked = extraction_directory / prefix + wheel_builder = WheelBuilder(str(unpacked), config=config) + wheels = list(wheel_builder.build(directory=str(temp_dir / "wheels"))) + with zipfile.ZipFile(wheels[0]) as wheel: + wheel_names = wheel.namelist() + + assert "wheel.py" in wheel_names + assert "sdist_wheel.py" in wheel_names + assert "sdist.py" not in wheel_names + + def test_sdist_exclude_overrides_wheel_include(self, temp_dir): + project_path = temp_dir / "foo" + project_path.mkdir() + (project_path / "kept.py").touch() + (project_path / "tree_only.py").touch() + + config = { + "project": {"name": "foo", "version": "0.0.1"}, + "tool": { + "hatch": { + "build": { + "targets": { + "sdist": {"include": ["kept.py", "tree_only.py"], "exclude": ["tree_only.py"]}, + "wheel": {"include": ["kept.py", "tree_only.py"]}, + } + } + } + }, + } + builder = SdistBuilder(str(project_path), config=config) + artifacts = list(builder.build(directory=str(project_path / "dist"))) + + with tarfile.open(artifacts[0], "r:gz") as archive: + names = archive.getnames() + + prefix = builder.project_id + assert f"{prefix}/kept.py" in names + assert f"{prefix}/tree_only.py" not in names + + def test_wheel_force_include_source_added_to_sdist(self, temp_dir): + project_path = temp_dir / "foo" + project_path.mkdir() + (project_path / "pkg").mkdir() + (project_path / "pkg" / "__init__.py").touch() + (project_path / "data.txt").write_text("payload\n") + + config = { + "project": {"name": "foo", "version": "0.0.1"}, + "tool": { + "hatch": { + "build": { + "targets": { + "sdist": {"only-include": ["pkg"]}, + "wheel": { + "packages": ["pkg"], + "force-include": {"data.txt": "pkg/data.txt"}, + }, + } + } + } + }, + } + builder = SdistBuilder(str(project_path), config=config) + artifacts = list(builder.build(directory=str(project_path / "dist"))) + + with tarfile.open(artifacts[0], "r:gz") as archive: + names = archive.getnames() + + prefix = builder.project_id + assert f"{prefix}/pkg/__init__.py" in names + assert f"{prefix}/data.txt" in names + + def test_missing_wheel_package_does_not_fail_sdist(self, temp_dir): + project_path = temp_dir / "proj" + project_path.mkdir() + (project_path / "notes.txt").write_text("hi\n") + config = { + "project": {"name": "MyApp", "version": "0.0.1"}, + "tool": {"hatch": {"build": {"targets": {"sdist": {"include": ["notes.txt"]}}}}}, + } + builder = SdistBuilder(str(project_path), config=config) + artifacts = list(builder.build(directory=str(project_path / "dist"))) + assert len(artifacts) == 1 + + with tarfile.open(artifacts[0], "r:gz") as archive: + names = archive.getnames() + + assert f"{builder.project_id}/notes.txt" in names