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

Abort install if Requires-Python do not match the running version #3846

Merged
merged 5 commits into from
Oct 27, 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
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)