Skip to content

Commit 8df742e

Browse files
authored
Merge pull request #3846 from xavfernandez/check_python_requires
Abort install if Requires-Python do not match the running version
2 parents 5f48bdd + b43fb54 commit 8df742e

File tree

9 files changed

+157
-3
lines changed

9 files changed

+157
-3
lines changed

CHANGES.txt

+3
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,9 @@
5252

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

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

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

pip/cmdoptions.py

+7
Original file line numberDiff line numberDiff line change
@@ -477,6 +477,13 @@ def only_binary():
477477
help='Directory to unpack packages into and build in.'
478478
)
479479

480+
ignore_requires_python = partial(
481+
Option,
482+
'--ignore-requires-python',
483+
dest='ignore_requires_python',
484+
action='store_true',
485+
help='Ignore the Requires-Python information.')
486+
480487
install_options = partial(
481488
Option,
482489
'--install-option',

pip/commands/install.py

+2
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ def __init__(self, *args, **kw):
118118
action='store_true',
119119
help='Ignore the installed packages (reinstalling instead).')
120120

121+
cmd_opts.add_option(cmdoptions.ignore_requires_python())
121122
cmd_opts.add_option(cmdoptions.no_deps())
122123

123124
cmd_opts.add_option(cmdoptions.install_options())
@@ -295,6 +296,7 @@ def run(self, options, args):
295296
as_egg=options.as_egg,
296297
ignore_installed=options.ignore_installed,
297298
ignore_dependencies=options.ignore_dependencies,
299+
ignore_requires_python=options.ignore_requires_python,
298300
force_reinstall=options.force_reinstall,
299301
use_user_site=options.use_user_site,
300302
target_dir=temp_target_dir,

pip/commands/wheel.py

+2
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ def __init__(self, *args, **kw):
7070
cmd_opts.add_option(cmdoptions.editable())
7171
cmd_opts.add_option(cmdoptions.requirements())
7272
cmd_opts.add_option(cmdoptions.src())
73+
cmd_opts.add_option(cmdoptions.ignore_requires_python())
7374
cmd_opts.add_option(cmdoptions.no_deps())
7475
cmd_opts.add_option(cmdoptions.build_dir())
7576

@@ -171,6 +172,7 @@ def run(self, options, args):
171172
download_dir=None,
172173
ignore_dependencies=options.ignore_dependencies,
173174
ignore_installed=True,
175+
ignore_requires_python=options.ignore_requires_python,
174176
isolated=options.isolated_mode,
175177
session=session,
176178
wheel_cache=wheel_cache,

pip/exceptions.py

+5
Original file line numberDiff line numberDiff line change
@@ -237,3 +237,8 @@ def hash_then_or(hash_name):
237237
self.gots[hash_name].hexdigest())
238238
prefix = ' or'
239239
return '\n'.join(lines)
240+
241+
242+
class UnsupportedPythonVersion(InstallationError):
243+
"""Unsupported python version according to Requires-Python package
244+
metadata."""

pip/req/req_set.py

+14-2
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,14 @@
1414
from pip.exceptions import (InstallationError, BestVersionAlreadyInstalled,
1515
DistributionNotFound, PreviousBuildDirError,
1616
HashError, HashErrors, HashUnpinned,
17-
DirectoryUrlHashUnsupported, VcsHashUnsupported)
17+
DirectoryUrlHashUnsupported, VcsHashUnsupported,
18+
UnsupportedPythonVersion)
1819
from pip.req.req_install import InstallRequirement
1920
from pip.utils import (
2021
display_path, dist_in_usersite, ensure_dir, normalize_path)
2122
from pip.utils.hashes import MissingHashes
2223
from pip.utils.logging import indent_log
24+
from pip.utils.packaging import check_dist_requires_python
2325
from pip.vcs import vcs
2426
from pip.wheel import Wheel
2527

