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

Tree pruning #67

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
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
15 changes: 15 additions & 0 deletions src/resolvelib/providers.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,21 @@ def get_dependencies(self, candidate):
"""
raise NotImplementedError

def match_identically(self, requirements_a, requirements_b):
"""Whether the two given requirement sets find the same candidates.
This is used by the resolver to perform tree-pruning. If the two
requirement sets provide the same candidates, the resolver can avoid
visiting the subtree again when it's encountered, and directly mark it
as a dead end instead.
Both arguments are iterators yielding requirement objects. A boolean
should be returned to indicate whether the two sets should be treated
as matching.
Copy link
Member Author

Choose a reason for hiding this comment

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

This should also mention requirements_a is the new state, and requirements_b is from a known-to-fail state. This is important since the provider can implement range merging to return True when requirements_a is a subset of requirements_b to prune additional subtrees.

(The method name should probably change.)

Copy link
Member Author

Choose a reason for hiding this comment

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

OTOH if requirements_a is a superset of requirements_b, this should be used to exclude requirements_b from requirements_a so the resolver can avoid visiting some subtrees. Maybe the interface should be defined to perform specifier merging instead.

"""
return False # TODO: Remove this and implement the method in tests.
raise NotImplementedError


class AbstractResolver(object):
"""The thing that performs the actual resolution work."""
Expand Down
27 changes: 24 additions & 3 deletions src/resolvelib/resolvers.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import collections
import itertools

from .providers import AbstractResolver
from .structs import DirectedGraph, build_iter_view
Expand Down Expand Up @@ -143,6 +144,7 @@ def __init__(self, provider, reporter):
self._p = provider
self._r = reporter
self._states = []
self._known_failures = []

@property
def state(self):
Expand Down Expand Up @@ -199,6 +201,22 @@ def _get_criteria_to_update(self, candidate):
criteria[name] = crit
return criteria

def _match_known_failure_causes(self, updating_criteria):
criteria = self.state.criteria.copy()
criteria.update(updating_criteria)
for state in self._known_failures:
identical = self._p.match_identically(
itertools.chain.from_iterable(
crit.iter_requirement() for crit in criteria.values()
),
itertools.chain.from_iterable(
crit.iter_requirement() for crit in state.criteria.values()
),
)
if identical:
return True
return False

def _attempt_to_pin_criterion(self, name, criterion):
causes = []
for candidate in criterion.candidates:
Expand All @@ -208,6 +226,9 @@ def _attempt_to_pin_criterion(self, name, criterion):
causes.append(e.criterion)
continue

if self._match_known_failure_causes(criteria):
continue

# Check the newly-pinned candidate actually works. This should
# always pass under normal circumstances, but in the case of a
# faulty provider, we will raise an error to notify the implementer
Expand All @@ -226,7 +247,7 @@ def _attempt_to_pin_criterion(self, name, criterion):
self.state.mapping[name] = candidate
self.state.criteria.update(criteria)

return []
return None

# All candidates tried, nothing works. This criterion is a dead
# end, signal for backtracking.
Expand Down Expand Up @@ -260,7 +281,7 @@ def _backtrack(self):
"""
while len(self._states) >= 3:
# Remove the state that triggered backtracking.
del self._states[-1]
self._known_failures.append(self._states.pop())

# Retrieve the last candidate pin and known incompatibilities.
broken_state = self._states.pop()
Expand Down Expand Up @@ -345,7 +366,7 @@ def resolve(self, requirements, max_rounds):
)
failure_causes = self._attempt_to_pin_criterion(name, criterion)

if failure_causes:
if failure_causes is not None:
# Backtrack if pinning fails. The backtrack process puts us in
# an unpinned state, so we can work on it in the next round.
success = self._backtrack()
Expand Down
5 changes: 5 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,8 @@ def reporter_cls():
@pytest.fixture()
def reporter(reporter_cls):
return reporter_cls()


@pytest.fixture()
def base_reporter():
return BaseReporter()
75 changes: 75 additions & 0 deletions tests/test_resolvers.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import collections
import operator

import pytest

from resolvelib import (
Expand Down Expand Up @@ -40,3 +43,75 @@ def is_satisfied_by(self, r, c):
assert str(ctx.value) == "Provided candidate 'bar' does not satisfy 'foo'"
assert ctx.value.candidate is candidate
assert list(ctx.value.criterion.iter_requirement()) == [requirement]


def test_criteria_pruning(reporter_cls, base_reporter):
C = collections.namedtuple("C", "name version dependencies")
R = collections.namedtuple("R", "name versions")

# Both C versions have the same dependencies. The resolver should be start
# enough to not pin C1 after C2 fails.
candidate_definitions = [
C("a", 1, []),
C("a", 2, []),
C("b", 1, [R("a", [2])]),
C("c", 1, [R("b", [1]), R("a", [2])]),
C("c", 2, [R("b", [1]), R("a", [1])]),
C("c", 3, [R("b", [1]), R("a", [1])]),
]

class Provider(AbstractProvider):
def identify(self, d):
return d.name

def get_preference(self, resolution, candidates, information):
# Order by name for reproducibility.
return next(iter(candidates)).name

def find_matches(self, requirements):
if not requirements:
return ()
matches = (
c
for c in candidate_definitions
if all(self.is_satisfied_by(r, c) for r in requirements)
)
return sorted(
matches,
key=operator.attrgetter("version"),
reverse=True,
)

def is_satisfied_by(self, requirement, candidate):
return (
candidate.name == requirement.name
and candidate.version in requirement.versions
)

def match_identically(self, reqs1, reqs2):
vers1 = collections.defaultdict(set)
vers2 = collections.defaultdict(set)
for rs, vs in [(reqs1, vers1), (reqs2, vers2)]:
for r in rs:
vs[r.name] = vs[r.name].union(r.versions)
return vers1 == vers2

def get_dependencies(self, candidate):
return candidate.dependencies

class Reporter(reporter_cls):
def __init__(self):
super(Reporter, self).__init__()
self.pinned_c = []

def pinning(self, candidate):
super(Reporter, self).pinning(candidate)
if candidate.name == "c":
self.pinned_c.append(candidate.version)

reporter = Reporter()
result = Resolver(Provider(), reporter).resolve([R("c", [1, 2, 3])])

pinned_versions = {c.name: c.version for c in result.mapping.values()}
assert pinned_versions == {"a": 2, "b": 1, "c": 1}
assert reporter.pinned_c == [3, 1], "should be smart enough to skip c==2"