diff --git a/.bazelrc.deleted_packages b/.bazelrc.deleted_packages index 2d8a8075fa..e767e0ae56 100644 --- a/.bazelrc.deleted_packages +++ b/.bazelrc.deleted_packages @@ -17,7 +17,6 @@ common --deleted_packages=examples/multi_python_versions/requirements common --deleted_packages=examples/multi_python_versions/tests common --deleted_packages=examples/pip_parse common --deleted_packages=examples/pip_parse_vendored -common --deleted_packages=examples/pip_repository_annotations common --deleted_packages=gazelle common --deleted_packages=gazelle/examples/bzlmod_build_file_generation common --deleted_packages=gazelle/examples/bzlmod_build_file_generation/other_module/other_module/pkg @@ -28,8 +27,8 @@ common --deleted_packages=gazelle/manifest/hasher common --deleted_packages=gazelle/manifest/test common --deleted_packages=gazelle/modules_mapping common --deleted_packages=gazelle/python -common --deleted_packages=gazelle/pythonconfig common --deleted_packages=gazelle/python/private +common --deleted_packages=gazelle/pythonconfig common --deleted_packages=tests/integration/compile_pip_requirements common --deleted_packages=tests/integration/compile_pip_requirements_test_from_external_repo common --deleted_packages=tests/integration/custom_commands diff --git a/CHANGELOG.md b/CHANGELOG.md index 39f0223e4a..e9bd2fb189 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -124,6 +124,8 @@ Other changes: 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. +* (wheel) Add support for `add_path_prefix` argument in `py_wheel` which can be + used to prepend a prefix to the files in the wheel. {#v1-9-0} ## [1.9.0] - 2026-02-21 diff --git a/examples/wheel/BUILD.bazel b/examples/wheel/BUILD.bazel index 3cf6e9f350..01dc4fab41 100644 --- a/examples/wheel/BUILD.bazel +++ b/examples/wheel/BUILD.bazel @@ -230,6 +230,25 @@ py_wheel( ], ) +# An example of how to change the wheel package root directory using 'add_path_prefix'. +py_wheel( + name = "custom_prefix_package_root", + add_path_prefix = "custom_prefix", + # Package data. We're building "examples_custom_prefix_package_root-0.0.1-py3-none-any.whl" + distribution = "examples_custom_prefix_package_root", + entry_points = { + "console_scripts": ["main = foo.bar:baz"], + }, + python_tag = "py3", + strip_path_prefixes = [ + "examples", + ], + version = "0.0.1", + deps = [ + ":example_pkg", + ], +) + py_wheel( name = "python_requires_in_a_package", distribution = "example_python_requires_in_a_package", diff --git a/python/private/py_wheel.bzl b/python/private/py_wheel.bzl index e6a9925a15..274710bdb4 100644 --- a/python/private/py_wheel.bzl +++ b/python/private/py_wheel.bzl @@ -170,6 +170,19 @@ entry_points, e.g. `{'console_scripts': ['main = examples.wheel.main:main']}`. } _other_attrs = { + "add_path_prefix": attr.string( + default = "", + doc = """\ +Path prefix to prepend to files added to the generated package. +This prefix will be prepended **after** the paths are first stripped of the prefixes +specified in `strip_path_prefixes`. + +For example: ++ `"foo/" will prepend to `"bar/baz/file.py"` as `"foo/bar/baz/file.py"` ++ `"foo_" will prepend to `"bar/baz/file.py"` as `"foo_bar/baz/file.py"` ++ `stripping ["bar/"] and adding "foo/" will change `"bar/baz/file.py"` to `"foo/baz/file.py"` +""", + ), "author": attr.string( doc = "A string specifying the author of the package.", default = "", @@ -389,6 +402,7 @@ def _py_wheel_impl(ctx): args.add("--out", outfile) args.add("--name_file", name_file) args.add_all(ctx.attr.strip_path_prefixes, format_each = "--strip_path_prefix=%s") + args.add("--path_prefix", ctx.attr.add_path_prefix) # Pass workspace status files if stamping is enabled if is_stamping_enabled(ctx.attr): diff --git a/tests/tools/wheelmaker_test.py b/tests/tools/wheelmaker_test.py index 85094af9b8..945a109d63 100644 --- a/tests/tools/wheelmaker_test.py +++ b/tests/tools/wheelmaker_test.py @@ -34,39 +34,69 @@ def test_quote_all_false_leaves_simple_filenames_unquoted(self) -> None: def test_quote_all_quotes_filenames_with_commas(self) -> None: """Filenames with commas are always quoted, regardless of quote_all_filenames.""" whl = self._make_whl_file(quote_all=True) - self.assertEqual(whl._quote_filename("foo,bar/baz.py"), '"foo,bar/baz.py"') + self.assertEqual( + whl._quote_filename("foo,bar/baz.py"), '"foo,bar/baz.py"' + ) whl = self._make_whl_file(quote_all=False) - self.assertEqual(whl._quote_filename("foo,bar/baz.py"), '"foo,bar/baz.py"') + self.assertEqual( + whl._quote_filename("foo,bar/baz.py"), '"foo,bar/baz.py"' + ) class ArcNameFromTest(unittest.TestCase): def test_arcname_from(self) -> None: - # (name, distribution_prefix, strip_path_prefixes, want) tuples + # (name, distribution_prefix, strip_path_prefixes, add_path_prefix, want) tuples checks = [ - ("a/b/c/file.py", "", [], "a/b/c/file.py"), - ("a/b/c/file.py", "", ["a"], "/b/c/file.py"), - ("a/b/c/file.py", "", ["a/b/"], "c/file.py"), + ("a/b/c/file.py", "", [], "", "a/b/c/file.py"), + ("a/b/c/file.py", "", ["a"], "", "/b/c/file.py"), + ("a/b/c/file.py", "", ["a/b/"], "", "c/file.py"), # only first found is used and it's not cumulative. - ("a/b/c/file.py", "", ["a/", "b/"], "b/c/file.py"), + ("a/b/c/file.py", "", ["a/", "b/"], "", "b/c/file.py"), # Examples from docs - ("foo/bar/baz/file.py", "", ["foo", "foo/bar/baz"], "/bar/baz/file.py"), - ("foo/bar/baz/file.py", "", ["foo/bar/baz", "foo"], "/file.py"), - ("foo/file2.py", "", ["foo/bar/baz", "foo"], "/file2.py"), + ( + "foo/bar/baz/file.py", + "", + ["foo", "foo/bar/baz"], + "", + "/bar/baz/file.py", + ), + ("foo/bar/baz/file.py", "", ["foo/bar/baz", "foo"], "", "/file.py"), + ("foo/file2.py", "", ["foo/bar/baz", "foo"], "", "/file2.py"), # Files under the distribution prefix (eg mylib-1.0.0-dist-info) # are unmodified - ("mylib-0.0.1-dist-info/WHEEL", "mylib", [], "mylib-0.0.1-dist-info/WHEEL"), - ("mylib/a/b/c/WHEEL", "mylib", ["mylib"], "mylib/a/b/c/WHEEL"), + ( + "mylib-0.0.1-dist-info/WHEEL", + "mylib", + [], + "", + "mylib-0.0.1-dist-info/WHEEL", + ), + ("mylib/a/b/c/WHEEL", "mylib", ["mylib"], "", "mylib/a/b/c/WHEEL"), + # Check that prefixes are added + ("a/b/c/file.py", "", [], "namespace/", "namespace/a/b/c/file.py"), + ("a/b/c/file.py", "", ["a"], "namespace", "namespace/b/c/file.py"), + ( + "a/b/c/file.py", + "", + ["a/b/"], + "namespace_", + "namespace_c/file.py", + ), ] - for name, prefix, strip, want in checks: + for name, prefix, strip, add, want in checks: with self.subTest( name=name, distribution_prefix=prefix, strip_path_prefixes=strip, + add_path_prefix=add, want=want, ): got = wheelmaker.arcname_from( - name=name, distribution_prefix=prefix, strip_path_prefixes=strip + name=name, + distribution_prefix=prefix, + strip_path_prefixes=strip, + add_path_prefix=add, ) self.assertEqual(got, want) @@ -77,7 +107,9 @@ def test_requirement(self): self.assertEqual(result, "Requires-Dist: requests>=2.0") def test_requirement_and_extra(self): - result = wheelmaker.get_new_requirement_line("requests>=2.0", "extra=='dev'") + result = wheelmaker.get_new_requirement_line( + "requests>=2.0", "extra=='dev'" + ) self.assertEqual(result, "Requires-Dist: requests>=2.0; extra=='dev'") def test_requirement_with_url(self): diff --git a/tools/wheelmaker.py b/tools/wheelmaker.py index 7124ae7c9d..ada525e9bf 100644 --- a/tools/wheelmaker.py +++ b/tools/wheelmaker.py @@ -94,13 +94,18 @@ def normalize_pep440(version): substituted = re.sub(r"\{\w+\}", "0", version) delimiter = "." if "+" in substituted else "+" try: - return str(packaging.version.Version(f"{substituted}{delimiter}{sanitized}")) + return str( + packaging.version.Version(f"{substituted}{delimiter}{sanitized}") + ) except packaging.version.InvalidVersion: return str(packaging.version.Version(f"0+{sanitized}")) def arcname_from( - name: str, distribution_prefix: str, strip_path_prefixes: Sequence[str] = () + name: str, + distribution_prefix: str, + strip_path_prefixes: Sequence[str] = (), + add_path_prefix: str = "", ) -> str: """Return the within-archive name for a given file path name. @@ -110,17 +115,20 @@ def arcname_from( name: The file path eg 'mylib/a/b/c/file.py' distribution_prefix: The strip_path_prefixes: Remove these prefixes from names. + add_path_prefix: Add prefix after stripping the path from names. """ # Always use unix path separators. normalized_arcname = name.replace(os.path.sep, "/") # Don't manipulate names filenames in the .distinfo or .data directories. - if distribution_prefix and normalized_arcname.startswith(distribution_prefix): + if distribution_prefix and normalized_arcname.startswith( + distribution_prefix + ): return normalized_arcname for prefix in strip_path_prefixes: if normalized_arcname.startswith(prefix): - return normalized_arcname[len(prefix) :] + return add_path_prefix + normalized_arcname[len(prefix) :] - return normalized_arcname + return add_path_prefix + normalized_arcname class _WhlFile(zipfile.ZipFile): @@ -131,6 +139,7 @@ def __init__( mode, distribution_prefix: str, strip_path_prefixes=None, + add_path_prefix=None, compression=zipfile.ZIP_DEFLATED, quote_all_filenames: bool = False, **kwargs, @@ -138,6 +147,7 @@ def __init__( self._distribution_prefix = distribution_prefix self._strip_path_prefixes = strip_path_prefixes or [] + self._add_path_prefix = add_path_prefix or "" # Entries for the RECORD file as (filename, digest, size) tuples. self._record: list[tuple[str, str, str]] = [] # Whether to quote filenames in the RECORD file (for compatibility with @@ -168,6 +178,7 @@ def add_file(self, package_filename, real_filename): package_filename, distribution_prefix=self._distribution_prefix, strip_path_prefixes=self._strip_path_prefixes, + add_path_prefix=self._add_path_prefix, ) zinfo = self._zipinfo(arcname) @@ -194,7 +205,9 @@ def add_string(self, filename, contents): self.writestr(zinfo, contents) hash = hashlib.sha256() hash.update(contents) - self._add_to_record(filename, self._serialize_digest(hash), len(contents)) + self._add_to_record( + filename, self._serialize_digest(hash), len(contents) + ) def _serialize_digest(self, hash) -> str: # https://www.python.org/dev/peps/pep-0376/#record @@ -231,7 +244,9 @@ def _quote_filename(self, filename: str) -> str: filename = filename.lstrip("/") # Some RECORDs like torch have *all* filenames quoted and we must minimize diff. # Otherwise, we quote only when necessary (e.g. for filenames with commas). - quoting = csv.QUOTE_ALL if self.quote_all_filenames else csv.QUOTE_MINIMAL + quoting = ( + csv.QUOTE_ALL if self.quote_all_filenames else csv.QUOTE_MINIMAL + ) with io.StringIO() as buf: csv.writer(buf, quoting=quoting).writerow([filename]) return buf.getvalue().strip() @@ -261,6 +276,7 @@ def __init__( compress, outfile=None, strip_path_prefixes=None, + add_path_prefix=None, ): self._name = name self._version = normalize_pep440(version) @@ -270,9 +286,10 @@ def __init__( self._platform = platform self._outfile = outfile self._strip_path_prefixes = strip_path_prefixes + self._add_path_prefix = add_path_prefix self._compress = compress - self._wheelname_fragment_distribution_name = escape_filename_distribution_name( - self._name + self._wheelname_fragment_distribution_name = ( + escape_filename_distribution_name(self._name) ) self._distribution_prefix = ( @@ -287,7 +304,10 @@ def __enter__(self): mode="w", distribution_prefix=self._distribution_prefix, strip_path_prefixes=self._strip_path_prefixes, - compression=zipfile.ZIP_DEFLATED if self._compress else zipfile.ZIP_STORED, + add_path_prefix=self._add_path_prefix, + compression=( + zipfile.ZIP_DEFLATED if self._compress else zipfile.ZIP_STORED + ), ) return self @@ -330,7 +350,9 @@ def add_wheelfile(self): Wheel-Version: 1.0 Generator: bazel-wheelmaker 1.0 Root-Is-Purelib: {} -""".format("true" if self._platform == "any" else "false") +""".format( + "true" if self._platform == "any" else "false" + ) for tag in self.disttags(): wheel_contents += "Tag: %s\n" % tag self._whlfile.add_string(self.distinfo_path("WHEEL"), wheel_contents) @@ -339,7 +361,9 @@ def add_metadata(self, metadata, name, description): """Write METADATA file to the distribution.""" # https://www.python.org/dev/peps/pep-0566/ # https://packaging.python.org/specifications/core-metadata/ - metadata = re.sub("^Name: .*$", "Name: %s" % name, metadata, flags=re.MULTILINE) + metadata = re.sub( + "^Name: .*$", "Name: %s" % name, metadata, flags=re.MULTILINE + ) metadata += "Version: %s\n\n" % self._version # setuptools seems to insert UNKNOWN as description when none is # provided. @@ -418,7 +442,9 @@ def resolve_argument_stamp( def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description="Builds a python wheel") - metadata_group = parser.add_argument_group("Wheel name, version and platform") + metadata_group = parser.add_argument_group( + "Wheel name, version and platform" + ) metadata_group.add_argument( "--name", required=True, type=str, help="Name of the distribution" ) @@ -465,6 +491,13 @@ def parse_args() -> argparse.Namespace: help="Path prefix to be stripped from input package files' path. " "Can be supplied multiple times. Evaluated in order.", ) + output_group.add_argument( + "--path_prefix", + type=str, + default="", + help="Path prefix to be prepended to input package files' path. " + "It is prepended after stripping any specified path prefixes first.", + ) wheel_group = parser.add_argument_group("Wheel metadata") wheel_group.add_argument( @@ -477,7 +510,8 @@ def parse_args() -> argparse.Namespace: "--description_file", help="Path to the file with package description" ) wheel_group.add_argument( - "--description_content_type", help="Content type of the package description" + "--description_content_type", + help="Content type of the package description", ) wheel_group.add_argument( "--entry_points_file", @@ -579,6 +613,7 @@ def main() -> None: platform=arguments.platform, outfile=arguments.out, strip_path_prefixes=strip_prefixes, + add_path_prefix=arguments.path_prefix, compress=not arguments.no_compress, ) as maker: for package_filename, real_filename in all_files: @@ -608,7 +643,9 @@ def main() -> None: if not meta_line[len("Requires-Dist: ") :].startswith("@"): # This is a normal requirement. - package, _, extra = meta_line[len("Requires-Dist: ") :].rpartition(";") + package, _, extra = meta_line[ + len("Requires-Dist: ") : + ].rpartition(";") if not package: # This is when the package requirement does not have markers. continue @@ -623,7 +660,9 @@ def main() -> None: extra = extra.strip() reqs = [] - for reqs_line in Path(file).read_text(encoding="utf-8").splitlines(): + for reqs_line in ( + Path(file).read_text(encoding="utf-8").splitlines() + ): reqs_text = reqs_line.strip() if not reqs_text or reqs_text.startswith(("#", "-")): continue @@ -650,7 +689,8 @@ def main() -> None: if arguments.entry_points_file: maker.add_file( - maker.distinfo_path("entry_points.txt"), arguments.entry_points_file + maker.distinfo_path("entry_points.txt"), + arguments.entry_points_file, ) # Sort the files for reproducible order in the archive.