Skip to content

Commit

Permalink
better unsatisfiable hints (conda#8638)
Browse files Browse the repository at this point in the history
better unsatisfiable hints
  • Loading branch information
msarahan authored May 14, 2019
2 parents aa8c876 + 1bc981b commit 810581d
Show file tree
Hide file tree
Showing 3 changed files with 254 additions and 36 deletions.
6 changes: 5 additions & 1 deletion conda/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -623,7 +623,7 @@ class UnsatisfiableError(CondaError):
unsatisfiable specifications.
"""

def __init__(self, bad_deps, chains=True):
def __init__(self, bad_deps, chains=True, strict=False):
from .models.match_spec import MatchSpec

# Remove any target values from the MatchSpecs, convert to strings
Expand Down Expand Up @@ -666,6 +666,10 @@ def __init__(self, bad_deps, chains=True):
others, or with the existing package set:%s
Use "conda search <package> --info" to see the dependencies for each package.'''
msg = msg % dashlist(bad_deps)
if strict:
msg += ('\nNote that strict channel priority may have removed '
'packages required for satisfiability.')

super(UnsatisfiableError, self).__init__(msg)


Expand Down
116 changes: 85 additions & 31 deletions conda/resolve.py
Original file line number Diff line number Diff line change
Expand Up @@ -318,48 +318,102 @@ def find_conflicts(self, specs):
above) that all of the specs depend on *but in different ways*. We
then identify the dependency chains that lead to those packages.
"""
# if only a single package matches the spec use the packages depends
# rather than the spec itself
if len(specs) == 1:
matches = self.find_matches(specs[0])
if len(matches) == 1:
specs = self.ms_depends(matches[0])

strict_channel_priority = context.channel_priority == ChannelPriority.STRICT

def find_matches_with_strict(ms):
matches = self.find_matches(ms)
if not strict_channel_priority:
return matches
sole_source_channel_name = self._get_strict_channel(ms.name)
return tuple(f for f in matches if f.channel.name == sole_source_channel_name)

sdeps = {}
# For each spec, assemble a dictionary of dependencies, with package
# name as key, and all of the matching packages as values.
for ms in specs:
rec = sdeps.setdefault(ms, {})
slist = [ms]
for top_level_spec in specs:
# find all packages matching a top level specification
top_level_pkgs = find_matches_with_strict(top_level_spec)
top_level_sdeps = {top_level_spec.name: set(top_level_pkgs)}

# find all depends specs for in top level packages
# find the depends names for each top level packages
second_level_specs = set()
top_level_pkg_dep_names = []
for pkg in top_level_pkgs:
pkg_deps = self.ms_depends(pkg)
second_level_specs.update(pkg_deps)
top_level_pkg_dep_names.append([d.name for d in pkg_deps])

# find all second level packages and their specs
slist = []
for ms in second_level_specs:
deps = top_level_sdeps.setdefault(ms.name, set())
for fkey in find_matches_with_strict(ms):
deps.add(fkey)
slist.extend(
ms2 for ms2 in self.ms_depends(fkey) if ms2.name != top_level_spec.name)

# dependency names which appear in all top level packages
# have been fully considered and not additions should be make to
# the package list for that name
locked_names = [top_level_spec.name]
for name in top_level_pkg_dep_names[0]:
if all(name in names for names in top_level_pkg_dep_names):
locked_names.append(name)

# build out the rest of the dependency tree
while slist:
ms2 = slist.pop()
deps = rec.setdefault(ms2.name, set())
for fkey in self.find_matches(ms2):
if ms2.name in locked_names:
continue
deps = top_level_sdeps.setdefault(ms2.name, set())
for fkey in find_matches_with_strict(ms2):
if fkey not in deps:
deps.add(fkey)
slist.extend(ms3 for ms3 in self.ms_depends(fkey) if ms3.name != ms.name)
slist.extend(ms3 for ms3 in self.ms_depends(fkey)
if ms3.name != top_level_spec.name)
sdeps[top_level_spec] = top_level_sdeps

# Find the list of dependencies they have in common. And for each of
# *those*, find the individual packages that they all share. Those need
# to be removed as conflict candidates.
commkeys = set.intersection(*(set(s.keys()) for s in sdeps.values()))
commkeys = {k: set.intersection(*(v[k] for v in sdeps.values())) for k in commkeys}

# and find the dependency chains that lead to them.
# find deps with zero intersection between specs which include that dep
bad_deps = []
for ms, sdep in iteritems(sdeps):
deps = set()
for sdep in sdeps.values():
deps.update(sdep.keys())
for dep in deps:
sdeps_with_dep = {k: v.get(dep) for k, v in sdeps.items() if dep in v.keys()}
if len(sdeps_with_dep) <= 1:
continue
intersection = set.intersection(*sdeps_with_dep.values())
if len(intersection) != 0:
continue
filter = {}
for mn, v in sdep.items():
if mn != ms.name and mn in commkeys:
# Mark this package's "unique" dependencies as invalid
for fkey in v - commkeys[mn]:
filter[fkey] = False
# Find the dependencies that lead to those invalid choices
ndeps = set(self.invalid_chains(ms, filter, False))
# This may produce some additional invalid chains that we
# don't care about. Select only those that terminate in our
# predetermined set of "common" keys.
ndeps = [nd for nd in ndeps if nd[-1].name in commkeys]
if ndeps:
for fkeys in sdeps_with_dep.values():
for fkey in fkeys:
filter[fkey] = False
for spec in sdeps_with_dep.keys():
ndeps = set(self.invalid_chains(spec, filter, False))
ndeps = [nd for nd in ndeps if nd[-1].name == dep]
bad_deps.extend(ndeps)
else:
# This means the package *itself* was the common conflict.
bad_deps.append((ms,))

raise UnsatisfiableError(bad_deps)
if not bad_deps:
for spec in specs:
filter = {}
for name, valid_pkgs in sdeps[spec].items():
if name == spec.name:
continue
for fkey in self.find_matches(MatchSpec(name)):
filter[fkey] = fkey in valid_pkgs
bad_deps.extend(self.invalid_chains(spec, filter, False))
if not bad_deps:
# no conflicting nor missing packages found, return the bad specs
bad_deps = [(ms, ) for ms in specs]
raise UnsatisfiableError(bad_deps, strict=strict_channel_priority)

def _get_strict_channel(self, package_name):
try:
Expand Down
168 changes: 164 additions & 4 deletions tests/test_resolve.py
Original file line number Diff line number Diff line change
Expand Up @@ -367,12 +367,172 @@ def test_generate_eq_1():
assert eqt == {}


def test_unsat():
def test_unsat_from_r1():
# scipy 0.12.0b1 is not built for numpy 1.5, only 1.6 and 1.7
assert raises(UnsatisfiableError, lambda: r.install(['numpy 1.5*', 'scipy 0.12.0b1']))
with pytest.raises(UnsatisfiableError) as excinfo:
r.install(['numpy 1.5*', 'scipy 0.12.0b1'])
assert "numpy=1.5" in str(excinfo.value)
assert "scipy==0.12.0b1 -> numpy=1.7" in str(excinfo.value)
# numpy 1.5 does not have a python 3 package
assert raises(UnsatisfiableError, lambda: r.install(['numpy 1.5*', 'python 3*']))
assert raises(UnsatisfiableError, lambda: r.install(['numpy 1.5*', 'numpy 1.6*']))
with pytest.raises(UnsatisfiableError) as excinfo:
r.install(['numpy 1.5*', 'python 3*'])
assert "numpy=1.5 -> python=2.7" in str(excinfo.value)
assert "python=3" in str(excinfo.value)
with pytest.raises(UnsatisfiableError) as excinfo:
r.install(['numpy 1.5*', 'numpy 1.6*'])
assert "numpy=1.5" in str(excinfo.value)
assert "numpy=1.6" in str(excinfo.value)


def simple_rec(name='a', version='1.0', depends=None, build='0',
build_number=0, channel='channel-1'):
if depends is None:
depends = []
return PackageRecord(**{
'name': name,
'version': version,
'depends': depends,
'build': build,
'build_number': build_number,
'channel': channel,
})


def test_unsat_simple():
# a and b depend on conflicting versions of c
index = (
simple_rec(name='a', depends=['c >=1,<2']),
simple_rec(name='b', depends=['c >=2,<3']),
simple_rec(name='c', version='1.0'),
simple_rec(name='c', version='2.0'),
)
r = Resolve(OrderedDict((prec, prec) for prec in index))
with pytest.raises(UnsatisfiableError) as excinfo:
r.install(['a', 'b'])
assert "a -> c[version='>=1,<2']" in str(excinfo.value)
assert "b -> c[version='>=2,<3']" in str(excinfo.value)


def test_unsat_chain():
# a -> b -> c=1.x -> d=1.x
# e -> c=2.x -> d=2.x
index = (
simple_rec(name='a', depends=['b']),
simple_rec(name='b', depends=['c >=1,<2']),
simple_rec(name='c', version='1.0', depends=['d >=1,<2']),
simple_rec(name='d', version='1.0'),

simple_rec(name='e', depends=['c >=2,<3']),
simple_rec(name='c', version='2.0', depends=['d >=2,<3']),
simple_rec(name='d', version='2.0'),
)
r = Resolve(OrderedDict((prec, prec) for prec in index))
with pytest.raises(UnsatisfiableError) as excinfo:
r.install(['a', 'e'])
assert "a -> b -> c[version='>=1,<2'] -> d[version='>=1,<2']" in str(excinfo.value)
assert "e -> c[version='>=2,<3'] -> d[version='>=2,<3']" in str(excinfo.value)


def test_unsat_any_two_not_three():
# can install any two of a, b and c but not all three
index = (
simple_rec(name='a', version='1.0', depends=['d >=1,<2']),
simple_rec(name='a', version='2.0', depends=['d >=2,<3']),

simple_rec(name='b', version='1.0', depends=['d >=1,<2']),
simple_rec(name='b', version='2.0', depends=['d >=3,<4']),

simple_rec(name='c', version='1.0', depends=['d >=2,<3']),
simple_rec(name='c', version='2.0', depends=['d >=3,<4']),

simple_rec(name='d', version='1.0'),
simple_rec(name='d', version='2.0'),
simple_rec(name='d', version='3.0'),
)
r = Resolve(OrderedDict((prec, prec) for prec in index))
# a and b can be installed
installed1 = r.install(['a', 'b'])
assert any(k.name == 'a' and k.version == '1.0' for k in installed1)
assert any(k.name == 'b' and k.version == '1.0' for k in installed1)
# a and c can be installed
installed1 = r.install(['a', 'c'])
assert any(k.name == 'a' and k.version == '2.0' for k in installed1)
assert any(k.name == 'c' and k.version == '1.0' for k in installed1)
# b and c can be installed
installed1 = r.install(['b', 'c'])
assert any(k.name == 'b' and k.version == '2.0' for k in installed1)
assert any(k.name == 'c' and k.version == '2.0' for k in installed1)
# a, b and c cannot be installed
with pytest.raises(UnsatisfiableError) as excinfo:
r.install(['a', 'b', 'c'])
assert "a -> d[version='>=2,<3']" in str(excinfo.value)
assert "b -> d[version='>=3,<4']" in str(excinfo.value)
assert "c -> d[version='>=3,<4']" in str(excinfo.value)
# TODO would also like to see these
#assert "a -> d[version='>=1,<2']" in str(excinfo.value)
#assert "b -> d[version='>=1,<2']" in str(excinfo.value)
#assert "c -> d[version='>=2,<3']" in str(excinfo.value)


def test_unsat_expand_single():
# if install maps to a single package, examine its dependencies
index = (
simple_rec(name='a', depends=['b', 'c']),
simple_rec(name='b', depends=['d >=1,<2']),
simple_rec(name='c', depends=['d >=2,<3']),
simple_rec(name='d', version='1.0'),
simple_rec(name='d', version='2.0'),
)
r = Resolve(OrderedDict((prec, prec) for prec in index))
with pytest.raises(UnsatisfiableError) as excinfo:
r.install(['a'])
assert "b -> d[version='>=1,<2']" in str(excinfo.value)
assert "c -> d[version='>=2,<3']" in str(excinfo.value)


def test_unsat_missing_dep():
# an install target has a missing dependency
index = (
simple_rec(name='a', depends=['b', 'c']),
simple_rec(name='b', depends=['c >=2,<3']),
simple_rec(name='c', version='1.0'),
)
r = Resolve(OrderedDict((prec, prec) for prec in index))
# this raises ResolvePackageNotFound not UnsatisfiableError
assert raises(ResolvePackageNotFound, lambda: r.install(['a', 'b']))


def test_unsat_channel_priority():
# c depends on c 2.x which is only available in channel-2
index = (
simple_rec(name='a', version='1.0', depends=['c'], channel='channel-1'),
simple_rec(name='b', version='1.0', depends=['c >=2,<3'], channel='channel-1'),
simple_rec(name='c', version='1.0', channel='channel-1'),

simple_rec(name='a', version='2.0', depends=['c'], channel='channel-2'),
simple_rec(name='b', version='2.0', depends=['c >=2,<3'], channel='channel-2'),
simple_rec(name='c', version='1.0', channel='channel-2'),
simple_rec(name='c', version='2.0', channel='channel-2'),
)
channels = (
Channel('channel-1'), # higher priority
Channel('channel-2'), # lower priority, missing c 2.0
)
r = Resolve(OrderedDict((prec, prec) for prec in index), channels=channels)
with env_var("CONDA_CHANNEL_PRIORITY", "True", stack_callback=conda_tests_ctxt_mgmt_def_pol):
# channel-1 a and b packages (1.0) installed
installed1 = r.install(['a', 'b'])
assert any(k.name == 'a' and k.version == '1.0' for k in installed1)
assert any(k.name == 'b' and k.version == '1.0' for k in installed1)
with env_var("CONDA_CHANNEL_PRIORITY", "False", stack_callback=conda_tests_ctxt_mgmt_def_pol):
# no channel priority, largest version of a and b (2.0) installed
installed1 = r.install(['a', 'b'])
assert any(k.name == 'a' and k.version == '2.0' for k in installed1)
assert any(k.name == 'b' and k.version == '2.0' for k in installed1)
with env_var("CONDA_CHANNEL_PRIORITY", "STRICT", stack_callback=conda_tests_ctxt_mgmt_def_pol):
with pytest.raises(UnsatisfiableError) as excinfo:
r.install(['a', 'b'])
assert "b -> c[version='>=2,<3']" in str(excinfo.value)


def test_nonexistent():
Expand Down

0 comments on commit 810581d

Please sign in to comment.