Repository: pypa/hatch (Python)

Issue:
"ValueError: At least one file selection option must be defined" error with hatchling-v1.19.0
Did hatchling-v1.19.0 introduce breaking changes?  My project is suddenly failing to build with this error:

```
# pip install -U '.[dev]'
Processing project_dir
  Installing build dependencies ... done
  Getting requirements to build wheel ... done
  Preparing metadata (pyproject.toml) ... error
  error: subprocess-exited-with-error

  × Preparing metadata (pyproject.toml) did not run successfully.
  │ exit code: 1
  ╰─> [30 lines of output]
      Traceback (most recent call last):
        File "/usr/local/lib/python3.11/dist-packages/pip/_vendor/pyproject_hooks/_in_process/_in_process.py", line 353, in <module>
          main()
        File "/usr/local/lib/python3.11/dist-packages/pip/_vendor/pyproject_hooks/_in_process/_in_process.py", line 335, in main
          json_out['return_val'] = hook(**hook_input['kwargs'])
                                   ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
        File "/usr/local/lib/python3.11/dist-packages/pip/_vendor/pyproject_hooks/_in_process/_in_process.py", line 152, in prepare_metadata_for_build_wheel
          whl_basename = backend.build_wheel(metadata_directory, config_settings)
                         ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
        File "/tmp/pip-build-env-n8gm4qgc/overlay/local/lib/python3.11/dist-packages/hatchling/build.py", line 58, in build_wheel
          return os.path.basename(next(builder.build(directory=wheel_directory, versions=['standard'])))
                                  ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
        File "/tmp/pip-build-env-n8gm4qgc/overlay/local/lib/python3.11/dist-packages/hatchling/builders/plugin/interface.py", line 155, in build
          artifact = version_api[version](directory, **build_data)
                     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
        File "/tmp/pip-build-env-n8gm4qgc/overlay/local/lib/python3.11/dist-packages/hatchling/builders/wheel.py", line 405, in build_standard
          for included_file in self.recurse_included_files():
        File "/tmp/pip-build-env-n8gm4qgc/overlay/local/lib/python3.11/dist-packages/hatchling/builders/plugin/interface.py", line 176, in recurse_included_files
          yield from self.recurse_selected_project_files()
        File "/tmp/pip-build-env-n8gm4qgc/overlay/local/lib/python3.11/dist-packages/hatchling/builders/plugin/interface.py", line 180, in recurse_selected_project_files
          if self.config.only_include:
             ^^^^^^^^^^^^^^^^^^^^^^^^
        File "/tmp/pip-build-env-n8gm4qgc/overlay/local/lib/python3.11/dist-packages/hatchling/builders/config.py", line 774, in only_include
          only_include = only_include_config.get('only-include', self.default_only_include()) or self.packages
                                                                 ^^^^^^^^^^^^^^^^^^^^^^^^^^^
        File "/tmp/pip-build-env-n8gm4qgc/overlay/local/lib/python3.11/dist-packages/hatchling/builders/wheel.py", line 231, in default_only_include
          self.set_default_file_selection()
        File "/tmp/pip-build-env-n8gm4qgc/overlay/local/lib/python3.11/dist-packages/hatchling/builders/wheel.py", line 209, in set_default_file_selection
          raise ValueError(message)
      ValueError: At least one file selection option must be defined, see: https://hatch.pypa.io/latest/config/build/
      [end of output]

  note: This error originates from a subprocess, and is likely not a problem with pip.
error: metadata-generation-failed

× Encountered error while generating package metadata.
╰─> See above for output.

note: This is an issue with the package mentioned above, not pip.
hint: See above for details.
```

I tried the following workaround to revert the version to hatchling-v1.18.0, which fixed the error:

```
[build-system]
requires = ["hatchling<=1.18.0"]  
build-backend = "hatchling.build"
```




Discussion:
Yes, this is mentioned in the changelog: https://github.com/pypa/hatch/releases/tag/hatchling-v1.19.0.

