From 87f59c42bf0528173fbf9da305752e485675d7fb Mon Sep 17 00:00:00 2001 From: Damian Shaw Date: Fri, 21 Feb 2025 19:56:55 -0500 Subject: [PATCH 1/4] Use `parse_wheel_filename` to parse wheel filename, with fallback --- src/pip/_internal/index/package_finder.py | 6 +- src/pip/_internal/metadata/importlib/_envs.py | 12 ++- src/pip/_internal/models/wheel.py | 96 +++++++++++-------- tests/functional/test_install_wheel.py | 2 +- tests/unit/test_models_wheel.py | 29 ++++-- 5 files changed, 84 insertions(+), 61 deletions(-) diff --git a/src/pip/_internal/index/package_finder.py b/src/pip/_internal/index/package_finder.py index 85628ee5d7a..ce0842b07e6 100644 --- a/src/pip/_internal/index/package_finder.py +++ b/src/pip/_internal/index/package_finder.py @@ -514,11 +514,7 @@ def _sort_key(self, candidate: InstallationCandidate) -> CandidateSortingKey: ) if self._prefer_binary: binary_preference = 1 - if wheel.build_tag is not None: - match = re.match(r"^(\d+)(.*)$", wheel.build_tag) - assert match is not None, "guaranteed by filename validation" - build_tag_groups = match.groups() - build_tag = (int(build_tag_groups[0]), build_tag_groups[1]) + build_tag = wheel.build_tag else: # sdist pri = -(support_num) has_allowed_hash = int(link.is_hash_allowed(self._hashes)) diff --git a/src/pip/_internal/metadata/importlib/_envs.py b/src/pip/_internal/metadata/importlib/_envs.py index 4d906fd3149..681fd241fdc 100644 --- a/src/pip/_internal/metadata/importlib/_envs.py +++ b/src/pip/_internal/metadata/importlib/_envs.py @@ -8,10 +8,14 @@ import zipimport from typing import Iterator, List, Optional, Sequence, Set, Tuple -from pip._vendor.packaging.utils import NormalizedName, canonicalize_name +from pip._vendor.packaging.utils import ( + InvalidWheelFilename, + NormalizedName, + canonicalize_name, + parse_wheel_filename, +) from pip._internal.metadata.base import BaseDistribution, BaseEnvironment -from pip._internal.models.wheel import Wheel from pip._internal.utils.deprecation import deprecated from pip._internal.utils.filetypes import WHEEL_EXTENSION @@ -26,7 +30,9 @@ def _looks_like_wheel(location: str) -> bool: return False if not os.path.isfile(location): return False - if not Wheel.wheel_file_re.match(os.path.basename(location)): + try: + parse_wheel_filename(os.path.basename(location)) + except InvalidWheelFilename: return False return zipfile.is_zipfile(location) diff --git a/src/pip/_internal/models/wheel.py b/src/pip/_internal/models/wheel.py index ea8560089d3..a1e6accf727 100644 --- a/src/pip/_internal/models/wheel.py +++ b/src/pip/_internal/models/wheel.py @@ -6,10 +6,10 @@ from typing import Dict, Iterable, List from pip._vendor.packaging.tags import Tag +from pip._vendor.packaging.utils import BuildTag, parse_wheel_filename from pip._vendor.packaging.utils import ( - InvalidWheelFilename as PackagingInvalidWheelName, + InvalidWheelFilename as _PackagingInvalidWheelFilename, ) -from pip._vendor.packaging.utils import parse_wheel_filename from pip._internal.exceptions import InvalidWheelFilename from pip._internal.utils.deprecation import deprecated @@ -18,7 +18,7 @@ class Wheel: """A wheel file""" - wheel_file_re = re.compile( + legacy_wheel_file_re = re.compile( r"""^(?P(?P[^\s-]+?)-(?P[^\s-]*?)) ((-(?P\d[^-]*?))?-(?P[^\s-]+?)-(?P[^\s-]+?)-(?P[^\s-]+?) \.whl|\.dist-info)$""", @@ -26,46 +26,58 @@ class Wheel: ) def __init__(self, filename: str) -> None: - """ - :raises InvalidWheelFilename: when the filename is invalid for a wheel - """ - wheel_info = self.wheel_file_re.match(filename) - if not wheel_info: - raise InvalidWheelFilename(f"{filename} is not a valid wheel filename.") self.filename = filename - self.name = wheel_info.group("name").replace("_", "-") - _version = wheel_info.group("ver") - if "_" in _version: - try: - parse_wheel_filename(filename) - except PackagingInvalidWheelName as e: - deprecated( - reason=( - f"Wheel filename {filename!r} is not correctly normalised. " - "Future versions of pip will raise the following error:\n" - f"{e.args[0]}\n\n" - ), - replacement=( - "to rename the wheel to use a correctly normalised " - "name (this may require updating the version in " - "the project metadata)" - ), - gone_in="25.1", - issue=12938, - ) - - _version = _version.replace("_", "-") - - self.version = _version - self.build_tag = wheel_info.group("build") - self.pyversions = wheel_info.group("pyver").split(".") - self.abis = wheel_info.group("abi").split(".") - self.plats = wheel_info.group("plat").split(".") - - # All the tag combinations from this file - self.file_tags = { - Tag(x, y, z) for x in self.pyversions for y in self.abis for z in self.plats - } + + # To make mypy happy specify type hints that can come from either + # parse_wheel_filename or the legacy_wheel_file_re match. + self.name: str + self.build_tag: BuildTag + + try: + wheel_info = parse_wheel_filename(filename) + self.name, _version, self.build_tag, self.file_tags = wheel_info + self.version = str(_version) + except _PackagingInvalidWheelFilename as e: + # Check if the wheel filename is in the legacy format + legacy_wheel_info = self.legacy_wheel_file_re.match(filename) + if not legacy_wheel_info: + raise InvalidWheelFilename(e.args[0]) from None + + deprecated( + reason=( + f"Wheel filename {filename!r} is not correctly normalised. " + "Future versions of pip will raise the following error:\n" + f"{e.args[0]}\n\n" + ), + replacement=( + "to rename the wheel to use a correctly normalised " + "name (this may require updating the version in " + "the project metadata)" + ), + gone_in="25.3", + issue=12938, + ) + + self.name = legacy_wheel_info.group("name").replace("_", "-") + self.version = legacy_wheel_info.group("ver").replace("_", "-") + + # Parse the build tag + build_tag = legacy_wheel_info.group("build") + match = re.match(r"^(\d+)(.*)$", build_tag) + assert match is not None, "guaranteed by filename validation" + build_tag_groups = match.groups() + self.build_tag = (int(build_tag_groups[0]), build_tag_groups[1]) + + # Generate the file tags + pyversions = legacy_wheel_info.group("pyver").split(".") + abis = legacy_wheel_info.group("abi").split(".") + plats = legacy_wheel_info.group("plat").split(".") + self.file_tags = frozenset( + Tag(interpreter=py, abi=abi, platform=plat) + for py in pyversions + for abi in abis + for plat in plats + ) def get_formatted_file_tags(self) -> List[str]: """Return the wheel's tags as a sorted list of strings.""" diff --git a/tests/functional/test_install_wheel.py b/tests/functional/test_install_wheel.py index 7e7aeaf7a81..e01ecfb57f3 100644 --- a/tests/functional/test_install_wheel.py +++ b/tests/functional/test_install_wheel.py @@ -190,7 +190,7 @@ def test_install_from_wheel_with_headers(script: PipTestEnvironment) -> None: dist_info_folder = script.site_packages / "headers.dist-0.1.dist-info" result.did_create(dist_info_folder) - header_scheme_path = get_header_scheme_path_for_script(script, "headers.dist") + header_scheme_path = get_header_scheme_path_for_script(script, "headers-dist") header_path = header_scheme_path / "header.h" assert header_path.read_text() == header_text diff --git a/tests/unit/test_models_wheel.py b/tests/unit/test_models_wheel.py index e87b2c107a0..46991e3487b 100644 --- a/tests/unit/test_models_wheel.py +++ b/tests/unit/test_models_wheel.py @@ -12,17 +12,24 @@ def test_std_wheel_pattern(self) -> None: w = Wheel("simple-1.1.1-py2-none-any.whl") assert w.name == "simple" assert w.version == "1.1.1" - assert w.pyversions == ["py2"] - assert w.abis == ["none"] - assert w.plats == ["any"] + assert w.build_tag == () + assert w.file_tags == frozenset( + [Tag(interpreter="py2", abi="none", platform="any")] + ) def test_wheel_pattern_multi_values(self) -> None: w = Wheel("simple-1.1-py2.py3-abi1.abi2-any.whl") assert w.name == "simple" assert w.version == "1.1" - assert w.pyversions == ["py2", "py3"] - assert w.abis == ["abi1", "abi2"] - assert w.plats == ["any"] + assert w.build_tag == () + assert w.file_tags == frozenset( + [ + Tag(interpreter="py2", abi="abi1", platform="any"), + Tag(interpreter="py2", abi="abi2", platform="any"), + Tag(interpreter="py3", abi="abi1", platform="any"), + Tag(interpreter="py3", abi="abi2", platform="any"), + ] + ) def test_wheel_with_build_tag(self) -> None: # pip doesn't do anything with build tags, but theoretically, we might @@ -30,16 +37,18 @@ def test_wheel_with_build_tag(self) -> None: w = Wheel("simple-1.1-4-py2-none-any.whl") assert w.name == "simple" assert w.version == "1.1" - assert w.pyversions == ["py2"] - assert w.abis == ["none"] - assert w.plats == ["any"] + assert w.build_tag == (4, "") + assert w.file_tags == frozenset( + [Tag(interpreter="py2", abi="none", platform="any")] + ) def test_single_digit_version(self) -> None: w = Wheel("simple-1-py2-none-any.whl") assert w.version == "1" def test_non_pep440_version(self) -> None: - w = Wheel("simple-_invalid_-py2-none-any.whl") + with pytest.warns(deprecation.PipDeprecationWarning): + w = Wheel("simple-_invalid_-py2-none-any.whl") assert w.version == "-invalid-" def test_missing_version_raises(self) -> None: From c153810324716fdb217d407e861bdcf3e4aa6ef4 Mon Sep 17 00:00:00 2001 From: Damian Shaw Date: Fri, 21 Feb 2025 20:02:17 -0500 Subject: [PATCH 2/4] NEWS ENTRY --- news/13229.bugfix.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 news/13229.bugfix.rst diff --git a/news/13229.bugfix.rst b/news/13229.bugfix.rst new file mode 100644 index 00000000000..e13f0731cf2 --- /dev/null +++ b/news/13229.bugfix.rst @@ -0,0 +1 @@ +Expand deprecation warning for wheels with non-standard file names. From 00698f6f0cb5b1782f8de71e3a19e2c7644d2866 Mon Sep 17 00:00:00 2001 From: Damian Shaw Date: Fri, 21 Feb 2025 20:09:39 -0500 Subject: [PATCH 3/4] Make legacy build_tag lazy --- src/pip/_internal/models/wheel.py | 31 ++++++++++++++++++++----------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/src/pip/_internal/models/wheel.py b/src/pip/_internal/models/wheel.py index a1e6accf727..d905d652e3c 100644 --- a/src/pip/_internal/models/wheel.py +++ b/src/pip/_internal/models/wheel.py @@ -3,7 +3,7 @@ """ import re -from typing import Dict, Iterable, List +from typing import Dict, Iterable, List, Optional from pip._vendor.packaging.tags import Tag from pip._vendor.packaging.utils import BuildTag, parse_wheel_filename @@ -31,11 +31,11 @@ def __init__(self, filename: str) -> None: # To make mypy happy specify type hints that can come from either # parse_wheel_filename or the legacy_wheel_file_re match. self.name: str - self.build_tag: BuildTag + self._build_tag: Optional[BuildTag] = None try: wheel_info = parse_wheel_filename(filename) - self.name, _version, self.build_tag, self.file_tags = wheel_info + self.name, _version, self._build_tag, self.file_tags = wheel_info self.version = str(_version) except _PackagingInvalidWheelFilename as e: # Check if the wheel filename is in the legacy format @@ -61,14 +61,7 @@ def __init__(self, filename: str) -> None: self.name = legacy_wheel_info.group("name").replace("_", "-") self.version = legacy_wheel_info.group("ver").replace("_", "-") - # Parse the build tag - build_tag = legacy_wheel_info.group("build") - match = re.match(r"^(\d+)(.*)$", build_tag) - assert match is not None, "guaranteed by filename validation" - build_tag_groups = match.groups() - self.build_tag = (int(build_tag_groups[0]), build_tag_groups[1]) - - # Generate the file tags + # Generate the file tags from the legacy wheel filename pyversions = legacy_wheel_info.group("pyver").split(".") abis = legacy_wheel_info.group("abi").split(".") plats = legacy_wheel_info.group("plat").split(".") @@ -79,6 +72,22 @@ def __init__(self, filename: str) -> None: for plat in plats ) + @property + def build_tag(self) -> BuildTag: + if self._build_tag is not None: + return self._build_tag + + # Parse the build tag from the legacy wheel filename + legacy_wheel_info = self.legacy_wheel_file_re.match(self.filename) + assert legacy_wheel_info is not None, "guaranteed by filename validation" + build_tag = legacy_wheel_info.group("build") + match = re.match(r"^(\d+)(.*)$", build_tag) + assert match is not None, "guaranteed by filename validation" + build_tag_groups = match.groups() + self._build_tag = (int(build_tag_groups[0]), build_tag_groups[1]) + + return self._build_tag + def get_formatted_file_tags(self) -> List[str]: """Return the wheel's tags as a sorted list of strings.""" return sorted(str(tag) for tag in self.file_tags) From b49dd3ad9e9ac4dff389edf801d982501ccd9a94 Mon Sep 17 00:00:00 2001 From: Damian Shaw Date: Mon, 24 Feb 2025 23:16:54 -0500 Subject: [PATCH 4/4] Update news entry --- news/13229.bugfix.rst | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/news/13229.bugfix.rst b/news/13229.bugfix.rst index e13f0731cf2..fa9d600954e 100644 --- a/news/13229.bugfix.rst +++ b/news/13229.bugfix.rst @@ -1 +1,4 @@ -Expand deprecation warning for wheels with non-standard file names. +Parse wheel filenames according to `binary distribution format specification +`_. +When a filename doesn't match the spec a deprecation warning is emitted and the +filename is parsed using the old method.