diff --git a/CHANGES.txt b/CHANGES.txt index a5189c4a39b..08cfc061ed6 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -8,6 +8,10 @@ * Fix regression in pip freeze: when there is more than one git remote, priority is given to the remote named origin (:issue:`3616`) +* Implementation of pep-503 ``data-requires-python``. When this field is + present for a release link, pip will ignore the download when + installing to a Python version that doesn't satisfy the requirement. + * Pip wheel now works on editable packages too (it was only working on editable dependencies before); this allows running pip wheel on the result of pip freeze in presence of editable requirements (:issue:`3291`) diff --git a/pip/baseparser.py b/pip/baseparser.py index 9b53aca160a..2dd4533016b 100644 --- a/pip/baseparser.py +++ b/pip/baseparser.py @@ -113,6 +113,7 @@ def expand_default(self, option): class CustomOptionParser(optparse.OptionParser): + def insert_option_group(self, idx, *args, **kwargs): """Insert an OptionGroup at a given position.""" group = self.add_option_group(*args, **kwargs) diff --git a/pip/index.py b/pip/index.py index f8a298e9f0d..15cf460e76b 100644 --- a/pip/index.py +++ b/pip/index.py @@ -22,6 +22,7 @@ ) from pip.utils.deprecation import RemovedInPip9Warning, RemovedInPip10Warning from pip.utils.logging import indent_log +from pip.utils.packaging import check_requires_python from pip.exceptions import ( DistributionNotFound, BestVersionAlreadyInstalled, InvalidWheelFilename, UnsupportedWheel, @@ -32,7 +33,9 @@ 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 +from pip._vendor.packaging import specifiers from pip._vendor.requests.exceptions import SSLError +from pip._vendor.distlib.compat import unescape __all__ = ['FormatControl', 'fmt_ctl_handle_mutual_exclude', 'PackageFinder'] @@ -640,6 +643,18 @@ def _link_package_versions(self, link, search): self._log_skipped_link( link, 'Python version is incorrect') return + try: + support_this_python = check_requires_python(link.requires_python) + except specifiers.InvalidSpecifier: + logger.debug("Package %s has an invalid Requires-Python entry: %s", + link.filename, link.requires_python) + support_this_python = True + + if not support_this_python: + logger.debug("The package %s is incompatible with the python" + "version in use. Acceptable python versions are:%s", + link, link.requires_python) + return logger.debug('Found link %s, version: %s', link, version) return InstallationCandidate(search.supplied, version, link) @@ -828,7 +843,9 @@ def links(self): url = self.clean_link( urllib_parse.urljoin(self.base_url, href) ) - yield Link(url, self) + pyrequire = anchor.get('data-requires-python') + pyrequire = unescape(pyrequire) if pyrequire else None + yield Link(url, self, requires_python=pyrequire) _clean_re = re.compile(r'[^a-z0-9$&+,/:;=?@.#%_\\|-]', re.I) @@ -842,7 +859,19 @@ def clean_link(self, url): class Link(object): - def __init__(self, url, comes_from=None): + def __init__(self, url, comes_from=None, requires_python=None): + """ + Object representing a parsed link from https://pypi.python.org/simple/* + + url: + url of the resource pointed to (href of the link) + comes_from: + instance of HTMLPage where the link was found, or string. + requires_python: + String containing the `Requires-Python` metadata field, specified + in PEP 345. This may be specified by a data-requires-python + attribute in the HTML link tag, as described in PEP 503. + """ # url can be a UNC windows share if url.startswith('\\\\'): @@ -850,10 +879,15 @@ def __init__(self, url, comes_from=None): self.url = url self.comes_from = comes_from + self.requires_python = requires_python if requires_python else None def __str__(self): + if self.requires_python: + rp = ' (requires-python:%s)' % self.requires_python + else: + rp = '' if self.comes_from: - return '%s (from %s)' % (self.url, self.comes_from) + return '%s (from %s)%s' % (self.url, self.comes_from, rp) else: return str(self.url) diff --git a/pip/utils/packaging.py b/pip/utils/packaging.py new file mode 100644 index 00000000000..96318867d2a --- /dev/null +++ b/pip/utils/packaging.py @@ -0,0 +1,28 @@ +from __future__ import absolute_import +import logging +import sys + +from pip._vendor.packaging import specifiers +from pip._vendor.packaging import version + +logger = logging.getLogger(__name__) + + +def check_requires_python(requires_python): + """ + Check if the python version in use match the `requires_python` specifier. + + Returns `True` if the version of python in use matches the requirement. + Returns `False` if the version of python in use does not matches the + requirement. + + Raises an InvalidSpecifier if `requires_python` have an invalid format. + """ + if requires_python is None: + # The package provides no information + return True + requires_python_specifier = specifiers.SpecifierSet(requires_python) + + # We only use major.minor.micro + python_version = version.parse('.'.join(map(str, sys.version_info[:3]))) + return python_version in requires_python_specifier diff --git a/tests/data/indexes/datarequire/fakepackage/index.html b/tests/data/indexes/datarequire/fakepackage/index.html new file mode 100644 index 00000000000..8f61127bc28 --- /dev/null +++ b/tests/data/indexes/datarequire/fakepackage/index.html @@ -0,0 +1,8 @@ +