Skip to content

Commit eeb74ae

Browse files
authored
Merge pull request #6518 from cjerdonek/issue-6371-ignore-requires-python
Fix #6371: make pip install respect --ignore-requires-python
2 parents 9eccfae + 5528d35 commit eeb74ae

File tree

6 files changed

+188
-20
lines changed

6 files changed

+188
-20
lines changed

news/6371.bugfix

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Fix ``pip install`` to respect ``--ignore-requires-python`` when evaluating
2+
links.

src/pip/_internal/cli/base_command.py

+6-1
Original file line numberDiff line numberDiff line change
@@ -326,11 +326,15 @@ def _build_package_finder(
326326
platform=None, # type: Optional[str]
327327
python_versions=None, # type: Optional[List[str]]
328328
abi=None, # type: Optional[str]
329-
implementation=None # type: Optional[str]
329+
implementation=None, # type: Optional[str]
330+
ignore_requires_python=None, # type: Optional[bool]
330331
):
331332
# type: (...) -> PackageFinder
332333
"""
333334
Create a package finder appropriate to this requirement command.
335+
336+
:param ignore_requires_python: Whether to ignore incompatible
337+
"Requires-Python" values in links. Defaults to False.
334338
"""
335339
index_urls = [options.index_url] + options.extra_index_urls
336340
if options.no_index:
@@ -352,4 +356,5 @@ def _build_package_finder(
352356
abi=abi,
353357
implementation=implementation,
354358
prefer_binary=options.prefer_binary,
359+
ignore_requires_python=ignore_requires_python,
355360
)

src/pip/_internal/commands/install.py

+1
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,7 @@ def run(self, options, args):
297297
python_versions=python_versions,
298298
abi=options.abi,
299299
implementation=options.implementation,
300+
ignore_requires_python=options.ignore_requires_python,
300301
)
301302
build_delete = (not (options.no_clean or options.build_dir))
302303
wheel_cache = WheelCache(options.cache_dir, options.format_control)

src/pip/_internal/index.py

+63-18
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,49 @@ def _get_html_page(link, session=None):
256256
return None
257257

258258

259+
def _check_link_requires_python(
260+
link, # type: Link
261+
version_info, # type: Tuple[int, ...]
262+
ignore_requires_python=False, # type: bool
263+
):
264+
# type: (...) -> bool
265+
"""
266+
Return whether the given Python version is compatible with a link's
267+
"Requires-Python" value.
268+
269+
:param version_info: The Python version to use to check, as a 3-tuple
270+
of ints (major-minor-micro).
271+
:param ignore_requires_python: Whether to ignore the "Requires-Python"
272+
value if the given Python version isn't compatible.
273+
"""
274+
try:
275+
support_this_python = check_requires_python(
276+
link.requires_python, version_info=version_info,
277+
)
278+
except specifiers.InvalidSpecifier:
279+
logger.debug(
280+
"Ignoring invalid Requires-Python (%r) for link: %s",
281+
link.requires_python, link,
282+
)
283+
else:
284+
if not support_this_python:
285+
version = '.'.join(map(str, version_info))
286+
if not ignore_requires_python:
287+
logger.debug(
288+
'Link requires a different Python (%s not in: %r): %s',
289+
version, link.requires_python, link,
290+
)
291+
return False
292+
293+
logger.debug(
294+
'Ignoring failed Requires-Python check (%s not in: %r) '
295+
'for link: %s',
296+
version, link.requires_python, link,
297+
)
298+
299+
return True
300+
301+
259302
class CandidateEvaluator(object):
260303

