feat: Allow files in wheels to be installed to directories (#3233)
When specifying `data_files` in `py_wheel`, allow just the directory to
be specified (with a trailing slash), in which case it will use the
existing filename. This avoids duplicating (potentially
platform-specific) names. Additionally, targets with multiple files can
be installed as a group to a folder, with the same filename-preserving
behavior. In general I think this is a better starting point, as I
imagine most of the time users would want to preserve the names.
Before, this would result in the file simply not being installed, so
this only changes already-broken behavior.
---------
Co-authored-by: Richard Levasseur <rlevasseur@google.com>
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d908992..e574870 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -87,6 +87,8 @@
{obj}`experimental_index_url` which should speed up consecutive initializations and should no
longer require the network access if the cache is hydrated.
Implements [#2731](https://github.com/bazel-contrib/rules_python/issues/2731).
+* (wheel) Specifying a path ending in `/` as a destination in `data_files`
+ will now install file(s) to a folder, preserving their basename.
{#v1-9-0}
## [1.9.0] - 2026-02-21
diff --git a/examples/wheel/BUILD.bazel b/examples/wheel/BUILD.bazel
index e52e0fc..3cf6e9f 100644
--- a/examples/wheel/BUILD.bazel
+++ b/examples/wheel/BUILD.bazel
@@ -401,6 +401,29 @@
version = "0.0.1",
)
+filegroup(
+ name = "data_files_test_group",
+ # Re-using some files already checked into the repo.
+ srcs = [
+ "README.md",
+ "//examples/wheel:NOTICE",
+ ],
+)
+
+py_wheel(
+ name = "data_files_installed_in_folder",
+ testonly = True, # Set this to verify the generated .dist target doesn't break things
+ # Re-using some files already checked into the repo.
+ data_files = {
+ # Single file
+ "//examples/wheel:NOTICE": "scripts/",
+ # Filegroup
+ ":data_files_test_group": "data/",
+ },
+ distribution = "data_files_installed_in_folder",
+ version = "0.0.1",
+)
+
py_test(
name = "wheel_test",
srcs = ["wheel_test.py"],
@@ -409,6 +432,7 @@
":custom_package_root_multi_prefix",
":custom_package_root_multi_prefix_reverse_order",
":customized",
+ ":data_files_installed_in_folder",
":empty_requires_files",
":extra_requires",
":filename_escaping",
diff --git a/examples/wheel/wheel_test.py b/examples/wheel/wheel_test.py
index 7f19ecd..9ed2b84 100644
--- a/examples/wheel/wheel_test.py
+++ b/examples/wheel/wheel_test.py
@@ -615,6 +615,25 @@
requires,
)
+ def test_data_files_installed_in_folder(self):
+ filename = self._get_path(
+ "data_files_installed_in_folder-0.0.1-py3-none-any.whl"
+ )
+
+ with zipfile.ZipFile(filename) as zf:
+ self.assertAllEntriesHasReproducibleMetadata(zf)
+ self.assertEqual(
+ zf.namelist(),
+ [
+ "data_files_installed_in_folder-0.0.1.dist-info/WHEEL",
+ "data_files_installed_in_folder-0.0.1.dist-info/METADATA",
+ "data_files_installed_in_folder-0.0.1.data/data/NOTICE",
+ "data_files_installed_in_folder-0.0.1.data/data/README.md",
+ "data_files_installed_in_folder-0.0.1.data/scripts/NOTICE",
+ "data_files_installed_in_folder-0.0.1.dist-info/RECORD",
+ ],
+ )
+
if __name__ == "__main__":
unittest.main()
diff --git a/python/private/py_wheel.bzl b/python/private/py_wheel.bzl
index 1d98d21..e6a9925 100644
--- a/python/private/py_wheel.bzl
+++ b/python/private/py_wheel.bzl
@@ -182,8 +182,35 @@
doc = "A list of strings describing the categories for the package. For valid classifiers see https://pypi.org/classifiers",
),
"data_files": attr.label_keyed_string_dict(
- doc = ("Any file that is not normally installed inside site-packages goes into the .data directory, named " +
- "as the .dist-info directory but with the .data/ extension. Allowed paths: {prefixes}".format(prefixes = ALLOWED_DATA_FILE_PREFIX)),
+ doc = ("""
+Mapping of data files to go into the wheel.
+
+The keys are targets of files to include, and the values are the `.data`-relative
+path to use.
+
+Any file that is not normally installed inside site-packages goes into the .data
+directory, named as the .dist-info directory but with the .data/ extension. If
+the destination of a file or group of files ends in a `/`, the destination is a
+folder and files are placed with their existing basenames under that folder.
+
+For example:
+
+```
+":file1.txt": "data/file1.txt", # Destination: <wheelname>.data/data/file1.txt
+":file1.txt": "data/", # Destination: <wheelname>.data/data/file1.txt
+":file1.txt": "data/special.txt", # Destination: <wheelname>.data/data/special.txt
+
+filegroup(name = "files", srcs = [":file1.txt", ":file2.txt"])
+":files": "data/", # Destinations: <wheelname>.data/data/file1.txt, <wheelname>.data/data/file2.txt
+```
+
+Allowed paths: {prefixes}
+
+:::{{versionchanged}} VERSION_NEXT_FEATURE
+Values can end in slash (`/`) to indicate that all files of the target should
+be moved under that directory.
+:::
+""".format(prefixes = ALLOWED_DATA_FILE_PREFIX)),
allow_files = True,
),
"description_content_type": attr.string(
@@ -506,9 +533,9 @@
for target, filename in ctx.attr.data_files.items():
target_files = target[DefaultInfo].files.to_list()
- if len(target_files) != 1:
+ if len(target_files) != 1 and not filename.endswith("/"):
fail(
- "Multi-file target listed in data_files %s",
+ "Multi-file target listed in data_files %s, this is only supported when specifying a folder path (i.e. a path ending in '/')",
filename,
)
@@ -520,11 +547,15 @@
filename,
),
)
- other_inputs.extend(target_files)
- args.add(
- "--data_files",
- filename + ";" + target_files[0].path,
- )
+
+ for file in target_files:
+ final_filename = filename + file.basename if filename.endswith("/") else filename
+
+ other_inputs.extend(target_files)
+ args.add(
+ "--data_files",
+ final_filename + ";" + file.path,
+ )
ctx.actions.run(
mnemonic = "PyWheel",