Merge pull request #7850 from uranusjr/resolvelib-vendor

Vendor ResolveLib from Git
This commit is contained in:
Paul Moore 2020-03-13 13:44:21 +00:00 committed by GitHub
commit 98b3221fd4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 632 additions and 0 deletions

1
news/7850.vendor Normal file
View File

@ -0,0 +1 @@
Add ResolveLib as a vendored dependency.

View File

@ -56,4 +56,5 @@ msgpack-python = "msgpack"
[tool.vendoring.license.fallback-urls]
pytoml = "https://github.com/avakar/pytoml/raw/master/LICENSE"
resolvelib = "https://github.com/sarugaku/resolvelib/raw/master/LICENSE"
webencodings = "https://github.com/SimonSapin/python-webencodings/raw/master/LICENSE"

View File

@ -107,4 +107,5 @@ if DEBUNDLED:
vendored("requests.packages.urllib3.util.ssl_")
vendored("requests.packages.urllib3.util.timeout")
vendored("requests.packages.urllib3.util.url")
vendored("resolvelib")
vendored("urllib3")

View File

@ -0,0 +1 @@
from resolvelib import *

View File

@ -0,0 +1,13 @@
Copyright (c) 2018, Tzu-ping Chung <uranusjr@gmail.com>
Permission to use, copy, modify, and distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.

View File

@ -0,0 +1,24 @@
__all__ = [
"__version__",
"AbstractProvider",
"AbstractResolver",
"BaseReporter",
"Resolver",
"RequirementsConflicted",
"ResolutionError",
"ResolutionImpossible",
"ResolutionTooDeep",
]
__version__ = "0.2.3.dev0"
from .providers import AbstractProvider, AbstractResolver
from .reporters import BaseReporter
from .resolvers import (
RequirementsConflicted,
Resolver,
ResolutionError,
ResolutionImpossible,
ResolutionTooDeep,
)

View File

@ -0,0 +1,121 @@
class AbstractProvider(object):
"""Delegate class to provide requirement interface for the resolver.
"""
def identify(self, dependency):
"""Given a dependency, return an identifier for it.
This is used in many places to identify the dependency, e.g. whether
two requirements should have their specifier parts merged, whether
two specifications would conflict with each other (because they the
same name but different versions).
"""
raise NotImplementedError
def get_preference(self, resolution, candidates, information):
"""Produce a sort key for given specification based on preference.
The preference is defined as "I think this requirement should be
resolved first". The lower the return value is, the more preferred
this group of arguments is.
:param resolution: Currently pinned candidate, or `None`.
:param candidates: A list of possible candidates.
:param information: A list of requirement information.
Each information instance is a named tuple with two entries:
* `requirement` specifies a requirement contributing to the current
candidate list
* `parent` specifies the candidate that provids (dependend on) the
requirement, or `None` to indicate a root requirement.
The preference could depend on a various of issues, including (not
necessarily in this order):
* Is this package pinned in the current resolution result?
* How relaxed is the requirement? Stricter ones should probably be
worked on first? (I don't know, actually.)
* How many possibilities are there to satisfy this requirement? Those
with few left should likely be worked on first, I guess?
* Are there any known conflicts for this requirement? We should
probably work on those with the most known conflicts.
A sortable value should be returned (this will be used as the `key`
parameter of the built-in sorting function). The smaller the value is,
the more preferred this specification is (i.e. the sorting function
is called with `reverse=False`).
"""
raise NotImplementedError
def find_matches(self, requirement):
"""Find all possible candidates that satisfy a requirement.
This should try to get candidates based on the requirement's type.
For VCS, local, and archive requirements, the one-and-only match is
returned, and for a "named" requirement, the index(es) should be
consulted to find concrete candidates for this requirement.
The returned candidates should be sorted by reversed preference, e.g.
the most preferred should be LAST. This is done so list-popping can be
as efficient as possible.
"""
raise NotImplementedError
def is_satisfied_by(self, requirement, candidate):
"""Whether the given requirement can be satisfied by a candidate.
A boolean should be returned to indicate whether `candidate` is a
viable solution to the requirement.
"""
raise NotImplementedError
def get_dependencies(self, candidate):
"""Get dependencies of a candidate.
This should return a collection of requirements that `candidate`
specifies as its dependencies.
"""
raise NotImplementedError
class AbstractResolver(object):
"""The thing that performs the actual resolution work.
"""
base_exception = Exception
def __init__(self, provider, reporter):
self.provider = provider
self.reporter = reporter
def resolve(self, requirements, **kwargs):
"""Take a collection of constraints, spit out the resolution result.
Parameters
----------
requirements : Collection
A collection of constraints
kwargs : optional
Additional keyword arguments that subclasses may accept.
Raises
------
self.base_exception
Any raised exception is guaranteed to be a subclass of
self.base_exception. The string representation of an exception
should be human readable and provide context for why it occurred.
Returns
-------
retval : object
A representation of the final resolution state. It can be any object
with a `mapping` attribute that is a Mapping. Other attributes can
be used to provide resolver-specific information.
The `mapping` attribute MUST be key-value pair is an identifier of a
requirement (as returned by the provider's `identify` method) mapped
to the resolved candidate (chosen from the return value of the
provider's `find_matches` method).
"""
raise NotImplementedError

