diff --git a/dev-requirements.txt b/dev-requirements.txt index dc578b7c328..4ca7aecf3e2 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -5,6 +5,6 @@ pytest-capturelog pytest-cov pytest-timeout pytest-xdist -mock +mock<1.1 scripttest>=1.3 https://github.com/pypa/virtualenv/archive/develop.zip#egg=virtualenv diff --git a/pip/commands/install.py b/pip/commands/install.py index dbcf100e1e4..427462bd8a2 100644 --- a/pip/commands/install.py +++ b/pip/commands/install.py @@ -14,6 +14,7 @@ from pip.req import RequirementSet from pip.basecommand import RequirementCommand from pip.locations import virtualenv_no_global, distutils_scheme +from pip.pep425tags import get_platform from pip.index import PackageFinder from pip.exceptions import ( InstallationError, CommandError, PreviousBuildDirError, @@ -82,6 +83,33 @@ def __init__(self, *args, **kw): "regardless of what's already installed."), ) + cmd_opts.add_option( + '--platform', + dest='platform', + metavar='platform', + default=get_platform(), + help=("Specifically download wheels compatible with " + "where the default is the platfrom of the local computer. " + "This option may only be used if --download is also being " + "used."), + ) + + cmd_opts.add_option( + '--interpreter-version', + dest='interpreter_version', + metavar='version', + default='', + help=("Specifically download wheels compatible with Python " + "interpreter version . If not specified, then the " + "current system interpreter version is used. This option " + "may only be used if --download is also being used. This " + "is a stricter approach compared to the native search " + "performed without this option specified: this does not " + "accept previous minor versions of Python. It is meant to " + "be used when you want to download packages that support an " + "exact version of Python."), + ) + cmd_opts.add_option(cmdoptions.download_cache()) cmd_opts.add_option(cmdoptions.src()) @@ -175,7 +203,14 @@ def __init__(self, *args, **kw): self.parser.insert_option_group(0, index_opts) self.parser.insert_option_group(0, cmd_opts) - def _build_package_finder(self, options, index_urls, session): + def _build_package_finder( + self, + options, + index_urls, + session, + platform, + desired_interp_versions + ): """ Create a package finder appropriate to this install command. This method is meant to be overridden by subclasses, not @@ -192,6 +227,8 @@ def _build_package_finder(self, options, index_urls, session): allow_all_prereleases=options.pre, process_dependency_links=options.process_dependency_links, session=session, + platform=platform, + versions=desired_interp_versions, ) def run(self, options, args): @@ -201,6 +238,26 @@ def run(self, options, args): if options.download_dir: options.ignore_installed = True + desired_platform = options.platform + if desired_platform != get_platform() and not options.download_dir: + raise CommandError( + "Usage: Cannot use --platform without also using " + "--download option." + ) + + if options.interpreter_version: + if options.download_dir: + desired_interp_versions = [ + options.interpreter_version + ] + else: + raise CommandError( + "Usage: Cannot use --interpreter-version " + "without also using --download option." + ) + else: + desired_interp_versions = None + if options.build_dir: options.build_dir = os.path.abspath(options.build_dir) @@ -243,8 +300,13 @@ def run(self, options, args): ) with self._build_session(options) as session: - - finder = self._build_package_finder(options, index_urls, session) + finder = self._build_package_finder( + options, + index_urls, + session, + desired_platform, + desired_interp_versions + ) build_delete = (not (options.no_clean or options.build_dir)) wheel_cache = WheelCache(options.cache_dir, options.format_control) if options.cache_dir and not check_path_owner(options.cache_dir): diff --git a/pip/index.py b/pip/index.py index 36c93c847a7..07f768f7c45 100644 --- a/pip/index.py +++ b/pip/index.py @@ -28,7 +28,7 @@ from pip.download import HAS_TLS, url_to_path, path_to_url from pip.models import PyPI from pip.wheel import Wheel, wheel_ext -from pip.pep425tags import supported_tags, supported_tags_noarch, get_platform +from pip.pep425tags import get_supported, get_platform from pip._vendor import html5lib, requests, pkg_resources, six from pip._vendor.packaging.version import parse as parse_version from pip._vendor.requests.exceptions import SSLError @@ -103,12 +103,20 @@ def __init__(self, find_links, index_urls, allow_external=(), allow_unverified=(), allow_all_external=False, 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): """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. """ if session is None: raise TypeError( @@ -174,6 +182,19 @@ def __init__(self, find_links, index_urls, # The Session we'll use to make requests self.session = session + # The platform for which to find compatible packages + self.platform = platform or get_platform() + + # The valid tags to check potential found wheel candidates against + self.valid_tags = get_supported( + versions=versions, + specificplatform=self.platform + ) + 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: @@ -248,24 +269,24 @@ 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 == INSTALLED_VERSION: pri = 1 elif 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) @@ -703,7 +724,7 @@ def _log_skipped_link(self, link, reason): def _link_package_versions(self, link, search): """Return an InstallationCandidate or None""" - platform = get_platform() + platform = self.platform version = None if link.egg_fragment: @@ -736,7 +757,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 @@ -757,7 +779,7 @@ def _link_package_versions(self, link, search): urllib_parse.urlparse( comes_from.url ).netloc.endswith(PyPI.netloc)): - if not wheel.supported(tags=supported_tags_noarch): + if not wheel.supported(tags=self.valid_tags_noarch): self._log_skipped_link( link, "it is a pypi-hosted binary " diff --git a/pip/pep425tags.py b/pip/pep425tags.py index e619f6b3fc4..4fe518e4f94 100644 --- a/pip/pep425tags.py +++ b/pip/pep425tags.py @@ -39,12 +39,14 @@ def get_platform(): return distutils.util.get_platform().replace('.', '_').replace('-', '_') -def get_supported(versions=None, noarch=False): +def get_supported(versions=None, noarch=False, specificplatform=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 specificplatform: specify the exact platform you want valid + tags for, or None. If None, use the local system platform. """ supported = [] @@ -80,7 +82,7 @@ def get_supported(versions=None, noarch=False): abis.append('none') if not noarch: - arch = get_platform() + arch = specificplatform or get_platform() if sys.platform == 'darwin': # support macosx-10.6-intel on macosx-10.9-x86_64 match = _osx_arch_pat.match(arch) diff --git a/tests/functional/test_install_download.py b/tests/functional/test_install_download.py index ba22c9531fa..913e842f753 100644 --- a/tests/functional/test_install_download.py +++ b/tests/functional/test_install_download.py @@ -146,3 +146,29 @@ def test_download_vcs_link(script): in result.files_created ) assert script.site_packages / 'piptestpackage' not in result.files_created + + +@pytest.mark.network +def test_pip_install_download_specify_platform(script, data): + """ + Test using "pip install --download --platform" to download a .whl archive + supported for a specific platform + """ + result = script.pip( + 'install', '--no-index', '--find-links', data.find_links, + '--download', '.', '--platform', 'linux_x86_64', 'simplewheel' + ) + assert ( + Path('scratch') / 'simplewheel-2.0-py2.py3-none-any.whl' + in result.files_created + ) + + result = script.pip( + 'install', '--no-index', '--find-links', data.find_links, + '--download', '.', '--platform', 'macosx_10_9_x86_64', + 'requires_source' + ) + assert ( + Path('scratch') / 'requires_source-1.0-py2.py3-none-any.whl' + in result.files_created + ) diff --git a/tests/unit/test_finder.py b/tests/unit/test_finder.py index ffb6e3dcf2f..4adb621b570 100644 --- a/tests/unit/test_finder.py +++ b/tests/unit/test_finder.py @@ -154,6 +154,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) @@ -215,11 +216,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 @@ -248,9 +244,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 = finder._sort_versions(links) results2 = finder._sort_versions(reversed(links))