261304
"""
@@ -269,6 +312,7 @@ def __init__(
269312
prefer_binary=False, # type: bool
270313
allow_all_prereleases=False, # type: bool
271314
py_version_info=None, # type: Optional[Tuple[int, ...]]
315+
ignore_requires_python=None, # type: Optional[bool]
272316
):
273317
# type: (...) -> None
274318
"""
@@ -277,12 +321,17 @@ def __init__(
277321
representing a major-minor-micro version, to use to check both
278322
the Python version embedded in the filename and the package's
279323
"Requires-Python" metadata. Defaults to `sys.version_info[:3]`.
324+
:param ignore_requires_python: Whether to ignore incompatible
325+
"Requires-Python" values in links. Defaults to False.
280326
"""
281327
if py_version_info is None:
282328
py_version_info = sys.version_info[:3]
329+
if ignore_requires_python is None:
330+
ignore_requires_python = False
283331

284332
py_version = '.'.join(map(str, py_version_info[:2]))
285333

334+
self._ignore_requires_python = ignore_requires_python
286335
self._prefer_binary = prefer_binary
287336
self._py_version = py_version
288337
self._py_version_info = py_version_info
@@ -354,23 +403,15 @@ def evaluate_link(self, link, search):
354403
py_version = match.group(1)
355404
if py_version != self._py_version:
356405
return (False, 'Python version is incorrect')
357-
try:
358-
support_this_python = check_requires_python(
359-
link.requires_python, version_info=self._py_version_info,
360-
)
361-
except specifiers.InvalidSpecifier:
362-
logger.debug("Package %s has an invalid Requires-Python entry: %s",
363-
link.filename, link.requires_python)
364-
else:
365-
if not support_this_python:
366-
logger.debug(
367-
"The package %s is incompatible with the python "
368-
"version in use. Acceptable python versions are: %s",
369-
link, link.requires_python,
370-
)
371-
# Return None for the reason text to suppress calling
372-
# _log_skipped_link().
373-
return (False, None)
406+
407+
supports_python = _check_link_requires_python(
408+
link, version_info=self._py_version_info,
409+
ignore_requires_python=self._ignore_requires_python,
410+
)
411+
if not supports_python:
412+
# Return None for the reason text to suppress calling
413+
# _log_skipped_link().
414+
return (False, None)
374415

375416
logger.debug('Found link %s, version: %s', link, version)
376417

@@ -558,7 +599,8 @@ def create(
558599
versions=None, # type: Optional[List[str]]
559600
abi=None, # type: Optional[str]
560601
implementation=None, # type: Optional[str]
561-
prefer_binary=False # type: bool
602+
prefer_binary=False, # type: bool
603+
ignore_requires_python=None, # type: Optional[bool]
562604
):
563605
# type: (...) -> PackageFinder
564606
"""Create a PackageFinder.
@@ -582,6 +624,8 @@ def create(
582624
to pep425tags.py in the get_supported() method.
583625
:param prefer_binary: Whether to prefer an old, but valid, binary
584626
dist over a new source dist.
627+
:param ignore_requires_python: Whether to ignore incompatible
628+
"Requires-Python" values in links. Defaults to False.
585629
"""
586630
if session is None:
587631
raise TypeError(
@@ -617,6 +661,7 @@ def create(
617661
candidate_evaluator = CandidateEvaluator(
618662
valid_tags=valid_tags, prefer_binary=prefer_binary,
619663
allow_all_prereleases=allow_all_prereleases,
664+
ignore_requires_python=ignore_requires_python,
620665
)
621666

622667
# If we don't have TLS enabled, then WARN if anyplace we're looking

tests/unit/test_index.py

+94-1
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,78 @@
88

99
from pip._internal.download import PipSession
1010
from pip._internal.index import (
11-
CandidateEvaluator, Link, PackageFinder, _clean_link, _determine_base_url,
11+
CandidateEvaluator, Link, PackageFinder, Search,
12+
_check_link_requires_python, _clean_link, _determine_base_url,
1213
_egg_info_matches, _find_name_version_sep, _get_html_page,
1314
)
1415

1516

17+
@pytest.mark.parametrize('requires_python, expected', [
18+
('== 3.6.4', False),
19+
('== 3.6.5', True),
20+
# Test an invalid Requires-Python value.
21+
('invalid', True),
22+
])
23+
def test_check_link_requires_python(requires_python, expected):
24+
version_info = (3, 6, 5)
25+
link = Link('https://example.com', requires_python=requires_python)
26+
actual = _check_link_requires_python(link, version_info)
27+
assert actual == expected
28+
29+
30+
def check_caplog(caplog, expected_level, expected_message):
31+
assert len(caplog.records) == 1
32+
record = caplog.records[0]
33+
assert record.levelname == expected_level
34+
assert record.message == expected_message
35+
36+
37+
@pytest.mark.parametrize('ignore_requires_python, expected', [
38+
(None, (
39+
False, 'DEBUG',
40+
"Link requires a different Python (3.6.5 not in: '== 3.6.4'): "
41+
"https://example.com"
42+
)),
43+
(True, (
44+
True, 'DEBUG',
45+
"Ignoring failed Requires-Python check (3.6.5 not in: '== 3.6.4') "
46+
"for link: https://example.com"
47+
)),
48+
])
49+
def test_check_link_requires_python__incompatible_python(
50+
caplog, ignore_requires_python, expected,
51+
):
52+
"""
53+
Test an incompatible Python.
54+
"""
55+
expected_return, expected_level, expected_message = expected
56+
link = Link('https://example.com', requires_python='== 3.6.4')
57+
caplog.set_level(logging.DEBUG)
58+
actual = _check_link_requires_python(
59+
link, version_info=(3, 6, 5),
60+
ignore_requires_python=ignore_requires_python,
61+
)
62+
assert actual == expected_return
63+
64+
check_caplog(caplog, expected_level, expected_message)
65+
66+
67+
def test_check_link_requires_python__invalid_requires(caplog):
68+
"""
69+
Test the log message for an invalid Requires-Python.
70+
"""
71+
link = Link('https://example.com', requires_python='invalid')
72+
caplog.set_level(logging.DEBUG)
73+
actual = _check_link_requires_python(link, version_info=(3, 6, 5))
74+
assert actual
75+
76+
expected_message = (
77+
"Ignoring invalid Requires-Python ('invalid') for link: "
78+
"https://example.com"
79+
)
80+
check_caplog(caplog, 'DEBUG', expected_message)
81+
82+
1683
class TestCandidateEvaluator:
1784

1885
@pytest.mark.parametrize("version_info, expected", [
@@ -37,6 +104,32 @@ def test_init__py_version_default(self):
37104
index = sys.version.find('.', 2)
38105
assert evaluator._py_version == sys.version[:index]
39106

107+
@pytest.mark.parametrize(
108+
'py_version_info,ignore_requires_python,expected', [
109+
((3, 6, 5), None, (True, '1.12')),
110+
# Test an incompatible Python.
111+
((3, 6, 4), None, (False, None)),
112+
# Test an incompatible Python with ignore_requires_python=True.
113+
((3, 6, 4), True, (True, '1.12')),
114+
],
115+
)
116+
def test_evaluate_link(
117+
self, py_version_info, ignore_requires_python, expected,
118+
):
119+
link = Link(
120+
'https://example.com/#egg=twine-1.12',
121+
requires_python='== 3.6.5',
122+
)
123+
search = Search(
124+
supplied='twine', canonical='twine', formats=['source'],
125+
)
126+
evaluator = CandidateEvaluator(
127+
[], py_version_info=py_version_info,
128+
ignore_requires_python=ignore_requires_python,
129+
)
130+
actual = evaluator.evaluate_link(link, search=search)
131+
assert actual == expected
132+
40133

41134
def test_sort_locations_file_expand_dir(data):
42135
"""

tests/unit/test_packaging.py

+22
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import pytest
2+
from pip._vendor.packaging import specifiers
3+
4+
from pip._internal.utils.packaging import check_requires_python
5+
6+
7+
@pytest.mark.parametrize('version_info, requires_python, expected', [
8+
((3, 6, 5), '== 3.6.4', False),
9+
((3, 6, 5), '== 3.6.5', True),
10+
((3, 6, 5), None, True),
11+
])
12+
def test_check_requires_python(version_info, requires_python, expected):
13+
actual = check_requires_python(requires_python, version_info)
14+
assert actual == expected
15+
16+
17+
def test_check_requires_python__invalid():
18+
"""
19+
Test an invalid Requires-Python value.
20+
"""
21+
with pytest.raises(specifiers.InvalidSpecifier):
22+
check_requires_python('invalid', (3, 6, 5))

0 commit comments

Comments
 (0)