Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions backend/src/hatchling/builders/sdist.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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

Expand Down Expand Up @@ -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]:
Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions docs/config/build.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
4 changes: 4 additions & 0 deletions docs/history/hatchling.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:***
Expand Down
2 changes: 2 additions & 0 deletions docs/plugins/builder/sdist.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion docs/plugins/builder/wheel.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
132 changes: 132 additions & 0 deletions tests/backend/builders/test_sdist.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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