diff --git a/nab-python/src/nab_python/provider.py b/nab-python/src/nab_python/provider.py index 89cc077e1..ea83344ed 100644 --- a/nab-python/src/nab_python/provider.py +++ b/nab-python/src/nab_python/provider.py @@ -781,8 +781,8 @@ def __init__( # noqa: PLR0913, PLR0915 - resolver config is wide; bundling all # The dep range each rejected candidate declared for the blocker, # unioned per group. Feeds the membership widening of the flushed - # blocker term; the range-keyed union also feeds the no-versions - # message. + # blocker term, and the no-versions message, which names the range the + # candidate declared rather than negating the blocker it hit. self.pending_decision_dep_ranges: defaultdict[ tuple[str, str, Version], _lookahead.DepRangeUnion ] = defaultdict(_lookahead.DepRangeUnion.zero) @@ -1780,7 +1780,14 @@ def _capture_lookahead_blockers(self, normalized: str) -> list[str]: for cand, blocker_pkg, blocker_version in self.pending_blocks: if cand != normalized: continue - out.append(f"requires {blocker_pkg} != {blocker_version}") + dep_range = self.pending_decision_dep_ranges[ + (cand, blocker_pkg, blocker_version) + ].union + out.append( + f"requires {blocker_pkg} in {self.format_range(dep_range)}" + " but solution has it in " + f"{self.format_range(VersionRange.singleton(blocker_version))}" + ) for cand, blocker_pkg, pos_range in self.pending_range_blocks: if cand != normalized: diff --git a/nab-python/tests/test_provider.py b/nab-python/tests/test_provider.py index 4a90559b4..29df8cafe 100644 --- a/nab-python/tests/test_provider.py +++ b/nab-python/tests/test_provider.py @@ -1669,9 +1669,9 @@ def test_blocker_reason_outlives_a_later_empty_range(self) -> None: # Nothing falls in this range, so the second ask has no blockers. assert provider.choose_version("foo", SpecifierSet(">=5.0").to_range()) is None - assert ( - provider.get_no_versions_reason("foo") - == "every version in range was rejected: requires bar != 1.0" + assert provider.get_no_versions_reason("foo") == ( + "every version in range was rejected:" + " requires bar in [2.0, 2.0] but solution has it in [1.0, 1.0]" ) def test_sdist_only_under_dynamic_local_names_build_policy(self) -> None: @@ -1832,6 +1832,42 @@ def test_post_release_pin_blocker_renders_post_excluding_bound(self) -> None: " but solution has it in [2.0.post1, 2.0.post1]" in reason ) + def test_decision_block_rejection_names_the_requirement(self) -> None: + """The decision-block diagnostic names the candidate's real + requirement, mirroring the range-block path. ``foo`` 1.0 requires + ``bar==2.0`` and the resolver has already decided ``bar==1.0``, so + every ``foo`` candidate is rejected. The message must name the + ``bar==2.0`` foo needs, not ``requires bar != 1.0`` (which wrongly + implies any bar other than 1.0 would satisfy foo). + + The ranges are spelled out rather than interpolated, so the + assertion fails if the message ever prints the debug repr again. + """ + coordinator = make_coordinator( + [make_wheel("1.0")], + metadata_text=( + "Metadata-Version: 2.1\n" + "Name: foo\n" + "Version: 1.0\n" + "Requires-Dist: bar==2.0\n" + ), + package="foo", + ) + provider = Provider( + coordinator, + target=_PY312, + root_requirements={"foo": VersionRange.full(admit_arbitrary=False)}, + ) + provider.solution_decisions["bar"] = V("1.0") + result = provider.choose_version("foo", VersionRange.full()) + assert result is None + reason = provider.get_no_versions_reason("foo") + assert reason is not None + assert " dict[str, object]: """A Simple-API file record for ``foo``'s sdist at ``version``.""" diff --git a/nab-python/tests/test_resolve.py b/nab-python/tests/test_resolve.py index ad79f0be6..7a21f6056 100644 --- a/nab-python/tests/test_resolve.py +++ b/nab-python/tests/test_resolve.py @@ -3266,10 +3266,13 @@ def test_constraint_does_not_hide_the_transitive_blocker( _resolved(pyproject, _FAKE_TRANSPORT, python_version="3.12.0") diagnostics = str(info.value).split("Diagnostics:")[1] + assert " None: