Skip to content
Merged
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
10 changes: 10 additions & 0 deletions backend/src/hatchling/builders/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@ def __init__(
self.__exclude_patterns: list[str] | None = None
self.__artifact_patterns: list[str] | None = None

# This is used when the only file selection is based on forced inclusion or build-time artifacts. This
# instructs to `exclude` every encountered path without doing pattern matching that matches everything.
self.__exclude_all: bool = False

# Modified at build time
self.build_artifact_spec: pathspec.GitIgnoreSpec | None = None
self.build_force_include: dict[str, str] = {}
Expand Down Expand Up @@ -102,6 +106,9 @@ def path_is_included(self, relative_path: str) -> bool:
return self.include_spec.match_file(relative_path)

def path_is_excluded(self, relative_path: str) -> bool:
if self.__exclude_all:
return True

if self.exclude_spec is None:
return False

Expand Down Expand Up @@ -876,6 +883,9 @@ def default_global_exclude(self) -> list[str]: # noqa: PLR6301
patterns.sort()
return patterns

def set_exclude_all(self) -> None:
self.__exclude_all = True

def get_force_include(self) -> dict[str, str]:
force_include = self.force_include.copy()
force_include.update(self.build_force_include)
Expand Down
94 changes: 45 additions & 49 deletions backend/src/hatchling/builders/wheel.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@
import sys
import tempfile
import zipfile
from functools import cached_property
from io import StringIO
from typing import TYPE_CHECKING, Any, Callable, Iterable, Sequence, Tuple, cast
from typing import TYPE_CHECKING, Any, Callable, Iterable, NamedTuple, Sequence, Tuple, cast

from hatchling.__about__ import __version__
from hatchling.builders.config import BuilderConfig
Expand Down Expand Up @@ -36,6 +37,13 @@
TIME_TUPLE = Tuple[int, int, int, int, int, int]


class FileSelectionOptions(NamedTuple):
include: list[str]
exclude: list[str]
packages: list[str]
only_include: list[str]


class RecordFile:
def __init__(self) -> None:
self.__file_obj = StringIO()
Expand Down Expand Up @@ -154,89 +162,77 @@ class WheelBuilderConfig(BuilderConfig):
def __init__(self, *args: Any, **kwargs: Any) -> None:
super().__init__(*args, **kwargs)

self.__include_defined: bool = bool(
self.target_config.get('include', self.build_config.get('include'))
or self.target_config.get('packages', self.build_config.get('packages'))
or self.target_config.get('only-include', self.build_config.get('only-include'))
)
self.__include: list[str] = []
self.__exclude: list[str] = []
self.__packages: list[str] = []
self.__only_include: list[str] = []

self.__core_metadata_constructor: Callable[..., str] | None = None
self.__shared_data: dict[str, str] | None = None
self.__extra_metadata: dict[str, str] | None = None
self.__strict_naming: bool | None = None
self.__macos_max_compat: bool | None = None

def set_default_file_selection(self) -> None:
if self.__include or self.__exclude or self.__packages or self.__only_include:
return
@cached_property
def default_file_selection_options(self) -> FileSelectionOptions:
if include := self.target_config.get('include', self.build_config.get('include', [])):
return FileSelectionOptions(include, [], [], [])

if exclude := self.target_config.get('exclude', self.build_config.get('exclude', [])):
return FileSelectionOptions([], exclude, [], [])

if packages := self.target_config.get('packages', self.build_config.get('packages', [])):
return FileSelectionOptions([], [], packages, [])

if only_include := self.target_config.get('only-include', self.build_config.get('only-include', [])):
return FileSelectionOptions([], [], [], only_include)

for project_name in (
self.builder.normalize_file_name_component(self.builder.metadata.core.raw_name),
self.builder.normalize_file_name_component(self.builder.metadata.core.name),
):
if os.path.isfile(os.path.join(self.root, project_name, '__init__.py')):
normalized_project_name = self.get_raw_fs_path_name(self.root, project_name)
self.__packages.append(normalized_project_name)
break
return FileSelectionOptions([], [], [normalized_project_name], [])

if os.path.isfile(os.path.join(self.root, 'src', project_name, '__init__.py')):
normalized_project_name = self.get_raw_fs_path_name(os.path.join(self.root, 'src'), project_name)
self.__packages.append(f'src/{normalized_project_name}')
break
return FileSelectionOptions([], [], [f'src/{normalized_project_name}'], [])

module_file = f'{project_name}.py'
if os.path.isfile(os.path.join(self.root, module_file)):
normalized_project_name = self.get_raw_fs_path_name(self.root, module_file)
self.__only_include.append(module_file)
break
return FileSelectionOptions([], [], [], [module_file])

from glob import glob

possible_namespace_packages = glob(os.path.join(self.root, '*', project_name, '__init__.py'))
if len(possible_namespace_packages) == 1:
relative_path = os.path.relpath(possible_namespace_packages[0], self.root)
namespace = relative_path.split(os.sep)[0]
self.__packages.append(namespace)
break
else:
message = (
'Unable to determine which files to ship inside the wheel using the following heuristics: '
'https://hatch.pypa.io/latest/plugins/builder/wheel/#default-file-selection\n\nAt least one '
'file selection option must be defined in the `tool.hatch.build.targets.wheel` table, see: '
'https://hatch.pypa.io/latest/config/build/\n\nAs an example, if you intend to ship a '
'directory named `foo` that resides within a `src` directory located at the root of your '
'project, you can define the following:\n\n[tool.hatch.build.targets.wheel]\n'
'packages = ["src/foo"]'
)
raise ValueError(message)
return FileSelectionOptions([], [], [namespace], [])

if self.build_artifact_spec is not None or self.get_force_include():
self.set_exclude_all()
return FileSelectionOptions([], [], [], [])

message = (
'Unable to determine which files to ship inside the wheel using the following heuristics: '
'https://hatch.pypa.io/latest/plugins/builder/wheel/#default-file-selection\n\nAt least one '
'file selection option must be defined in the `tool.hatch.build.targets.wheel` table, see: '
'https://hatch.pypa.io/latest/config/build/\n\nAs an example, if you intend to ship a '
'directory named `foo` that resides within a `src` directory located at the root of your '
'project, you can define the following:\n\n[tool.hatch.build.targets.wheel]\n'
'packages = ["src/foo"]'
)
raise ValueError(message)

def default_include(self) -> list[str]:
if not self.__include_defined:
self.set_default_file_selection()

return self.__include
return self.default_file_selection_options.include

def default_exclude(self) -> list[str]:
if not self.__include_defined:
self.set_default_file_selection()

return self.__exclude
return self.default_file_selection_options.exclude

def default_packages(self) -> list[str]:
if not self.__include_defined:
self.set_default_file_selection()

return self.__packages
return self.default_file_selection_options.packages

def default_only_include(self) -> list[str]:
if not self.__include_defined:
self.set_default_file_selection()

return self.__only_include
return self.default_file_selection_options.only_include

@property
def core_metadata_constructor(self) -> Callable[..., str]:
Expand Down
1 change: 1 addition & 0 deletions docs/history/hatchling.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
***Fixed:***

- Add better error message when the `wheel` build target cannot determine what to ship
- Consider forced inclusion patterns and build-time artifacts as file selection as some build hooks generate the entire wheel contents without user configuration

## [1.19.0](https://github.com/pypa/hatch/releases/tag/hatchling-v1.19.0) - 2023-12-11 ## {: #hatchling-v1.19.0 }

Expand Down
36 changes: 35 additions & 1 deletion tests/backend/builders/test_wheel.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ def test_namespace(self, temp_dir):
assert builder.config.default_packages() == ['ns']
assert builder.config.default_only_include() == []

def test_default(self, temp_dir):
def test_default_error(self, temp_dir):
config = {'project': {'name': 'my-app', 'version': '0.0.1'}}
builder = WheelBuilder(str(temp_dir), config=config)

Expand All @@ -131,6 +131,40 @@ def test_default(self, temp_dir):
):
_ = method()

