diff --git a/news/13229.bugfix.rst b/news/13229.bugfix.rst new file mode 100644 index 00000000000..fa9d600954e --- /dev/null +++ b/news/13229.bugfix.rst @@ -0,0 +1,4 @@ +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. 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..d905d652e3c 100644 --- a/src/pip/_internal/models/wheel.py +++ b/src/pip/_internal/models/wheel.py @@ -3,13 +3,13 @@ """ 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 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,67 @@ 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: Optional[BuildTag] = None + + 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("_", "-") + + # 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(".") + self.file_tags = frozenset( + Tag(interpreter=py, abi=abi, platform=plat) + for py in pyversions + for abi in abis + 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.""" 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: