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

Implement the new resolver's Requirement class #7843

Merged
merged 1 commit into from
Mar 11, 2020
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
Empty file.
51 changes: 51 additions & 0 deletions src/pip/_internal/resolution/resolvelib/base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
from pip._vendor.packaging.utils import canonicalize_name

from pip._internal.utils.typing import MYPY_CHECK_RUNNING

if MYPY_CHECK_RUNNING:
from typing import (Sequence, Set)

from pip._vendor.packaging.version import _BaseVersion
from pip._internal.index.package_finder import PackageFinder


def format_name(project, extras):
# type: (str, Set[str]) -> str
if not extras:
return project
canonical_extras = sorted(canonicalize_name(e) for e in extras)
return "{}[{}]".format(project, ",".join(canonical_extras))


class Requirement(object):
@property
def name(self):
# type: () -> str
raise NotImplementedError("Subclass should override")

def find_matches(
self,
finder, # type: PackageFinder
):
# type: (...) -> Sequence[Candidate]
raise NotImplementedError("Subclass should override")

def is_satisfied_by(self, candidate):
# type: (Candidate) -> bool
return False


class Candidate(object):
@property
def name(self):
# type: () -> str
raise NotImplementedError("Override in subclass")

@property
def version(self):
# type: () -> _BaseVersion
raise NotImplementedError("Override in subclass")

def get_dependencies(self):
# type: () -> Sequence[Requirement]
raise NotImplementedError("Override in subclass")
141 changes: 141 additions & 0 deletions src/pip/_internal/resolution/resolvelib/requirements.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
from pip._vendor.packaging.utils import canonicalize_name

from pip._internal.req.req_install import InstallRequirement
from pip._internal.utils.typing import MYPY_CHECK_RUNNING

from .base import Candidate, Requirement, format_name

if MYPY_CHECK_RUNNING:
from typing import (Optional, Sequence)

from pip._vendor.packaging.version import _BaseVersion

from pip._internal.index.package_finder import PackageFinder


def make_requirement(install_req):
# type: (InstallRequirement) -> Requirement
if install_req.link:
if install_req.req and install_req.req.name:
return NamedRequirement(install_req)
else:
return UnnamedRequirement(install_req)
else:
return VersionedRequirement(install_req)


class UnnamedRequirement(Requirement):
def __init__(self, req):
# type: (InstallRequirement) -> None
self._ireq = req
self._candidate = None # type: Optional[Candidate]

@property
def name(self):
# type: () -> str
assert self._ireq.req is None or self._ireq.name is None, \
"Unnamed requirement has a name"
# TODO: Get the candidate and use its name...
return ""
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are there cases where name wouldn’t be called? I can’t think of any. I am tempted to suggest always NamedRequirement instead, by getting the candidate very eagerly in make_requirement and assigning it to the InstallRequirement.

Copy link
Member

@uranusjr uranusjr Mar 11, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I.e. something like

def make_requirement(install_req):
    # type: (InstallRequirement) -> Requirement
    if not install_req.link:
        return VersionedRequirement(install_req)
    if not install_req.req:
        install_req.req = _parse_link_into_req(install_req.link)  # TODO: Implement me.
    if install_req.req.name:
        return NamedRequirement(install_req, candidate=None)
    candidate = _make_candidate(install_req)  # TODO: Implement me.
    install_req.req.name = candidate.name
    return NamedRequirement(install_req, candidate)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, but without a "proper" candidate object, I can't do this without the tests failing (see the test that currently asserts that a sdist has a name of ""). So I plan on addressing this in one of the follow up PRs (either the one that implements candidates, or more likely a further PR that tidies up all the links).

In hindsight, I'm not convinced that splitting up the original change from #7799 was worthwhile, it's created a lot of busy-work, and I don't think the resulting PRs are that much more reviewable. But adding the tests was (IMO) a definite benefit, so I'm not going to worry too much..


def _get_candidate(self):
# type: () -> Candidate
if self._candidate is None:
self._candidate = Candidate()
return self._candidate

def find_matches(
self,
finder, # type: PackageFinder
):
# type: (...) -> Sequence[Candidate]
return [self._get_candidate()]

def is_satisfied_by(self, candidate):
# type: (Candidate) -> bool
return candidate is self._get_candidate()


class NamedRequirement(Requirement):
def __init__(self, req):
# type: (InstallRequirement) -> None
self._ireq = req
self._candidate = None # type: Optional[Candidate]

@property
def name(self):
# type: () -> str
assert self._ireq.req.name is not None, "Named requirement has no name"
canonical_name = canonicalize_name(self._ireq.req.name)
return format_name(canonical_name, self._ireq.req.extras)

def _get_candidate(self):
# type: () -> Candidate
if self._candidate is None:
self._candidate = Candidate()
return self._candidate

def find_matches(
self,
finder, # type: PackageFinder
):
# type: (...) -> Sequence[Candidate]
return [self._get_candidate()]

def is_satisfied_by(self, candidate):
# type: (Candidate) -> bool
return candidate is self._get_candidate()


