Commit 1a82099d by Sébastien Eustace

Improve solver error messages

parent b4ab4d56
...@@ -10,6 +10,7 @@ ...@@ -10,6 +10,7 @@
### Changed ### Changed
- Changed the dependency installation order, deepest dependencies are now installed first. - Changed the dependency installation order, deepest dependencies are now installed first.
- Improved solver error messages.
### Fixed ### Fixed
......
...@@ -4,6 +4,7 @@ from typing import Tuple ...@@ -4,6 +4,7 @@ from typing import Tuple
from .incompatibility import Incompatibility from .incompatibility import Incompatibility
from .incompatibility_cause import ConflictCause from .incompatibility_cause import ConflictCause
from .incompatibility_cause import PythonCause
class SolveFailure(Exception): class SolveFailure(Exception):
...@@ -30,6 +31,20 @@ class _Writer: ...@@ -30,6 +31,20 @@ class _Writer:
def write(self): def write(self):
buffer = [] buffer = []
required_python_version = None
for incompatibility in self._root.external_incompatibilities:
if isinstance(incompatibility.cause, PythonCause):
required_python_version = incompatibility.cause.root_python_version
break
if required_python_version is not None:
buffer.append(
"The current supported Python versions are {}".format(
required_python_version
)
)
buffer.append("")
if isinstance(self._root.cause, ConflictCause): if isinstance(self._root.cause, ConflictCause):
self._visit(self._root, {}) self._visit(self._root, {})
else: else:
...@@ -40,7 +55,7 @@ class _Writer: ...@@ -40,7 +55,7 @@ class _Writer:
padding = ( padding = (
0 0
if not self._line_numbers if not self._line_numbers
else len("({})".format(list(self._line_numbers.values())[-1])) else len("({}) ".format(list(self._line_numbers.values())[-1]))
) )
last_was_empty = False last_was_empty = False
...@@ -79,7 +94,7 @@ class _Writer: ...@@ -79,7 +94,7 @@ class _Writer:
self, incompatibility, details_for_incompatibility, conclusion=False self, incompatibility, details_for_incompatibility, conclusion=False
): # type: (Incompatibility, Dict, bool) -> None ): # type: (Incompatibility, Dict, bool) -> None
numbered = conclusion or self._derivations[incompatibility] > 1 numbered = conclusion or self._derivations[incompatibility] > 1
conjunction = conclusion or ("So," if incompatibility == self._root else "And") conjunction = "So," if conclusion or incompatibility == self._root else "And"
incompatibility_string = str(incompatibility) incompatibility_string = str(incompatibility)
cause = incompatibility.cause # type: ConflictCause cause = incompatibility.cause # type: ConflictCause
......
...@@ -84,6 +84,22 @@ class Incompatibility: ...@@ -84,6 +84,22 @@ class Incompatibility:
def cause(self): # type: () -> IncompatibilityCause def cause(self): # type: () -> IncompatibilityCause
return self._cause return self._cause
@property
def external_incompatibilities(self): # type: () -> Generator[Incompatibility]
"""
Returns all external incompatibilities in this incompatibility's
derivation graph.
"""
if isinstance(self._cause, ConflictCause):
cause = self._cause # type: ConflictCause
for incompatibility in cause.conflict.external_incompatibilities:
yield incompatibility
for incompatibility in cause.other.external_incompatibilities:
yield incompatibility
else:
yield self
def is_failure(self): # type: () -> bool def is_failure(self): # type: () -> bool
return len(self._terms) == 0 or ( return len(self._terms) == 0 or (
len(self._terms) == 1 and self._terms[0].dependency.is_root len(self._terms) == 1 and self._terms[0].dependency.is_root
......
...@@ -48,13 +48,18 @@ class PythonCause(IncompatibilityCause): ...@@ -48,13 +48,18 @@ class PythonCause(IncompatibilityCause):
with the current python version. with the current python version.
""" """
def __init__(self, python_version): def __init__(self, python_version, root_python_version):
self._python_version = python_version self._python_version = python_version
self._root_python_version = root_python_version
@property @property
def python_version(self): def python_version(self):
return self._python_version return self._python_version
@property
def root_python_version(self):
return self._root_python_version
class PlatformCause(IncompatibilityCause): class PlatformCause(IncompatibilityCause):
""" """
......
...@@ -279,7 +279,7 @@ class Provider: ...@@ -279,7 +279,7 @@ class Provider:
return [ return [
Incompatibility( Incompatibility(
[Term(package.to_dependency(), True)], [Term(package.to_dependency(), True)],
PythonCause(package.python_versions), PythonCause(package.python_versions, self._package.python_versions),
) )
] ]
......
...@@ -225,6 +225,12 @@ class VersionUnion(VersionConstraint): ...@@ -225,6 +225,12 @@ class VersionUnion(VersionConstraint):
raise ValueError("Unknown VersionConstraint type {}".format(constraint)) raise ValueError("Unknown VersionConstraint type {}".format(constraint))
def _excludes_single_version(self): # type: () -> bool
from .version import Version
from .version_range import VersionRange
return isinstance(VersionRange().difference(self), Version)
def __eq__(self, other): def __eq__(self, other):
if not isinstance(other, VersionUnion): if not isinstance(other, VersionUnion):
return False return False
...@@ -232,6 +238,11 @@ class VersionUnion(VersionConstraint): ...@@ -232,6 +238,11 @@ class VersionUnion(VersionConstraint):
return self._ranges == other.ranges return self._ranges == other.ranges
def __str__(self): def __str__(self):
from .version_range import VersionRange
if self._excludes_single_version():
return "!={}".format(VersionRange().difference(self))
return " || ".join([str(r) for r in self._ranges]) return " || ".join([str(r) for r in self._ranges])
def __repr__(self): def __repr__(self):
......
...@@ -3,8 +3,10 @@ from poetry.mixology.failure import SolveFailure ...@@ -3,8 +3,10 @@ from poetry.mixology.failure import SolveFailure
from poetry.mixology.version_solver import VersionSolver from poetry.mixology.version_solver import VersionSolver
def add_to_repo(repository, name, version, deps=None): def add_to_repo(repository, name, version, deps=None, python=None):
package = Package(name, version) package = Package(name, version)
if python:
package.python_versions = python
if deps: if deps:
for dep_name, dep_constraint in deps.items(): for dep_name, dep_constraint in deps.items():
......
...@@ -50,3 +50,101 @@ def test_backjumps_after_partial_satisfier(root, provider, repo): ...@@ -50,3 +50,101 @@ def test_backjumps_after_partial_satisfier(root, provider, repo):
add_to_repo(repo, "y", "2.0.0") add_to_repo(repo, "y", "2.0.0")
check_solver_result(root, provider, {"c": "1.0.0", "y": "2.0.0"}, tries=2) check_solver_result(root, provider, {"c": "1.0.0", "y": "2.0.0"}, tries=2)
def test_rolls_back_leaf_versions_first(root, provider, repo):
# The latest versions of a and b disagree on c. An older version of either
# will resolve the problem. This test validates that b, which is farther
# in the dependency graph from myapp is downgraded first.
root.add_dependency("a", "*")
add_to_repo(repo, "a", "1.0.0", deps={"b": "*"})
add_to_repo(repo, "a", "2.0.0", deps={"b": "*", "c": "2.0.0"})
add_to_repo(repo, "b", "1.0.0")
add_to_repo(repo, "b", "2.0.0", deps={"c": "1.0.0"})
add_to_repo(repo, "c", "1.0.0")
add_to_repo(repo, "c", "2.0.0")
check_solver_result(root, provider, {"a": "2.0.0", "b": "1.0.0", "c": "2.0.0"})
def test_simple_transitive(root, provider, repo):
# Only one version of baz, so foo and bar will have to downgrade
# until they reach it
root.add_dependency("foo", "*")
add_to_repo(repo, "foo", "1.0.0", deps={"bar": "1.0.0"})
add_to_repo(repo, "foo", "2.0.0", deps={"bar": "2.0.0"})
add_to_repo(repo, "foo", "3.0.0", deps={"bar": "3.0.0"})
add_to_repo(repo, "bar", "1.0.0", deps={"baz": "*"})
add_to_repo(repo, "bar", "2.0.0", deps={"baz": "2.0.0"})
add_to_repo(repo, "bar", "3.0.0", deps={"baz": "3.0.0"})
add_to_repo(repo, "baz", "1.0.0")
check_solver_result(
root, provider, {"foo": "1.0.0", "bar": "1.0.0", "baz": "1.0.0"}, tries=3
)
def test_backjump_to_nearer_unsatisfied_package(root, provider, repo):
# This ensures it doesn't exhaustively search all versions of b when it's
# a-2.0.0 whose dependency on c-2.0.0-nonexistent led to the problem. We
# make sure b has more versions than a so that the solver tries a first
# since it sorts sibling dependencies by number of versions.
root.add_dependency("a", "*")
root.add_dependency("b", "*")
add_to_repo(repo, "a", "1.0.0", deps={"c": "1.0.0"})
add_to_repo(repo, "a", "2.0.0", deps={"c": "2.0.0-nonexistent"})
add_to_repo(repo, "b", "1.0.0")
add_to_repo(repo, "b", "2.0.0")
add_to_repo(repo, "b", "3.0.0")
add_to_repo(repo, "c", "1.0.0")
check_solver_result(
root, provider, {"a": "1.0.0", "b": "3.0.0", "c": "1.0.0"}, tries=2
)
def test_traverse_into_package_with_fewer_versions_first(root, provider, repo):
# Dependencies are ordered so that packages with fewer versions are tried
# first. Here, there are two valid solutions (either a or b must be
# downgraded once). The chosen one depends on which dep is traversed first.
# Since b has fewer versions, it will be traversed first, which means a will
# come later. Since later selections are revised first, a gets downgraded.
root.add_dependency("a", "*")
root.add_dependency("b", "*")
add_to_repo(repo, "a", "1.0.0", deps={"c": "*"})
add_to_repo(repo, "a", "2.0.0", deps={"c": "*"})
add_to_repo(repo, "a", "3.0.0", deps={"c": "*"})
add_to_repo(repo, "a", "4.0.0", deps={"c": "*"})
add_to_repo(repo, "a", "5.0.0", deps={"c": "1.0.0"})
add_to_repo(repo, "b", "1.0.0", deps={"c": "*"})
add_to_repo(repo, "b", "2.0.0", deps={"c": "*"})
add_to_repo(repo, "b", "3.0.0", deps={"c": "*"})
add_to_repo(repo, "b", "4.0.0", deps={"c": "2.0.0"})
add_to_repo(repo, "c", "1.0.0")
add_to_repo(repo, "c", "2.0.0")
check_solver_result(root, provider, {"a": "4.0.0", "b": "4.0.0", "c": "2.0.0"})
def test_backjump_past_failed_package_on_disjoint_constraint(root, provider, repo):
root.add_dependency("a", "*")
root.add_dependency("foo", ">2.0.0")
add_to_repo(repo, "a", "1.0.0", deps={"foo": "*"}) # ok
add_to_repo(
repo, "a", "2.0.0", deps={"foo": "<1.0.0"}
) # disjoint with myapp's constraint on foo
add_to_repo(repo, "foo", "2.0.0")
add_to_repo(repo, "foo", "2.0.1")
add_to_repo(repo, "foo", "2.0.2")
add_to_repo(repo, "foo", "2.0.3")
add_to_repo(repo, "foo", "2.0.4")
check_solver_result(root, provider, {"a": "1.0.0", "foo": "2.0.4"})
from ..helpers import add_to_repo
from ..helpers import check_solver_result
def test_dependency_does_not_match_root_python_constraint(root, provider, repo):
root.python_versions = "^3.6"
root.add_dependency("foo", "*")
add_to_repo(repo, "foo", "1.0.0", python="<3.5")
error = """The current supported Python versions are ^3.6
Because no versions of foo match !=1.0.0
and foo (1.0.0) requires Python <3.5, foo is forbidden.
So, because myapp depends on foo (*), version solving failed."""
check_solver_result(root, provider, error=error)
...@@ -79,7 +79,7 @@ def test_no_valid_solution(root, provider, repo): ...@@ -79,7 +79,7 @@ def test_no_valid_solution(root, provider, repo):
error = """\ error = """\
Because no versions of b match <1.0.0 || >1.0.0,<2.0.0 || >2.0.0 Because no versions of b match <1.0.0 || >1.0.0,<2.0.0 || >2.0.0
and b (1.0.0) depends on a (2.0.0), b (<2.0.0 || >2.0.0) requires a (2.0.0). and b (1.0.0) depends on a (2.0.0), b (!=2.0.0) requires a (2.0.0).
And because a (2.0.0) depends on b (2.0.0), b is forbidden. And because a (2.0.0) depends on b (2.0.0), b is forbidden.
Because b (2.0.0) depends on a (1.0.0) which depends on b (1.0.0), b is forbidden. Because b (2.0.0) depends on a (1.0.0) which depends on b (1.0.0), b is forbidden.
Thus, b is forbidden. Thus, b is forbidden.
......
...@@ -841,7 +841,7 @@ def test_solver_duplicate_dependencies_different_constraints_same_requirements( ...@@ -841,7 +841,7 @@ def test_solver_duplicate_dependencies_different_constraints_same_requirements(
expected = """\ expected = """\
Because a (1.0) depends on both B (^1.0) and B (^2.0), a is forbidden. Because a (1.0) depends on both B (^1.0) and B (^2.0), a is forbidden.
So, because no versions of a match <1.0 || >1.0 So, because no versions of a match !=1.0
and root depends on A (*), version solving failed.""" and root depends on A (*), version solving failed."""
assert str(e.value) == expected assert str(e.value) == expected
......
...@@ -160,9 +160,9 @@ def test_parse_constraints_negative_wildcard(input, constraint): ...@@ -160,9 +160,9 @@ def test_parse_constraints_negative_wildcard(input, constraint):
("1", "1"), ("1", "1"),
("1.2", "1.2"), ("1.2", "1.2"),
("1.2.3", "1.2.3"), ("1.2.3", "1.2.3"),
("!=1", "<1 || >1"), ("!=1", "!=1"),
("!=1.2", "<1.2 || >1.2"), ("!=1.2", "!=1.2"),
("!=1.2.3", "<1.2.3 || >1.2.3"), ("!=1.2.3", "!=1.2.3"),
("^1", ">=1,<2"), ("^1", ">=1,<2"),
("^1.0", ">=1.0,<2.0"), ("^1.0", ">=1.0,<2.0"),
("^1.0.0", ">=1.0.0,<2.0.0"), ("^1.0.0", ">=1.0.0,<2.0.0"),
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment