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
3 changes: 3 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ repos:
- id: end-of-file-fixer
- id: mixed-line-ending
- id: trailing-whitespace
# Vendoring patches carry unified-diff context lines whose
# leading space must survive verbatim for git apply.
exclude: ^tasks/vendoring/

- repo: https://github.com/astral-sh/ruff-pre-commit
rev: 745be0411e3c150e246d527e531e5a221b8c3462 # frozen: v0.15.19
Expand Down
12 changes: 12 additions & 0 deletions nab-python/src/nab_python/_packaging_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,3 +106,15 @@ def consume_pending_clauses(self) -> list[Incompatibility[str, Version]]:
def consume_force_backtrack_targets(self) -> list[str]:
"""No force-backtrack signal from this in-memory provider."""
return []

def widen_decision(self, package: str, version: Version) -> VersionRange | None:
"""No widening: dependency clauses keep the exact decided version."""
del package, version
return None

def narrow_for_display(
self, package: str, constraint: RangeProtocol[Version]
) -> RangeProtocol[Version]:
"""Identity: constraints render as stored."""
del package
return constraint
7 changes: 6 additions & 1 deletion nab-python/src/nab_python/_vendor/packaging/PROVENANCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,12 @@ plus at most one checked-in patch, and nothing else.
to.
- `tasks/vendoring/packaging.patch`, when present, is the only divergence from
pristine; the rebuild reapplies it after refreshing. With no patch file the
tree is byte-identical to upstream at the pin.
tree is byte-identical to upstream at the pin. The current patch adds
`VersionRange.from_bounds` and `VersionRange.snap_bounds` to `ranges.py`,
generated from the `range-from-bounds` branch of the packaging fork
(`git diff upstream/main..range-from-bounds -- src/packaging/ranges.py`,
paths rewritten to this directory). An upstream PR is planned; until it
lands the patch carries the two methods.
- `tasks/vendor-packaging.sh` fetches `pypa/packaging` at the pin, replaces this
package with the pristine `src/packaging/` tree plus the repo-root license
texts, and reapplies the patch. `--check` rebuilds into a temp location and
Expand Down
183 changes: 181 additions & 2 deletions nab-python/src/nab_python/_vendor/packaging/ranges.py
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,47 @@ def _struct_admits(
return matches_bounds_only(bounds, parsed)


def _bisect_predicate(
versions: Sequence[Version], predicate: Callable[[Version], bool]
) -> int:
"""First index whose ``predicate`` is true over an ascending list.

The predicate must be monotonic (false runs then true runs). Equivalent to
``bisect.bisect_left`` on the mapped booleans, done by hand because the
``key`` parameter for :mod:`bisect` only exists on Python 3.10 and later.
"""
low, high = 0, len(versions)
while low < high:
mid = (low + high) // 2
if predicate(versions[mid]):
high = mid
else:
low = mid + 1
return low


def _partition_indexes(
versions: Sequence[Version], lower: LowerBound, upper: UpperBound
) -> tuple[int, int]:
"""Locate one interval's bounds in an ascending version list.

Returns ``(first_inside, first_above)``: the index of the first version at
or above ``lower`` and of the first version strictly above ``upper``, so
``versions[first_inside:first_above]`` are the versions the interval
contains. The bound predicates are monotonic over an ascending list, so
both cuts bisect on the predicate value.
"""
above = lower._above
below = upper._below

first_inside = 0 if above is None else _bisect_predicate(versions, above)
if below is None:
first_above = len(versions)
else:
first_above = _bisect_predicate(versions, lambda v: not below(v))
return first_inside, first_above


# Repr helpers:


Expand Down Expand Up @@ -316,7 +357,8 @@ class VersionRange:
:class:`~packaging.specifiers.SpecifierSet`.

Construct via :meth:`~packaging.specifiers.SpecifierSet.to_range`, or with
the :meth:`full`, :meth:`empty`, and :meth:`singleton` class methods.
the :meth:`full`, :meth:`empty`, :meth:`singleton`, and :meth:`from_bounds`
class methods.
Compose with :meth:`intersection`, :meth:`union`, :meth:`complement`, and
:meth:`difference` (or the ``&`` / ``|`` / ``~`` / ``-`` operators). Test
membership with ``in`` or :meth:`contains`, filter an iterable with
Expand Down Expand Up @@ -407,7 +449,8 @@ def __new__(cls, *args: object, **kwargs: object) -> VersionRange: # noqa: PYI0
raise TypeError(
"cannot create 'VersionRange' instances directly; use "
"SpecifierSet.to_range(), VersionRange.full(), "
"VersionRange.empty(), or VersionRange.singleton() instead"
"VersionRange.empty(), VersionRange.singleton(), or "
"VersionRange.from_bounds() instead"
)

@classmethod
Expand Down Expand Up @@ -599,6 +642,73 @@ def singleton(
prereleases_configured=prereleases,
)

@classmethod
def from_bounds(
cls,
lower: Version | str | None = None,
upper: Version | str | None = None,
*,
include_lower: bool = True,
include_upper: bool = True,
prereleases: bool | None = None,
) -> VersionRange:
"""Return the raw version-order interval from ``lower`` to ``upper``.

A single interval in the PEP 440 total order. The bounds are pure order
cuts, not specifier semantics: ``None`` on a side is unbounded there.
Both ends are inclusive by default, so ``from_bounds(v, v)`` is
:meth:`singleton`; pass ``include_lower=False`` or ``include_upper=False``
for a half-open interval.

>>> "2.0" in VersionRange.from_bounds("1.0", "2.0")
True
>>> "2.0" in VersionRange.from_bounds("1.0", "2.0", include_upper=False)
False
>>> VersionRange.from_bounds("1.5", "1.5") == VersionRange.singleton("1.5")
True

Membership is decided by the bounds alone, so pre-releases, post-releases,
and locals inside them are members even where the matching specifier
would exclude them:

>>> "1.0.post1" in VersionRange.from_bounds("1.0", "2.0", include_lower=False)
True
>>> "1.0.post1" in SpecifierSet(">1.0,<2.0").to_range()
False
>>> "2.0rc1" in VersionRange.from_bounds("1.0", "2.0")
True
>>> "2.0rc1" in SpecifierSet(">=1.0,<2.0").to_range()
False

An inverted pair, or an equal pair with either end exclusive, is the
empty range; an unbounded pair is the versions-only full range.

>>> VersionRange.from_bounds("2.0", "1.0").is_empty
True
>>> VersionRange.from_bounds() == VersionRange.full(admit_arbitrary=False)
True

:raises packaging.version.InvalidVersion: if ``lower`` or ``upper`` is a
string that does not parse as a PEP 440 version.
"""
if lower is not None and not isinstance(lower, Version):
lower = Version(lower)
if upper is not None and not isinstance(upper, Version):
upper = Version(upper)

if lower is not None and upper is not None:
closed = include_lower and include_upper
if lower > upper or (lower == upper and not closed):
return cls.empty(prereleases=prereleases)

lower_bound = NEG_INF if lower is None else LowerBound(lower, include_lower)
upper_bound = POS_INF if upper is None else UpperBound(upper, include_upper)

return cls._build(
_canonical_floor(((lower_bound, upper_bound),)),
prereleases_configured=prereleases,
)

def intersection(self, other: VersionRange) -> VersionRange:
"""Range containing exactly the versions in both self and other.

Expand Down Expand Up @@ -1078,6 +1188,75 @@ def admit(item: Any) -> tuple[bool, Version | None, bool]: # noqa: ANN401
if not found_final:
yield from all_nonfinal

def snap_bounds(self, versions: Iterable[Version | str]) -> VersionRange:
"""Snap each finite bound inward onto the given versions.

Returns a subset of self that agrees with self on membership of every
given version: each finite segment end moves inward onto the outermost
given version its segment contains, and a bound with no given version
to land on is unchanged, as are unbounded ends.

Solver arithmetic and set algebra leave bounds at versions nobody
released; snapping them onto real versions yields the range a human
would write, and the subset guarantee means the snapped range never
admits a version the original excluded, even if the given list was
stale.

``versions`` may be any iterable of versions or version strings, in any
order; it is sorted internally. ``snap_bounds([])`` returns an equal
range.

>>> r = SpecifierSet(">=1.0,<2.0").to_range()
>>> r.snap_bounds(["1.2", "1.5", "1.8"])
<VersionRange '[1.2, 1.8]'>
>>> SpecifierSet(">=1.0").to_range().snap_bounds(["1.2", "1.5"])
<VersionRange '[1.2, +inf)'>
>>> r.snap_bounds([]) == r
True

A version-order gap round-trips to the singleton it surrounds:

>>> versions = [Version("1.0"), Version("2.0"), Version("3.0")]
>>> gap = VersionRange.from_bounds(
... "1.0", "3.0", include_lower=False, include_upper=False
... )
>>> gap.snap_bounds(versions) == VersionRange.singleton("2.0")
True

:raises packaging.version.InvalidVersion: if a string does not parse as a
PEP 440 version.
"""
anchors = sorted(v if isinstance(v, Version) else Version(v) for v in versions)
if not self._bounds:
return self

simplified: list[Interval] = []
for lower, upper in self._bounds:
first_inside, first_above = _partition_indexes(anchors, lower, upper)
if first_inside >= first_above:
simplified.append((lower, upper))
continue
new_lower = (
lower
if lower.version is None
else LowerBound(anchors[first_inside], True)
)
new_upper = (
upper
if upper.version is None
else UpperBound(anchors[first_above - 1], True)
)
simplified.append((new_lower, new_upper))

return self._build(
_canonical_floor(tuple(simplified)),
admit=self._admit,
reject=self._reject,
admit_arbitrary=self._admit_arbitrary,
pre_region=self._pre_region,
prereleases_configured=self._prereleases_configured,
)

@classmethod
def _from_specifier_set(cls, specifier_set: SpecifierSet) -> VersionRange:
"""Build the range accepted by ``specifier_set``.
Expand Down
90 changes: 86 additions & 4 deletions nab-python/src/nab_python/provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

from __future__ import annotations

import bisect
import enum
import logging
import re
Expand Down Expand Up @@ -620,8 +621,13 @@ def __init__( # noqa: PLR0913, PLR0915 - resolver config is wide; bundling all
self.versions_only_cache: dict[str, list[Version]] = {}
self.wheel_by_version_cache: dict[str, dict[Version, DistFile]] = {}

self.solution_ranges: dict[str, RangeProtocol[Version]] = {}
self.solution_decisions: dict[str, Version] = {}
# Widening state: the ascending versions_only view per normalized
# name, and the widened parent range per decided (name, version).
self._ascending_versions_cache: dict[str, list[Version]] = {}
self._widened_ranges: dict[tuple[str, Version], VersionRange] = {}

self.solution_ranges: Mapping[str, RangeProtocol[Version]] = {}
self.solution_decisions: Mapping[str, Version] = {}
self.pending_clauses: list[Incompatibility[str, Version]] = []
self.pending_blocks: defaultdict[tuple[str, str, Version], list[Version]] = (
defaultdict(list)
Expand Down Expand Up @@ -1519,8 +1525,8 @@ def _await_metadata_batch(

def receive_partial_solution_hint(
self,
positive_ranges: dict[str, RangeProtocol[Version]],
decisions: dict[str, Version],
positive_ranges: Mapping[str, RangeProtocol[Version]],
decisions: Mapping[str, Version],
) -> None:
"""Accept a snapshot of the resolver's positive-range assignments.

Expand Down Expand Up @@ -1564,6 +1570,82 @@ def consume_force_backtrack_targets(self) -> list[str]:
self._force_backtrack_targets = []
return targets

def _ascending_versions(
self,
normalized: str,
version_list: list[tuple[Version, DistFile]],
) -> list[Version]:
"""Return the widening universe for ``normalized``: ascending, cached.

The reversed ``versions_only`` view of the post-filter listing, so
pre-release, dev, post, and local versions all fence widening.
Yanked files are dropped at nab-index parse time and can never be
selected; if that ever moves into a provider-level filter, this
universe must be sourced below it, or widened ranges would span
selectable yanked versions.
"""
cached = self._ascending_versions_cache.get(normalized)
if cached is None:
cached = list(reversed(self.versions_only(normalized, version_list)))
self._ascending_versions_cache[normalized] = cached
return cached

def widen_decision(self, package: str, version: Version) -> VersionRange | None:
"""Return the widened parent range for a decided ``version``, or None.

The widened range spans the open gap between ``version``'s listed
neighbors, so it contains no other selectable version (see
``ResolverProvider.widen_decision`` for the contract). Extras
proxies share the base package's universe. Local, VCS, and archive
sources (synthesized single-version listings) and packages whose
listing is not cached are not widened.
"""
_, _, normalized = self.split_and_normalize(package)
if (
normalized in self.local_sources
or normalized in self.vcs_sources
or normalized in self.archive_sources
):
return None
version_list = self.versions_cache.get(normalized)
if version_list is None:
return None

key = (normalized, version)
widened = self._widened_ranges.get(key)
if widened is None:
universe = self._ascending_versions(normalized, version_list)
below = bisect.bisect_left(universe, version)
above = bisect.bisect_right(universe, version)
prev = universe[below - 1] if below else None
nxt = universe[above] if above < len(universe) else None

widened = VersionRange.from_bounds(
prev, nxt, include_lower=False, include_upper=False
)
self._widened_ranges[key] = widened
return widened

def narrow_for_display(
self, package: object, constraint: RangeProtocol[Version]
) -> RangeProtocol[Version]:
"""Map a possibly-widened ``constraint`` back onto listed versions.

Render-time only and cache-only: the ROOT sentinel (a non-str
package) and packages whose listing is not cached return
``constraint`` unchanged, and nothing is ever fetched.
"""
if not isinstance(package, str):
return constraint
_, _, normalized = self.split_and_normalize(package)
version_list = self.versions_cache.get(normalized)
if version_list is None:
return constraint
assert isinstance(constraint, VersionRange)
return constraint.snap_bounds(
self._ascending_versions(normalized, version_list)
)

def get_dependencies(
self, package: str, version: Version
) -> dict[str, VersionRange]:
Expand Down
Loading
Loading