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",