1"""Support functions for working with wheel files."""
2
3import logging
4from email.message import Message
5from email.parser import Parser
6from zipfile import BadZipFile, ZipFile
7
8from pip._vendor.packaging.utils import canonicalize_name
9
10from pip._internal.exceptions import UnsupportedWheel
11
12VERSION_COMPATIBLE = (1, 0)
13
14
15logger = logging.getLogger(__name__)
16
17
18def parse_wheel(wheel_zip: ZipFile, name: str) -> tuple[str, Message]:
19 """Extract information from the provided wheel, ensuring it meets basic
20 standards.
21
22 Returns the name of the .dist-info directory and the parsed WHEEL metadata.
23 """
24 try:
25 info_dir = wheel_dist_info_dir(wheel_zip, name)
26 metadata = wheel_metadata(wheel_zip, info_dir)
27 version = wheel_version(metadata)
28 except UnsupportedWheel as e:
29 raise UnsupportedWheel(f"{name} has an invalid wheel, {e}")
30
31 check_compatibility(version, name)
32
33 return info_dir, metadata
34
35
36def wheel_dist_info_dir(source: ZipFile, name: str) -> str:
37 """Returns the name of the contained .dist-info directory.
38
39 Raises AssertionError or UnsupportedWheel if not found, >1 found, or
40 it doesn't match the provided name.
41 """
42 # Zip file path separators must be /
43 subdirs = {p.split("/", 1)[0] for p in source.namelist()}
44
45 info_dirs = [s for s in subdirs if s.endswith(".dist-info")]
46
47 if not info_dirs:
48 raise UnsupportedWheel(".dist-info directory not found")
49
50 if len(info_dirs) > 1:
51 raise UnsupportedWheel(
52 "multiple .dist-info directories found: {}".format(", ".join(info_dirs))
53 )
54
55 info_dir = info_dirs[0]
56
57 info_dir_name = canonicalize_name(info_dir)
58 canonical_name = canonicalize_name(name)
59 if not info_dir_name.startswith(canonical_name):
60 raise UnsupportedWheel(
61 f".dist-info directory {info_dir!r} does not start with {canonical_name!r}"
62 )
63
64 return info_dir
65
66
67def read_wheel_metadata_file(source: ZipFile, path: str) -> bytes:
68 try:
69 return source.read(path)
70 # BadZipFile for general corruption, KeyError for missing entry,
71 # and RuntimeError for password-protected files
72 except (BadZipFile, KeyError, RuntimeError) as e:
73 raise UnsupportedWheel(f"could not read {path!r} file: {e!r}")
74
75
76def wheel_metadata(source: ZipFile, dist_info_dir: str) -> Message:
77 """Return the WHEEL metadata of an extracted wheel, if possible.
78 Otherwise, raise UnsupportedWheel.
79 """
80 path = f"{dist_info_dir}/WHEEL"
81 # Zip file path separators must be /
82 wheel_contents = read_wheel_metadata_file(source, path)
83
84 try:
85 wheel_text = wheel_contents.decode()
86 except UnicodeDecodeError as e:
87 raise UnsupportedWheel(f"error decoding {path!r}: {e!r}")
88
89 # FeedParser (used by Parser) does not raise any exceptions. The returned
90 # message may have .defects populated, but for backwards-compatibility we
91 # currently ignore them.
92 return Parser().parsestr(wheel_text)
93
94
95def wheel_version(wheel_data: Message) -> tuple[int, ...]:
96 """Given WHEEL metadata, return the parsed Wheel-Version.
97 Otherwise, raise UnsupportedWheel.
98 """
99 version_text = wheel_data["Wheel-Version"]
100 if version_text is None:
101 raise UnsupportedWheel("WHEEL is missing Wheel-Version")
102
103 version = version_text.strip()
104
105 try:
106 return tuple(map(int, version.split(".")))
107 except ValueError:
108 raise UnsupportedWheel(f"invalid Wheel-Version: {version!r}")
109
110
111def check_compatibility(version: tuple[int, ...], name: str) -> None:
112 """Raises errors or warns if called with an incompatible Wheel-Version.
113
114 pip should refuse to install a Wheel-Version that's a major series
115 ahead of what it's compatible with (e.g 2.0 > 1.1); and warn when
116 installing a version only minor version ahead (e.g 1.2 > 1.1).
117
118 version: a 2-tuple representing a Wheel-Version (Major, Minor)
119 name: name of wheel or package to raise exception about
120
121 :raises UnsupportedWheel: when an incompatible Wheel-Version is given
122 """
123 if version[0] > VERSION_COMPATIBLE[0]:
124 raise UnsupportedWheel(
125 "{}'s Wheel-Version ({}) is not compatible with this version "
126 "of pip".format(name, ".".join(map(str, version)))
127 )
128 elif version > VERSION_COMPATIBLE:
129 logger.warning(
130 "Installing from a newer Wheel-Version (%s)",
131 ".".join(map(str, version)),
132 )