> An error will now be raised for the wheel target if no file selection options are defined

File selection options are documented here: https://hatch.pypa.io/latest/config/build/#file-selection

I'm wondering what exact rules apply, though. urllib3 does not seem to be affected as it defines `include` in `[tool.hatch.build.targets.sdist]` while rally-tracks [is affected](https://github.com/elastic/rally-tracks/actions/runs/7166353468/job/19510181175?pr=538) while [defining](https://github.com/elastic/rally-tracks/blob/b16122421f4fbd76f0d28de238084cf8793eed91/pyproject.toml) `exclude` in `[tool.hatch.build.targets.sdist]`, which looks like a file selection option. Is `include` needed in that case?
This is specifically for the wheel target. Previously if projects were building wheels successfully then you were hitting the fall back and basically including every Python file found that doesn't look like a test file. That I think is undesirable which is why there is now an error.

I will keep this open and improve that message to indicate that issue is specific to wheel config.
@pquentin https://hatch.pypa.io/latest/plugins/builder/wheel/#default-file-selection
@ofek Users are hitting this when using my `hatch-odoo` plugin where no file selection is provided in pyproject.toml, but the plugin does the selection with force_include (with a mapping from source directory to target directory in the wheel) like this:

https://github.com/acsone/hatch-odoo/blob/d1fae86c0522ffdc368db979ac2eab294e108a9d/src/hatch_odoo/build_hook.py#L27-L36

Should I adapt the plugin?

Does the source directory exist?
> Does the source directory exist?

Yes all the source directories I add in the `force_include` dict exist. 
Do you have an example traceback?
Sure here it is:

```
      Traceback (most recent call last):
        File "/home/thomas/.virtualenvs/migration-odoo-silog/lib/python3.11/site-packages/pip/_vendor/pyproject_hooks/_in_process/_in_process.py", line 353, in <module>
          main()
        File "/home/thomas/.virtualenvs/migration-odoo-silog/lib/python3.11/site-packages/pip/_vendor/pyproject_hooks/_in_process/_in_process.py", line 335, in main
          json_out['return_val'] = hook(**hook_input['kwargs'])
                                   ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
        File "/home/thomas/.virtualenvs/migration-odoo-silog/lib/python3.11/site-packages/pip/_vendor/pyproject_hooks/_in_process/_in_process.py", line 152, in prepare_metadata_for_build_wheel
          whl_basename = backend.build_wheel(metadata_directory, config_settings)
                         ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
        File "/tmp/pip-build-env-s7eqwz1i/overlay/lib/python3.11/site-packages/hatchling/build.py", line 58, in build_wheel
          return os.path.basename(next(builder.build(directory=wheel_directory, versions=['standard'])))
                                  ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
        File "/tmp/pip-build-env-s7eqwz1i/overlay/lib/python3.11/site-packages/hatchling/builders/plugin/interface.py", line 154, in build
          with self.config.set_build_data(build_data):
        File "/usr/lib/python3.11/contextlib.py", line 137, in __enter__
          return next(self.gen)
                 ^^^^^^^^^^^^^^
        File "/tmp/pip-build-env-s7eqwz1i/overlay/lib/python3.11/site-packages/hatchling/builders/config.py", line 900, in set_build_data
          self.build_reserved_paths.add(self.get_distribution_path(os.path.relpath(source, self.root)))
                                        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
        File "/tmp/pip-build-env-s7eqwz1i/overlay/lib/python3.11/site-packages/hatchling/builders/config.py", line 803, in get_distribution_path
          for source, replacement in self.sources.items():
                                     ^^^^^^^^^^^^
        File "/tmp/pip-build-env-s7eqwz1i/overlay/lib/python3.11/site-packages/hatchling/builders/config.py", line 693, in sources
          for relative_path in self.packages:
                               ^^^^^^^^^^^^^
        File "/tmp/pip-build-env-s7eqwz1i/overlay/lib/python3.11/site-packages/hatchling/builders/config.py", line 712, in packages
          packages = package_config.get('packages', self.default_packages())
                                                    ^^^^^^^^^^^^^^^^^^^^^^^
        File "/tmp/pip-build-env-s7eqwz1i/overlay/lib/python3.11/site-packages/hatchling/builders/wheel.py", line 225, in default_packages
          self.set_default_file_selection()
        File "/tmp/pip-build-env-s7eqwz1i/overlay/lib/python3.11/site-packages/hatchling/builders/wheel.py", line 209, in set_default_file_selection
          raise ValueError(message)
      ValueError: At least one file selection option must be defined, see: https://hatch.pypa.io/latest/config/build/
```

