diff --git a/examples/extras_provider.py b/examples/extras_provider.py index b4c4eb67..8ce12050 100644 --- a/examples/extras_provider.py +++ b/examples/extras_provider.py @@ -52,3 +52,13 @@ def get_dependencies(self, candidate): req = self.get_base_requirement(candidate) deps.append(req) return deps + + def narrow_backtrack_selection( + self, + unsatisfied_names, + resolutions, + candidates, + information, + backtrack_causes, + ): + return unsatisfied_names diff --git a/examples/reporter_demo.py b/examples/reporter_demo.py index d98c299a..5e915fc2 100644 --- a/examples/reporter_demo.py +++ b/examples/reporter_demo.py @@ -101,6 +101,16 @@ def is_satisfied_by(self, requirement, candidate): def get_dependencies(self, candidate): return self.candidates[candidate] + def narrow_backtrack_selection( + self, + unsatisfied_names, + resolutions, + candidates, + information, + backtrack_causes, + ): + return unsatisfied_names + class Reporter(resolvelib.BaseReporter): def starting(self): diff --git a/src/resolvelib/providers.py b/src/resolvelib/providers.py index f7e240f5..53a9ee19 100644 --- a/src/resolvelib/providers.py +++ b/src/resolvelib/providers.py @@ -136,3 +136,32 @@ def get_dependencies(self, candidate: CT) -> Iterable[RT]: specifies as its dependencies. """ raise NotImplementedError + + def narrow_backtrack_selection( + self, + unsatisfied_names: Iterable[KT], + resolutions: Mapping[KT, CT], + candidates: Mapping[KT, Iterator[CT]], + information: Mapping[KT, Iterator[RequirementInformation[RT, CT]]], + backtrack_causes: Sequence[RequirementInformation[RT, CT]], + ) -> Iterable[KT]: + """ + Narrows the selection of unsatisfied dependency names during the + backtracking process. + + It is required to return a non-empty subset of `unsatisfied_names`, + the simplest implementation is to return `unsatisfied_names` unchanged. + + This method can be used by the provider to optimizes the dependency + resolution process by determining which unsatisfied dependencies may + be selected for the next phase of backtracking. + + Serving a similar purpose as `get_preference`, this method allows + the provider to guide resolvelib through the backtracking process. + It should be used instead of `get_preference` when the provider needs + to consider multiple unsatisfied dependency names simultaneously. + + Returns: + Iterable[KT]: A non-empty subset of `unsatisfied_names`. + """ + raise NotImplementedError diff --git a/src/resolvelib/resolvers.py b/src/resolvelib/resolvers.py index ecc4dadb..c6262739 100644 --- a/src/resolvelib/resolvers.py +++ b/src/resolvelib/resolvers.py @@ -435,8 +435,33 @@ def resolve( unsatisfied_names ) + if len(unsatisfied_names) > 1: + filtered_unstatisfied_names = list( + self._p.narrow_backtrack_selection( + unsatisfied_names=unsatisfied_names, + resolutions=self.state.mapping, + candidates=IteratorMapping( + self.state.criteria, + operator.attrgetter("candidates"), + ), + information=IteratorMapping( + self.state.criteria, + operator.attrgetter("information"), + ), + backtrack_causes=self.state.backtrack_causes, + ) + ) + else: + filtered_unstatisfied_names = unsatisfied_names + # Choose the most preferred unpinned criterion to try. - name = min(unsatisfied_names, key=self._get_preference) + if len(filtered_unstatisfied_names) > 1: + name = min( + filtered_unstatisfied_names, key=self._get_preference + ) + else: + name = filtered_unstatisfied_names[0] + failure_criterion = self._attempt_to_pin_criterion(name) if failure_criterion: diff --git a/tests/functional/cocoapods/test_resolvers_cocoapods.py b/tests/functional/cocoapods/test_resolvers_cocoapods.py index 12dff461..81bb018d 100644 --- a/tests/functional/cocoapods/test_resolvers_cocoapods.py +++ b/tests/functional/cocoapods/test_resolvers_cocoapods.py @@ -222,6 +222,16 @@ def is_satisfied_by(self, requirement, candidate): def get_dependencies(self, candidate): return candidate.deps + def narrow_backtrack_selection( + self, + unsatisfied_names, + resolutions, + candidates, + information, + backtrack_causes, + ): + return unsatisfied_names + XFAIL_CASES = { # ResolveLib does not complain about cycles, so these will be different. diff --git a/tests/functional/python/test_resolvers_python.py b/tests/functional/python/test_resolvers_python.py index 2b6de367..e27407b1 100644 --- a/tests/functional/python/test_resolvers_python.py +++ b/tests/functional/python/test_resolvers_python.py @@ -119,6 +119,16 @@ def _iter_dependencies(self, candidate): def get_dependencies(self, candidate): return list(self._iter_dependencies(candidate)) + def narrow_backtrack_selection( + self, + unsatisfied_names, + resolutions, + candidates, + information, + backtrack_causes, + ): + return unsatisfied_names + INPUTS_DIR = os.path.abspath(os.path.join(__file__, "..", "inputs")) diff --git a/tests/functional/swift-package-manager/test_resolvers_swift.py b/tests/functional/swift-package-manager/test_resolvers_swift.py index ad0e48f0..0868b68e 100644 --- a/tests/functional/swift-package-manager/test_resolvers_swift.py +++ b/tests/functional/swift-package-manager/test_resolvers_swift.py @@ -132,6 +132,16 @@ def _iter_dependencies(self, candidate): def get_dependencies(self, candidate): return list(self._iter_dependencies(candidate)) + def narrow_backtrack_selection( + self, + unsatisfied_names, + resolutions, + candidates, + information, + backtrack_causes, + ): + return unsatisfied_names + @pytest.fixture( params=[os.path.join(INPUTS_DIR, n) for n in INPUT_NAMES], diff --git a/tests/test_resolvers.py b/tests/test_resolvers.py index 46d0f5d5..38f3952a 100644 --- a/tests/test_resolvers.py +++ b/tests/test_resolvers.py @@ -58,6 +58,9 @@ def is_satisfied_by(self, requirement, candidate): assert candidate is self.candidate return False + def narrow_backtrack_selection(self, unsatisfied_names, **_): + return unsatisfied_names + resolver = Resolver(Provider(requirement, candidate), BaseReporter()) with pytest.raises(InconsistentCandidate) as ctx: @@ -104,6 +107,9 @@ def find_matches(self, identifier, requirements, incompatibilities): def is_satisfied_by(self, requirement, candidate): return candidate[1] in requirement[1] + def narrow_backtrack_selection(self, unsatisfied_names, **_): + return unsatisfied_names + # Now when resolved, both requirements to child specified by parent should # be pulled, and the resolver should choose v1, not v2 (happens if the # v1-only requirement is dropped). @@ -164,6 +170,9 @@ def find_matches(self, identifier, requirements, incompatibilities): def is_satisfied_by(self, requirement, candidate): return candidate.version in requirement.versions + def narrow_backtrack_selection(self, unsatisfied_names, **_): + return unsatisfied_names + def run_resolver(*args): reporter = Reporter() resolver = Resolver(Provider(), reporter) @@ -243,6 +252,9 @@ def is_satisfied_by( ) -> bool: return candidate[1] in Requirement(requirement).specifier + def narrow_backtrack_selection(self, unsatisfied_names, **_): + return unsatisfied_names + # patch Resolution._get_updated_criteria to collect rejected states rejected_criteria: list[Criterion] = [] get_updated_criteria_orig = (