mirror of https://github.com/pypa/pip
372 lines
12 KiB
Python
372 lines
12 KiB
Python
import email.message
|
|
import logging
|
|
from typing import List, Optional, Type, TypeVar, cast
|
|
from unittest import mock
|
|
|
|
import pytest
|
|
from pip._vendor.packaging.specifiers import SpecifierSet
|
|
from pip._vendor.packaging.utils import NormalizedName
|
|
|
|
from pip._internal.exceptions import (
|
|
InstallationError,
|
|
NoneMetadataError,
|
|
UnsupportedPythonVersion,
|
|
)
|
|
from pip._internal.metadata import BaseDistribution
|
|
from pip._internal.models.candidate import InstallationCandidate
|
|
from pip._internal.req.constructors import install_req_from_line
|
|
from pip._internal.req.req_set import RequirementSet
|
|
from pip._internal.resolution.legacy.resolver import (
|
|
Resolver,
|
|
_check_dist_requires_python,
|
|
)
|
|
from tests.lib import TestData, make_test_finder
|
|
from tests.lib.index import make_mock_candidate
|
|
|
|
T = TypeVar("T")
|
|
|
|
|
|
class FakeDist(BaseDistribution):
|
|
def __init__(self, metadata: email.message.Message) -> None:
|
|
self._canonical_name = cast(NormalizedName, "my-project")
|
|
self._metadata = metadata
|
|
|
|
def __str__(self) -> str:
|
|
return f"<distribution {self.canonical_name!r}>"
|
|
|
|
@property
|
|
def canonical_name(self) -> NormalizedName:
|
|
return self._canonical_name
|
|
|
|
@property
|
|
def metadata(self) -> email.message.Message:
|
|
return self._metadata
|
|
|
|
|
|
def make_fake_dist(
|
|
*, klass: Type[BaseDistribution] = FakeDist, requires_python: Optional[str] = None
|
|
) -> BaseDistribution:
|
|
metadata = email.message.Message()
|
|
metadata["Name"] = "my-project"
|
|
if requires_python is not None:
|
|
metadata["Requires-Python"] = requires_python
|
|
|
|
# Too many arguments for "BaseDistribution"
|
|
return klass(metadata) # type: ignore[call-arg]
|
|
|
|
|
|
def make_test_resolver(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
mock_candidates: List[InstallationCandidate],
|
|
) -> Resolver:
|
|
def _find_candidates(project_name: str) -> List[InstallationCandidate]:
|
|
return mock_candidates
|
|
|
|
finder = make_test_finder()
|
|
monkeypatch.setattr(finder, "find_all_candidates", _find_candidates)
|
|
|
|
return Resolver(
|
|
finder=finder,
|
|
preparer=mock.Mock(), # Not used.
|
|
make_install_req=install_req_from_line,
|
|
wheel_cache=None,
|
|
use_user_site=False,
|
|
force_reinstall=False,
|
|
ignore_dependencies=False,
|
|
ignore_installed=False,
|
|
ignore_requires_python=False,
|
|
upgrade_strategy="to-satisfy-only",
|
|
)
|
|
|
|
|
|
class TestAddRequirement:
|
|
"""
|
|
Test _add_requirement_to_set().
|
|
"""
|
|
|
|
def test_unsupported_wheel_link_requirement_raises(
|
|
self, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
# GIVEN
|
|
resolver = make_test_resolver(monkeypatch, [])
|
|
requirement_set = RequirementSet(check_supported_wheels=True)
|
|
|
|
install_req = install_req_from_line(
|
|
"https://whatever.com/peppercorn-0.4-py2.py3-bogus-any.whl",
|
|
)
|
|
assert install_req.link is not None
|
|
assert install_req.link.is_wheel
|
|
assert install_req.link.scheme == "https"
|
|
|
|
# WHEN / THEN
|
|
with pytest.raises(InstallationError):
|
|
resolver._add_requirement_to_set(requirement_set, install_req)
|
|
|
|
def test_unsupported_wheel_local_file_requirement_raises(
|
|
self, data: TestData, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
# GIVEN
|
|
resolver = make_test_resolver(monkeypatch, [])
|
|
requirement_set = RequirementSet(check_supported_wheels=True)
|
|
|
|
install_req = install_req_from_line(
|
|
data.packages.joinpath("simple.dist-0.1-py1-none-invalid.whl"),
|
|
)
|
|
assert install_req.link is not None
|
|
assert install_req.link.is_wheel
|
|
assert install_req.link.scheme == "file"
|
|
|
|
# WHEN / THEN
|
|
with pytest.raises(InstallationError):
|
|
resolver._add_requirement_to_set(requirement_set, install_req)
|
|
|
|
def test_exclusive_environment_markers(
|
|
self, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""Make sure excluding environment markers are handled correctly."""
|
|
# GIVEN
|
|
resolver = make_test_resolver(monkeypatch, [])
|
|
requirement_set = RequirementSet(check_supported_wheels=True)
|
|
|
|
eq36 = install_req_from_line("Django>=1.6.10,<1.7 ; python_version == '3.6'")
|
|
eq36.user_supplied = True
|
|
ne36 = install_req_from_line("Django>=1.6.10,<1.8 ; python_version != '3.6'")
|
|
ne36.user_supplied = True
|
|
|
|
# WHEN
|
|
resolver._add_requirement_to_set(requirement_set, eq36)
|
|
resolver._add_requirement_to_set(requirement_set, ne36)
|
|
|
|
# THEN
|
|
assert requirement_set.has_requirement("Django")
|
|
assert len(requirement_set.all_requirements) == 1
|
|
|
|
|
|
class TestCheckDistRequiresPython:
|
|
"""
|
|
Test _check_dist_requires_python().
|
|
"""
|
|
|
|
def test_compatible(self, caplog: pytest.LogCaptureFixture) -> None:
|
|
"""
|
|
Test a Python version compatible with the dist's Requires-Python.
|
|
"""
|
|
caplog.set_level(logging.DEBUG)
|
|
dist = make_fake_dist(requires_python="== 3.6.5")
|
|
|
|
_check_dist_requires_python(
|
|
dist,
|
|
version_info=(3, 6, 5),
|
|
ignore_requires_python=False,
|
|
)
|
|
assert not len(caplog.records)
|
|
|
|
def test_incompatible(self) -> None:
|
|
"""
|
|
Test a Python version incompatible with the dist's Requires-Python.
|
|
"""
|
|
dist = make_fake_dist(requires_python="== 3.6.4")
|
|
with pytest.raises(UnsupportedPythonVersion) as exc:
|
|
_check_dist_requires_python(
|
|
dist,
|
|
version_info=(3, 6, 5),
|
|
ignore_requires_python=False,
|
|
)
|
|
assert str(exc.value) == (
|
|
"Package 'my-project' requires a different Python: "
|
|
"3.6.5 not in '==3.6.4'"
|
|
)
|
|
|
|
def test_incompatible_with_ignore_requires(
|
|
self, caplog: pytest.LogCaptureFixture
|
|
) -> None:
|
|
"""
|
|
Test a Python version incompatible with the dist's Requires-Python
|
|
while passing ignore_requires_python=True.
|
|
"""
|
|
caplog.set_level(logging.DEBUG)
|
|
dist = make_fake_dist(requires_python="== 3.6.4")
|
|
_check_dist_requires_python(
|
|
dist,
|
|
version_info=(3, 6, 5),
|
|
ignore_requires_python=True,
|
|
)
|
|
assert len(caplog.records) == 1
|
|
record = caplog.records[0]
|
|
assert record.levelname == "DEBUG"
|
|
assert record.message == (
|
|
"Ignoring failed Requires-Python check for package 'my-project': "
|
|
"3.6.5 not in '==3.6.4'"
|
|
)
|
|
|
|
def test_none_requires_python(self, caplog: pytest.LogCaptureFixture) -> None:
|
|
"""
|
|
Test a dist with Requires-Python None.
|
|
"""
|
|
caplog.set_level(logging.DEBUG)
|
|
dist = make_fake_dist()
|
|
# Make sure our test setup is correct.
|
|
assert dist.requires_python == SpecifierSet()
|
|
assert len(caplog.records) == 0
|
|
|
|
# Then there is no exception and no log message.
|
|
_check_dist_requires_python(
|
|
dist,
|
|
version_info=(3, 6, 5),
|
|
ignore_requires_python=False,
|
|
)
|
|
assert len(caplog.records) == 0
|
|
|
|
def test_invalid_requires_python(self, caplog: pytest.LogCaptureFixture) -> None:
|
|
"""
|
|
Test a dist with an invalid Requires-Python.
|
|
"""
|
|
caplog.set_level(logging.DEBUG)
|
|
dist = make_fake_dist(requires_python="invalid")
|
|
_check_dist_requires_python(
|
|
dist,
|
|
version_info=(3, 6, 5),
|
|
ignore_requires_python=False,
|
|
)
|
|
assert len(caplog.records) == 1
|
|
record = caplog.records[0]
|
|
assert record.levelname == "WARNING"
|
|
assert record.message == (
|
|
"Package 'my-project' has an invalid Requires-Python: "
|
|
"Invalid specifier: 'invalid'"
|
|
)
|
|
|
|
@pytest.mark.parametrize(
|
|
"metadata_name",
|
|
[
|
|
"METADATA",
|
|
"PKG-INFO",
|
|
],
|
|
)
|
|
def test_empty_metadata_error(self, metadata_name: str) -> None:
|
|
"""Test dist.metadata raises FileNotFoundError."""
|
|
|
|
class NotWorkingFakeDist(FakeDist):
|
|
@property
|
|
def metadata(self) -> email.message.Message:
|
|
raise FileNotFoundError(metadata_name)
|
|
|
|
dist = make_fake_dist(klass=NotWorkingFakeDist)
|
|
|
|
with pytest.raises(NoneMetadataError) as exc:
|
|
_check_dist_requires_python(
|
|
dist,
|
|
version_info=(3, 6, 5),
|
|
ignore_requires_python=False,
|
|
)
|
|
assert str(exc.value) == (
|
|
"None {} metadata found for distribution: "
|
|
"<distribution 'my-project'>".format(metadata_name)
|
|
)
|
|
|
|
|
|
class TestYankedWarning:
|
|
"""
|
|
Test _populate_link() emits warning if one or more candidates are yanked.
|
|
"""
|
|
|
|
def test_sort_best_candidate__has_non_yanked(
|
|
self, caplog: pytest.LogCaptureFixture, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""
|
|
Test unyanked candidate preferred over yanked.
|
|
"""
|
|
# Ignore spurious DEBUG level messages
|
|
# TODO: Probably better to work out why they are occurring, but IMO the
|
|
# tests are at fault here for being to dependent on exact output.
|
|
caplog.set_level(logging.WARNING)
|
|
candidates = [
|
|
make_mock_candidate("1.0"),
|
|
make_mock_candidate("2.0", yanked_reason="bad metadata #2"),
|
|
]
|
|
ireq = install_req_from_line("pkg")
|
|
|
|
resolver = make_test_resolver(monkeypatch, candidates)
|
|
resolver._populate_link(ireq)
|
|
|
|
assert ireq.link == candidates[0].link
|
|
assert len(caplog.records) == 0
|
|
|
|
def test_sort_best_candidate__all_yanked(
|
|
self, caplog: pytest.LogCaptureFixture, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""
|
|
Test all candidates yanked.
|
|
"""
|
|
# Ignore spurious DEBUG level messages
|
|
# TODO: Probably better to work out why they are occurring, but IMO the
|
|
# tests are at fault here for being to dependent on exact output.
|
|
caplog.set_level(logging.WARNING)
|
|
candidates = [
|
|
make_mock_candidate("1.0", yanked_reason="bad metadata #1"),
|
|
# Put the best candidate in the middle, to test sorting.
|
|
make_mock_candidate("3.0", yanked_reason="bad metadata #3"),
|
|
make_mock_candidate("2.0", yanked_reason="bad metadata #2"),
|
|
]
|
|
ireq = install_req_from_line("pkg")
|
|
|
|
resolver = make_test_resolver(monkeypatch, candidates)
|
|
resolver._populate_link(ireq)
|
|
|
|
assert ireq.link == candidates[1].link
|
|
|
|
# Check the log messages.
|
|
assert len(caplog.records) == 1
|
|
record = caplog.records[0]
|
|
assert record.levelname == "WARNING"
|
|
assert record.message == (
|
|
"The candidate selected for download or install is a yanked "
|
|
"version: 'mypackage' candidate "
|
|
"(version 3.0 at https://example.com/pkg-3.0.tar.gz)\n"
|
|
"Reason for being yanked: bad metadata #3"
|
|
)
|
|
|
|
@pytest.mark.parametrize(
|
|
"yanked_reason, expected_reason",
|
|
[
|
|
# Test no reason given.
|
|
("", "<none given>"),
|
|
# Test a unicode string with a non-ascii character.
|
|
("curly quote: \u2018", "curly quote: \u2018"),
|
|
],
|
|
)
|
|
def test_sort_best_candidate__yanked_reason(
|
|
self,
|
|
caplog: pytest.LogCaptureFixture,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
yanked_reason: str,
|
|
expected_reason: str,
|
|
) -> None:
|
|
"""
|
|
Test the log message with various reason strings.
|
|
"""
|
|
# Ignore spurious DEBUG level messages
|
|
# TODO: Probably better to work out why they are occurring, but IMO the
|
|
# tests are at fault here for being to dependent on exact output.
|
|
caplog.set_level(logging.WARNING)
|
|
candidates = [
|
|
make_mock_candidate("1.0", yanked_reason=yanked_reason),
|
|
]
|
|
ireq = install_req_from_line("pkg")
|
|
|
|
resolver = make_test_resolver(monkeypatch, candidates)
|
|
resolver._populate_link(ireq)
|
|
|
|
assert ireq.link == candidates[0].link
|
|
|
|
assert len(caplog.records) == 1
|
|
record = caplog.records[0]
|
|
assert record.levelname == "WARNING"
|
|
expected_message = (
|
|
"The candidate selected for download or install is a yanked "
|
|
"version: 'mypackage' candidate "
|
|
"(version 1.0 at https://example.com/pkg-1.0.tar.gz)\n"
|
|
"Reason for being yanked: "
|
|
) + expected_reason
|
|
assert record.message == expected_message
|