Unless I'm misunderstanding, it looks like that is a valid error and has nothing to do with your plugin. What am I missing?

FYI for everyone here, this behavior changed due to multiple requests one of which was this: https://github.com/pypa/hatch/issues/992
> Unless I'm misunderstanding, it looks like that is a valid error and has nothing to do with your plugin. What am I missing?

The error appeared with hatchling 1.19. My point is that I have no file selection option that I can provide in pyproject.toml since all the file selection is done by the hatch-odoo plugin. 
I see. So the fix (for your particular case) would be to prevent this check if a plugin does provide forced inclusion patterns, is that right? I plan to fix this and release today so I want to be sure.
> prevent this check if a plugin does provide forced inclusion patterns

@ofek, yes that sounds good. 

Note however that I tried to add this to my config as a workaround and it still fails with the same error, there might be something else at play:

```
[tool.hatch.build.targets.wheel.force-include]
"pyproject.toml" = "./pyproject.toml"
```
We're also hitting this with [Sphinx Lint](https://github.com/sphinx-contrib/sphinx-lint):

```pytb
❯ git clone https://github.com/sphinx-contrib/sphinx-lint && cd sphinx-lint
...
❯ pip install -e .
Obtaining file:///private/tmp/sphinx-lint
  Installing build dependencies ... done
  Checking if build backend supports build_editable ... done
  Getting requirements to build editable ... done
  Preparing editable metadata (pyproject.toml) ... error
  error: subprocess-exited-with-error

  × Preparing editable metadata (pyproject.toml) did not run successfully.
  │ exit code: 1
  ╰─> [39 lines of output]
      Traceback (most recent call last):
        File "/Library/Frameworks/Python.framework/Versions/3.12/lib/python3.12/site-packages/pip/_vendor/pyproject_hooks/_in_process/_in_process.py", line 167, in prepare_metadata_for_build_editable
          hook = backend.prepare_metadata_for_build_editable
                 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
      AttributeError: module 'hatchling.build' has no attribute 'prepare_metadata_for_build_editable'

      During handling of the above exception, another exception occurred:

      Traceback (most recent call last):
        File "/Library/Frameworks/Python.framework/Versions/3.12/lib/python3.12/site-packages/pip/_vendor/pyproject_hooks/_in_process/_in_process.py", line 353, in <module>
          main()
        File "/Library/Frameworks/Python.framework/Versions/3.12/lib/python3.12/site-packages/pip/_vendor/pyproject_hooks/_in_process/_in_process.py", line 335, in main
          json_out['return_val'] = hook(**hook_input['kwargs'])
                                   ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
        File "/Library/Frameworks/Python.framework/Versions/3.12/lib/python3.12/site-packages/pip/_vendor/pyproject_hooks/_in_process/_in_process.py", line 176, in prepare_metadata_for_build_editable
          whl_basename = build_hook(metadata_directory, config_settings)
                         ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
        File "/private/var/folders/p6/lf2s1s5d4kb335g2n1td8z8c0000gn/T/pip-build-env-z5yx9mlz/overlay/lib/python3.12/site-packages/hatchling/build.py", line 82, in build_editable
          return os.path.basename(next(builder.build(directory=wheel_directory, versions=['editable'])))
                                  ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
        File "/private/var/folders/p6/lf2s1s5d4kb335g2n1td8z8c0000gn/T/pip-build-env-z5yx9mlz/overlay/lib/python3.12/site-packages/hatchling/builders/plugin/interface.py", line 155, in build
          artifact = version_api[version](directory, **build_data)
                     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
        File "/private/var/folders/p6/lf2s1s5d4kb335g2n1td8z8c0000gn/T/pip-build-env-z5yx9mlz/overlay/lib/python3.12/site-packages/hatchling/builders/wheel.py", line 423, in build_editable
          return self.build_editable_detection(directory, **build_data)
                 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
        File "/private/var/folders/p6/lf2s1s5d4kb335g2n1td8z8c0000gn/T/pip-build-env-z5yx9mlz/overlay/lib/python3.12/site-packages/hatchling/builders/wheel.py", line 434, in build_editable_detection
          for included_file in self.recurse_selected_project_files():
        File "/private/var/folders/p6/lf2s1s5d4kb335g2n1td8z8c0000gn/T/pip-build-env-z5yx9mlz/overlay/lib/python3.12/site-packages/hatchling/builders/plugin/interface.py", line 180, in recurse_selected_project_files
          if self.config.only_include:
             ^^^^^^^^^^^^^^^^^^^^^^^^
        File "/private/var/folders/p6/lf2s1s5d4kb335g2n1td8z8c0000gn/T/pip-build-env-z5yx9mlz/overlay/lib/python3.12/site-packages/hatchling/builders/config.py", line 774, in only_include
          only_include = only_include_config.get('only-include', self.default_only_include()) or self.packages
                                                                 ^^^^^^^^^^^^^^^^^^^^^^^^^^^
        File "/private/var/folders/p6/lf2s1s5d4kb335g2n1td8z8c0000gn/T/pip-build-env-z5yx9mlz/overlay/lib/python3.12/site-packages/hatchling/builders/wheel.py", line 231, in default_only_include
          self.set_default_file_selection()
        File "/private/var/folders/p6/lf2s1s5d4kb335g2n1td8z8c0000gn/T/pip-build-env-z5yx9mlz/overlay/lib/python3.12/site-packages/hatchling/builders/wheel.py", line 209, in set_default_file_selection
          raise ValueError(message)
      ValueError: At least one file selection option must be defined, see: https://hatch.pypa.io/latest/config/build/
      [end of output]

  note: This error originates from a subprocess, and is likely not a problem with pip.
error: metadata-generation-failed

× Encountered error while generating package metadata.
╰─> See above for output.

note: This is an issue with the package mentioned above, not pip.
hint: See above for details.
```