# TODO: This is temporary, to make the tests pass
class DummyCandidate(Candidate):
def __init__(self, name, version):
# type: (str, _BaseVersion) -> None
self._name = name
self._version = version

@property
def name(self):
# type: () -> str
return self._name

@property
def version(self):
# type: () -> _BaseVersion
return self._version


class VersionedRequirement(Requirement):
def __init__(self, ireq):
# type: (InstallRequirement) -> None
assert ireq.req is not None, "Un-specified requirement not allowed"
assert ireq.req.url is None, "Direct reference not allowed"
self._ireq = ireq

@property
def name(self):
# type: () -> str
canonical_name = canonicalize_name(self._ireq.req.name)
return format_name(canonical_name, self._ireq.req.extras)

def find_matches(
self,
finder, # type: PackageFinder
):
# type: (...) -> Sequence[Candidate]
found = finder.find_best_candidate(
project_name=self._ireq.req.name,
specifier=self._ireq.req.specifier,
hashes=self._ireq.hashes(trust_internet=False),
)
return [
DummyCandidate(ican.name, ican.version)
for ican in found.iter_applicable()
]

def is_satisfied_by(self, candidate):
# type: (Candidate) -> bool
# TODO: Should check name matches as well. Defer this
# until we have the proper Candidate object, and
# no longer have to deal with unnmed requirements...
return candidate.version in self._ireq.req.specifier
43 changes: 43 additions & 0 deletions tests/unit/resolution_resolvelib/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import pytest

from pip._internal.cli.req_command import RequirementCommand
from pip._internal.commands.install import InstallCommand
from pip._internal.index.collector import LinkCollector
from pip._internal.index.package_finder import PackageFinder
# from pip._internal.models.index import PyPI
from pip._internal.models.search_scope import SearchScope
from pip._internal.models.selection_prefs import SelectionPreferences
from pip._internal.network.session import PipSession
from pip._internal.req.req_tracker import get_requirement_tracker
from pip._internal.utils.temp_dir import TempDirectory, global_tempdir_manager


@pytest.fixture
def finder(data):
session = PipSession()
scope = SearchScope([str(data.packages)], [])
collector = LinkCollector(session, scope)
prefs = SelectionPreferences(allow_yanked=False)
finder = PackageFinder.create(collector, prefs)
yield finder


@pytest.fixture
def preparer(finder):
session = PipSession()
rc = InstallCommand("x", "y")
o = rc.parse_args([])

with global_tempdir_manager():
with TempDirectory() as tmp:
with get_requirement_tracker() as tracker:
preparer = RequirementCommand.make_requirement_preparer(
tmp,
options=o[0],
req_tracker=tracker,
session=session,
finder=finder,
use_user_site=False
)

yield preparer
75 changes: 75 additions & 0 deletions tests/unit/resolution_resolvelib/test_requirement.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import pytest

from pip._internal.req.constructors import install_req_from_line
from pip._internal.resolution.resolvelib.base import Candidate
from pip._internal.resolution.resolvelib.requirements import make_requirement
from pip._internal.utils.urls import path_to_url

# NOTE: All tests are prefixed `test_rlr` (for "test resolvelib resolver").
# This helps select just these tests using pytest's `-k` option, and
# keeps test names shorter.

# Basic tests:
# Create a requirement from a project name - "pip"
# Create a requirement from a name + version constraint - "pip >= 20.0"
# Create a requirement from a wheel filename
# Create a requirement from a sdist filename
# Create a requirement from a local directory (which has no obvious name!)
# Editables
#


@pytest.fixture
def test_cases(data):
def data_file(name):
return data.packages.joinpath(name)

def data_url(name):
return path_to_url(data_file(name))

test_cases = [
# requirement, name, matches
# Version specifiers
("simple", "simple", 3),
("simple>1.0", "simple", 2),
("simple[extra]==1.0", "simple[extra]", 1),
# Wheels
(data_file("simplewheel-1.0-py2.py3-none-any.whl"), "simplewheel", 1),
(data_url("simplewheel-1.0-py2.py3-none-any.whl"), "simplewheel", 1),
# Direct URLs
("foo @ " + data_url("simple-1.0.tar.gz"), "foo", 1),
# SDists
# TODO: sdists should have a name
(data_file("simple-1.0.tar.gz"), "", 1),
(data_url("simple-1.0.tar.gz"), "", 1),
# TODO: directory, editables
]

yield test_cases


def req_from_line(line):
return make_requirement(install_req_from_line(line))


def test_rlr_requirement_has_name(test_cases):
"""All requirements should have a name"""
for requirement, name, matches in test_cases:
req = req_from_line(requirement)
assert req.name == name


def test_rlr_correct_number_of_matches(test_cases, finder):
"""Requirements should return the correct number of candidates"""
for requirement, name, matches in test_cases:
req = req_from_line(requirement)
assert len(req.find_matches(finder)) == matches


def test_rlr_candidates_match_requirement(test_cases, finder):
"""Candidates returned from find_matches should satisfy the requirement"""
for requirement, name, matches in test_cases:
req = req_from_line(requirement)
for c in req.find_matches(finder):
assert isinstance(c, Candidate)
assert req.is_satisfied_by(c)