Script 'mail_helper' called by obssrc Hello community, here is the log from the commit of package python-resolvelib for openSUSE:Factory checked in at 2023-12-08 22:33:33 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Comparing /work/SRC/openSUSE:Factory/python-resolvelib (Old) and /work/SRC/openSUSE:Factory/.python-resolvelib.new.25432 (New) ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "python-resolvelib" Fri Dec 8 22:33:33 2023 rev:14 rq:1132100 version:1.0.1 Changes: -------- --- /work/SRC/openSUSE:Factory/python-resolvelib/python-resolvelib.changes 2023-05-08 17:24:38.668823112 +0200 +++ /work/SRC/openSUSE:Factory/.python-resolvelib.new.25432/python-resolvelib.changes 2023-12-08 22:34:26.117258251 +0100 @@ -1,0 +2,21 @@ +Fri Dec 8 13:39:00 UTC 2023 - Dirk Müller <dmuel...@suse.com> + +- update to 1.0.1: + * Fix calls to opaque objects and use provider interface calls + instead. `#126 + * Implement backjumping to significantly speed up the + resolution process by skipping over irrelevant parts of the + resolution search space. `#113 + * A new reporter hook ``rejecting_candidate`` is added, + replacing ``backtracking``. + * The hook is called every time the resolver rejects a + conflicting candidate before trying out the next one in line. + * Some valid states that were previously rejected are now + accepted. This affects states where multiple candidates for + the same dependency conflict with each other. + The ``information`` argument passed to + ``AbstractProvider.get_preference`` may now contain empty + iterators. This has always been allowed by the method + definition but it was previously not possible in practice. + +------------------------------------------------------------------- Old: ---- resolvelib-0.8.1.tar.gz New: ---- resolvelib-1.0.1.tar.gz ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Other differences: ------------------ ++++++ python-resolvelib.spec ++++++ --- /var/tmp/diff_new_pack.8MFJkz/_old 2023-12-08 22:34:26.617276649 +0100 +++ /var/tmp/diff_new_pack.8MFJkz/_new 2023-12-08 22:34:26.617276649 +0100 @@ -19,7 +19,7 @@ %{?sle15_python_module_pythons} Name: python-resolvelib # ansible-core 2.14.x is currently requiring < 0.9.0 -Version: 0.8.1 +Version: 1.0.1 Release: 0 Summary: Module to resolve abstract dependencies into concrete ones License: ISC ++++++ resolvelib-0.8.1.tar.gz -> resolvelib-1.0.1.tar.gz ++++++ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/resolvelib-0.8.1/.github/workflows/ci.yml new/resolvelib-1.0.1/.github/workflows/ci.yml --- old/resolvelib-0.8.1/.github/workflows/ci.yml 2021-10-11 23:05:57.000000000 +0200 +++ new/resolvelib-1.0.1/.github/workflows/ci.yml 2023-03-09 06:10:19.000000000 +0100 @@ -9,39 +9,35 @@ name: Lint runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - uses: actions/setup-python@v2 - - run: pip install .[lint,test] - - run: black --check . - - run: isort . - - run: flake8 . - - run: mypy src/ tests/ + - uses: actions/checkout@v3 + - uses: actions/setup-python@v4 + - run: pip install nox + - run: nox -s lint package: name: Package runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - uses: actions/setup-python@v2 - - run: pip install .[release] - - run: python -m build . + - uses: actions/checkout@v3 + - uses: actions/setup-python@v4 + - run: pip install nox + - run: nox -s release -- --version '' --repo '' --prebump '' test: name: Test - runs-on: ubuntu-latest + runs-on: ubuntu-20.04 needs: [lint] strategy: fail-fast: true matrix: python: - - "2.7" + - "3.11" - "3.10" - "3.9" - "3.8" - "3.7" - - "3.6" steps: - - uses: actions/checkout@v2 - - uses: actions/setup-python@v2 + - uses: actions/checkout@v3 + - uses: actions/setup-python@v4 with: python-version: ${{ matrix.python }} - - run: pip install .[test] - - run: pytest tests + - run: pip install nox + - run: nox -s tests-${{ matrix.python }} diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/resolvelib-0.8.1/CHANGELOG.rst new/resolvelib-1.0.1/CHANGELOG.rst --- old/resolvelib-0.8.1/CHANGELOG.rst 2021-10-11 23:05:57.000000000 +0200 +++ new/resolvelib-1.0.1/CHANGELOG.rst 2023-03-09 06:10:19.000000000 +0100 @@ -1,3 +1,43 @@ +1.0.1 (2023-03-09) +================== + +Bug Fixes +--------- + +- Fix calls to opaque objects and use provider interface calls instead. `#126 <https://github.com/sarugaku/resolvelib/issues/126>`_ + + +1.0.0 (2023-03-08) +================== + +Features +-------- + +- Implement backjumping to significantly speed up the resolution process by skipping over irrelevant parts of the resolution search space. `#113 <https://github.com/sarugaku/resolvelib/issues/113>`_ + + +0.9.0 (2022-11-17) +================== + +Features +-------- + +- A new reporter hook ``rejecting_candidate`` is added, replacing ``backtracking``. + The hook is called every time the resolver rejects a conflicting candidate before + trying out the next one in line. `#101 <https://github.com/sarugaku/resolvelib/issues/101>`_ + + +Bug Fixes +--------- + +- Some valid states that were previously rejected are now accepted. This affects + states where multiple candidates for the same dependency conflict with each + other. The ``information`` argument passed to + ``AbstractProvider.get_preference`` may now contain empty iterators. This has + always been allowed by the method definition but it was previously not possible + in practice. `#91 <https://github.com/sarugaku/resolvelib/issues/91>`_ + + 0.8.1 (2021-10-12) ================== diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/resolvelib-0.8.1/CODE_OF_CONDUCT.md new/resolvelib-1.0.1/CODE_OF_CONDUCT.md --- old/resolvelib-0.8.1/CODE_OF_CONDUCT.md 1970-01-01 01:00:00.000000000 +0100 +++ new/resolvelib-1.0.1/CODE_OF_CONDUCT.md 2023-03-09 06:10:19.000000000 +0100 @@ -0,0 +1 @@ +Everyone interacting in the ResolveLib project's codebases and issue trackers is expected to follow the [PSF Code of Conduct](https://www.python.org/psf/conduct/). diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/resolvelib-0.8.1/DEVELOPMENT.rst new/resolvelib-1.0.1/DEVELOPMENT.rst --- old/resolvelib-0.8.1/DEVELOPMENT.rst 2021-10-11 23:05:57.000000000 +0200 +++ new/resolvelib-1.0.1/DEVELOPMENT.rst 2023-03-09 06:10:19.000000000 +0100 @@ -32,10 +32,14 @@ Replace ``X.Y.Z`` with the release you would like to make. +(The following assumes the remote you forked is called ``origin``, and the canonical sarugaku/resolvelib is under ``upstream``.) + * Make sure the news fragments are in place. +* ``git checkout -b release/X.Y.Z`` * ``nox -s release -- --repo https://upload.pypi.org/legacy/ --prebump X.Y.Z+1.dev0 --version X.Y.Z`` -* ``git push origin master --tags`` -* ``git push upstream master --tags`` +* ``git push upstream --tags`` +* ``git push origin release/X.Y.Z`` +* Open a pull request on GitHub and merge the ``release/X.Y.Z`` branch into main. Breakdown of the ``release`` nox task: diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/resolvelib-0.8.1/README.rst new/resolvelib-1.0.1/README.rst --- old/resolvelib-0.8.1/README.rst 2021-10-11 23:05:57.000000000 +0200 +++ new/resolvelib-1.0.1/README.rst 2023-03-09 06:10:19.000000000 +0100 @@ -56,8 +56,8 @@ ------- A string, usually in a number form, describing a snapshot of a Package. This -number should increase when a Package post a new snapshot, i.e. a higher number -means a more up-to-date snapshot. +number should increase when a Package posts a new snapshot, +i.e a higher number means a more up-to-date snapshot. Specifier --------- diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/resolvelib-0.8.1/noxfile.py new/resolvelib-1.0.1/noxfile.py --- old/resolvelib-0.8.1/noxfile.py 2021-10-11 23:05:57.000000000 +0200 +++ new/resolvelib-1.0.1/noxfile.py 2023-03-09 06:10:19.000000000 +0100 @@ -22,7 +22,7 @@ session.run("mypy", "src", "tests") -@nox.session(python=["3.9", "3.8", "3.7", "3.6", "3.5", "2.7"]) +@nox.session(python=["3.11", "3.10", "3.9", "3.8", "3.7", "2.7"]) def tests(session): session.install(".[test]") @@ -82,7 +82,7 @@ if options.version: _write_package_version(options.version) - session.run("towncrier", "--version", options.version) + session.run("towncrier", "build", "--version", options.version) session.run( "git", "commit", diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/resolvelib-0.8.1/setup.cfg new/resolvelib-1.0.1/setup.cfg --- old/resolvelib-0.8.1/setup.cfg 2021-10-11 23:05:57.000000000 +0200 +++ new/resolvelib-1.0.1/setup.cfg 2023-03-09 06:10:19.000000000 +0100 @@ -6,6 +6,7 @@ author = Tzu-ping Chung author_email = uranu...@gmail.com long_description = file: README.rst +long_description_content_type = text/x-rst license = ISC License keywords = dependency @@ -56,7 +57,7 @@ [flake8] max-line-length = 88 select = C,E,F,W,B -ignore = E203, W503 +ignore = E203, W503, F401 exclude = .git, .venv, diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/resolvelib-0.8.1/src/resolvelib/__init__.py new/resolvelib-1.0.1/src/resolvelib/__init__.py --- old/resolvelib-0.8.1/src/resolvelib/__init__.py 2021-10-11 23:05:57.000000000 +0200 +++ new/resolvelib-1.0.1/src/resolvelib/__init__.py 2023-03-09 06:10:19.000000000 +0100 @@ -11,7 +11,7 @@ "ResolutionTooDeep", ] -__version__ = "0.8.1" +__version__ = "1.0.1" from .providers import AbstractProvider, AbstractResolver diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/resolvelib-0.8.1/src/resolvelib/compat/collections_abc.pyi new/resolvelib-1.0.1/src/resolvelib/compat/collections_abc.pyi --- old/resolvelib-0.8.1/src/resolvelib/compat/collections_abc.pyi 1970-01-01 01:00:00.000000000 +0100 +++ new/resolvelib-1.0.1/src/resolvelib/compat/collections_abc.pyi 2023-03-09 06:10:19.000000000 +0100 @@ -0,0 +1 @@ +from collections.abc import Mapping, Sequence diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/resolvelib-0.8.1/src/resolvelib/providers.py new/resolvelib-1.0.1/src/resolvelib/providers.py --- old/resolvelib-0.8.1/src/resolvelib/providers.py 2021-10-11 23:05:57.000000000 +0200 +++ new/resolvelib-1.0.1/src/resolvelib/providers.py 2023-03-09 06:10:19.000000000 +0100 @@ -1,5 +1,5 @@ class AbstractProvider(object): - """Delegate class to provide requirement interface for the resolver.""" + """Delegate class to provide the required interface for the resolver.""" def identify(self, requirement_or_candidate): """Given a requirement, return an identifier for it. @@ -24,9 +24,9 @@ this group of arguments is. :param identifier: An identifier as returned by ``identify()``. This - identifies the dependency matches of which should be returned. + identifies the dependency matches which should be returned. :param resolutions: Mapping of candidates currently pinned by the - resolver. Each key is an identifier, and the value a candidate. + 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. @@ -39,10 +39,10 @@ * ``requirement`` specifies a requirement contributing to the current list of candidates. - * ``parent`` specifies the candidate that provides (dependend on) the + * ``parent`` specifies the candidate that provides (depended on) the requirement, or ``None`` to indicate a root requirement. - The preference could depend on a various of issues, including (not + The preference could depend on various issues, including (not necessarily in this order): * Is this package pinned in the current resolution result? @@ -61,7 +61,7 @@ raise NotImplementedError def find_matches(self, identifier, requirements, incompatibilities): - """Find all possible candidates that satisfy given constraints. + """Find all possible candidates that satisfy the given constraints. :param identifier: An identifier as returned by ``identify()``. This identifies the dependency matches of which should be returned. @@ -92,7 +92,7 @@ def is_satisfied_by(self, requirement, candidate): """Whether the given requirement can be satisfied by a candidate. - The candidate is guarenteed to have been generated from the + The candidate is guaranteed to have been generated from the requirement. A boolean should be returned to indicate whether ``candidate`` is a diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/resolvelib-0.8.1/src/resolvelib/providers.pyi new/resolvelib-1.0.1/src/resolvelib/providers.pyi --- old/resolvelib-0.8.1/src/resolvelib/providers.pyi 2021-10-11 23:05:57.000000000 +0200 +++ new/resolvelib-1.0.1/src/resolvelib/providers.pyi 2023-03-09 06:10:19.000000000 +0100 @@ -1,12 +1,11 @@ from typing import ( Any, - Collection, Generic, Iterable, Iterator, Mapping, - Optional, Protocol, + Sequence, Union, ) @@ -25,6 +24,7 @@ resolutions: Mapping[KT, CT], candidates: Mapping[KT, Iterator[CT]], information: Mapping[KT, Iterator[RequirementInformation[RT, CT]]], + backtrack_causes: Sequence[RequirementInformation[RT, CT]], ) -> Preference: ... def find_matches( self, diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/resolvelib-0.8.1/src/resolvelib/reporters.py new/resolvelib-1.0.1/src/resolvelib/reporters.py --- old/resolvelib-0.8.1/src/resolvelib/reporters.py 2021-10-11 23:05:57.000000000 +0200 +++ new/resolvelib-1.0.1/src/resolvelib/reporters.py 2023-03-09 06:10:19.000000000 +0100 @@ -36,7 +36,7 @@ :param causes: The information on the collision that caused the backtracking. """ - def backtracking(self, candidate): + def rejecting_candidate(self, criterion, candidate): """Called when rejecting a candidate during backtracking.""" def pinning(self, candidate): diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/resolvelib-0.8.1/src/resolvelib/reporters.pyi new/resolvelib-1.0.1/src/resolvelib/reporters.pyi --- old/resolvelib-0.8.1/src/resolvelib/reporters.pyi 2021-10-11 23:05:57.000000000 +0200 +++ new/resolvelib-1.0.1/src/resolvelib/reporters.pyi 2023-03-09 06:10:19.000000000 +0100 @@ -6,6 +6,6 @@ def ending_round(self, index: int, state: Any) -> Any: ... def ending(self, state: Any) -> Any: ... def adding_requirement(self, requirement: Any, parent: Any) -> Any: ... - def backtracking(self, candidate: Any) -> Any: ... + def rejecting_candidate(self, criterion: Any, candidate: Any) -> Any: ... def resolving_conflicts(self, causes: Any) -> Any: ... def pinning(self, candidate: Any) -> Any: ... diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/resolvelib-0.8.1/src/resolvelib/resolvers.py new/resolvelib-1.0.1/src/resolvelib/resolvers.py --- old/resolvelib-0.8.1/src/resolvelib/resolvers.py 2021-10-11 23:05:57.000000000 +0200 +++ new/resolvelib-1.0.1/src/resolvelib/resolvers.py 2023-03-09 06:10:19.000000000 +0100 @@ -1,4 +1,5 @@ import collections +import itertools import operator from .providers import AbstractResolver @@ -173,6 +174,31 @@ raise RequirementsConflicted(criterion) criteria[identifier] = criterion + def _remove_information_from_criteria(self, criteria, parents): + """Remove information from parents of criteria. + + Concretely, removes all values from each criterion's ``information`` + field that have one of ``parents`` as provider of the requirement. + + :param criteria: The criteria to update. + :param parents: Identifiers for which to remove information from all criteria. + """ + if not parents: + return + for key, criterion in criteria.items(): + criteria[key] = Criterion( + criterion.candidates, + [ + information + for information in criterion.information + if ( + information.parent is None + or self._p.identify(information.parent) not in parents + ) + ], + criterion.incompatibilities, + ) + def _get_preference(self, name): return self._p.get_preference( identifier=name, @@ -212,6 +238,7 @@ try: criteria = self._get_updated_criteria(candidate) except RequirementsConflicted as e: + self._r.rejecting_candidate(e.criterion, candidate) causes.append(e.criterion) continue @@ -240,8 +267,8 @@ # end, signal for backtracking. return causes - def _backtrack(self): - """Perform backtracking. + def _backjump(self, causes): + """Perform backjumping. When we enter here, the stack is like this:: @@ -257,22 +284,46 @@ Each iteration of the loop will: - 1. Discard Z. - 2. Discard Y but remember its incompatibility information gathered + 1. Identify Z. The incompatibility is not always caused by the latest + state. For example, given three requirements A, B and C, with + dependencies A1, B1 and C1, where A1 and B1 are incompatible: the + last state might be related to C, so we want to discard the + previous state. + 2. Discard Z. + 3. Discard Y but remember its incompatibility information gathered previously, and the failure we're dealing with right now. - 3. Push a new state Y' based on X, and apply the incompatibility + 4. Push a new state Y' based on X, and apply the incompatibility information from Y to Y'. - 4a. If this causes Y' to conflict, we need to backtrack again. Make Y' + 5a. If this causes Y' to conflict, we need to backtrack again. Make Y' the new Z and go back to step 2. - 4b. If the incompatibilities apply cleanly, end backtracking. + 5b. If the incompatibilities apply cleanly, end backtracking. """ + incompatible_reqs = itertools.chain( + (c.parent for c in causes if c.parent is not None), + (c.requirement for c in causes), + ) + incompatible_deps = {self._p.identify(r) for r in incompatible_reqs} while len(self._states) >= 3: # Remove the state that triggered backtracking. del self._states[-1] - # Retrieve the last candidate pin and known incompatibilities. - broken_state = self._states.pop() - name, candidate = broken_state.mapping.popitem() + # Ensure to backtrack to a state that caused the incompatibility + incompatible_state = False + while not incompatible_state: + # Retrieve the last candidate pin and known incompatibilities. + try: + broken_state = self._states.pop() + name, candidate = broken_state.mapping.popitem() + except (IndexError, KeyError): + raise ResolutionImpossible(causes) + current_dependencies = { + self._p.identify(d) + for d in self._p.get_dependencies(candidate) + } + incompatible_state = not current_dependencies.isdisjoint( + incompatible_deps + ) + incompatibilities_from_broken = [ (k, list(v.incompatibilities)) for k, v in broken_state.criteria.items() @@ -281,8 +332,6 @@ # Also mark the newly known incompatibility. incompatibilities_from_broken.append((name, [candidate])) - self._r.backtracking(candidate=candidate) - # Create a new state from the last known-to-work one, and apply # the previously gathered incompatibility information. def _patch_criteria(): @@ -368,22 +417,38 @@ self._r.ending(state=self.state) return self.state + # keep track of satisfied names to calculate diff after pinning + satisfied_names = set(self.state.criteria.keys()) - set( + unsatisfied_names + ) + # Choose the most preferred unpinned criterion to try. name = min(unsatisfied_names, key=self._get_preference) failure_causes = self._attempt_to_pin_criterion(name) if failure_causes: causes = [i for c in failure_causes for i in c.information] - # Backtrack if pinning fails. The backtrack process puts us in + # Backjump if pinning fails. The backjump process puts us in # an unpinned state, so we can work on it in the next round. self._r.resolving_conflicts(causes=causes) - success = self._backtrack() + success = self._backjump(causes) self.state.backtrack_causes[:] = causes # Dead ends everywhere. Give up. if not success: raise ResolutionImpossible(self.state.backtrack_causes) else: + # discard as information sources any invalidated names + # (unsatisfied names that were previously satisfied) + newly_unsatisfied_names = { + key + for key, criterion in self.state.criteria.items() + if key in satisfied_names + and not self._is_current_pin_satisfying(key, criterion) + } + self._remove_information_from_criteria( + self.state.criteria, newly_unsatisfied_names + ) # Pinning was successful. Push a new state to do another pin. self._push_new_state() diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/resolvelib-0.8.1/src/resolvelib/resolvers.pyi new/resolvelib-1.0.1/src/resolvelib/resolvers.pyi --- old/resolvelib-0.8.1/src/resolvelib/resolvers.pyi 2021-10-11 23:05:57.000000000 +0200 +++ new/resolvelib-1.0.1/src/resolvelib/resolvers.pyi 2023-03-09 06:10:19.000000000 +0100 @@ -55,6 +55,18 @@ class ResolutionTooDeep(ResolutionError): round_count: int +# This should be a NamedTuple, but Python 3.6 has a bug that prevents it. +# https://stackoverflow.com/a/50531189/1376863 +class State(tuple, Generic[RT, CT, KT]): + mapping: Mapping[KT, CT] + criteria: Mapping[KT, Criterion[RT, CT, KT]] + backtrack_causes: Collection[RequirementInformation[RT, CT]] + +class Resolution(Generic[RT, CT, KT]): + def resolve( + self, requirements: Iterable[RT], max_rounds: int + ) -> State[RT, CT, KT]: ... + class Result(Generic[RT, CT, KT]): mapping: Mapping[KT, CT] graph: DirectedGraph[Optional[KT]] diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/resolvelib-0.8.1/src/resolvelib/structs.py new/resolvelib-1.0.1/src/resolvelib/structs.py --- old/resolvelib-0.8.1/src/resolvelib/structs.py 2021-10-11 23:05:57.000000000 +0200 +++ new/resolvelib-1.0.1/src/resolvelib/structs.py 2023-03-09 06:10:19.000000000 +0100 @@ -117,13 +117,14 @@ def __init__(self, factory): self._factory = factory + self._iterable = None def __repr__(self): - return "{}({})".format(type(self).__name__, list(self._factory())) + return "{}({})".format(type(self).__name__, list(self)) def __bool__(self): try: - next(self._factory()) + next(iter(self)) except StopIteration: return False return True @@ -131,7 +132,11 @@ __nonzero__ = __bool__ # XXX: Python 2. def __iter__(self): - return self._factory() + iterable = ( + self._factory() if self._iterable is None else self._iterable + ) + self._iterable, current = itertools.tee(iterable) + return current class _SequenceIterableView(object): diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/resolvelib-0.8.1/src/resolvelib/structs.pyi new/resolvelib-1.0.1/src/resolvelib/structs.pyi --- old/resolvelib-0.8.1/src/resolvelib/structs.pyi 2021-10-11 23:05:57.000000000 +0200 +++ new/resolvelib-1.0.1/src/resolvelib/structs.pyi 2023-03-09 06:10:19.000000000 +0100 @@ -16,7 +16,7 @@ CT = TypeVar("CT") # Candidate. _T = TypeVar("_T") -Matches = Union[Iterable[CT], Callable[[], Iterator[CT]]] +Matches = Union[Iterable[CT], Callable[[], Iterable[CT]]] class IteratorMapping(Mapping[KT, _T], metaclass=ABCMeta): pass diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/resolvelib-0.8.1/tests/conftest.py new/resolvelib-1.0.1/tests/conftest.py --- old/resolvelib-0.8.1/tests/conftest.py 2021-10-11 23:05:57.000000000 +0200 +++ new/resolvelib-1.0.1/tests/conftest.py 2023-03-09 06:10:19.000000000 +0100 @@ -9,10 +9,9 @@ def __init__(self): self._indent = 0 - def backtracking(self, candidate): + def rejecting_candidate(self, criterion, candidate): self._indent -= 1 - assert self._indent >= 0 - print(" " * self._indent, "Back ", candidate, sep="") + print(" " * self._indent, "Reject ", candidate, sep="") def pinning(self, candidate): print(" " * self._indent, "Pin ", candidate, sep="") diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/resolvelib-0.8.1/tests/functional/cocoapods/test_resolvers_cocoapods.py new/resolvelib-1.0.1/tests/functional/cocoapods/test_resolvers_cocoapods.py --- old/resolvelib-0.8.1/tests/functional/cocoapods/test_resolvers_cocoapods.py 2021-10-11 23:05:57.000000000 +0200 +++ new/resolvelib-1.0.1/tests/functional/cocoapods/test_resolvers_cocoapods.py 2023-03-09 06:10:19.000000000 +0100 @@ -6,8 +6,6 @@ import string import commentjson # type: ignore -import packaging.specifiers -import packaging.version import pytest from resolvelib import AbstractProvider, ResolutionImpossible, Resolver @@ -17,40 +15,112 @@ INPUTS_DIR = os.path.abspath(os.path.join(__file__, "..", "inputs")) - CASE_DIR = os.path.join(INPUTS_DIR, "case") - CASE_NAMES = [name for name in os.listdir(CASE_DIR) if name.endswith(".json")] -def _convert_specifier(s): - if not s: - return s - m = re.match(r"^([<>=~!]*)\s*(.+)$", s) +def _parse_version(v): + parts = [] + for part in re.split(r"[.-]", v): + if part[:1] in "0123456789": + parts.append(part.zfill(8)) + else: + parts.append("*" + part) + parts.append("*z") # end mark + return tuple(parts) + + +class Version: + def __init__(self, v): + self.v = v + self._comp_key = _parse_version(v) + + def __repr__(self): + return self.v + + @property + def is_prerelease(self): + return any(part[0] == "*" for part in self._comp_key[:-1]) + + def __len__(self): + return len(self._comp_key) + + def __eq__(self, o): + if not isinstance(o, Version): + return NotImplemented + left = self + if len(left) < len(o): + left = left.pad(len(o) - len(left)) + elif len(left) > len(o): + o = o.pad(len(left) - len(o)) + return left._comp_key == o._comp_key + + def __lt__(self, o): + return self._comp_key < o._comp_key + + def __le__(self, o): + return self._comp_key <= o._comp_key + + def __gt__(self, o): + return self._comp_key > o._comp_key + + def __ge__(self, o): + return self._comp_key >= o._comp_key + + def __hash__(self): + return hash(self._comp_key) + + def pad(self, n): + return Version(self.v + ".0" * n) + + +def _compatible_gt(a, b): + """a ~> b""" + if a < b: + return False + a_digits = [part for part in a._comp_key if part[0] != "*"] + b_digits = [part for part in b._comp_key if part[0] != "*"] + target_len = len(b_digits) + return a_digits[: target_len - 1] == b_digits[: target_len - 1] + + +_compare_ops = { + "=": operator.eq, + ">": operator.gt, + ">=": operator.ge, + "<": operator.lt, + "<=": operator.le, + "~>": _compatible_gt, + "!=": operator.ne, +} + + +def _version_in_spec(version, spec): + if not spec: + return not version.is_prerelease + m = re.match(r"([><=~!]*)\s*(.*)", spec) op, ver = m.groups() - if not op or op == "=": - return "== {}".format(ver) - elif op == "~>": - if len(ver) == 1: - # PEP 440 can't handle "~= X" (no minor part). This translates to - # a simple ">= X" because it means we accept major version changes. - return ">= {}".format(ver) - return "~= {0}, >= {0}".format(ver) - return s + if not op: + op = "=" + spec_ver = Version(ver) + allow_prereleases = spec_ver.is_prerelease + if not allow_prereleases and version.is_prerelease: + return False + if len(spec_ver) > len(version): + version = version.pad(len(spec_ver) - len(version)) + return _compare_ops[op](version, spec_ver) def _iter_convert_specifiers(inp): for raw in inp.split(","): - cov = _convert_specifier(raw.strip()) - if not cov: - continue - yield cov + yield raw.strip() -def _parse_specifier_set(inp): - return packaging.specifiers.SpecifierSet( - ", ".join(_iter_convert_specifiers(inp)), - ) +def _version_in_specset(version, specset): + for spec in _iter_convert_specifiers(specset): + if not _version_in_spec(version, spec): + return False + return True def _safe_json_load(filename): @@ -74,7 +144,7 @@ def _iter_resolved(dependencies): for entry in dependencies: - yield (entry["name"], packaging.version.parse(entry["version"])) + yield (entry["name"], Version(entry["version"])) for sub in _iter_resolved(entry["dependencies"]): yield sub @@ -91,11 +161,11 @@ self.index = _safe_json_load(index_name) self.root_requirements = [ - Requirement(_clean_identifier(key), _parse_specifier_set(spec)) + Requirement(_clean_identifier(key), spec) for key, spec in case_data["requested"].items() ] self.pinned_versions = { - entry["name"]: packaging.version.parse(entry["version"]) + entry["name"]: Version(entry["version"]) for entry in case_data["base"] } self.expected_resolution = dict(_iter_resolved(case_data["resolved"])) @@ -121,21 +191,20 @@ return bad_versions = {c.ver for c in incompatibilities[name]} for entry in data: - version = packaging.version.parse(entry["version"]) - if any(version not in r.spec for r in requirements[name]): + version = Version(entry["version"]) + if any( + not _version_in_specset(version, r.spec) + for r in requirements[name] + ): continue if version in bad_versions: continue # Some fixtures incorrectly set dependencies to an empty list. dependencies = entry["dependencies"] or {} - dependencies = [ - Requirement(k, _parse_specifier_set(v)) - for k, v in dependencies.items() - ] + dependencies = [Requirement(k, v) for k, v in dependencies.items()] yield Candidate(entry["name"], version, dependencies) def find_matches(self, identifier, requirements, incompatibilities): - candidates = sorted( self._iter_matches(identifier, requirements, incompatibilities), key=operator.attrgetter("ver"), @@ -148,7 +217,7 @@ yield c def is_satisfied_by(self, requirement, candidate): - return candidate.ver in requirement.spec + return _version_in_specset(candidate.ver, requirement.spec) def get_dependencies(self, candidate): return candidate.deps diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/resolvelib-0.8.1/tests/functional/python/inputs/index/same-package.json new/resolvelib-1.0.1/tests/functional/python/inputs/index/same-package.json --- old/resolvelib-0.8.1/tests/functional/python/inputs/index/same-package.json 2021-10-11 23:05:57.000000000 +0200 +++ new/resolvelib-1.0.1/tests/functional/python/inputs/index/same-package.json 2023-03-09 06:10:19.000000000 +0100 @@ -2,37 +2,37 @@ "package-a": { "0.1.0": { "dependencies": [ - "package-x=='0.1.0'; extra == 'x'", - "package-y=='0.1.0'; extra == 'y'", - "package-z=='0.1.0'; extra == 'z'" + "package-x==0.1.0; extra == 'x'", + "package-y==0.1.0; extra == 'y'", + "package-z==0.1.0; extra == 'z'" ] }, "1.0.0": { "dependencies": [ - "package-x=='1.0.0'; extra == 'x'", - "package-y=='1.0.0'; extra == 'y'", - "package-z=='1.0.0'; extra == 'z'" + "package-x==1.0.0; extra == 'x'", + "package-y==1.0.0; extra == 'y'", + "package-z==1.0.0; extra == 'z'" ] }, "1.1.0": { "dependencies": [ - "package-x=='1.1.0'; extra == 'x'", - "package-y=='1.1.0'; extra == 'y'", - "package-z=='1.1.0'; extra == 'z'" + "package-x==1.1.0; extra == 'x'", + "package-y==1.1.0; extra == 'y'", + "package-z==1.1.0; extra == 'z'" ] }, "1.2.0": { "dependencies": [ - "package-x=='1.2.0'; extra == 'x'", - "package-y=='1.2.0'; extra == 'y'", - "package-z=='1.2.0'; extra == 'z'" + "package-x==1.2.0; extra == 'x'", + "package-y==1.2.0; extra == 'y'", + "package-z==1.2.0; extra == 'z'" ] }, "1.3.0": { "dependencies": [ - "package-x=='1.3.0'; extra == 'x'", - "package-y=='1.3.0'; extra == 'y'", - "package-z=='1.3.0'; extra == 'z'" + "package-x==1.3.0; extra == 'x'", + "package-y==1.3.0; extra == 'y'", + "package-z==1.3.0; extra == 'z'" ] } }, diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/resolvelib-0.8.1/tests/functional/python/test_resolvers_python.py new/resolvelib-1.0.1/tests/functional/python/test_resolvers_python.py --- old/resolvelib-0.8.1/tests/functional/python/test_resolvers_python.py 2021-10-11 23:05:57.000000000 +0200 +++ new/resolvelib-1.0.1/tests/functional/python/test_resolvers_python.py 2023-03-09 06:10:19.000000000 +0100 @@ -129,7 +129,6 @@ XFAIL_CASES = { "pyrex-1.9.8.json": "Too many rounds (>500)", - "same-package-extras.json": "State not cleaned up correctly", } diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/resolvelib-0.8.1/tests/test_resolvers.py new/resolvelib-1.0.1/tests/test_resolvers.py --- old/resolvelib-0.8.1/tests/test_resolvers.py 2021-10-11 23:05:57.000000000 +0200 +++ new/resolvelib-1.0.1/tests/test_resolvers.py 2023-03-09 06:10:19.000000000 +0100 @@ -1,4 +1,21 @@ +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing import ( + Any, + Iterable, + Iterator, + List, + Mapping, + Sequence, + Set, + Tuple, + Union, + ) + import pytest +from packaging.requirements import Requirement +from packaging.version import Version from resolvelib import ( AbstractProvider, @@ -8,6 +25,17 @@ Resolver, ) +if TYPE_CHECKING: + from resolvelib.resolvers import ( + Criterion, + RequirementInformation, + RequirementsConflicted, + ) + +from collections import namedtuple + +from resolvelib.resolvers import Resolution + def test_candidate_inconsistent_error(): requirement = "foo" @@ -95,10 +123,21 @@ def test_resolving_conflicts(): + Candidate = namedtuple( + "Candidate", ["name", "version", "requirements"] + ) # name, version, requirements + _Requirement = namedtuple( + "Requirement", ["name", "versions"] + ) # name, versions + a1 = Candidate("a", 1, [_Requirement("q", {1})]) + a2 = Candidate("a", 2, [_Requirement("q", {2})]) + b = Candidate("b", 1, [_Requirement("q", {1})]) + q1 = Candidate("q", 1, []) + q2 = Candidate("q", 2, []) all_candidates = { - "a": [("a", 1, [("q", {1})]), ("a", 2, [("q", {2})])], - "b": [("b", 1, [("q", {1})])], - "q": [("q", 1, []), ("q", 2, [])], + "a": [a1, a2], + "b": [b], + "q": [q1, q2], } class Reporter(BaseReporter): @@ -116,20 +155,22 @@ return 0 def get_dependencies(self, candidate): - return candidate[2] + return candidate.requirements def find_matches(self, identifier, requirements, incompatibilities): - bad_versions = {c[1] for c in incompatibilities[identifier]} + bad_versions = {c.version for c in incompatibilities[identifier]} candidates = [ c for c in all_candidates[identifier] - if all(c[1] in r[1] for r in requirements[identifier]) - and c[1] not in bad_versions + if all( + c.version in r.versions for r in requirements[identifier] + ) + and c.version not in bad_versions ] - return sorted(candidates, key=lambda c: c[1], reverse=True) + return sorted(candidates, key=lambda c: c.version, reverse=True) def is_satisfied_by(self, requirement, candidate): - return candidate[1] in requirement[1] + return candidate.version in requirement.versions def run_resolver(*args): reporter = Reporter() @@ -140,6 +181,117 @@ except ResolutionImpossible as e: return e.causes - backtracking_causes = run_resolver([("a", {1, 2}), ("b", {1})]) - exception_causes = run_resolver([("a", {2}), ("b", {1})]) + backtracking_causes = run_resolver( + [_Requirement("a", {1, 2}), _Requirement("b", {1})] + ) + exception_causes = run_resolver( + [_Requirement("a", {2}), _Requirement("b", {1})] + ) assert exception_causes == backtracking_causes + + +def test_pin_conflict_with_self(monkeypatch, reporter): + # type: (Any, BaseReporter) -> None + """ + Verify correct behavior of attempting to pin a candidate version that conflicts + with a previously pinned (now invalidated) version for that same candidate (#91). + """ + if TYPE_CHECKING: + Candidate = Tuple[ # noqa: F841 + str, Version, Sequence[str] + ] # name, version, requirements + all_candidates = { + "parent": [("parent", Version("1"), ["child<2"])], + "child": [ + ("child", Version("2"), ["grandchild>=2"]), + ("child", Version("1"), ["grandchild<2"]), + ("child", Version("0.1"), ["grandchild"]), + ], + "grandchild": [ + ("grandchild", Version("2"), []), + ("grandchild", Version("1"), []), + ], + } # type: Mapping[str, Sequence[Candidate]] + + class Provider(AbstractProvider): # AbstractProvider[str, Candidate, str] + def identify(self, requirement_or_candidate): + # type: (Union[str, Candidate]) -> str + result = ( + Requirement(requirement_or_candidate).name + if isinstance(requirement_or_candidate, str) + else requirement_or_candidate[0] + ) + assert result in all_candidates, "unknown requirement_or_candidate" + return result + + def get_preference(self, identifier, *args, **kwargs): + # type: (str, *object, **object) -> str + # prefer child over parent (alphabetically) + return identifier + + def get_dependencies(self, candidate): + # type: (Candidate) -> Sequence[str] + return candidate[2] + + def find_matches( + self, + identifier, # type: str + requirements, # type: Mapping[str, Iterator[str]] + incompatibilities, # type: Mapping[str, Iterator[Candidate]] + ): + # type: (...) -> Iterator[Candidate] + return ( + candidate + for candidate in all_candidates[identifier] + if all( + self.is_satisfied_by(req, candidate) + for req in requirements[identifier] + ) + if candidate not in incompatibilities[identifier] + ) + + def is_satisfied_by(self, requirement, candidate): + # type: (str, Candidate) -> bool + return candidate[1] in Requirement(requirement).specifier + + # patch Resolution._get_updated_criteria to collect rejected states + rejected_criteria = [] # type: List[Criterion] + get_updated_criteria_orig = ( + Resolution._get_updated_criteria # type: ignore[attr-defined] + ) + + def get_updated_criteria_patch(self, candidate): + try: + return get_updated_criteria_orig(self, candidate) + except RequirementsConflicted as e: + rejected_criteria.append(e.criterion) + raise + + monkeypatch.setattr( + Resolution, "_get_updated_criteria", get_updated_criteria_patch + ) + + resolver = Resolver( + Provider(), reporter + ) # type: Resolver[str, Candidate, str] + result = resolver.resolve(["child", "parent"]) + + def get_child_versions(information): + # type: (Iterable[RequirementInformation[str, Candidate]]) -> Set[str] + return { + str(inf.parent[1]) + for inf in information + if inf.parent is not None and inf.parent[0] == "child" + } + + # verify that none of the rejected criteria are based on more than one candidate for + # child + assert not any( + len(get_child_versions(criterion.information)) > 1 + for criterion in rejected_criteria + ) + + assert set(result.mapping) == {"parent", "child", "grandchild"} + assert result.mapping["parent"][1] == Version("1") + assert result.mapping["child"][1] == Version("1") + assert result.mapping["grandchild"][1] == Version("1")