Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement pep 503 data-requires-python #3877

Merged
merged 9 commits into from
Aug 11, 2016
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGES.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
Expand Down
1 change: 1 addition & 0 deletions pip/baseparser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
40 changes: 37 additions & 3 deletions pip/index.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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']
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would put the unescape here since we are already dealing with html here

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.


_clean_re = re.compile(r'[^a-z0-9$&+,/:;=?@.#%_\\|-]', re.I)

Expand All @@ -842,18 +859,35 @@ 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('\\\\'):
url = path_to_url(url)

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)

Expand Down
28 changes: 28 additions & 0 deletions pip/utils/packaging.py
Original file line number Diff line number Diff line change
@@ -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
8 changes: 8 additions & 0 deletions tests/data/indexes/datarequire/fakepackage/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<html><head><title>Links for fakepackage</title><meta name="api-version" value="2" /></head><body><h1>Links for fakepackage</h1>
<a data-requires-python='' href="/fakepackage-1.0.0.tar.gz#md5=00000000000000000000000000000000" rel="internal">fakepackage-1.0.0.tar.gz</a><br/>
<a data-requires-python='&lt;2.7' href="/fakepackage-2.6.0.tar.gz#md5=00000000000000000000000000000000" rel="internal">fakepackage-2.6.0.tar.gz</a><br/>
<a data-requires-python='&gt;=2.7,&lt;3' href="/fakepackage-2.7.0.tar.gz#md5=00000000000000000000000000000000" rel="internal">fakepackage-2.7.0.tar.gz</a><br/>
<a data-requires-python='&gt;=3.3' href="/fakepackage-3.3.0.tar.gz#md5=00000000000000000000000000000000" rel="internal">fakepackage-3.3.0.tar.gz</a><br/>
<a data-requires-python='&gt;&lt;X.y.z' href="/fakepackage-9.9.9.tar.gz#md5=00000000000000000000000000000000" rel="internal">fakepackage-9.9.9.tar.gz</a><br/>
</body></html>

7 changes: 3 additions & 4 deletions tests/scripts/test_all_pip.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,9 @@ def main(args=None):
print('Downloading pending list')
projects = all_projects()
print('Found %s projects' % len(projects))
f = open(pending_fn, 'w')
for name in projects:
f.write(name + '\n')
f.close()
with open(pending_fn, 'w') as f:
for name in projects:
f.write(name + '\n')
print('Starting testing...')
while os.stat(pending_fn).st_size:
_test_packages(output, pending_fn)
Expand Down
29 changes: 29 additions & 0 deletions tests/unit/test_finder.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import pytest
import sys

import pip.wheel
import pip.pep425tags
Expand Down Expand Up @@ -365,6 +366,34 @@ def test_finder_only_installs_stable_releases(data):
assert link.url == "https://foo/bar-1.0.tar.gz"


def test_finder_only_installs_data_require(data):
"""
Test whether the PackageFinder understand data-python-requires

This can optionally be exposed by a simple-repository to tell which
distribution are compatible with which version of Python by adding a
data-python-require to the anchor links.

See pep 503 for more informations.
"""
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No need for empty docstring ;)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oops, likely did not commit the description of the tests.


# using a local index (that has pre & dev releases)
finder = PackageFinder([],
[data.index_url("datarequire")],
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm wondering if it would not be clearer to create the index.html file in the test directly ?
But I'm not sure ^^

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried to do that, but it appeared to be extremely painful to figure out how to get the index.html to be found by PackageFinder, but I'm not familiar with API.

session=PipSession())
links = finder.find_all_candidates("fakepackage")

expected = ['1.0.0', '9.9.9']
if sys.version_info < (2, 7):
expected.append('2.6.0')
elif (2, 7) < sys.version_info < (3,):
expected.append('2.7.0')
elif sys.version_info > (3, 3):
expected.append('3.3.0')

assert set([str(v.version) for v in links]) == set(expected)


def test_finder_installs_pre_releases(data):
"""
Test PackageFinder finds pre-releases if asked to.
Expand Down