Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 15 additions & 6 deletions nab-python/src/nab_python/provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand All @@ -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 (
Expand All @@ -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
Expand Down
80 changes: 71 additions & 9 deletions nab-python/tests/test_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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 "<VersionRange" not in reason
assert "requires bar in ==2.0 but root has it in ==1.0" in reason

def test_range_block_rejection_names_the_blocker(self) -> None:
"""Same as above but the blocker is a positive-range constraint
Expand All @@ -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 "<VersionRange" not in reason
assert "AFTER_LOCALS" not in reason
assert "requires bar in ==2.0 but solution has it in <2.0" in reason
assert "disjoint with current solution range" not in reason

def test_post_release_pin_blocker_spells_both_sides_as_specifiers(self) -> 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 "<VersionRange" not in reason
assert "AFTER_LOCALS" not in reason
assert "requires bar in ==2.0 but solution has it at 1.0" in reason
assert "requires bar != 1.0" not in reason


def _sdist_entry(version: str) -> dict[str, object]:
"""A Simple-API file record for ``foo``'s sdist at ``version``."""
Expand Down
45 changes: 43 additions & 2 deletions nab-python/tests/test_resolve.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<VersionRange" not in diagnostics
assert (
"foo: every version in range was rejected: requires lib != 9.0"
in diagnostics
"foo: every version in range was rejected:"
" requires lib in ==5.0 but solution has it at 9.0" in diagnostics
)
assert "requires lib != 9.0" not in diagnostics
assert "foo: no version matches the requirement" not in diagnostics

def test_blocker_diagnostics_render_readable_ranges(self, tmp_path: Path) -> 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 "<VersionRange" not in diagnostics
assert "AFTER_LOCALS" not in diagnostics
assert (
"c: every version in range was rejected:"
" requires b in ==1.0 but root has it in >=2"
) in diagnostics


class TestConflictingRootRequirements:
def test_both_requirements_are_named(self, tmp_path: Path) -> None:
Expand Down