def test_force_include_option_considered_selection(self, temp_dir):
config = {
'project': {'name': 'my-app', 'version': '0.0.1'},
'tool': {'hatch': {'build': {'targets': {'wheel': {'force-include': {'foo': 'bar'}}}}}},
}
builder = WheelBuilder(str(temp_dir), config=config)

assert builder.config.default_include() == []
assert builder.config.default_exclude() == []
assert builder.config.default_packages() == []
assert builder.config.default_only_include() == []

def test_force_include_build_data_considered_selection(self, temp_dir):
config = {'project': {'name': 'my-app', 'version': '0.0.1'}}
builder = WheelBuilder(str(temp_dir), config=config)

build_data = {'artifacts': [], 'force_include': {'foo': 'bar'}}
with builder.config.set_build_data(build_data):
assert builder.config.default_include() == []
assert builder.config.default_exclude() == []
assert builder.config.default_packages() == []
assert builder.config.default_only_include() == []

def test_artifacts_build_data_considered_selection(self, temp_dir):
config = {'project': {'name': 'my-app', 'version': '0.0.1'}}
builder = WheelBuilder(str(temp_dir), config=config)

build_data = {'artifacts': ['foo'], 'force_include': {}}
with builder.config.set_build_data(build_data):
assert builder.config.default_include() == []
assert builder.config.default_exclude() == []
assert builder.config.default_packages() == []
assert builder.config.default_only_include() == []

def test_unnormalized_name_with_unnormalized_directory(self, temp_dir):
config = {'project': {'name': 'MyApp', 'version': '0.0.1'}}
builder = WheelBuilder(str(temp_dir), config=config)
Expand Down