Skip to content

Commit 89a51a6

Browse files
authored
Merge pull request #8588 from McSinyx/fast-deps
Use lazy wheel to obtain dep info for new resolver
2 parents 43485f5 + 4efae5c commit 89a51a6

File tree

9 files changed

+124
-16
lines changed

9 files changed

+124
-16
lines changed

news/8588.feature

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Allow the new resolver to obtain dependency information through wheels
2+
lazily downloaded using HTTP range requests. To enable this feature,
3+
invoke ``pip`` with ``--use-feature=fast-deps``.

src/pip/_internal/cli/cmdoptions.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -913,7 +913,7 @@ def check_list_path_option(options):
913913
metavar='feature',
914914
action='append',
915915
default=[],
916-
choices=['2020-resolver'],
916+
choices=['2020-resolver', 'fast-deps'],
917917
help='Enable new functionality, that may be backward incompatible.',
918918
) # type: Callable[..., Option]
919919

src/pip/_internal/cli/req_command.py

+1
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,7 @@ def make_resolver(
271271
force_reinstall=force_reinstall,
272272
upgrade_strategy=upgrade_strategy,
273273
py_version_info=py_version_info,
274+
lazy_wheel='fast-deps' in options.features_enabled,
274275
)
275276
import pip._internal.resolution.legacy.resolver
276277
return pip._internal.resolution.legacy.resolver.Resolver(

src/pip/_internal/resolution/resolvelib/candidates.py

+46-15
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,22 @@
11
import logging
22
import sys
33

4+
from pip._vendor.contextlib2 import suppress
45
from pip._vendor.packaging.specifiers import InvalidSpecifier, SpecifierSet
56
from pip._vendor.packaging.utils import canonicalize_name
67
from pip._vendor.packaging.version import Version
78

89
from pip._internal.exceptions import HashError, MetadataInconsistent
10+
from pip._internal.network.lazy_wheel import (
11+
HTTPRangeRequestUnsupported,
12+
dist_from_wheel_url,
13+
)
914
from pip._internal.req.constructors import (
1015
install_req_from_editable,
1116
install_req_from_line,
1217
)
1318
from pip._internal.req.req_install import InstallRequirement
19+
from pip._internal.utils.logging import indent_log
1420
from pip._internal.utils.misc import dist_is_editable, normalize_version_info
1521
from pip._internal.utils.packaging import get_requires_python
1622
from pip._internal.utils.typing import MYPY_CHECK_RUNNING
@@ -142,6 +148,7 @@ def __init__(
142148
self._name = name
143149
self._version = version
144150
self._dist = None # type: Optional[Distribution]
151+
self._prepared = False
145152

146153
def __repr__(self):
147154
# type: () -> str
@@ -197,11 +204,23 @@ def _prepare_abstract_distribution(self):
197204
# type: () -> AbstractDistribution
198205
raise NotImplementedError("Override in subclass")
199206

207+
def _check_metadata_consistency(self):
208+
# type: () -> None
209+
"""Check for consistency of project name and version of dist."""
210+
# TODO: (Longer term) Rather than abort, reject this candidate
211+
# and backtrack. This would need resolvelib support.
212+
dist = self._dist # type: Distribution
213+
name = canonicalize_name(dist.project_name)
214+
if self._name is not None and self._name != name:
215+
raise MetadataInconsistent(self._ireq, "name", dist.project_name)
216+
version = dist.parsed_version
217+
if self._version is not None and self._version != version:
218+
raise MetadataInconsistent(self._ireq, "version", dist.version)
219+
200220
def _prepare(self):
201221
# type: () -> None
202-
if self._dist is not None:
222+
if self._prepared:
203223
return
204-
205224
try:
206225
abstract_dist = self._prepare_abstract_distribution()
207226
except HashError as e:
@@ -210,24 +229,36 @@ def _prepare(self):
210229

211230
self._dist = abstract_dist.get_pkg_resources_distribution()
212231
assert self._dist is not None, "Distribution already installed"
232+
self._check_metadata_consistency()
233+
self._prepared = True
213234

214-
# TODO: (Longer term) Rather than abort, reject this candidate
215-
# and backtrack. This would need resolvelib support.
216-
name = canonicalize_name(self._dist.project_name)
217-
if self._name is not None and self._name != name:
218-
raise MetadataInconsistent(
219-
self._ireq, "name", self._dist.project_name,
220-
)
221-
version = self._dist.parsed_version
222-
if self._version is not None and self._version != version:
223-
raise MetadataInconsistent(
224-
self._ireq, "version", self._dist.version,
225-
)
235+
def _fetch_metadata(self):
236+
# type: () -> None
237+
"""Fetch metadata, using lazy wheel if possible."""
238+
preparer = self._factory.preparer
239+
use_lazy_wheel = self._factory.use_lazy_wheel
240+
remote_wheel = self._link.is_wheel and not self._link.is_file
241+
if use_lazy_wheel and remote_wheel and not preparer.require_hashes:
242+
assert self._name is not None
243+
logger.info('Collecting %s', self._ireq.req or self._ireq)
244+
# If HTTPRangeRequestUnsupported is raised, fallback silently.
245+
with indent_log(), suppress(HTTPRangeRequestUnsupported):
246+
logger.info(
247+
'Obtaining dependency information from %s %s',
248+
self._name, self._version,
249+
)
250+
url = self._link.url.split('#', 1)[0]
251+
session = preparer.downloader._session
252+
self._dist = dist_from_wheel_url(self._name, url, session)
253+
self._check_metadata_consistency()
254+
if self._dist is None:
255+
self._prepare()
226256

227257
@property
228258
def dist(self):
229259
# type: () -> Distribution
230-
self._prepare()
260+
if self._dist is None:
261+
self._fetch_metadata()
231262
return self._dist
232263

233264
def _get_requires_python_specifier(self):

src/pip/_internal/resolution/resolvelib/factory.py

+2
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ def __init__(
8282
ignore_installed, # type: bool
8383
ignore_requires_python, # type: bool
8484
py_version_info=None, # type: Optional[Tuple[int, ...]]
85+
lazy_wheel=False, # type: bool
8586
):
8687
# type: (...) -> None
8788
self._finder = finder
@@ -92,6 +93,7 @@ def __init__(
9293
self._use_user_site = use_user_site
9394
self._force_reinstall = force_reinstall
9495
self._ignore_requires_python = ignore_requires_python
96+
self.use_lazy_wheel = lazy_wheel
9597

9698
self._link_candidate_cache = {} # type: Cache[LinkCandidate]
9799
self._editable_candidate_cache = {} # type: Cache[EditableCandidate]

src/pip/_internal/resolution/resolvelib/resolver.py

+9
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,16 @@ def __init__(
4949
force_reinstall, # type: bool
5050
upgrade_strategy, # type: str
5151
py_version_info=None, # type: Optional[Tuple[int, ...]]
52+
lazy_wheel=False, # type: bool
5253
):
5354
super(Resolver, self).__init__()
55+
if lazy_wheel:
56+
logger.warning(
57+
'pip is using lazily downloaded wheels using HTTP '
58+
'range requests to obtain dependency information. '
59+
'This experimental feature is enabled through '
60+
'--use-feature=fast-deps and it is not ready for production.'
61+
)
5462

5563
assert upgrade_strategy in self._allowed_strategies
5664

@@ -64,6 +72,7 @@ def __init__(
6472
ignore_installed=ignore_installed,
6573
ignore_requires_python=ignore_requires_python,
6674
py_version_info=py_version_info,
75+
lazy_wheel=lazy_wheel,
6776
)
6877
self.ignore_dependencies = ignore_dependencies
6978
self.upgrade_strategy = upgrade_strategy
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
[build-system]
2+
requires = ['flit_core >=2,<4']
3+
build-backend = 'flit_core.buildapi'
4+
5+
[tool.flit.metadata]
6+
module = 'requiresPaste'
7+
author = 'A. Random Developer'
8+
author-email = '[email protected]'
9+
requires = ['Paste==3.4.2']
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
"""Module requiring Paste to test dependencies download of pip wheel."""
2+
3+
__version__ = '3.1.4'

tests/functional/test_fast_deps.py

+50
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import fnmatch
2+
import json
3+
from os.path import basename
4+
5+
from pip._vendor.packaging.utils import canonicalize_name
6+
from pytest import mark
7+
8+
9+
def pip(script, command, requirement):
10+
return script.pip(
11+
command, '--prefer-binary', '--no-cache-dir',
12+
'--use-feature=fast-deps', requirement,
13+
allow_stderr_warning=True,
14+
)
15+
16+
17+
def assert_installed(script, names):
18+
list_output = json.loads(script.pip('list', '--format=json').stdout)
19+
installed = {canonicalize_name(item['name']) for item in list_output}
20+
assert installed.issuperset(map(canonicalize_name, names))
21+
22+
23+
@mark.network
24+
@mark.parametrize(('requirement', 'expected'), (
25+
('Paste==3.4.2', ('Paste', 'six')),
26+
('Paste[flup]==3.4.2', ('Paste', 'six', 'flup')),
27+
))
28+
def test_install_from_pypi(requirement, expected, script):
29+
pip(script, 'install', requirement)
30+
assert_installed(script, expected)
31+
32+
33+
@mark.network
34+
@mark.parametrize(('requirement', 'expected'), (
35+
('Paste==3.4.2', ('Paste-3.4.2-*.whl', 'six-*.whl')),
36+
('Paste[flup]==3.4.2', ('Paste-3.4.2-*.whl', 'six-*.whl', 'flup-*')),
37+
))
38+
def test_download_from_pypi(requirement, expected, script):
39+
result = pip(script, 'download', requirement)
40+
created = list(map(basename, result.files_created))
41+
assert all(fnmatch.filter(created, f) for f in expected)
42+
43+
44+
@mark.network
45+
def test_build_wheel_with_deps(data, script):
46+
result = pip(script, 'wheel', data.packages/'requiresPaste')
47+
created = list(map(basename, result.files_created))
48+
assert fnmatch.filter(created, 'requiresPaste-3.1.4-*.whl')
49+
assert fnmatch.filter(created, 'Paste-3.4.2-*.whl')
50+
assert fnmatch.filter(created, 'six-*.whl')

0 commit comments

Comments
 (0)