diff --git a/examples/extras_provider.py b/examples/extras_provider.py index b4c4eb67..46771736 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_requirement_selection( + self, + identifiers, + resolutions, + candidates, + information, + backtrack_causes, + ): + return identifiers diff --git a/examples/reporter_demo.py b/examples/reporter_demo.py index d98c299a..e2258e46 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_requirement_selection( + self, + identifiers, + resolutions, + candidates, + information, + backtrack_causes, + ): + return identifiers + class Reporter(resolvelib.BaseReporter): def starting(self): diff --git a/src/resolvelib/providers.py b/src/resolvelib/providers.py index f7e240f5..fa80ba6d 100644 --- a/src/resolvelib/providers.py +++ b/src/resolvelib/providers.py @@ -136,3 +136,60 @@ def get_dependencies(self, candidate: CT) -> Iterable[RT]: specifies as its dependencies. """ raise NotImplementedError + + def narrow_requirement_selection( + self, + identifiers: 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 requirements being considered during + resolution. + + The requirement selection is defined as "The possible requirements + that will be resolved next." If a requirement isn't part of the returned + iterable, it will not be considered during the next step of resolution. + + :param identifiers: An iterable of `identifier` as returned by + ``identify()``. These identify all requirements currently being + considered. + :param resolutions: Mapping of candidates currently pinned by the + resolver. Each key is an identifier, and the value is a candidate. + The candidate may conflict with requirements from ``information``. + :param candidates: Mapping of each dependency's possible candidates. + Each value is an iterator of candidates. + :param information: Mapping of requirement information of each package. + Each value is an iterator of *requirement information*. + :param backtrack_causes: Sequence of *requirement information* that are + the requirements that caused the resolver to most recently + backtrack. + + A *requirement information* instance is a named tuple with two members: + + * ``requirement`` specifies a requirement contributing to the current + list of candidates. + * ``parent`` specifies the candidate that provides (depended on) the + requirement, or ``None`` to indicate a root requirement. + + Must return a non-empty subset of `identifiers`, with the simplest + implementation being to return `identifiers` unchanged. + + Can be used by the provider to optimize the dependency resolution + process. `get_preference` will only be called on the identifiers + returned. If there is only one identifier returned, then `get_preference` + won't be called at all. + + Serving a similar purpose as `get_preference`, this method allows the + provider to guide resolvelib through the resolution process. It should + be used instead of `get_preference` when the provider needs to consider + multiple identifiers simultaneously, or when the provider wants to skip + checking all identifiers, e.g., because the checks are prohibitively + expensive. + + Returns: + Iterable[KT]: A non-empty subset of `identifiers`. + """ + raise NotImplementedError diff --git a/src/resolvelib/resolvers.py b/src/resolvelib/resolvers.py index ecc4dadb..3f5fcf53 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_requirement_selection( + identifiers=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..296d37e7 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_requirement_selection( + self, + identifiers, + resolutions, + candidates, + information, + backtrack_causes, + ): + return identifiers + 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..98185041 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_requirement_selection( + self, + identifiers, + resolutions, + candidates, + information, + backtrack_causes, + ): + return identifiers + 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..e28e7c22 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_requirement_selection( + self, + identifiers, + resolutions, + candidates, + information, + backtrack_causes, + ): + return identifiers + @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..693eef31 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_requirement_selection(self, identifiers, **_): + return identifiers + 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_requirement_selection(self, identifiers, **_): + return identifiers + # 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_requirement_selection(self, identifiers, **_): + return identifiers + 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_requirement_selection(self, identifiers, **_): + return identifiers + # patch Resolution._get_updated_criteria to collect rejected states rejected_criteria: list[Criterion] = [] get_updated_criteria_orig = (