diff --git a/nab-python/src/nab_python/provider.py b/nab-python/src/nab_python/provider.py index 76841dcda..e947c542b 100644 --- a/nab-python/src/nab_python/provider.py +++ b/nab-python/src/nab_python/provider.py @@ -780,8 +780,8 @@ def __init__( # noqa: PLR0913, PLR0915, PLR0917 - resolver config is wide; bund # 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) @@ -1791,7 +1791,15 @@ 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 + # The blocker is decided, so the line names that version rather + # than a singleton range, which has no specifier spelling. + out.append( + f"requires {blocker_pkg} in {self.format_range(dep_range)}" + f" but solution has it at {blocker_version}" + ) for cand, blocker_pkg, pos_range in self.pending_range_blocks: if cand != normalized: @@ -1800,8 +1808,8 @@ def _capture_lookahead_blockers(self, normalized: str) -> list[str]: (cand, blocker_pkg, pos_range) ].union out.append( - f"requires {blocker_pkg} in {dep_range}" - f" but solution has it in {pos_range}" + f"requires {blocker_pkg} in {self.format_range(dep_range)}" + f" but solution has it in {self.format_range(pos_range)}" ) for ( @@ -1813,7 +1821,8 @@ def _capture_lookahead_blockers(self, normalized: str) -> list[str]: if cand != normalized: continue out.append( - f"requires {blocker_pkg} in {dep_range} but root has it in {root_range}" + f"requires {blocker_pkg} in {self.format_range(dep_range)}" + f" but root has it in {self.format_range(root_range)}" ) # Collapse repeated metadata-error blockers (one per version) into diff --git a/nab-python/tests/test_provider.py b/nab-python/tests/test_provider.py index c5ab30409..44a4944fa 100644 --- a/nab-python/tests/test_provider.py +++ b/nab-python/tests/test_provider.py @@ -1744,9 +1744,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 but solution has it at 1.0" ) def test_sdist_only_under_dynamic_local_names_build_policy(self) -> None: @@ -1837,8 +1837,8 @@ def test_root_requirement_rejection_names_the_blocker(self) -> None: assert result is None reason = provider.get_no_versions_reason("foo") assert reason is not None - assert "bar" in reason - assert "root has it in" in reason + assert " None: """Same as above but the blocker is a positive-range constraint @@ -1863,18 +1863,80 @@ def test_range_block_rejection_names_the_blocker(self) -> None: root_requirements={"foo": VersionRange.full(admit_arbitrary=False)}, ) pos_range = SpecifierSet("<2.0").to_range() - dep_range = SpecifierSet("==2.0").to_range() provider.solution_ranges["bar"] = pos_range result = provider.choose_version("foo", VersionRange.full()) assert result is None reason = provider.get_no_versions_reason("foo") assert reason is not None # foo requires bar==2.0; the message must name that, not the solution range. - assert ( - f"requires bar in {dep_range} but solution has it in {pos_range}" in reason - ) + assert " None: + """``bar>2.0`` excludes 2.0's post releases, so it is disjoint with + ``==2.0.post1``. Both sides read as the specifiers a user would write, + so the reader can see why they do not overlap. + """ + 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_ranges["bar"] = SpecifierSet("==2.0.post1").to_range() + result = provider.choose_version("foo", VersionRange.full()) + assert result is None + reason = provider.get_no_versions_reason("foo") + assert reason is not None + assert "requires bar in >2.0 but solution has it in ==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 2af1d393a..ec7dd7799 100644 --- a/nab-python/tests/test_resolve.py +++ b/nab-python/tests/test_resolve.py @@ -3361,12 +3361,53 @@ 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: + """Blocker diagnostics render declared ranges, not the debug repr. + + ``c`` declares ``b==1.0`` while the project requires ``b>=2``, so + look-ahead rejects every ``c`` candidate against the root requirement. + """ + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text( + '[project]\nname = "proj"\ndependencies = ["b>=2", "c"]\n', + encoding="utf-8", + ) + + coordinator = make_coordinator( + listings={ + "b": _index_wheels("b", "1.0", "2.0"), + "c": _index_wheels("c", "5.0", "6.0"), + }, + metadata_by_version={ + "1.0": _metadata("b", "1.0"), + "2.0": _metadata("b", "2.0"), + "5.0": _metadata("c", "5.0", "b==1.0"), + "6.0": _metadata("c", "6.0", "b==1.0"), + }, + ) + + with patch("nab_python.resolve.FetchCoordinator") as mock_coord_cls: + mock_coord_cls.return_value.__enter__ = lambda _self: coordinator + mock_coord_cls.return_value.__exit__ = MagicMock(return_value=False) + with pytest.raises(ResolutionError) as info: + _resolved(pyproject, _FAKE_TRANSPORT, python_version="3.12.0") + + diagnostics = str(info.value).split("Diagnostics:")[1] + assert "=2" + ) in diagnostics + class TestConflictingRootRequirements: def test_both_requirements_are_named(self, tmp_path: Path) -> None: