Skip to content

Commit b7ed044

Browse files
authored
Merge pull request #7539 from chrahunt/refactor/get-dist-from-zip
Use wheelfile-based pkg_resources.Distribution for metadata
2 parents c3ab0a0 + a94fb53 commit b7ed044

File tree

8 files changed

+240
-43
lines changed

8 files changed

+240
-43
lines changed

src/pip/_internal/distributions/wheel.py

+15-3
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
from pip._vendor import pkg_resources
1+
from zipfile import ZipFile
22

33
from pip._internal.distributions.base import AbstractDistribution
44
from pip._internal.utils.typing import MYPY_CHECK_RUNNING
5+
from pip._internal.utils.wheel import pkg_resources_distribution_for_wheel
56

67
if MYPY_CHECK_RUNNING:
78
from pip._vendor.pkg_resources import Distribution
@@ -16,8 +17,19 @@ class WheelDistribution(AbstractDistribution):
1617

1718
def get_pkg_resources_distribution(self):
1819
# type: () -> Distribution
19-
return list(pkg_resources.find_distributions(
20-
self.req.source_dir))[0]
20+
"""Loads the metadata from the wheel file into memory and returns a
21+
Distribution that uses it, not relying on the wheel file or
22+
requirement.
23+
"""
24+
# Set as part of preparation during download.
25+
assert self.req.local_file_path
26+
# Wheels are never unnamed.
27+
assert self.req.name
28+
29+
with ZipFile(self.req.local_file_path, allowZip64=True) as z:
30+
return pkg_resources_distribution_for_wheel(
31+
z, self.req.name, self.req.local_file_path
32+
)
2133

2234
def prepare_distribution_metadata(self, finder, build_isolation):
2335
# type: (PackageFinder, bool) -> None
+44
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
from pip._vendor.pkg_resources import yield_lines
2+
from pip._vendor.six import ensure_str
3+
4+
from pip._internal.utils.typing import MYPY_CHECK_RUNNING
5+
6+
if MYPY_CHECK_RUNNING:
7+
from typing import Dict, Iterable, List
8+
9+
10+
class DictMetadata(object):
11+
"""IMetadataProvider that reads metadata files from a dictionary.
12+
"""
13+
def __init__(self, metadata):
14+
# type: (Dict[str, bytes]) -> None
15+
self._metadata = metadata
16+
17+
def has_metadata(self, name):
18+
# type: (str) -> bool
19+
return name in self._metadata
20+
21+
def get_metadata(self, name):
22+
# type: (str) -> str
23+
try:
24+
return ensure_str(self._metadata[name])
25+
except UnicodeDecodeError as e:
26+
# Mirrors handling done in pkg_resources.NullProvider.
27+
e.reason += " in {} file".format(name)
28+
raise
29+
30+
def get_metadata_lines(self, name):
31+
# type: (str) -> Iterable[str]
32+
return yield_lines(self.get_metadata(name))
33+
34+
def metadata_isdir(self, name):
35+
# type: (str) -> bool
36+
return False
37+
38+
def metadata_listdir(self, name):
39+
# type: (str) -> List[str]
40+
return []
41+
42+
def run_script(self, script_name, namespace):
43+
# type: (str, str) -> None
44+
pass

src/pip/_internal/utils/wheel.py

+80-9
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,18 @@
88
from zipfile import ZipFile
99

1010
from pip._vendor.packaging.utils import canonicalize_name
11+
from pip._vendor.pkg_resources import DistInfoDistribution
1112
from pip._vendor.six import PY2, ensure_str
1213

1314
from pip._internal.exceptions import UnsupportedWheel
15+
from pip._internal.utils.pkg_resources import DictMetadata
1416
from pip._internal.utils.typing import MYPY_CHECK_RUNNING
1517

1618
if MYPY_CHECK_RUNNING:
1719
from email.message import Message
18-
from typing import Tuple
20+
from typing import Dict, Tuple
21+
22+
from pip._vendor.pkg_resources import Distribution
1923

