Skip to content

Commit b1bdf0d

Browse files
author
Antonio Kim
committed
feat(wheel): Add support for add_path_prefix
1 parent 293e643 commit b1bdf0d

File tree

6 files changed

+140
-34
lines changed

6 files changed

+140
-34
lines changed

.bazelrc.deleted_packages

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ common --deleted_packages=examples/multi_python_versions/requirements
1717
common --deleted_packages=examples/multi_python_versions/tests
1818
common --deleted_packages=examples/pip_parse
1919
common --deleted_packages=examples/pip_parse_vendored
20-
common --deleted_packages=examples/pip_repository_annotations
2120
common --deleted_packages=gazelle
2221
common --deleted_packages=gazelle/examples/bzlmod_build_file_generation
2322
common --deleted_packages=gazelle/examples/bzlmod_build_file_generation/other_module/other_module/pkg
@@ -28,8 +27,8 @@ common --deleted_packages=gazelle/manifest/hasher
2827
common --deleted_packages=gazelle/manifest/test
2928
common --deleted_packages=gazelle/modules_mapping
3029
common --deleted_packages=gazelle/python
31-
common --deleted_packages=gazelle/pythonconfig
3230
common --deleted_packages=gazelle/python/private
31+
common --deleted_packages=gazelle/pythonconfig
3332
common --deleted_packages=tests/integration/compile_pip_requirements
3433
common --deleted_packages=tests/integration/compile_pip_requirements_test_from_external_repo
3534
common --deleted_packages=tests/integration/custom_commands

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,8 @@ Other changes:
100100
Implements [#2731](https://github.com/bazel-contrib/rules_python/issues/2731).
101101
* (wheel) Specifying a path ending in `/` as a destination in `data_files`
102102
will now install file(s) to a folder, preserving their basename.
103+
* (wheel) Add support for `add_path_prefix` argument in `py_wheel` which can be
104+
used to prepend a prefix to the files in the wheel.
103105

104106
{#v1-9-0}
105107
## [1.9.0] - 2026-02-21

examples/wheel/BUILD.bazel

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,25 @@ py_wheel(
230230
],
231231
)
232232

233+
# An example of how to change the wheel package root directory using 'add_path_prefix'.
234+
py_wheel(
235+
name = "custom_prefix_package_root",
236+
add_path_prefix = "custom_prefix",
237+
# Package data. We're building "examples_custom_prefix_package_root-0.0.1-py3-none-any.whl"
238+
distribution = "examples_custom_prefix_package_root",
239+
entry_points = {
240+
"console_scripts": ["main = foo.bar:baz"],
241+
},
242+
python_tag = "py3",
243+
strip_path_prefixes = [
244+
"examples",
245+
],
246+
version = "0.0.1",
247+
deps = [
248+
":example_pkg",
249+
],
250+
)
251+
233252
py_wheel(
234253
name = "python_requires_in_a_package",
235254
distribution = "example_python_requires_in_a_package",

python/private/py_wheel.bzl

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,19 @@ entry_points, e.g. `{'console_scripts': ['main = examples.wheel.main:main']}`.
170170
}
171171

172172
_other_attrs = {
173+
"add_path_prefix": attr.string(
174+
default = "",
175+
doc = """\
176+
Path prefix to prepend to files added to the generated package.
177+
This prefix will be prepended **after** the paths are first stripped of the prefixes
178+
specified in `strip_path_prefixes`.
179+
180+
For example:
181+
+ `"foo/" will prepend to `"bar/baz/file.py"` as `"foo/bar/baz/file.py"`
182+
+ `"foo_" will prepend to `"bar/baz/file.py"` as `"foo_bar/baz/file.py"`
183+
+ `stripping ["bar/"] and adding "foo/" will change `"bar/baz/file.py"` to `"foo/baz/file.py"`
184+
""",
185+
),
173186
"author": attr.string(
174187
doc = "A string specifying the author of the package.",
175188
default = "",
@@ -389,6 +402,7 @@ def _py_wheel_impl(ctx):
389402
args.add("--out", outfile)
390403
args.add("--name_file", name_file)
391404
args.add_all(ctx.attr.strip_path_prefixes, format_each = "--strip_path_prefix=%s")
405+
args.add("--path_prefix", ctx.attr.add_path_prefix)
392406

393407
# Pass workspace status files if stamping is enabled
394408
if is_stamping_enabled(ctx.attr):

tests/tools/wheelmaker_test.py

Lines changed: 47 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -34,39 +34,69 @@ def test_quote_all_false_leaves_simple_filenames_unquoted(self) -> None:
3434
def test_quote_all_quotes_filenames_with_commas(self) -> None:
3535
"""Filenames with commas are always quoted, regardless of quote_all_filenames."""
3636
whl = self._make_whl_file(quote_all=True)
37-
self.assertEqual(whl._quote_filename("foo,bar/baz.py"), '"foo,bar/baz.py"')
37+
self.assertEqual(
38+
whl._quote_filename("foo,bar/baz.py"), '"foo,bar/baz.py"'
39+
)
3840

3941
whl = self._make_whl_file(quote_all=False)
40-
self.assertEqual(whl._quote_filename("foo,bar/baz.py"), '"foo,bar/baz.py"')
42+
self.assertEqual(
43+
whl._quote_filename("foo,bar/baz.py"), '"foo,bar/baz.py"'
44+
)
4145

4246

4347
class ArcNameFromTest(unittest.TestCase):
4448
def test_arcname_from(self) -> None:
45-
# (name, distribution_prefix, strip_path_prefixes, want) tuples
49+
# (name, distribution_prefix, strip_path_prefixes, add_path_prefix, want) tuples
4650
checks = [
47-
("a/b/c/file.py", "", [], "a/b/c/file.py"),
48-
("a/b/c/file.py", "", ["a"], "/b/c/file.py"),
49-
("a/b/c/file.py", "", ["a/b/"], "c/file.py"),
51+
("a/b/c/file.py", "", [], "", "a/b/c/file.py"),
52+
("a/b/c/file.py", "", ["a"], "", "/b/c/file.py"),
53+
("a/b/c/file.py", "", ["a/b/"], "", "c/file.py"),
5054
# only first found is used and it's not cumulative.
51-
("a/b/c/file.py", "", ["a/", "b/"], "b/c/file.py"),
55+
("a/b/c/file.py", "", ["a/", "b/"], "", "b/c/file.py"),
5256
# Examples from docs
53-
("foo/bar/baz/file.py", "", ["foo", "foo/bar/baz"], "/bar/baz/file.py"),
54-
("foo/bar/baz/file.py", "", ["foo/bar/baz", "foo"], "/file.py"),
55-
("foo/file2.py", "", ["foo/bar/baz", "foo"], "/file2.py"),
57+
(
58+
"foo/bar/baz/file.py",
59+
"",
60+
["foo", "foo/bar/baz"],
61+
"",
62+
"/bar/baz/file.py",
63+
),
64+
("foo/bar/baz/file.py", "", ["foo/bar/baz", "foo"], "", "/file.py"),
65+
("foo/file2.py", "", ["foo/bar/baz", "foo"], "", "/file2.py"),
5666
# Files under the distribution prefix (eg mylib-1.0.0-dist-info)
5767
# are unmodified
58-
("mylib-0.0.1-dist-info/WHEEL", "mylib", [], "mylib-0.0.1-dist-info/WHEEL"),
59-
("mylib/a/b/c/WHEEL", "mylib", ["mylib"], "mylib/a/b/c/WHEEL"),
68+
(
69+
"mylib-0.0.1-dist-info/WHEEL",
70+
"mylib",
71+
[],
72+
"",
73+
"mylib-0.0.1-dist-info/WHEEL",
74+
),
75+
("mylib/a/b/c/WHEEL", "mylib", ["mylib"], "", "mylib/a/b/c/WHEEL"),
76+
# Check that prefixes are added
77+
("a/b/c/file.py", "", [], "namespace/", "namespace/a/b/c/file.py"),
78+
("a/b/c/file.py", "", ["a"], "namespace", "namespace/b/c/file.py"),
79+
(
80+
"a/b/c/file.py",
81+
"",
82+
["a/b/"],
83+
"namespace_",
84+
"namespace_c/file.py",
85+
),
6086
]
61-
for name, prefix, strip, want in checks:
87+
for name, prefix, strip, add, want in checks:
6288
with self.subTest(
6389
name=name,
6490
distribution_prefix=prefix,
6591
strip_path_prefixes=strip,
92+
add_path_prefix=add,
6693
want=want,
6794
):
6895
got = wheelmaker.arcname_from(
69-
name=name, distribution_prefix=prefix, strip_path_prefixes=strip
96+
name=name,
97+
distribution_prefix=prefix,
98+
strip_path_prefixes=strip,
99+
add_path_prefix=add,
70100
)
71101
self.assertEqual(got, want)
72102

@@ -77,7 +107,9 @@ def test_requirement(self):
77107
self.assertEqual(result, "Requires-Dist: requests>=2.0")
78108

79109
def test_requirement_and_extra(self):
80-
result = wheelmaker.get_new_requirement_line("requests>=2.0", "extra=='dev'")
110+
result = wheelmaker.get_new_requirement_line(
111+
"requests>=2.0", "extra=='dev'"
112+
)
81113
self.assertEqual(result, "Requires-Dist: requests>=2.0; extra=='dev'")
82114

83115
def test_requirement_with_url(self):

tools/wheelmaker.py

Lines changed: 57 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -94,13 +94,18 @@ def normalize_pep440(version):
9494
substituted = re.sub(r"\{\w+\}", "0", version)
9595
delimiter = "." if "+" in substituted else "+"
9696
try:
97-
return str(packaging.version.Version(f"{substituted}{delimiter}{sanitized}"))
97+
return str(
98+
packaging.version.Version(f"{substituted}{delimiter}{sanitized}")
99+
)
98100
except packaging.version.InvalidVersion:
99101
return str(packaging.version.Version(f"0+{sanitized}"))
100102

101103

102104
def arcname_from(
103-
name: str, distribution_prefix: str, strip_path_prefixes: Sequence[str] = ()
105+
name: str,
106+
distribution_prefix: str,
107+
strip_path_prefixes: Sequence[str] = (),
108+
add_path_prefix: str = "",
104109
) -> str:
105110
"""Return the within-archive name for a given file path name.
106111
@@ -110,17 +115,20 @@ def arcname_from(
110115
name: The file path eg 'mylib/a/b/c/file.py'
111116
distribution_prefix: The
112117
strip_path_prefixes: Remove these prefixes from names.
118+
add_path_prefix: Add prefix after stripping the path from names.
113119
"""
114120
# Always use unix path separators.
115121
normalized_arcname = name.replace(os.path.sep, "/")
116122
# Don't manipulate names filenames in the .distinfo or .data directories.
117-
if distribution_prefix and normalized_arcname.startswith(distribution_prefix):
123+
if distribution_prefix and normalized_arcname.startswith(
124+
distribution_prefix
125+
):
118126
return normalized_arcname
119127
for prefix in strip_path_prefixes:
120128
if normalized_arcname.startswith(prefix):
121-
return normalized_arcname[len(prefix) :]
129+
return add_path_prefix + normalized_arcname[len(prefix) :]
122130

123-
return normalized_arcname
131+
return add_path_prefix + normalized_arcname
124132

125133

126134
class _WhlFile(zipfile.ZipFile):
@@ -131,13 +139,15 @@ def __init__(
131139
mode,
132140
distribution_prefix: str,
133141
strip_path_prefixes=None,
142+
add_path_prefix=None,
134143
compression=zipfile.ZIP_DEFLATED,
135144
quote_all_filenames: bool = False,
136145
**kwargs,
137146
):
138147
self._distribution_prefix = distribution_prefix
139148

140149
self._strip_path_prefixes = strip_path_prefixes or []
150+
self._add_path_prefix = add_path_prefix or ""
141151
# Entries for the RECORD file as (filename, digest, size) tuples.
142152
self._record: list[tuple[str, str, str]] = []
143153
# Whether to quote filenames in the RECORD file (for compatibility with
@@ -168,6 +178,7 @@ def add_file(self, package_filename, real_filename):
168178
package_filename,
169179
distribution_prefix=self._distribution_prefix,
170180
strip_path_prefixes=self._strip_path_prefixes,
181+
add_path_prefix=self._add_path_prefix,
171182
)
172183
zinfo = self._zipinfo(arcname)
173184

@@ -194,7 +205,9 @@ def add_string(self, filename, contents):
194205
self.writestr(zinfo, contents)
195206
hash = hashlib.sha256()
196207
hash.update(contents)
197-
self._add_to_record(filename, self._serialize_digest(hash), len(contents))
208+
self._add_to_record(
209+
filename, self._serialize_digest(hash), len(contents)
210+
)
198211

199212
def _serialize_digest(self, hash) -> str:
200213
# https://www.python.org/dev/peps/pep-0376/#record
@@ -231,7 +244,9 @@ def _quote_filename(self, filename: str) -> str:
231244
filename = filename.lstrip("/")
232245
# Some RECORDs like torch have *all* filenames quoted and we must minimize diff.
233246
# Otherwise, we quote only when necessary (e.g. for filenames with commas).
234-
quoting = csv.QUOTE_ALL if self.quote_all_filenames else csv.QUOTE_MINIMAL
247+
quoting = (
248+
csv.QUOTE_ALL if self.quote_all_filenames else csv.QUOTE_MINIMAL
249+
)
235250
with io.StringIO() as buf:
236251
csv.writer(buf, quoting=quoting).writerow([filename])
237252
return buf.getvalue().strip()
@@ -261,6 +276,7 @@ def __init__(
261276
compress,
262277
outfile=None,
263278
strip_path_prefixes=None,
279+
add_path_prefix=None,
264280
):
265281
self._name = name
266282
self._version = normalize_pep440(version)
@@ -270,9 +286,10 @@ def __init__(
270286
self._platform = platform
271287
self._outfile = outfile
272288
self._strip_path_prefixes = strip_path_prefixes
289+
self._add_path_prefix = add_path_prefix
273290
self._compress = compress
274-
self._wheelname_fragment_distribution_name = escape_filename_distribution_name(
275-
self._name
291+
self._wheelname_fragment_distribution_name = (
292+
escape_filename_distribution_name(self._name)
276293
)
277294

278295
self._distribution_prefix = (
@@ -287,7 +304,10 @@ def __enter__(self):
287304
mode="w",
288305
distribution_prefix=self._distribution_prefix,
289306
strip_path_prefixes=self._strip_path_prefixes,
290-
compression=zipfile.ZIP_DEFLATED if self._compress else zipfile.ZIP_STORED,
307+
add_path_prefix=self._add_path_prefix,
308+
compression=(
309+
zipfile.ZIP_DEFLATED if self._compress else zipfile.ZIP_STORED
310+
),
291311
)
292312
return self
293313

@@ -330,7 +350,9 @@ def add_wheelfile(self):
330350
Wheel-Version: 1.0
331351
Generator: bazel-wheelmaker 1.0
332352
Root-Is-Purelib: {}
333-
""".format("true" if self._platform == "any" else "false")
353+
""".format(
354+
"true" if self._platform == "any" else "false"
355+
)
334356
for tag in self.disttags():
335357
wheel_contents += "Tag: %s\n" % tag
336358
self._whlfile.add_string(self.distinfo_path("WHEEL"), wheel_contents)
@@ -339,7 +361,9 @@ def add_metadata(self, metadata, name, description):
339361
"""Write METADATA file to the distribution."""
340362
# https://www.python.org/dev/peps/pep-0566/
341363
# https://packaging.python.org/specifications/core-metadata/
342-
metadata = re.sub("^Name: .*$", "Name: %s" % name, metadata, flags=re.MULTILINE)
364+
metadata = re.sub(
365+
"^Name: .*$", "Name: %s" % name, metadata, flags=re.MULTILINE
366+
)
343367
metadata += "Version: %s\n\n" % self._version
344368
# setuptools seems to insert UNKNOWN as description when none is
345369
# provided.
@@ -418,7 +442,9 @@ def resolve_argument_stamp(
418442

419443
def parse_args() -> argparse.Namespace:
420444
parser = argparse.ArgumentParser(description="Builds a python wheel")
421-
metadata_group = parser.add_argument_group("Wheel name, version and platform")
445+
metadata_group = parser.add_argument_group(
446+
"Wheel name, version and platform"
447+
)
422448
metadata_group.add_argument(
423449
"--name", required=True, type=str, help="Name of the distribution"
424450
)
@@ -465,6 +491,13 @@ def parse_args() -> argparse.Namespace:
465491
help="Path prefix to be stripped from input package files' path. "
466492
"Can be supplied multiple times. Evaluated in order.",
467493
)
494+
output_group.add_argument(
495+
"--path_prefix",
496+
type=str,
497+
default="",
498+
help="Path prefix to be prepended to input package files' path. "
499+
"It is prepended after stripping any specified path prefixes first.",
500+
)
468501

469502
wheel_group = parser.add_argument_group("Wheel metadata")
470503
wheel_group.add_argument(
@@ -477,7 +510,8 @@ def parse_args() -> argparse.Namespace:
477510
"--description_file", help="Path to the file with package description"
478511
)
479512
wheel_group.add_argument(
480-
"--description_content_type", help="Content type of the package description"
513+
"--description_content_type",
514+
help="Content type of the package description",
481515
)
482516
wheel_group.add_argument(
483517
"--entry_points_file",
@@ -579,6 +613,7 @@ def main() -> None:
579613
platform=arguments.platform,
580614
outfile=arguments.out,
581615
strip_path_prefixes=strip_prefixes,
616+
add_path_prefix=arguments.path_prefix,
582617
compress=not arguments.no_compress,
583618
) as maker:
584619
for package_filename, real_filename in all_files:
@@ -608,7 +643,9 @@ def main() -> None:
608643

609644
if not meta_line[len("Requires-Dist: ") :].startswith("@"):
610645
# This is a normal requirement.
611-
package, _, extra = meta_line[len("Requires-Dist: ") :].rpartition(";")
646+
package, _, extra = meta_line[
647+
len("Requires-Dist: ") :
648+
].rpartition(";")
612649
if not package:
613650
# This is when the package requirement does not have markers.
614651
continue
@@ -623,7 +660,9 @@ def main() -> None:
623660
extra = extra.strip()
624661

625662
reqs = []
626-
for reqs_line in Path(file).read_text(encoding="utf-8").splitlines():
663+
for reqs_line in (
664+
Path(file).read_text(encoding="utf-8").splitlines()
665+
):
627666
reqs_text = reqs_line.strip()
628667
if not reqs_text or reqs_text.startswith(("#", "-")):
629668
continue
@@ -650,7 +689,8 @@ def main() -> None:
650689

651690
if arguments.entry_points_file:
652691
maker.add_file(
653-
maker.distinfo_path("entry_points.txt"), arguments.entry_points_file
692+
maker.distinfo_path("entry_points.txt"),
693+
arguments.entry_points_file,
654694
)
655695

656696
# Sort the files for reproducible order in the archive.

0 commit comments

Comments
 (0)