View File

@ -0,0 +1,24 @@
class BaseReporter(object):
"""Delegate class to provider progress reporting for the resolver.
"""
def starting(self):
"""Called before the resolution actually starts.
"""
def starting_round(self, index):
"""Called before each round of resolution starts.
The index is zero-based.
"""
def ending_round(self, index, state):
"""Called before each round of resolution ends.
This is NOT called if the resolution ends at this round. Use `ending`
if you want to report finalization. The index is zero-based.
"""
def ending(self, state):
"""Called before the resolution ends successfully.
"""

View File

@ -0,0 +1,376 @@
import collections
from .providers import AbstractResolver
from .structs import DirectedGraph
RequirementInformation = collections.namedtuple(
"RequirementInformation", ["requirement", "parent"]
)
class ResolverException(Exception):
"""A base class for all exceptions raised by this module.
Exceptions derived by this class should all be handled in this module. Any
bubbling pass the resolver should be treated as a bug.
"""
class RequirementsConflicted(ResolverException):
def __init__(self, criterion):
super(RequirementsConflicted, self).__init__(criterion)
self.criterion = criterion
class Criterion(object):
"""Representation of possible resolution results of a package.
This holds three attributes:
* `information` is a collection of `RequirementInformation` pairs.
Each pair is a requirement contributing to this criterion, and the
candidate that provides the requirement.
* `incompatibilities` is a collection of all known not-to-work candidates
to exclude from consideration.
* `candidates` is a collection containing all possible candidates deducted
from the union of contributing requirements and known incompatibilities.
It should never be empty, except when the criterion is an attribute of a
raised `RequirementsConflicted` (in which case it is always empty).
.. note::
This class is intended to be externally immutable. **Do not** mutate
any of its attribute containers.
"""
def __init__(self, candidates, information, incompatibilities):
self.candidates = candidates
self.information = information
self.incompatibilities = incompatibilities
@classmethod
def from_requirement(cls, provider, requirement, parent):
"""Build an instance from a requirement.
"""
candidates = provider.find_matches(requirement)
criterion = cls(
candidates=candidates,
information=[RequirementInformation(requirement, parent)],
incompatibilities=[],
)
if not candidates:
raise RequirementsConflicted(criterion)
return criterion
def iter_requirement(self):
return (i.requirement for i in self.information)
def iter_parent(self):
return (i.parent for i in self.information)
def merged_with(self, provider, requirement, parent):
"""Build a new instance from this and a new requirement.
"""
infos = list(self.information)
infos.append(RequirementInformation(requirement, parent))
candidates = [
c
for c in self.candidates
if provider.is_satisfied_by(requirement, c)
]
criterion = type(self)(candidates, infos, list(self.incompatibilities))
if not candidates:
raise RequirementsConflicted(criterion)
return criterion
def excluded_of(self, candidate):
"""Build a new instance from this, but excluding specified candidate.
"""
incompats = list(self.incompatibilities)
incompats.append(candidate)
candidates = [c for c in self.candidates if c != candidate]
criterion = type(self)(candidates, list(self.information), incompats)
if not candidates:
raise RequirementsConflicted(criterion)
return criterion
class ResolutionError(ResolverException):
pass
class ResolutionImpossible(ResolutionError):
def __init__(self, requirements):
super(ResolutionImpossible, self).__init__(requirements)
self.requirements = requirements
class ResolutionTooDeep(ResolutionError):
def __init__(self, round_count):
super(ResolutionTooDeep, self).__init__(round_count)
self.round_count = round_count
# Resolution state in a round.
State = collections.namedtuple("State", "mapping criteria")
class Resolution(object):
"""Stateful resolution object.
This is designed as a one-off object that holds information to kick start
the resolution process, and holds the results afterwards.
"""
def __init__(self, provider, reporter):
self._p = provider
self._r = reporter
self._states = []
@property
def state(self):
try:
return self._states[-1]
except IndexError:
raise AttributeError("state")
def _push_new_state(self):
"""Push a new state into history.
This new state will be used to hold resolution results of the next
coming round.
"""
try:
base = self._states[-1]
except IndexError:
state = State(mapping=collections.OrderedDict(), criteria={})
else:
state = State(
mapping=base.mapping.copy(), criteria=base.criteria.copy(),
)
self._states.append(state)
def _merge_into_criterion(self, requirement, parent):
name = self._p.identify(requirement)
try:
crit = self.state.criteria[name]
except KeyError:
crit = Criterion.from_requirement(self._p, requirement, parent)
else:
crit = crit.merged_with(self._p, requirement, parent)
return name, crit
def _get_criterion_item_preference(self, item):
name, criterion = item
try:
pinned = self.state.mapping[name]
except KeyError:
pinned = None
return self._p.get_preference(
pinned, criterion.candidates, criterion.information,
)
def _is_current_pin_satisfying(self, name, criterion):
try:
current_pin = self.state.mapping[name]
except KeyError:
return False
return all(
self._p.is_satisfied_by(r, current_pin)
for r in criterion.iter_requirement()
)
def _get_criteria_to_update(self, candidate):
criteria = {}
for r in self._p.get_dependencies(candidate):
name, crit = self._merge_into_criterion(r, parent=candidate)
criteria[name] = crit
return criteria
def _attempt_to_pin_criterion(self, name, criterion):
causes = []
for candidate in reversed(criterion.candidates):
try:
criteria = self._get_criteria_to_update(candidate)
except RequirementsConflicted as e:
causes.append(e.criterion)
continue
# Put newly-pinned candidate at the end. This is essential because
# backtracking looks at this mapping to get the last pin.
self.state.mapping.pop(name, None)
self.state.mapping[name] = candidate
self.state.criteria.update(criteria)
return []
# All candidates tried, nothing works. This criterion is a dead
# end, signal for backtracking.
return causes
def _backtrack(self):
# We need at least 3 states here:
# (a) One known not working, to drop.
# (b) One to backtrack to.
# (c) One to restore state (b) to its state prior to candidate-pinning,
# so we can pin another one instead.
while len(self._states) >= 3:
del self._states[-1]
# Retract the last candidate pin, and create a new (b).
name, candidate = self._states.pop().mapping.popitem()
self._push_new_state()
try:
# Mark the retracted candidate as incompatible.
criterion = self.state.criteria[name].excluded_of(candidate)
except RequirementsConflicted:
# This state still does not work. Try the still previous state.
continue
self.state.criteria[name] = criterion
return True
return False
def resolve(self, requirements, max_rounds):
if self._states:
raise RuntimeError("already resolved")
self._push_new_state()
for r in requirements:
try:
name, crit = self._merge_into_criterion(r, parent=None)
except RequirementsConflicted as e:
# If initial requirements conflict, nothing would ever work.
raise ResolutionImpossible(e.requirements + [r])
self.state.criteria[name] = crit
self._r.starting()
for round_index in range(max_rounds):
self._r.starting_round(round_index)
self._push_new_state()
curr = self.state
unsatisfied_criterion_items = [
item
for item in self.state.criteria.items()
if not self._is_current_pin_satisfying(*item)
]
# All criteria are accounted for. Nothing more to pin, we are done!
if not unsatisfied_criterion_items:
del self._states[-1]
self._r.ending(curr)
return self.state
# Choose the most preferred unpinned criterion to try.
name, criterion = min(
unsatisfied_criterion_items,
key=self._get_criterion_item_preference,
)
failure_causes = self._attempt_to_pin_criterion(name, criterion)
# Backtrack if pinning fails.
if failure_causes:
result = self._backtrack()
if not result:
requirements = [
requirement
for crit in failure_causes
for requirement in crit.iter_requirement()
]
raise ResolutionImpossible(requirements)
self._r.ending_round(round_index, curr)
raise ResolutionTooDeep(max_rounds)
def _has_route_to_root(criteria, key, all_keys, connected):
if key in connected:
return True
if key not in criteria:
return False
for p in criteria[key].iter_parent():
try:
pkey = all_keys[id(p)]
except KeyError:
continue
if pkey in connected:
connected.add(key)
return True
if _has_route_to_root(criteria, pkey, all_keys, connected):
connected.add(key)
return True
return False
Result = collections.namedtuple("Result", "mapping graph criteria")
def _build_result(state):
mapping = state.mapping
all_keys = {id(v): k for k, v in mapping.items()}
all_keys[id(None)] = None
graph = DirectedGraph()
graph.add(None) # Sentinel as root dependencies' parent.
connected = {None}
for key, criterion in state.criteria.items():
if not _has_route_to_root(state.criteria, key, all_keys, connected):
continue
if key not in graph:
graph.add(key)
for p in criterion.iter_parent():
try:
pkey = all_keys[id(p)]
except KeyError:
continue
if pkey not in graph:
graph.add(pkey)
graph.connect(pkey, key)
return Result(
mapping={k: v for k, v in mapping.items() if k in connected},
graph=graph,
criteria=state.criteria,
)
class Resolver(AbstractResolver):
"""The thing that performs the actual resolution work.
"""
base_exception = ResolverException
def resolve(self, requirements, max_rounds=100):
"""Take a collection of constraints, spit out the resolution result.
The return value is a representation to the final resolution result. It
is a tuple subclass with three public members:
* `mapping`: A dict of resolved candidates. Each key is an identifier
of a requirement (as returned by the provider's `identify` method),
and the value is the resolved candidate.
* `graph`: A `DirectedGraph` instance representing the dependency tree.
The vertices are keys of `mapping`, and each edge represents *why*
a particular package is included. A special vertex `None` is
included to represent parents of user-supplied requirements.
* `criteria`: A dict of "criteria" that hold detailed information on
how edges in the graph are derived. Each key is an identifier of a
requirement, and the value is a `Criterion` instance.
The following exceptions may be raised if a resolution cannot be found:
* `ResolutionImpossible`: A resolution cannot be found for the given
combination of requirements.
* `ResolutionTooDeep`: The dependency tree is too deeply nested and
the resolver gave up. This is usually caused by a circular
dependency, but you can try to resolve this by increasing the
`max_rounds` argument.
"""
resolution = Resolution(self.provider, self.reporter)
state = resolution.resolve(requirements, max_rounds=max_rounds)
return _build_result(state)

