From 9b10b9350301cb9ff0c62c745f3ca8197f393753 Mon Sep 17 00:00:00 2001
From: Paul Moore
Date: Mon, 9 Mar 2020 09:06:32 +0000
Subject: [PATCH] Implement the resolvelib Requirement class
---
...a56cb3-00b1-4ccb-805a-ac4807c72a52.trivial | 0
.../_internal/resolution/resolvelib/base.py | 51 +++++++
.../resolution/resolvelib/requirements.py | 141 ++++++++++++++++++
tests/unit/resolution_resolvelib/conftest.py | 43 ++++++
.../resolution_resolvelib/test_requirement.py | 75 ++++++++++
5 files changed, 310 insertions(+)
create mode 100644 news/c9a56cb3-00b1-4ccb-805a-ac4807c72a52.trivial
create mode 100644 src/pip/_internal/resolution/resolvelib/base.py
create mode 100644 src/pip/_internal/resolution/resolvelib/requirements.py
create mode 100644 tests/unit/resolution_resolvelib/conftest.py
create mode 100644 tests/unit/resolution_resolvelib/test_requirement.py
diff --git a/news/c9a56cb3-00b1-4ccb-805a-ac4807c72a52.trivial b/news/c9a56cb3-00b1-4ccb-805a-ac4807c72a52.trivial
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/src/pip/_internal/resolution/resolvelib/base.py b/src/pip/_internal/resolution/resolvelib/base.py
new file mode 100644
index 00000000000..76c3ec444ff
--- /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 00000000000..76fa16a96e4
--- /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 00000000000..f885d1c855b
--- /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 00000000000..28fdaaa20e9
--- /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)