Skip to content

Commit

Permalink
Merge pull request #3846 from xavfernandez/check_python_requires
Browse files Browse the repository at this point in the history
Abort install if Requires-Python do not match the running version
  • Loading branch information
xavfernandez authored Oct 27, 2016
2 parents 5f48bdd + b43fb54 commit 8df742e
Show file tree
Hide file tree
Showing 9 changed files with 157 additions and 3 deletions.
3 changes: 3 additions & 0 deletions CHANGES.txt
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@

* Normalize package names before using in ``pip show`` (:issue:`3976`)

* Raises when Requires-Python do not match the running version.
Add ``--ignore-requires-python`` escape hatch.


**8.1.2 (2016-05-10)**

Expand Down
7 changes: 7 additions & 0 deletions pip/cmdoptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -477,6 +477,13 @@ def only_binary():
help='Directory to unpack packages into and build in.'
)

ignore_requires_python = partial(
Option,
'--ignore-requires-python',
dest='ignore_requires_python',
action='store_true',
help='Ignore the Requires-Python information.')

install_options = partial(
Option,
'--install-option',
Expand Down
2 changes: 2 additions & 0 deletions pip/commands/install.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ def __init__(self, *args, **kw):
action='store_true',
help='Ignore the installed packages (reinstalling instead).')

cmd_opts.add_option(cmdoptions.ignore_requires_python())
cmd_opts.add_option(cmdoptions.no_deps())

cmd_opts.add_option(cmdoptions.install_options())
Expand Down Expand Up @@ -295,6 +296,7 @@ def run(self, options, args):
as_egg=options.as_egg,
ignore_installed=options.ignore_installed,
ignore_dependencies=options.ignore_dependencies,
ignore_requires_python=options.ignore_requires_python,
force_reinstall=options.force_reinstall,
use_user_site=options.use_user_site,
target_dir=temp_target_dir,
Expand Down
2 changes: 2 additions & 0 deletions pip/commands/wheel.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ def __init__(self, *args, **kw):
cmd_opts.add_option(cmdoptions.editable())
cmd_opts.add_option(cmdoptions.requirements())
cmd_opts.add_option(cmdoptions.src())
cmd_opts.add_option(cmdoptions.ignore_requires_python())
cmd_opts.add_option(cmdoptions.no_deps())
cmd_opts.add_option(cmdoptions.build_dir())

Expand Down Expand Up @@ -171,6 +172,7 @@ def run(self, options, args):
download_dir=None,
ignore_dependencies=options.ignore_dependencies,
ignore_installed=True,
ignore_requires_python=options.ignore_requires_python,
isolated=options.isolated_mode,
session=session,
wheel_cache=wheel_cache,
Expand Down
5 changes: 5 additions & 0 deletions pip/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -237,3 +237,8 @@ def hash_then_or(hash_name):
self.gots[hash_name].hexdigest())
prefix = ' or'
return '\n'.join(lines)


class UnsupportedPythonVersion(InstallationError):
"""Unsupported python version according to Requires-Python package
metadata."""
16 changes: 14 additions & 2 deletions pip/req/req_set.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,14 @@
from pip.exceptions import (InstallationError, BestVersionAlreadyInstalled,
DistributionNotFound, PreviousBuildDirError,
HashError, HashErrors, HashUnpinned,
DirectoryUrlHashUnsupported, VcsHashUnsupported)
DirectoryUrlHashUnsupported, VcsHashUnsupported,
UnsupportedPythonVersion)
from pip.req.req_install import InstallRequirement
from pip.utils import (
display_path, dist_in_usersite, ensure_dir, normalize_path)
from pip.utils.hashes import MissingHashes
from pip.utils.logging import indent_log
from pip.utils.packaging import check_dist_requires_python
from pip.vcs import vcs
from pip.wheel import Wheel

