diff --git a/news/c9a56cb3-00b1-4ccb-805a-ac4807c72a52.trivial b/news/c9a56cb3-00b1-4ccb-805a-ac4807c72a52.trivial new file mode 100644 index 000000000..e69de29bb diff --git a/src/pip/_internal/resolution/resolvelib/base.py b/src/pip/_internal/resolution/resolvelib/base.py new file mode 100644 index 000000000..76c3ec444 --- /dev/null +++ b/src/pip/_internal/resolution/resolvelib/base.py @@ -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") diff --git a/src/pip/_internal/resolution/resolvelib/requirements.py b/src/pip/_internal/resolution/resolvelib/requirements.py new file mode 100644 index 000000000..76fa16a96 --- /dev/null +++ b/src/pip/_internal/resolution/resolvelib/requirements.py @@ -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 "" + + 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 diff --git a/tests/unit/resolution_resolvelib/conftest.py b/tests/unit/resolution_resolvelib/conftest.py new file mode 100644 index 000000000..f885d1c85 --- /dev/null +++ b/tests/unit/resolution_resolvelib/conftest.py @@ -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 diff --git a/tests/unit/resolution_resolvelib/test_requirement.py b/tests/unit/resolution_resolvelib/test_requirement.py new file mode 100644 index 000000000..28fdaaa20 --- /dev/null +++ b/tests/unit/resolution_resolvelib/test_requirement.py @@ -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)