Similarly for `pip install .`, and so it also fails to install as a pre-commit hook (which uses `pip install .` from a Git clone).

It's fine with Hatchling 1.18.

Sphinx Lint also uses hatch-vcs: https://github.com/sphinx-contrib/sphinx-lint/blob/main/pyproject.toml
Adding this fixes it:

```toml
[tool.hatch.build.targets.wheel]
packages = ["sphinx-lint"]
```

* https://github.com/hugovk/sphinx-lint/commit/62aebe3036f8923df7b8a01bcd933a2a785ed6b7
* https://github.com/hugovk/sphinx-lint/actions/runs/7171624448
I'm so sorry all of these errors are happening but it indicates to me that the users who asked for this were indeed correct and that people have been shipping wheels improperly in a subset of cases. In your case, the project name:

https://github.com/sphinx-contrib/sphinx-lint/blob/74db402328abca2bb1f1a72950b28ca1fee0d53c/pyproject.toml#L9

does not match the directory name (no hyphen) so as a fallback it tries every Python file that does not look like a test.
Ah right, so we actually need this without a hyphen to get wheels containing source and not just metadata:

```toml
[tool.hatch.build.targets.wheel]
packages = ["sphinxlint"]
```

Thanks, this now makes it create wheels identical to those with Hatchling 1.18.
> does not match the directory name (no hyphen) so as a fallback it tries every Python file that does not look like a test.