@@ -144,7 +146,8 @@ def __init__(self, build_dir, src_dir, download_dir, upgrade=False,
144146
target_dir=None, ignore_dependencies=False,
145147
force_reinstall=False, use_user_site=False, session=None,
146148
pycompile=True, isolated=False, wheel_download_dir=None,
147-
wheel_cache=None, require_hashes=False):
149+
wheel_cache=None, require_hashes=False,
150+
ignore_requires_python=False):
148151
"""Create a RequirementSet.
149152
150153
:param wheel_download_dir: Where still-packed .whl files should be
@@ -178,6 +181,7 @@ def __init__(self, build_dir, src_dir, download_dir, upgrade=False,
178181
self.requirement_aliases = {}
179182
self.unnamed_requirements = []
180183
self.ignore_dependencies = ignore_dependencies
184+
self.ignore_requires_python = ignore_requires_python
181185
self.successfully_downloaded = []
182186
self.successfully_installed = []
183187
self.reqs_to_cleanup = []
@@ -655,6 +659,14 @@ def _prepare_file(self,
655659
# # parse dependencies # #
656660
# ###################### #
657661
dist = abstract_dist.dist(finder)
662+
try:
663+
check_dist_requires_python(dist)
664+
except UnsupportedPythonVersion as e:
665+
if self.ignore_requires_python:
666+
logger.warning(e.args[0])
667+
else:
668+
req_to_install.remove_temporary_source()
669+
raise
658670
more_reqs = []
659671

660672
def add_req(subreq):

pip/utils/packaging.py

+35
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
11
from __future__ import absolute_import
2+
3+
from email.parser import FeedParser
4+
25
import logging
36
import sys
47

58
from pip._vendor.packaging import specifiers
69
from pip._vendor.packaging import version
10+
from pip._vendor import pkg_resources
11+
12+
from pip import exceptions
713

814
logger = logging.getLogger(__name__)
915

@@ -26,3 +32,32 @@ def check_requires_python(requires_python):
2632
# We only use major.minor.micro
2733
python_version = version.parse('.'.join(map(str, sys.version_info[:3])))
2834
return python_version in requires_python_specifier
35+
36+
37+
def get_metadata(dist):
38+
if (isinstance(dist, pkg_resources.DistInfoDistribution) and
39+
dist.has_metadata('METADATA')):
40+
return dist.get_metadata('METADATA')
41+
elif dist.has_metadata('PKG-INFO'):
42+
return dist.get_metadata('PKG-INFO')
43+
44+
45+
def check_dist_requires_python(dist):
46+
metadata = get_metadata(dist)
47+
feed_parser = FeedParser()
48+
feed_parser.feed(metadata)
49+
pkg_info_dict = feed_parser.close()
50+
requires_python = pkg_info_dict.get('Requires-Python')
51+
try:
52+
if not check_requires_python(requires_python):
53+
raise exceptions.UnsupportedPythonVersion(
54+
"%s requires Python '%s' but the running Python is %s" % (
55+
dist.project_name,
56+
requires_python,
57+
'.'.join(map(str, sys.version_info[:3])),)
58+
)
59+
except specifiers.InvalidSpecifier as e:
60+
logger.warning(
61+
"Package %s has an invalid Requires-Python entry %s - %s" % (
62+
dist.project_name, requires_python, e))
63+
return

tests/functional/test_install.py

+64
Original file line numberDiff line numberDiff line change
@@ -1076,3 +1076,67 @@ def test_double_install_fail(script, data):
10761076
msg = ("Double requirement given: pip==7.1.2 (already in pip==*, "
10771077
"name='pip')")
10781078
assert msg in result.stderr
1079+
1080+
1081+
def test_install_incompatible_python_requires(script):
1082+
script.scratch_path.join("pkga").mkdir()
1083+
pkga_path = script.scratch_path / 'pkga'
1084+
pkga_path.join("setup.py").write(textwrap.dedent("""
1085+
from setuptools import setup
1086+
setup(name='pkga',
1087+
python_requires='<1.0',
1088+
version='0.1')
1089+
"""))
1090+
script.pip('install', 'setuptools>24.2') # This should not be needed
1091+
result = script.pip('install', pkga_path, expect_error=True)
1092+
assert ("pkga requires Python '<1.0' "
1093+
"but the running Python is ") in result.stderr
1094+
1095+
1096+
def test_install_incompatible_python_requires_editable(script):
1097+
script.scratch_path.join("pkga").mkdir()
1098+
pkga_path = script.scratch_path / 'pkga'
1099+
pkga_path.join("setup.py").write(textwrap.dedent("""
1100+
from setuptools import setup
1101+
setup(name='pkga',
1102+
python_requires='<1.0',
1103+
version='0.1')
1104+
"""))
1105+
script.pip('install', 'setuptools>24.2') # This should not be needed
1106+
result = script.pip(
1107+
'install', '--editable=%s' % pkga_path, expect_error=True)
1108+
assert ("pkga requires Python '<1.0' "
1109+
"but the running Python is ") in result.stderr
1110+
1111+
1112+
def test_install_incompatible_python_requires_wheel(script):
1113+
script.scratch_path.join("pkga").mkdir()
1114+
pkga_path = script.scratch_path / 'pkga'
1115+
pkga_path.join("setup.py").write(textwrap.dedent("""
1116+
from setuptools import setup
1117+
setup(name='pkga',
1118+
python_requires='<1.0',
1119+
version='0.1')
1120+
"""))
1121+
script.pip('install', 'setuptools>24.2') # This should not be needed
1122+
script.pip('install', 'wheel')
1123+
script.run(
1124+
'python', 'setup.py', 'bdist_wheel', '--universal', cwd=pkga_path)
1125+
result = script.pip('install', './pkga/dist/pkga-0.1-py2.py3-none-any.whl',
1126+
expect_error=True)
1127+
assert ("pkga requires Python '<1.0' "
1128+
"but the running Python is ") in result.stderr
1129+
1130+
1131+
def test_install_compatible_python_requires(script):
1132+
script.scratch_path.join("pkga").mkdir()
1133+
pkga_path = script.scratch_path / 'pkga'
1134+
pkga_path.join("setup.py").write(textwrap.dedent("""
1135+
from setuptools import setup
1136+
setup(name='pkga',
1137+
python_requires='>1.0',
1138+
version='0.1')
1139+
"""))
1140+
script.pip('install', 'setuptools>24.2') # This should not be needed
1141+
res = script.pip('install', pkga_path, expect_error=True)
1142+
assert "Successfully installed pkga-0.1" in res.stdout, res

tests/unit/test_utils.py

+25-1
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,15 @@
1515
import pytest
1616

1717
from mock import Mock, patch
18-
from pip.exceptions import HashMismatch, HashMissing, InstallationError
18+
from pip.exceptions import (HashMismatch, HashMissing, InstallationError,
19+
UnsupportedPythonVersion)
1920
from pip.utils import (egg_link_path, get_installed_distributions,
2021
untar_file, unzip_file, rmtree, normalize_path)
2122
from pip.utils.build import BuildDirectory
2223
from pip.utils.encoding import auto_decode
2324
from pip.utils.hashes import Hashes, MissingHashes
2425
from pip.utils.glibc import check_glibc_version
26+
from pip.utils.packaging import check_dist_requires_python
2527
from pip._vendor.six import BytesIO
2628

2729

@@ -524,3 +526,25 @@ def test_manylinux1_check_glibc_version(self):
524526
else:
525527
# Didn't find the warning we were expecting
526528
assert False
529+
530+
531+
class TestCheckRequiresPython(object):
532+
533+
@pytest.mark.parametrize(
534+
("metadata", "should_raise"),
535+
[
536+
("Name: test\n", False),
537+
("Name: test\nRequires-Python:", False),
538+
("Name: test\nRequires-Python: invalid_spec", False),
539+
("Name: test\nRequires-Python: <=1", True),
540+
],
541+
)
542+
def test_check_requires(self, metadata, should_raise):
543+
fake_dist = Mock(
544+
has_metadata=lambda _: True,
545+
get_metadata=lambda _: metadata)
546+
if should_raise:
547+
with pytest.raises(UnsupportedPythonVersion):
548+
check_dist_requires_python(fake_dist)
549+
else:
550+
check_dist_requires_python(fake_dist)

0 commit comments

Comments
 (0)