diff --git a/news/3785.feature b/news/3785.feature new file mode 100644 index 00000000000..ca5463ec860 --- /dev/null +++ b/news/3785.feature @@ -0,0 +1 @@ +Introduce a new --prefer-binary flag, to prefer older wheels over newer source packages. diff --git a/src/pip/_internal/basecommand.py b/src/pip/_internal/basecommand.py index 38bdff24a04..cfd5d67577b 100644 --- a/src/pip/_internal/basecommand.py +++ b/src/pip/_internal/basecommand.py @@ -370,4 +370,5 @@ def _build_package_finder(self, options, session, versions=python_versions, abi=abi, implementation=implementation, + prefer_binary=options.prefer_binary, ) diff --git a/src/pip/_internal/cmdoptions.py b/src/pip/_internal/cmdoptions.py index 6319995d4e9..eb113457f30 100644 --- a/src/pip/_internal/cmdoptions.py +++ b/src/pip/_internal/cmdoptions.py @@ -406,6 +406,16 @@ def only_binary(): ) +def prefer_binary(): + return Option( + "--prefer-binary", + dest="prefer_binary", + action="store_true", + default=False, + help="Prefer older binary packages over newer source packages." + ) + + cache_dir = partial( Option, "--cache-dir", diff --git a/src/pip/_internal/commands/download.py b/src/pip/_internal/commands/download.py index 5713d07ce5d..66bcbd5c822 100644 --- a/src/pip/_internal/commands/download.py +++ b/src/pip/_internal/commands/download.py @@ -52,6 +52,7 @@ def __init__(self, *args, **kw): cmd_opts.add_option(cmdoptions.global_options()) cmd_opts.add_option(cmdoptions.no_binary()) cmd_opts.add_option(cmdoptions.only_binary()) + cmd_opts.add_option(cmdoptions.prefer_binary()) cmd_opts.add_option(cmdoptions.src()) cmd_opts.add_option(cmdoptions.pre()) cmd_opts.add_option(cmdoptions.no_clean()) diff --git a/src/pip/_internal/commands/install.py b/src/pip/_internal/commands/install.py index 9138683acdd..aa0988c43ad 100644 --- a/src/pip/_internal/commands/install.py +++ b/src/pip/_internal/commands/install.py @@ -183,6 +183,7 @@ def __init__(self, *args, **kw): cmd_opts.add_option(cmdoptions.no_binary()) cmd_opts.add_option(cmdoptions.only_binary()) + cmd_opts.add_option(cmdoptions.prefer_binary()) cmd_opts.add_option(cmdoptions.no_clean()) cmd_opts.add_option(cmdoptions.require_hashes()) cmd_opts.add_option(cmdoptions.progress_bar()) diff --git a/src/pip/_internal/commands/wheel.py b/src/pip/_internal/commands/wheel.py index ac55f91e62e..0fb72c1a92b 100644 --- a/src/pip/_internal/commands/wheel.py +++ b/src/pip/_internal/commands/wheel.py @@ -57,6 +57,7 @@ def __init__(self, *args, **kw): ) cmd_opts.add_option(cmdoptions.no_binary()) cmd_opts.add_option(cmdoptions.only_binary()) + cmd_opts.add_option(cmdoptions.prefer_binary()) cmd_opts.add_option( '--build-option', dest='build_options', diff --git a/src/pip/_internal/index.py b/src/pip/_internal/index.py index 3c3a92b7c1a..17aee7246c4 100644 --- a/src/pip/_internal/index.py +++ b/src/pip/_internal/index.py @@ -108,7 +108,8 @@ class PackageFinder(object): def __init__(self, find_links, index_urls, allow_all_prereleases=False, trusted_hosts=None, process_dependency_links=False, session=None, format_control=None, platform=None, - versions=None, abi=None, implementation=None): + versions=None, abi=None, implementation=None, + prefer_binary=False): """Create a PackageFinder. :param format_control: A FormatControl object or None. Used to control @@ -176,6 +177,9 @@ def __init__(self, find_links, index_urls, allow_all_prereleases=False, impl=implementation, ) + # Do we prefer old, but valid, binary dist over new source dist + self.prefer_binary = prefer_binary + # If we don't have TLS enabled, then WARN if anyplace we're looking # relies on TLS. if not HAS_TLS: @@ -275,12 +279,14 @@ def _candidate_sort_key(self, candidate): 1. existing installs 2. wheels ordered via Wheel.support_index_min(self.valid_tags) 3. source archives + If prefer_binary was set, then all wheels are sorted above sources. Note: it was considered to embed this logic into the Link comparison operators, but then different sdist links with the same version, would have to be considered equal """ support_num = len(self.valid_tags) build_tag = tuple() + binary_preference = 0 if candidate.location.is_wheel: # can raise InvalidWheelFilename wheel = Wheel(candidate.location.filename) @@ -289,6 +295,8 @@ def _candidate_sort_key(self, candidate): "%s is not a supported wheel for this platform. It " "can't be sorted." % wheel.filename ) + if self.prefer_binary: + binary_preference = 1 pri = -(wheel.support_index_min(self.valid_tags)) if wheel.build_tag is not None: match = re.match(r'^(\d+)(.*)$', wheel.build_tag) @@ -296,7 +304,7 @@ def _candidate_sort_key(self, candidate): build_tag = (int(build_tag_groups[0]), build_tag_groups[1]) else: # sdist pri = -(support_num) - return (candidate.version, build_tag, pri) + return (binary_preference, candidate.version, build_tag, pri) def _validate_secure_origin(self, logger, location): # Determine if this url used a secure transport mechanism diff --git a/tests/functional/test_download.py b/tests/functional/test_download.py index bdb46523d34..4cbfb5665a3 100644 --- a/tests/functional/test_download.py +++ b/tests/functional/test_download.py @@ -602,3 +602,60 @@ def test_download_exit_status_code_when_blank_requirements_file(script): """ script.scratch_path.join("blank.txt").write("\n") script.pip('download', '-r', 'blank.txt') + + +def test_download_prefer_binary_when_tarball_higher_than_wheel(script, data): + fake_wheel(data, 'source-0.8-py2.py3-none-any.whl') + result = script.pip( + 'download', + '--prefer-binary', + '--no-index', + '-f', data.packages, + '-d', '.', 'source' + ) + assert ( + Path('scratch') / 'source-0.8-py2.py3-none-any.whl' + in result.files_created + ) + assert ( + Path('scratch') / 'source-1.0.tar.gz' + not in result.files_created + ) + + +def test_download_prefer_binary_when_wheel_doesnt_satisfy_req(script, data): + fake_wheel(data, 'source-0.8-py2.py3-none-any.whl') + script.scratch_path.join("test-req.txt").write(textwrap.dedent(""" + source>0.9 + """)) + + result = script.pip( + 'download', + '--prefer-binary', + '--no-index', + '-f', data.packages, + '-d', '.', + '-r', script.scratch_path / 'test-req.txt' + ) + assert ( + Path('scratch') / 'source-1.0.tar.gz' + in result.files_created + ) + assert ( + Path('scratch') / 'source-0.8-py2.py3-none-any.whl' + not in result.files_created + ) + + +def test_download_prefer_binary_when_only_tarball_exists(script, data): + result = script.pip( + 'download', + '--prefer-binary', + '--no-index', + '-f', data.packages, + '-d', '.', 'source' + ) + assert ( + Path('scratch') / 'source-1.0.tar.gz' + in result.files_created + )