Does the logic at least check for the common trivial case of `distribution-package-name` -> `import_package_name`?
Yes it does, the logic can be found here: https://github.com/pypa/hatch/blob/hatchling-v1.19.0/backend/src/hatchling/builders/wheel.py#L177-L209
Can you describe how to update the `pyproject.toml` to get the default old behavior or otherwise the recommended new default behavior please?
Yes certainly! The old behavior is:

```toml
[tool.hatch.build.targets.wheel]
include = ["*.py"]
exclude = ["test*"]
```

I'm embarrassed that I thought that was a good fallback. I kinda just copied what everyone else did for the fallback but in hindsight it should have always been an error to protect the user.

The new recommendation is simply to define any inclusion options you want. The most common will be `packages`, for example:

```toml
[tool.hatch.build.targets.wheel]
packages = ["src/foo"]
```

Of course, in the majority of cases configuration is unnecessary because the project name would [fuzzy match](https://hatch.pypa.io/latest/plugins/builder/wheel/#default-file-selection) the directory name.
This should really be called out in the documentation under the build config, specifically as a change.

This was a breaking change, and should have been versioned as such.
I do understand that viewpoint and I went back and forth on whether or not to ship a new major version. I decided not to based on two factors:

1. I searched for uses of Hatchling on GitHub and it was <1% that actually pinned or had a version cap so this would not have helped very many people.
2. This is also a bug fix. What you don't see in this issue is the dozens of other people that I had to help and also I've seen when searching for issues that experienced confusion about why their builds aren't behaving as expected or even users complaining to the maintainers that the already-shipped distribution is incorrect.

I do however understand that this sucks, and for that I am genuinely sorry.
Part one of fixing this issue: https://github.com/pypa/hatch/pull/1119

The final second part is an actual bug fix to help with the plugin that was discussed above
https://github.com/pypa/hatch/releases/tag/hatchling-v1.19.1
Real life example of the new error 🙂 https://github.com/python/docs-community/actions/runs/7177951476/job/19545271892?pr=93#step:4:171

![image](https://github.com/pypa/hatch/assets/9677399/bbe0f80f-5581-4f9c-b9c2-aca037ced36a)



Patch:
diff --git a/backend/src/hatchling/builders/config.py b/backend/src/hatchling/builders/config.py
index d8725e03b..c308ba88f 100644
--- a/backend/src/hatchling/builders/config.py
+++ b/backend/src/hatchling/builders/config.py
@@ -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] = {}
@@ -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
 
@@ -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)
diff --git a/backend/src/hatchling/builders/wheel.py b/backend/src/hatchling/builders/wheel.py
index 3e7791746..711d6e81f 100644
--- a/backend/src/hatchling/builders/wheel.py
+++ b/backend/src/hatchling/builders/wheel.py
@@ -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
@@ -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()
@@ -154,25 +162,25 @@ 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),
@@ -180,19 +188,16 @@ def set_default_file_selection(self) -> None:
         ):
             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
 
@@ -200,43 +205,34 @@ def set_default_file_selection(self) -> None:
             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]:
diff --git a/docs/history/hatchling.md b/docs/history/hatchling.md
index 9121860fe..83d5dfac0 100644
--- a/docs/history/hatchling.md
+++ b/docs/history/hatchling.md
@@ -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 }
 


Test patch:
diff --git a/tests/backend/builders/test_wheel.py b/tests/backend/builders/test_wheel.py
index 271ef3f78..ecd6682c0 100644
--- a/tests/backend/builders/test_wheel.py
+++ b/tests/backend/builders/test_wheel.py
@@ -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)
 
@@ -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)