Expand Down Expand Up @@ -144,7 +146,8 @@ def __init__(self, build_dir, src_dir, download_dir, upgrade=False,
target_dir=None, ignore_dependencies=False,
force_reinstall=False, use_user_site=False, session=None,
pycompile=True, isolated=False, wheel_download_dir=None,
wheel_cache=None, require_hashes=False):
wheel_cache=None, require_hashes=False,
ignore_requires_python=False):
"""Create a RequirementSet.
:param wheel_download_dir: Where still-packed .whl files should be
Expand Down Expand Up @@ -178,6 +181,7 @@ def __init__(self, build_dir, src_dir, download_dir, upgrade=False,
self.requirement_aliases = {}
self.unnamed_requirements = []
self.ignore_dependencies = ignore_dependencies
self.ignore_requires_python = ignore_requires_python
self.successfully_downloaded = []
self.successfully_installed = []
self.reqs_to_cleanup = []
Expand Down Expand Up @@ -655,6 +659,14 @@ def _prepare_file(self,
# # parse dependencies # #
# ###################### #
dist = abstract_dist.dist(finder)
try:
check_dist_requires_python(dist)
except UnsupportedPythonVersion as e:
if self.ignore_requires_python:
logger.warning(e.args[0])
else:
req_to_install.remove_temporary_source()
raise
more_reqs = []

def add_req(subreq):
Expand Down
35 changes: 35 additions & 0 deletions pip/utils/packaging.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
from __future__ import absolute_import

from email.parser import FeedParser

import logging
import sys

from pip._vendor.packaging import specifiers
from pip._vendor.packaging import version
from pip._vendor import pkg_resources

from pip import exceptions

logger = logging.getLogger(__name__)

Expand All @@ -26,3 +32,32 @@ def check_requires_python(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


def get_metadata(dist):
if (isinstance(dist, pkg_resources.DistInfoDistribution) and
dist.has_metadata('METADATA')):
return dist.get_metadata('METADATA')
elif dist.has_metadata('PKG-INFO'):
return dist.get_metadata('PKG-INFO')


def check_dist_requires_python(dist):
metadata = get_metadata(dist)
feed_parser = FeedParser()
feed_parser.feed(metadata)
pkg_info_dict = feed_parser.close()
requires_python = pkg_info_dict.get('Requires-Python')
try:
if not check_requires_python(requires_python):
raise exceptions.UnsupportedPythonVersion(
"%s requires Python '%s' but the running Python is %s" % (
dist.project_name,
requires_python,
'.'.join(map(str, sys.version_info[:3])),)
)
except specifiers.InvalidSpecifier as e:
logger.warning(
"Package %s has an invalid Requires-Python entry %s - %s" % (
dist.project_name, requires_python, e))
return
64 changes: 64 additions & 0 deletions tests/functional/test_install.py
Original file line number Diff line number Diff line change
Expand Up @@ -1076,3 +1076,67 @@ def test_double_install_fail(script, data):
msg = ("Double requirement given: pip==7.1.2 (already in pip==*, "
"name='pip')")
assert msg in result.stderr


def test_install_incompatible_python_requires(script):
script.scratch_path.join("pkga").mkdir()
pkga_path = script.scratch_path / 'pkga'
pkga_path.join("setup.py").write(textwrap.dedent("""
from setuptools import setup
setup(name='pkga',
python_requires='<1.0',
version='0.1')
"""))
script.pip('install', 'setuptools>24.2') # This should not be needed
result = script.pip('install', pkga_path, expect_error=True)
assert ("pkga requires Python '<1.0' "
"but the running Python is ") in result.stderr


def test_install_incompatible_python_requires_editable(script):
script.scratch_path.join("pkga").mkdir()
pkga_path = script.scratch_path / 'pkga'
pkga_path.join("setup.py").write(textwrap.dedent("""
from setuptools import setup
setup(name='pkga',
python_requires='<1.0',
version='0.1')
"""))
script.pip('install', 'setuptools>24.2') # This should not be needed
result = script.pip(
'install', '--editable=%s' % pkga_path, expect_error=True)
assert ("pkga requires Python '<1.0' "
"but the running Python is ") in result.stderr


def test_install_incompatible_python_requires_wheel(script):
script.scratch_path.join("pkga").mkdir()
pkga_path = script.scratch_path / 'pkga'
pkga_path.join("setup.py").write(textwrap.dedent("""
from setuptools import setup
setup(name='pkga',
python_requires='<1.0',
version='0.1')
"""))
script.pip('install', 'setuptools>24.2') # This should not be needed
script.pip('install', 'wheel')
script.run(
'python', 'setup.py', 'bdist_wheel', '--universal', cwd=pkga_path)
result = script.pip('install', './pkga/dist/pkga-0.1-py2.py3-none-any.whl',
expect_error=True)
assert ("pkga requires Python '<1.0' "
"but the running Python is ") in result.stderr


def test_install_compatible_python_requires(script):
script.scratch_path.join("pkga").mkdir()
pkga_path = script.scratch_path / 'pkga'
pkga_path.join("setup.py").write(textwrap.dedent("""
from setuptools import setup
setup(name='pkga',
python_requires='>1.0',
version='0.1')
"""))
script.pip('install', 'setuptools>24.2') # This should not be needed
res = script.pip('install', pkga_path, expect_error=True)
assert "Successfully installed pkga-0.1" in res.stdout, res
26 changes: 25 additions & 1 deletion tests/unit/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,15 @@
import pytest

from mock import Mock, patch
from pip.exceptions import HashMismatch, HashMissing, InstallationError
from pip.exceptions import (HashMismatch, HashMissing, InstallationError,
UnsupportedPythonVersion)
from pip.utils import (egg_link_path, get_installed_distributions,
untar_file, unzip_file, rmtree, normalize_path)
from pip.utils.build import BuildDirectory
from pip.utils.encoding import auto_decode
from pip.utils.hashes import Hashes, MissingHashes
from pip.utils.glibc import check_glibc_version
from pip.utils.packaging import check_dist_requires_python
from pip._vendor.six import BytesIO


Expand Down Expand Up @@ -524,3 +526,25 @@ def test_manylinux1_check_glibc_version(self):
else:
# Didn't find the warning we were expecting
assert False


class TestCheckRequiresPython(object):

@pytest.mark.parametrize(
("metadata", "should_raise"),
[
("Name: test\n", False),
("Name: test\nRequires-Python:", False),
("Name: test\nRequires-Python: invalid_spec", False),
("Name: test\nRequires-Python: <=1", True),
],
)
def test_check_requires(self, metadata, should_raise):
fake_dist = Mock(
has_metadata=lambda _: True,
get_metadata=lambda _: metadata)
if should_raise:
with pytest.raises(UnsupportedPythonVersion):
check_dist_requires_python(fake_dist)
else:
check_dist_requires_python(fake_dist)

0 comments on commit 8df742e

Please sign in to comment.