2024
if PY2:
2125
from zipfile import BadZipfile as BadZipFile
@@ -29,6 +33,65 @@
2933
logger = logging.getLogger(__name__)
3034

3135

36+
class WheelMetadata(DictMetadata):
37+
"""Metadata provider that maps metadata decoding exceptions to our
38+
internal exception type.
39+
"""
40+
def __init__(self, metadata, wheel_name):
41+
# type: (Dict[str, bytes], str) -> None
42+
super(WheelMetadata, self).__init__(metadata)
43+
self._wheel_name = wheel_name
44+
45+
def get_metadata(self, name):
46+
# type: (str) -> str
47+
try:
48+
return super(WheelMetadata, self).get_metadata(name)
49+
except UnicodeDecodeError as e:
50+
# Augment the default error with the origin of the file.
51+
raise UnsupportedWheel(
52+
"Error decoding metadata for {}: {}".format(
53+
self._wheel_name, e
54+
)
55+
)
56+
57+
58+
def pkg_resources_distribution_for_wheel(wheel_zip, name, location):
59+
# type: (ZipFile, str, str) -> Distribution
60+
"""Get a pkg_resources distribution given a wheel.
61+
62+
:raises UnsupportedWheel: on any errors
63+
"""
64+
info_dir, _ = parse_wheel(wheel_zip, name)
65+
66+
metadata_files = [
67+
p for p in wheel_zip.namelist() if p.startswith("{}/".format(info_dir))
68+
]
69+
70+
metadata_text = {} # type: Dict[str, bytes]
71+
for path in metadata_files:
72+
# If a flag is set, namelist entries may be unicode in Python 2.
73+
# We coerce them to native str type to match the types used in the rest
74+
# of the code. This cannot fail because unicode can always be encoded
75+
# with UTF-8.
76+
full_path = ensure_str(path)
77+
_, metadata_name = full_path.split("/", 1)
78+
79+
try:
80+
metadata_text[metadata_name] = read_wheel_metadata_file(
81+
wheel_zip, full_path
82+
)
83+
except UnsupportedWheel as e:
84+
raise UnsupportedWheel(
85+
"{} has an invalid wheel, {}".format(name, str(e))
86+
)
87+
88+
metadata = WheelMetadata(metadata_text, location)
89+
90+
return DistInfoDistribution(
91+
location=location, metadata=metadata, project_name=name
92+
)
93+
94+
3295
def parse_wheel(wheel_zip, name):
3396
# type: (ZipFile, str) -> Tuple[str, Message]
3497
"""Extract information from the provided wheel, ensuring it meets basic
@@ -88,23 +151,31 @@ def wheel_dist_info_dir(source, name):
88151
return ensure_str(info_dir)
89152

90153

154+
def read_wheel_metadata_file(source, path):
155+
# type: (ZipFile, str) -> bytes
156+
try:
157+
return source.read(path)
158+
# BadZipFile for general corruption, KeyError for missing entry,
159+
# and RuntimeError for password-protected files
160+
except (BadZipFile, KeyError, RuntimeError) as e:
161+
raise UnsupportedWheel(
162+
"could not read {!r} file: {!r}".format(path, e)
163+
)
164+
165+
91166
def wheel_metadata(source, dist_info_dir):
92167
# type: (ZipFile, str) -> Message
93168
"""Return the WHEEL metadata of an extracted wheel, if possible.
94169
Otherwise, raise UnsupportedWheel.
95170
"""
96-
try:
97-
# Zip file path separators must be /
98-
wheel_contents = source.read("{}/WHEEL".format(dist_info_dir))
99-
# BadZipFile for general corruption, KeyError for missing entry,
100-
# and RuntimeError for password-protected files
101-
except (BadZipFile, KeyError, RuntimeError) as e:
102-
raise UnsupportedWheel("could not read WHEEL file: {!r}".format(e))
171+
path = "{}/WHEEL".format(dist_info_dir)
172+
# Zip file path separators must be /
173+
wheel_contents = read_wheel_metadata_file(source, path)
103174

104175
try:
105176
wheel_text = ensure_str(wheel_contents)
106177
except UnicodeDecodeError as e:
107-
raise UnsupportedWheel("error decoding WHEEL: {!r}".format(e))
178+
raise UnsupportedWheel("error decoding {!r}: {!r}".format(path, e))
108179

109180
# FeedParser (used by Parser) does not raise any exceptions. The returned
110181
# message may have .defects populated, but for backwards-compatibility we

src/pip/_internal/wheel_builder.py

-23
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,11 @@
1313
from pip._internal.operations.build.wheel import build_wheel_pep517
1414
from pip._internal.operations.build.wheel_legacy import build_wheel_legacy
1515
from pip._internal.utils.logging import indent_log
16-
from pip._internal.utils.marker_files import has_delete_marker_file
1716
from pip._internal.utils.misc import ensure_dir, hash_file
1817
from pip._internal.utils.setuptools_build import make_setuptools_clean_args
1918
from pip._internal.utils.subprocess import call_subprocess
2019
from pip._internal.utils.temp_dir import TempDirectory
2120
from pip._internal.utils.typing import MYPY_CHECK_RUNNING
22-
from pip._internal.utils.unpacking import unpack_file
2321
from pip._internal.utils.urls import path_to_url
2422
from pip._internal.vcs import vcs
2523

@@ -313,27 +311,6 @@ def build(
313311
req.link = Link(path_to_url(wheel_file))
314312
req.local_file_path = req.link.file_path
315313
assert req.link.is_wheel
316-
if should_unpack:
317-
# XXX: This is mildly duplicative with prepare_files,
318-
# but not close enough to pull out to a single common
319-
# method.
320-
# The code below assumes temporary source dirs -
321-
# prevent it doing bad things.
322-
if (
323-
req.source_dir and
324-
not has_delete_marker_file(req.source_dir)
325-
):
326-
raise AssertionError(
327-
"bad source dir - missing marker")
328-
# Delete the source we built the wheel from
329-
req.remove_temporary_source()
330-
# set the build directory again - name is known from
331-
# the work prepare_files did.
332-
req.source_dir = req.ensure_build_location(
333-
self.preparer.build_dir
334-
)
335-
# extract the wheel into the dir
336-
unpack_file(req.link.file_path, req.source_dir)
337314
build_successes.append(req)
338315
else:
339316
build_failures.append(req)

tests/functional/test_install_wheel.py

+36-1
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
import pytest
77

8-
from tests.lib import create_basic_wheel_for_package
8+
from tests.lib import create_basic_wheel_for_package, skip_if_python2
99
from tests.lib.path import Path
1010

1111

@@ -534,3 +534,38 @@ def test_wheel_installs_ok_with_nested_dist_info(script):
534534
script.pip(
535535
"install", "--no-cache-dir", "--no-index", package
536536
)
537+
538+
539+
def test_wheel_installs_ok_with_badly_encoded_irrelevant_dist_info_file(
540+
script
541+
):
542+
package = create_basic_wheel_for_package(
543+
script,
544+
"simple",
545+
"0.1.0",
546+
extra_files={
547+
"simple-0.1.0.dist-info/AUTHORS.txt": b"\xff"
548+
},
549+
)
550+
script.pip(
551+
"install", "--no-cache-dir", "--no-index", package
552+
)
553+
554+
555+
# Metadata is not decoded on Python 2.
556+
@skip_if_python2
557+
def test_wheel_install_fails_with_badly_encoded_metadata(script):
558+
package = create_basic_wheel_for_package(
559+
script,
560+
"simple",
561+
"0.1.0",
562+
extra_files={
563+
"simple-0.1.0.dist-info/METADATA": b"\xff"
564+
},
565+
)
566+
result = script.pip(
567+
"install", "--no-cache-dir", "--no-index", package, expect_error=True
568+
)
569+
assert "Error decoding metadata for" in result.stderr
570+
assert "simple-0.1.0-py2.py3-none-any.whl" in result.stderr
571+
assert "METADATA" in result.stderr

tests/lib/__init__.py

+6-5
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
from zipfile import ZipFile
1616

1717
import pytest
18-
from pip._vendor.six import PY2
18+
from pip._vendor.six import PY2, ensure_binary
1919
from scripttest import FoundDir, TestFileEnvironment
2020

2121
from pip._internal.index.collector import LinkCollector
@@ -1018,9 +1018,6 @@ def hello():
10181018
"{dist_info}/RECORD": ""
10191019
}
10201020

1021-
if extra_files:
1022-
files.update(extra_files)
1023-
10241021
# Some useful shorthands
10251022
archive_name = "{name}-{version}-py2.py3-none-any.whl".format(
10261023
name=name, version=version
@@ -1046,10 +1043,14 @@ def hello():
10461043
name=name, version=version, requires_dist=requires_dist
10471044
).strip()
10481045

1046+
# Add new files after formatting
1047+
if extra_files:
1048+
files.update(extra_files)
1049+
10491050
for fname in files:
10501051
path = script.temp_path / fname
10511052
path.parent.mkdir(exist_ok=True, parents=True)
1052-
path.write_text(files[fname])
1053+
path.write_bytes(ensure_binary(files[fname]))
10531054

10541055
retval = script.scratch_path / archive_name
10551056
generated = shutil.make_archive(retval, 'zip', script.temp_path)
+57
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
from email.message import Message
2+
3+
import pytest
4+
from pip._vendor.pkg_resources import DistInfoDistribution, Requirement
5+
from pip._vendor.six import ensure_binary
6+
7+
from pip._internal.utils.packaging import get_metadata, get_requires_python
8+
from pip._internal.utils.pkg_resources import DictMetadata
9+
from tests.lib import skip_if_python2
10+
11+
12+
def test_dict_metadata_works():
13+
name = "simple"
14+
version = "0.1.0"
15+
require_a = "a==1.0"
16+
require_b = "b==1.1; extra == 'also_b'"
17+
requires = [require_a, require_b, "c==1.2; extra == 'also_c'"]
18+
extras = ["also_b", "also_c"]
19+
requires_python = ">=3"
20+
21+
metadata = Message()
22+
metadata["Name"] = name
23+
metadata["Version"] = version
24+
for require in requires:
25+
metadata["Requires-Dist"] = require
26+
for extra in extras:
27+
metadata["Provides-Extra"] = extra
28+
metadata["Requires-Python"] = requires_python
29+
30+
inner_metadata = DictMetadata({
31+
"METADATA": ensure_binary(metadata.as_string())
32+
})
33+
dist = DistInfoDistribution(
34+
location="<in-memory>", metadata=inner_metadata, project_name=name
35+
)
36+
37+
assert name == dist.project_name
38+
assert version == dist.version
39+
assert set(extras) == set(dist.extras)
40+
assert [Requirement.parse(require_a)] == dist.requires([])
41+
assert [
42+
Requirement.parse(require_a), Requirement.parse(require_b)
43+
] == dist.requires(["also_b"])
44+
assert metadata.as_string() == get_metadata(dist).as_string()
45+
assert requires_python == get_requires_python(dist)
46+
47+
48+
# Metadata is not decoded on Python 2, so no chance for error.
49+
@skip_if_python2
50+
def test_dict_metadata_throws_on_bad_unicode():
51+
metadata = DictMetadata({
52+
"METADATA": b"\xff"
53+
})
54+
55+
with pytest.raises(UnicodeDecodeError) as e:
56+
metadata.get_metadata("METADATA")
57+
assert "METADATA" in str(e.value)

0 commit comments

Comments
 (0)