View File

@ -0,0 +1,68 @@
class DirectedGraph(object):
"""A graph structure with directed edges.
"""
def __init__(self):
self._vertices = set()
self._forwards = {} # <key> -> Set[<key>]
self._backwards = {} # <key> -> Set[<key>]
def __iter__(self):
return iter(self._vertices)
def __len__(self):
return len(self._vertices)
def __contains__(self, key):
return key in self._vertices
def copy(self):
"""Return a shallow copy of this graph.
"""
other = DirectedGraph()
other._vertices = set(self._vertices)
other._forwards = {k: set(v) for k, v in self._forwards.items()}
other._backwards = {k: set(v) for k, v in self._backwards.items()}
return other
def add(self, key):
"""Add a new vertex to the graph.
"""
if key in self._vertices:
raise ValueError("vertex exists")
self._vertices.add(key)
self._forwards[key] = set()
self._backwards[key] = set()
def remove(self, key):
"""Remove a vertex from the graph, disconnecting all edges from/to it.
"""
self._vertices.remove(key)
for f in self._forwards.pop(key):
self._backwards[f].remove(key)
for t in self._backwards.pop(key):
self._forwards[t].remove(key)
def connected(self, f, t):
return f in self._backwards[t] and t in self._forwards[f]
def connect(self, f, t):
"""Connect two existing vertices.
Nothing happens if the vertices are already connected.
"""
if t not in self._vertices:
raise KeyError(t)
self._forwards[f].add(t)
self._backwards[t].add(f)
def iter_edges(self):
for f, children in self._forwards.items():
for t in children:
yield f, t
def iter_children(self, key):
return iter(self._forwards[key])
def iter_parents(self, key):
return iter(self._backwards[key])

View File

@ -21,3 +21,5 @@ retrying==1.3.3
setuptools==44.0.0
six==1.14.0
webencodings==0.5.1
git+https://github.com/sarugaku/resolvelib.git@fbc8bb28d6cff98b2#egg=resolvelib