diff --git a/CHANGES.txt b/CHANGES.txt index 3189697b819..990bef8d82e 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -2,6 +2,11 @@ * Added optional column formatting to ``pip list`` (:issue:`3651`). +* Add --platform, --python-version, --implementation and --abi parameters to + ``pip download``. These allow utilities and advanced users to gather + distributions for interpreters other than the one pip is being run on. + (:pull:`3092`) + **8.1.2 (2016-05-10)** diff --git a/docs/reference/pip_download.rst b/docs/reference/pip_download.rst index b79a1ebc5ac..bf3e4c9898d 100644 --- a/docs/reference/pip_download.rst +++ b/docs/reference/pip_download.rst @@ -26,9 +26,22 @@ which is now deprecated and will be removed in pip 10. ``pip download`` does the same resolution and downloading as ``pip install``, but instead of installing the dependencies, it collects the downloaded distributions into the directory provided (defaulting to the current -directory). This directory can later be passed as the value to -``pip install --find-links`` to facilitate offline or locked down package -installation. +directory). This directory can later be passed as the value to ``pip install +--find-links`` to facilitate offline or locked down package installation. + +``pip download`` with the ``--platform``, ``--python-version``, +``--implementation``, and ``--abi`` options provides the ability to fetch +dependencies for an interpreter and system other than the ones that pip is +running on. ``--only-binary=:all:`` is required when using any of these +options. It is important to note that these options all default to the +current system/interpreter, and not to the most restrictive constraints (e.g. +platform any, abi none, etc). To avoid fetching dependencies that happen to +match the constraint of the current interpreter (but not your target one), it +is recommended to specify all of these options if you are specifying one of +them. Generic dependencies (e.g. universal wheels, or dependencies with no +platform, abi, or implementation constraints) will still match an over- +constrained download requirement. + Options @@ -50,4 +63,58 @@ Examples $ pip download -d . SomePackage # equivalent to above $ pip download --no-index --find-links=/tmp/wheelhouse -d /tmp/otherwheelhouse SomePackage +2. Download a package and all of its dependencies with OSX specific interpreter constraints. + This forces OSX 10.10 or lower compatibility. Since OSX deps are forward compatible, + this will also match ``macosx-10_9_x86_64``, ``macosx-10_8_x86_64``, ``macosx-10_8_intel``, + etc. + It will also match deps with platform ``any``. Also force the interpreter version to ``27`` + (or more generic, i.e. ``2``) and implementation to ``cp`` (or more generic, i.e. ``py``). + + :: + + $ pip download \ + --only-binary=:all: \ + --platform macosx-10_10_x86_64 \ + --python-version 27 \ + --implementation cp \ + SomePackage + +3. Download a package and its dependencies with linux specific constraints, including + packages that support the ``manylinux1`` platform. Force the interpreter to be any + minor version of py3k, and only accept ``cp34m`` or ``none`` as the abi. + + :: + + $ pip download \ + --only-binary=:all: \ + --platform linux_x86_64 --manylinux \ + --python-version 3 \ + --implementation cp \ + --abi cp34m \ + SomePackage + +4. Force platform, implementation, and abi agnostic deps. + + :: + + $ pip download \ + --only-binary=:all: \ + --platform any \ + --python-version 3 \ + --implementation py \ + --abi none \ + SomePackage + +5. Even when overconstrained, this will still correctly fetch the pip universal wheel. + + :: + $ pip download \ + --only-binary=:all: \ + --platform linux_x86_64 --manylinux \ + --python-version 33 \ + --implementation cp \ + --abi cp34m \ + pip>=8 + $ ls pip-8.1.1-py2.py3-none-any.whl + pip-8.1.1-py2.py3-none-any.whl diff --git a/pip/basecommand.py b/pip/basecommand.py index b56bab4e07d..b005639ab4b 100644 --- a/pip/basecommand.py +++ b/pip/basecommand.py @@ -311,7 +311,9 @@ def populate_requirement_set(requirement_set, args, options, finder, 'to %(name)s (see "pip help %(name)s")' % opts) logger.warning(msg) - def _build_package_finder(self, options, session): + def _build_package_finder(self, options, session, + platform=None, python_versions=None, + abi=None, implementation=None): """ Create a package finder appropriate to this requirement command. """ @@ -328,4 +330,8 @@ def _build_package_finder(self, options, session): allow_all_prereleases=options.pre, process_dependency_links=options.process_dependency_links, session=session, + platform=platform, + versions=python_versions, + abi=abi, + implementation=implementation, ) diff --git a/pip/commands/download.py b/pip/commands/download.py index 4155e052c5a..8e7fbca39c0 100644 --- a/pip/commands/download.py +++ b/pip/commands/download.py @@ -3,6 +3,8 @@ import logging import os +from pip.exceptions import CommandError +from pip.index import FormatControl from pip.req import RequirementSet from pip.basecommand import RequirementCommand from pip import cmdoptions @@ -63,6 +65,53 @@ def __init__(self, *args, **kw): help=("Download packages into ."), ) + cmd_opts.add_option( + '--platform', + dest='platform', + metavar='platform', + default=None, + help=("Only download wheels compatible with . " + "Defaults to the platform of the local computer."), + ) + + cmd_opts.add_option( + '--python-version', + dest='python_version', + metavar='python_version', + default=None, + help=("Only download wheels compatible with Python " + "interpreter version . If not specified, then the " + "current system interpreter minor version is used. A major " + "version (e.g. '2') can be specified to match all " + "minor revs of that major version. A minor version " + "(e.g. '34') can also be specified."), + ) + + cmd_opts.add_option( + '--implementation', + dest='implementation', + metavar='implementation', + default=None, + help=("Only download wheels compatible with Python " + "implementation , e.g. 'pp', 'jy', 'cp', " + " or 'ip'. If not specified, then the current " + "interpreter implementation is used. Use 'py' to force " + "implementation-agnostic wheels."), + ) + + cmd_opts.add_option( + '--abi', + dest='abi', + metavar='abi', + default=None, + help=("Only download wheels compatible with Python " + "abi , e.g. 'pypy_41'. If not specified, then the " + "current interpreter abi tag is used. Generally " + "you will need to specify --implementation, " + "--platform, and --python-version when using " + "this option."), + ) + index_opts = cmdoptions.make_option_group( cmdoptions.non_deprecated_index_group, self.parser, @@ -73,14 +122,41 @@ def __init__(self, *args, **kw): def run(self, options, args): options.ignore_installed = True + + if options.python_version: + python_versions = [options.python_version] + else: + python_versions = None + + dist_restriction_set = any([ + options.python_version, + options.platform, + options.abi, + options.implementation, + ]) + binary_only = FormatControl(set(), set([':all:'])) + if dist_restriction_set and options.format_control != binary_only: + raise CommandError( + "--only-binary=:all: must be set and --no-binary must not " + "be set (or must be set to :none:) when restricting platform " + "and interpreter constraints using --python-version, " + "--platform, --abi, or --implementation." + ) + options.src_dir = os.path.abspath(options.src_dir) options.download_dir = normalize_path(options.download_dir) ensure_dir(options.download_dir) with self._build_session(options) as session: - - finder = self._build_package_finder(options, session) + finder = self._build_package_finder( + options=options, + session=session, + platform=options.platform, + python_versions=python_versions, + abi=options.abi, + implementation=options.implementation, + ) build_delete = (not (options.no_clean or options.build_dir)) if options.cache_dir and not check_path_owner(options.cache_dir): logger.warning( diff --git a/pip/index.py b/pip/index.py index cae4c2d03da..8c511ec012c 100644 --- a/pip/index.py +++ b/pip/index.py @@ -28,7 +28,7 @@ ) from pip.download import HAS_TLS, is_url, path_to_url, url_to_path from pip.wheel import Wheel, wheel_ext -from pip.pep425tags import supported_tags +from pip.pep425tags import get_supported from pip._vendor import html5lib, requests, six from pip._vendor.packaging.version import parse as parse_version from pip._vendor.packaging.utils import canonicalize_name @@ -104,12 +104,26 @@ 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): + session=None, format_control=None, platform=None, + versions=None, abi=None, implementation=None): """Create a PackageFinder. :param format_control: A FormatControl object or None. Used to control the selection of source packages / binary packages when consulting the index and links. + :param platform: A string or None. If None, searches for packages + that are supported by the current system. Otherwise, will find + packages that can be built on the platform passed in. It is + understood that these packages will only be downloaded for + distribution: they will not be built locally. + :param versions: A list of strings or None. This is passed directly + to pep425tags.py in the get_supported() method. + :param abi: A string or None. This is passed directly + to pep425tags.py in the get_supported() method. + :param implementation: A string or None. This is passed directly + to pep425tags.py in the get_supported() method. + :param manylinux1: A boolean or None. This is passed directly + to pep425tags.py in the get_supported() method. """ if session is None: raise TypeError( @@ -153,6 +167,18 @@ def __init__(self, find_links, index_urls, allow_all_prereleases=False, # The Session we'll use to make requests self.session = session + # The valid tags to check potential found wheel candidates against + self.valid_tags = get_supported( + versions=versions, + platform=platform, + abi=abi, + impl=implementation, + ) + self.valid_tags_noarch = get_supported( + versions=versions, + noarch=True + ) + # If we don't have TLS enabled, then WARN if anyplace we're looking # relies on TLS. if not HAS_TLS: @@ -236,22 +262,22 @@ def _candidate_sort_key(self, candidate): If not finding wheels, then sorted by version only. If finding wheels, then the sort order is by version, then: 1. existing installs - 2. wheels ordered via Wheel.support_index_min() + 2. wheels ordered via Wheel.support_index_min(self.valid_tags) 3. source archives 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(supported_tags) + support_num = len(self.valid_tags) if candidate.location.is_wheel: # can raise InvalidWheelFilename wheel = Wheel(candidate.location.filename) - if not wheel.supported(): + if not wheel.supported(self.valid_tags): raise UnsupportedWheel( "%s is not a supported wheel for this platform. It " "can't be sorted." % wheel.filename ) - pri = -(wheel.support_index_min()) + pri = -(wheel.support_index_min(self.valid_tags)) else: # sdist pri = -(support_num) return (candidate.version, pri) @@ -581,7 +607,6 @@ def _log_skipped_link(self, link, reason): def _link_package_versions(self, link, search): """Return an InstallationCandidate or None""" - version = None if link.egg_fragment: egg_info = link.egg_fragment @@ -612,7 +637,8 @@ def _link_package_versions(self, link, search): self._log_skipped_link( link, 'wrong project name (not %s)' % search.supplied) return - if not wheel.supported(): + + if not wheel.supported(self.valid_tags): self._log_skipped_link( link, 'it is not compatible with this Python') return diff --git a/pip/pep425tags.py b/pip/pep425tags.py index 3584face871..c4a3c60d2bc 100644 --- a/pip/pep425tags.py +++ b/pip/pep425tags.py @@ -264,12 +264,19 @@ def _supports_arch(major, minor, arch): return arches -def get_supported(versions=None, noarch=False): +def get_supported(versions=None, noarch=False, platform=None, + impl=None, abi=None): """Return a list of supported tags for each version specified in `versions`. :param versions: a list of string versions, of the form ["33", "32"], or None. The first version will be assumed to support our ABI. + :param platform: specify the exact platform you want valid + tags for, or None. If None, use the local system platform. + :param impl: specify the exact implementation you want valid + tags for, or None. If None, use the local interpreter impl. + :param abi: specify the exact abi you want valid + tags for, or None. If None, use the local interpreter abi. """ supported = [] @@ -282,11 +289,11 @@ def get_supported(versions=None, noarch=False): for minor in range(version_info[-1], -1, -1): versions.append(''.join(map(str, major + (minor,)))) - impl = get_abbr_impl() + impl = impl or get_abbr_impl() abis = [] - abi = get_abi_tag() + abi = abi or get_abi_tag() if abi: abis[0:0] = [abi] @@ -301,8 +308,8 @@ def get_supported(versions=None, noarch=False): abis.append('none') if not noarch: - arch = get_platform() - if sys.platform == 'darwin': + arch = platform or get_platform() + if arch.startswith('macosx'): # support macosx-10.6-intel on macosx-10.9-x86_64 match = _osx_arch_pat.match(arch) if match: @@ -315,7 +322,7 @@ def get_supported(versions=None, noarch=False): else: # arch pattern didn't match (?!) arches = [arch] - elif is_manylinux1_compatible(): + elif platform is None and is_manylinux1_compatible(): arches = [arch.replace('linux', 'manylinux1'), arch] else: arches = [arch] diff --git a/tests/functional/test_download.py b/tests/functional/test_download.py index 54132261fbe..16da5f2d412 100644 --- a/tests/functional/test_download.py +++ b/tests/functional/test_download.py @@ -5,6 +5,12 @@ from tests.lib.path import Path +def fake_wheel(data, wheel_path): + data.packages.join( + 'simple.dist-0.1-py2.py3-none-any.whl' + ).copy(data.packages.join(wheel_path)) + + @pytest.mark.network def test_download_if_requested(script): """ @@ -159,3 +165,394 @@ def test_download_vcs_link(script): in result.files_created ) assert script.site_packages / 'piptestpackage' not in result.files_created + + +def test_download_specify_platform_only_binary(script, data): + """ + Confirm that specifying an interpreter/platform constraint + enforces that ``--only-binary=:all:`` is set. + """ + fake_wheel(data, 'fake-1.0-py2.py3-none-any.whl') + + result = script.pip( + 'download', '--no-index', '--find-links', data.find_links, + '--only-binary=:all:', + '--dest', '.', + '--platform', 'linux_x86_64', + 'fake' + ) + assert ( + Path('scratch') / 'fake-1.0-py2.py3-none-any.whl' + in result.files_created + ) + + result = script.pip( + 'download', '--no-index', '--find-links', data.find_links, + '--dest', '.', + '--platform', 'linux_x86_64', + 'fake', + expect_error=True, + ) + assert '--only-binary=:all:' in result.stderr + + result = script.pip( + 'download', '--no-index', '--find-links', data.find_links, + '--dest', '.', + '--platform', 'linux_x86_64', + 'fake', + expect_error=True, + ) + assert '--only-binary=:all:' in result.stderr + + result = script.pip( + 'download', '--no-index', '--find-links', data.find_links, + '--only-binary=:all:', + '--no-binary=fake', + '--dest', '.', + '--platform', 'linux_x86_64', + 'fake', + expect_error=True, + ) + assert '--only-binary=:all:' in result.stderr + + +def test_download_specify_platform(script, data): + """ + Test using "pip download --platform" to download a .whl archive + supported for a specific platform + """ + fake_wheel(data, 'fake-1.0-py2.py3-none-any.whl') + + # Confirm that universal wheels are returned even for specific + # platforms. + result = script.pip( + 'download', '--no-index', '--find-links', data.find_links, + '--only-binary=:all:', + '--dest', '.', + '--platform', 'linux_x86_64', + 'fake' + ) + assert ( + Path('scratch') / 'fake-1.0-py2.py3-none-any.whl' + in result.files_created + ) + + result = script.pip( + 'download', '--no-index', '--find-links', data.find_links, + '--only-binary=:all:', + '--dest', '.', + '--platform', 'macosx_10_9_x86_64', + 'fake' + ) + + data.reset() + fake_wheel(data, 'fake-1.0-py2.py3-none-macosx_10_9_x86_64.whl') + fake_wheel(data, 'fake-2.0-py2.py3-none-linux_x86_64.whl') + + result = script.pip( + 'download', '--no-index', '--find-links', data.find_links, + '--only-binary=:all:', + '--dest', '.', + '--platform', 'macosx_10_10_x86_64', + 'fake' + ) + assert ( + Path('scratch') / + 'fake-1.0-py2.py3-none-macosx_10_9_x86_64.whl' + in result.files_created + ) + + # OSX platform wheels are not backward-compatible. + result = script.pip( + 'download', '--no-index', '--find-links', data.find_links, + '--only-binary=:all:', + '--dest', '.', + '--platform', 'macosx_10_8_x86_64', + 'fake', + expect_error=True, + ) + + # No linux wheel provided for this version. + result = script.pip( + 'download', '--no-index', '--find-links', data.find_links, + '--only-binary=:all:', + '--dest', '.', + '--platform', 'linux_x86_64', + 'fake==1', + expect_error=True, + ) + + result = script.pip( + 'download', '--no-index', '--find-links', data.find_links, + '--only-binary=:all:', + '--dest', '.', + '--platform', 'linux_x86_64', + 'fake==2' + ) + assert ( + Path('scratch') / 'fake-2.0-py2.py3-none-linux_x86_64.whl' + in result.files_created + ) + + +def test_download_platform_manylinux(script, data): + """ + Test using "pip download --platform" to download a .whl archive + supported for a specific platform. + """ + fake_wheel(data, 'fake-1.0-py2.py3-none-any.whl') + # Confirm that universal wheels are returned even for specific + # platforms. + result = script.pip( + 'download', '--no-index', '--find-links', data.find_links, + '--only-binary=:all:', + '--dest', '.', + '--platform', 'linux_x86_64', + 'fake', + ) + assert ( + Path('scratch') / 'fake-1.0-py2.py3-none-any.whl' + in result.files_created + ) + + data.reset() + fake_wheel(data, 'fake-1.0-py2.py3-none-manylinux1_x86_64.whl') + result = script.pip( + 'download', '--no-index', '--find-links', data.find_links, + '--only-binary=:all:', + '--dest', '.', + '--platform', 'manylinux1_x86_64', + 'fake', + ) + assert ( + Path('scratch') / + 'fake-1.0-py2.py3-none-manylinux1_x86_64.whl' + in result.files_created + ) + + # When specifying the platform, manylinux1 needs to be the + # explicit platform--it won't ever be added to the compatible + # tags. + data.reset() + fake_wheel(data, 'fake-1.0-py2.py3-none-linux_x86_64.whl') + result = script.pip( + 'download', '--no-index', '--find-links', data.find_links, + '--only-binary=:all:', + '--dest', '.', + '--platform', 'linux_x86_64', + 'fake', + expect_error=True, + ) + + +def test_download_specify_python_version(script, data): + """ + Test using "pip download --python-version" to download a .whl archive + supported for a specific interpreter + """ + fake_wheel(data, 'fake-1.0-py2.py3-none-any.whl') + result = script.pip( + 'download', '--no-index', '--find-links', data.find_links, + '--only-binary=:all:', + '--dest', '.', + '--python-version', '2', + 'fake' + ) + assert ( + Path('scratch') / 'fake-1.0-py2.py3-none-any.whl' + in result.files_created + ) + + result = script.pip( + 'download', '--no-index', '--find-links', data.find_links, + '--only-binary=:all:', + '--dest', '.', + '--python-version', '3', + 'fake' + ) + + result = script.pip( + 'download', '--no-index', '--find-links', data.find_links, + '--only-binary=:all:', + '--dest', '.', + '--python-version', '27', + 'fake' + ) + + result = script.pip( + 'download', '--no-index', '--find-links', data.find_links, + '--only-binary=:all:', + '--dest', '.', + '--python-version', '33', + 'fake' + ) + + data.reset() + fake_wheel(data, 'fake-1.0-py2-none-any.whl') + fake_wheel(data, 'fake-2.0-py3-none-any.whl') + + # No py3 provided for version 1. + result = script.pip( + 'download', '--no-index', '--find-links', data.find_links, + '--only-binary=:all:', + '--dest', '.', + '--python-version', '3', + 'fake==1.0', + expect_error=True, + ) + + result = script.pip( + 'download', '--no-index', '--find-links', data.find_links, + '--only-binary=:all:', + '--dest', '.', + '--python-version', '2', + 'fake' + ) + assert ( + Path('scratch') / 'fake-1.0-py2-none-any.whl' + in result.files_created + ) + + result = script.pip( + 'download', '--no-index', '--find-links', data.find_links, + '--only-binary=:all:', + '--dest', '.', + '--python-version', '26', + 'fake' + ) + + result = script.pip( + 'download', '--no-index', '--find-links', data.find_links, + '--only-binary=:all:', + '--dest', '.', + '--python-version', '3', + 'fake' + ) + assert ( + Path('scratch') / 'fake-2.0-py3-none-any.whl' + in result.files_created + ) + + +def test_download_specify_abi(script, data): + """ + Test using "pip download --abi" to download a .whl archive + supported for a specific abi + """ + fake_wheel(data, 'fake-1.0-py2.py3-none-any.whl') + result = script.pip( + 'download', '--no-index', '--find-links', data.find_links, + '--only-binary=:all:', + '--dest', '.', + '--implementation', 'fk', + '--abi', 'fake_abi', + 'fake' + ) + assert ( + Path('scratch') / 'fake-1.0-py2.py3-none-any.whl' + in result.files_created + ) + + result = script.pip( + 'download', '--no-index', '--find-links', data.find_links, + '--only-binary=:all:', + '--dest', '.', + '--implementation', 'fk', + '--abi', 'none', + 'fake' + ) + + result = script.pip( + 'download', '--no-index', '--find-links', data.find_links, + '--only-binary=:all:', + '--dest', '.', + '--abi', 'cp27m', + 'fake', + expect_error=True, + ) + + data.reset() + fake_wheel(data, 'fake-1.0-fk2-fakeabi-fake_platform.whl') + result = script.pip( + 'download', '--no-index', '--find-links', data.find_links, + '--only-binary=:all:', + '--dest', '.', + '--python-version', '2', + '--implementation', 'fk', + '--platform', 'fake_platform', + '--abi', 'fakeabi', + 'fake' + ) + assert ( + Path('scratch') / 'fake-1.0-fk2-fakeabi-fake_platform.whl' + in result.files_created + ) + + result = script.pip( + 'download', '--no-index', '--find-links', data.find_links, + '--only-binary=:all:', + '--dest', '.', + '--implementation', 'fk', + '--platform', 'fake_platform', + '--abi', 'none', + 'fake', + expect_error=True, + ) + + +def test_download_specify_implementation(script, data): + """ + Test using "pip download --abi" to download a .whl archive + supported for a specific abi + """ + fake_wheel(data, 'fake-1.0-py2.py3-none-any.whl') + result = script.pip( + 'download', '--no-index', '--find-links', data.find_links, + '--only-binary=:all:', + '--dest', '.', + '--implementation', 'fk', + 'fake' + ) + assert ( + Path('scratch') / 'fake-1.0-py2.py3-none-any.whl' + in result.files_created + ) + + data.reset() + fake_wheel(data, 'fake-1.0-fk2.fk3-none-any.whl') + result = script.pip( + 'download', '--no-index', '--find-links', data.find_links, + '--only-binary=:all:', + '--dest', '.', + '--implementation', 'fk', + 'fake' + ) + assert ( + Path('scratch') / 'fake-1.0-fk2.fk3-none-any.whl' + in result.files_created + ) + + data.reset() + fake_wheel(data, 'fake-1.0-fk3-none-any.whl') + result = script.pip( + 'download', '--no-index', '--find-links', data.find_links, + '--only-binary=:all:', + '--dest', '.', + '--implementation', 'fk', + '--python-version', '3', + 'fake' + ) + assert ( + Path('scratch') / 'fake-1.0-fk3-none-any.whl' + in result.files_created + ) + + result = script.pip( + 'download', '--no-index', '--find-links', data.find_links, + '--only-binary=:all:', + '--dest', '.', + '--implementation', 'fk', + '--python-version', '2', + 'fake', + expect_error=True, + ) diff --git a/tests/unit/test_finder.py b/tests/unit/test_finder.py index 48265e98d89..b8d44f2e302 100644 --- a/tests/unit/test_finder.py +++ b/tests/unit/test_finder.py @@ -149,6 +149,7 @@ def test_not_find_wheel_not_supported(self, data, monkeypatch): [], session=PipSession(), ) + finder.valid_tags = pip.pep425tags.supported_tags with pytest.raises(DistributionNotFound): finder.find_requirement(req, True) @@ -210,11 +211,6 @@ def test_existing_over_wheel_priority(self, data): with pytest.raises(BestVersionAlreadyInstalled): finder.find_requirement(req, True) - @patch('pip.pep425tags.supported_tags', [ - ('pyT', 'none', 'TEST'), - ('pyT', 'TEST', 'any'), - ('pyT', 'none', 'any'), - ]) def test_link_sorting(self): """ Test link sorting @@ -242,9 +238,12 @@ def test_link_sorting(self): Link('simple-1.0.tar.gz'), ), ] - finder = PackageFinder([], [], session=PipSession()) - + finder.valid_tags = [ + ('pyT', 'none', 'TEST'), + ('pyT', 'TEST', 'any'), + ('pyT', 'none', 'any'), + ] results = sorted(links, key=finder._candidate_sort_key, reverse=True) results2 = sorted(reversed(links),