fix(pypi): use local version specifiers for patched whl output (#2365)
Before this change the installation of the patched wheels using `uv` or
similar would break. This change fixes that by using local version
specifier, which is better than using a build tag when installing the
wheels.
Before the change:
```console
$ cd examples/bzlmod
$ bazel build @pip//requests:whl
$ uv pip install <path to requests wheel in the example>
error: The wheel filename "requests-2.25.1-patched-py2.py3-none-any.whl" has an invalid build tag: must start with a digit
```
After:
```
$ uv pip install <path to requests wheel in the example>
Resolved 5 packages in 288ms
Prepared 5 packages in 152ms
Installed 5 packages in 13ms
+ certifi==2024.8.30
+ chardet==4.0.0
+ idna==2.10
+ requests==2.25.1+patched (from file:///home/aignas/src/github/aignas/rules_python/examples/bzlmod/bazel-bzlmod/external/rules_python~~pip~pip_39_requests_py2_none_any_c210084e/requests-2.25.1+patched-py2.py3-none-any.whl)
+ urllib3==1.26.20
```
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 95d1dc8..f21f9bb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -47,6 +47,8 @@
([617](https://github.com/bazelbuild/rules_python/issues/617)).
* (pypi) When {attr}`pip.parse.experimental_index_url` is set, we need to still
pass the `extra_pip_args` value when building an `sdist`.
+* (pypi) The patched wheel filenames from now on are using local version specifiers
+ which fixes usage of the said wheels using standard package managers.
{#v0-0-0-added}
### Added
diff --git a/examples/bzlmod/MODULE.bazel.lock b/examples/bzlmod/MODULE.bazel.lock
index 6e7d780..d34f4ec 100644
--- a/examples/bzlmod/MODULE.bazel.lock
+++ b/examples/bzlmod/MODULE.bazel.lock
@@ -1392,7 +1392,7 @@
},
"@@rules_python~//python/extensions:pip.bzl%pip": {
"general": {
- "bzlTransitiveDigest": "HF4Ob8+IVv7X7DHag07k47pv+QywfyjKF8Z6Q7M5/oU=",
+ "bzlTransitiveDigest": "qxyKk6sb6G2WeW3iUlRmVO5jafUab5qPwz66Y2anPp8=",
"usagesDigest": "MChlcSw99EuW3K7OOoMcXQIdcJnEh6YmfyjJm+9mxIg=",
"recordedFileInputs": {
"@@other_module~//requirements_lock_3_11.txt": "a7d0061366569043d5efcf80e34a32c732679367cb3c831c4cdc606adc36d314",
@@ -6581,7 +6581,7 @@
},
"@@rules_python~//python/private/pypi:pip.bzl%pip_internal": {
"general": {
- "bzlTransitiveDigest": "oEqeANhT7TnWlFpmzRhRdWn9kCRKTen7mIV72CWlAIA=",
+ "bzlTransitiveDigest": "6NoEDGeQugmtzNzf4Emcb8Sb/cW3RTxSSA6DTHLB1/A=",
"usagesDigest": "LYtSAPzhPjmfD9vF39mCED1UQSvHEo2Hv+aK5Z4ZWWc=",
"recordedFileInputs": {
"@@rules_python~//tools/publish/requirements_linux.txt": "8175b4c8df50ae2f22d1706961884beeb54e7da27bd2447018314a175981997d",
diff --git a/python/private/pypi/patch_whl.bzl b/python/private/pypi/patch_whl.bzl
index 74cd890..a7da224 100644
--- a/python/private/pypi/patch_whl.bzl
+++ b/python/private/pypi/patch_whl.bzl
@@ -32,6 +32,39 @@
_rules_python_root = Label("//:BUILD.bazel")
+def patched_whl_name(original_whl_name):
+ """Return the new filename to output the patched wheel.
+
+ Args:
+ original_whl_name: {type}`str` the whl name of the original file.
+
+ Returns:
+ {type}`str` an output name to write the patched wheel to.
+ """
+ parsed_whl = parse_whl_name(original_whl_name)
+ version = parsed_whl.version
+ suffix = "patched"
+ if "+" in version:
+ # This already has some local version, so we just append one more
+ # identifier here. We comply with the spec and mark the file as patched
+ # by adding a local version identifier at the end.
+ #
+ # By doing this we can still install the package using most of the package
+ # managers
+ #
+ # See https://packaging.python.org/en/latest/specifications/version-specifiers/#local-version-identifiers
+ version = "{}.{}".format(version, suffix)
+ else:
+ version = "{}+{}".format(version, suffix)
+
+ return "{distribution}-{version}-{python_tag}-{abi_tag}-{platform_tag}.whl".format(
+ distribution = parsed_whl.distribution,
+ version = version,
+ python_tag = parsed_whl.python_tag,
+ abi_tag = parsed_whl.abi_tag,
+ platform_tag = parsed_whl.platform_tag,
+ )
+
def patch_whl(rctx, *, python_interpreter, whl_path, patches, **kwargs):
"""Patch a whl file and repack it to ensure that the RECORD metadata stays correct.
@@ -66,18 +99,8 @@
for patch_file, patch_strip in patches.items():
rctx.patch(patch_file, strip = patch_strip)
- # Generate an output filename, which we will be returning
- parsed_whl = parse_whl_name(whl_input.basename)
- whl_patched = "{}.whl".format("-".join([
- parsed_whl.distribution,
- parsed_whl.version,
- (parsed_whl.build_tag or "") + "patched",
- parsed_whl.python_tag,
- parsed_whl.abi_tag,
- parsed_whl.platform_tag,
- ]))
-
record_patch = rctx.path("RECORD.patch")
+ whl_patched = patched_whl_name(whl_input.basename)
repo_utils.execute_checked(
rctx,
diff --git a/tests/pypi/patch_whl/BUILD.bazel b/tests/pypi/patch_whl/BUILD.bazel
new file mode 100644
index 0000000..d6c4f47
--- /dev/null
+++ b/tests/pypi/patch_whl/BUILD.bazel
@@ -0,0 +1,3 @@
+load(":patch_whl_tests.bzl", "patch_whl_test_suite")
+
+patch_whl_test_suite(name = "patch_whl_tests")
diff --git a/tests/pypi/patch_whl/patch_whl_tests.bzl b/tests/pypi/patch_whl/patch_whl_tests.bzl
new file mode 100644
index 0000000..f93fe45
--- /dev/null
+++ b/tests/pypi/patch_whl/patch_whl_tests.bzl
@@ -0,0 +1,40 @@
+# Copyright 2024 The Bazel Authors. All rights reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+""
+
+load("@rules_testing//lib:test_suite.bzl", "test_suite")
+load("//python/private/pypi:patch_whl.bzl", "patched_whl_name") # buildifier: disable=bzl-visibility
+
+_tests = []
+
+def _test_simple(env):
+ got = patched_whl_name("foo-1.2.3-py3-none-any.whl")
+ env.expect.that_str(got).equals("foo-1.2.3+patched-py3-none-any.whl")
+
+_tests.append(_test_simple)
+
+def _test_simple_local_version(env):
+ got = patched_whl_name("foo-1.2.3+special-py3-none-any.whl")
+ env.expect.that_str(got).equals("foo-1.2.3+special.patched-py3-none-any.whl")
+
+_tests.append(_test_simple_local_version)
+
+def patch_whl_test_suite(name):
+ """Create the test suite.
+
+ Args:
+ name: the name of the test suite
+ """
+ test_suite(name = name, basic_tests = _tests)