Repository navigation
feat: add public PEP 440 compliance VersionRange class - #1267
Conversation
VersionRange classVersionRange class
b8ba5d8 to
178b624
Compare
|
Is it expected for I thought a round-trip would always work, but I assume explicit In case you wonder about the use case, I'm working with version specifiers defined in markers (e.g. Edit: |
Nope, it's expected we can always round trip |
|
Since I actually found this while looking into another oddness, I guess I should mention it too. >>> version_range = VersionRange.full(prereleases=True) & ~SpecifierSet(">=3.14", prereleases=True).to_range()
>>> version_range
<VersionRange '(-inf, 3.14)' pre=True>
>>> version_range.to_specifier_set()
<SpecifierSet('!=3.14,<=3.14', prereleases=True)>I believe the above is equivalent to |
Right, in this PR I'm not going to make any claims about simplest form, I'd rather it be correct first, and then we can have follow up PRs to try and produce better forms. That said I will look at this example, if we can make the code simpler to produce that I will do it, but if it requires more complexity I would push it to another PR. |
This should be fixed now, but
Edit: I changed my mind. |
8dc01d3 to
2bb9e3e
Compare
|
Yeah, I decided this PR is meant to be a minimal This also made me realize, once this lands, there are a few set-like methods that would be useful such as is subset and is disjoint, but again, will be follow up PRs. |
|
I ran a review with GPT 5.5 (below) while doing something else. I'll try to review it myself soon. 🤖 AI text below 🤖 Found one substantive issue: Medium — Synthetic
This also affects public range algebra: unions that cover all real versions can leave a non-empty complement containing only an impossible synthetic gap. Suggested fix: extend empty-range detection/canonicalization to recognize gaps after |
Fixed all the findings, plus additional edge cases that this raised, in particular it showed two different specifiers can result in the exact same range: >>> SpecifierSet(">1.0a1").to_range() == SpecifierSet(">=1.0a2.dev0").to_range()
TrueThis is correct, these specifiers produce identical results for both |
There was a problem hiding this comment.
If you want to add == back in eventually if you pull it out, I think it's fine here as is. The definition makes sense.
I was investigating various parts with Claude, and when we got to my immutability questions, it popped up that VersionRange mutates; it calls _build then sets prereleases via _restamp. It's technically fine, the values are _ prefixed and it's done internally only, but you could fold this into _build by adding two parameters (prereleases: bool | None = None, prereleases_configured: bool | None = None), then the _restamp thing goes away. I tried it, full diff attached. Quick diff of what I think looks odd for an immutable class:
- result = cls._build(FULL_RANGE, admit_arbitrary=admit_arbitrary)
- if prereleases is not None:
- result._restamp(resolved=prereleases, configured=prereleases)
-
- return result
+ return cls._build(
+ FULL_RANGE,
+ admit_arbitrary=admit_arbitrary,
+ prereleases=prereleases,
+ prereleases_configured=prereleases,
+ )diff:
diff --git a/src/packaging/ranges.py b/src/packaging/ranges.py
index 0aa9e05..a2faffb 100644
--- a/src/packaging/ranges.py
+++ b/src/packaging/ranges.py
@@ -376,12 +376,16 @@ class VersionRange:
admit: frozenset[str] = frozenset(),
reject: frozenset[str] = frozenset(),
admit_arbitrary: bool = False,
+ *,
+ prereleases: bool | None = None,
+ prereleases_configured: bool | None = None,
) -> VersionRange:
"""Internal factory; bypasses :meth:`__new__`.
- Canonicalizes the bounds so equal version sets share one representation,
- then drops admit literals the bounds already admit and reject literals
- the bounds do not match anyway. Reject wins over admit on overlap.
+ Canonicalizes the bounds so equal version sets share one
+ representation, then drops admit literals the bounds already admit and
+ reject literals the bounds do not match anyway. Reject wins over admit
+ on overlap. The pre-release policy is supplied here.
"""
bounds = _canonicalize(bounds)
if admit and reject:
@@ -404,8 +408,8 @@ class VersionRange:
instance._admit = admit
instance._reject = reject
instance._admit_arbitrary = admit_arbitrary
- instance._prereleases = None
- instance._prereleases_configured = None
+ instance._prereleases = prereleases
+ instance._prereleases_configured = prereleases_configured
return instance
@@ -431,20 +435,35 @@ class VersionRange:
f"and {other._prereleases_configured!r}"
)
- def _propagate_prereleases(self, other: VersionRange, result: VersionRange) -> None:
- """Carry the shared pre-release policy onto a freshly built result."""
- result._prereleases_configured = self._prereleases_configured
- if self._prereleases_configured is not None:
- result._prereleases = self._prereleases_configured
+ def _combined_policy(self, other: VersionRange) -> tuple[bool | None, bool | None]:
+ """Resolved and configured pre-release policy for combining ``self`` with
+ ``other``.
+
+ Both operands share a configured policy by the time this is called (see
+ :meth:`_check_policy_compat`). Returns ``(resolved, configured)`` to feed
+ straight into :meth:`_build`.
+ """
+ configured = self._prereleases_configured
+ if configured is not None:
+ resolved: bool | None = configured
elif self._prereleases is True or other._prereleases is True:
- result._prereleases = True
+ resolved = True
else:
- result._prereleases = None
+ resolved = None
+ return resolved, configured
- def _restamp(self, *, resolved: bool | None, configured: bool | None) -> None:
- """Set the pre-release policy slots on a freshly built range."""
- self._prereleases = resolved
- self._prereleases_configured = configured
+ def _with_policy(
+ self, *, resolved: bool | None, configured: bool | None
+ ) -> VersionRange:
+ """Return a structural copy carrying the given pre-release policy."""
+ return self._build(
+ self._bounds,
+ admit=self._admit,
+ reject=self._reject,
+ admit_arbitrary=self._admit_arbitrary,
+ prereleases=resolved,
+ prereleases_configured=configured,
+ )
@classmethod
def empty(cls, *, prereleases: bool | None = None) -> VersionRange:
@@ -455,11 +474,9 @@ class VersionRange:
>>> "1.0" in VersionRange.empty()
False
"""
- result = cls._build(())
- if prereleases is not None:
- result._restamp(resolved=prereleases, configured=prereleases)
-
- return result
+ return cls._build(
+ (), prereleases=prereleases, prereleases_configured=prereleases
+ )
@classmethod
def full(
@@ -480,11 +497,12 @@ class VersionRange:
>>> "garbage" in VersionRange.full(admit_arbitrary=False)
False
"""
- result = cls._build(FULL_RANGE, admit_arbitrary=admit_arbitrary)
- if prereleases is not None:
- result._restamp(resolved=prereleases, configured=prereleases)
-
- return result
+ return cls._build(
+ FULL_RANGE,
+ admit_arbitrary=admit_arbitrary,
+ prereleases=prereleases,
+ prereleases_configured=prereleases,
+ )
@classmethod
def singleton(
@@ -512,11 +530,11 @@ class VersionRange:
# Collapse the floor: nothing sorts below ``MIN_VERSION``, so the
# ``0.dev0`` singleton is ``(-inf, 0.dev0]`` in canonical form.
- result = cls._build(_canonical_floor(((lower, upper),)))
- if prereleases is not None:
- result._restamp(resolved=prereleases, configured=prereleases)
-
- return result
+ return cls._build(
+ _canonical_floor(((lower, upper),)),
+ prereleases=prereleases,
+ prereleases_configured=prereleases,
+ )
def intersection(self, other: VersionRange) -> VersionRange:
"""Range containing exactly the versions in both self and other.
@@ -531,17 +549,24 @@ class VersionRange:
"""
self._check_policy_compat(other)
+ resolved, configured = self._combined_policy(other)
new_bounds = tuple(intersect_ranges(self._bounds, other._bounds))
combined_arb = self._admit_arbitrary and other._admit_arbitrary
if not self._has_literals() and not other._has_literals():
- result = self._build(new_bounds, admit_arbitrary=combined_arb)
- else:
- result = self._combine_literals(
- other, new_bounds, intersect=True, admit_arbitrary=combined_arb
+ return self._build(
+ new_bounds,
+ admit_arbitrary=combined_arb,
+ prereleases=resolved,
+ prereleases_configured=configured,
)
-
- self._propagate_prereleases(other, result)
- return result
+ return self._combine_literals(
+ other,
+ new_bounds,
+ intersect=True,
+ admit_arbitrary=combined_arb,
+ prereleases=resolved,
+ prereleases_configured=configured,
+ )
def union(self, other: VersionRange) -> VersionRange:
"""Range containing every version in self or other.
@@ -558,13 +583,24 @@ class VersionRange:
"""
self._check_policy_compat(other)
+ resolved, configured = self._combined_policy(other)
new_bounds = tuple(_union_ranges(self._bounds, other._bounds))
combined_arb = self._admit_arbitrary or other._admit_arbitrary
if not self._has_literals() and not other._has_literals():
- result = self._build(new_bounds, admit_arbitrary=combined_arb)
+ result = self._build(
+ new_bounds,
+ admit_arbitrary=combined_arb,
+ prereleases=resolved,
+ prereleases_configured=configured,
+ )
else:
result = self._combine_literals(
- other, new_bounds, intersect=False, admit_arbitrary=combined_arb
+ other,
+ new_bounds,
+ intersect=False,
+ admit_arbitrary=combined_arb,
+ prereleases=resolved,
+ prereleases_configured=configured,
)
# ``r | full()`` collapses to the canonical universal range only when
@@ -575,11 +611,8 @@ class VersionRange:
and not result._has_literals()
and self._prereleases_configured is None
):
- result._prereleases_configured = None
- result._prereleases = None
- return result
+ return self._build(result._bounds, admit_arbitrary=True)
- self._propagate_prereleases(other, result)
return result
def complement(self) -> VersionRange:
@@ -598,21 +631,20 @@ class VersionRange:
True
"""
if not self._has_literals():
- result = self._build(
+ return self._build(
tuple(_complement_ranges(self._bounds)),
admit_arbitrary=self._admit_arbitrary,
+ prereleases=self._prereleases,
+ prereleases_configured=self._prereleases_configured,
)
- else:
- result = self._build(
- tuple(_complement_ranges(self._bounds)),
- admit=self._reject,
- reject=self._admit,
- admit_arbitrary=self._admit_arbitrary,
- )
-
- result._prereleases = self._prereleases
- result._prereleases_configured = self._prereleases_configured
- return result
+ return self._build(
+ tuple(_complement_ranges(self._bounds)),
+ admit=self._reject,
+ reject=self._admit,
+ admit_arbitrary=self._admit_arbitrary,
+ prereleases=self._prereleases,
+ prereleases_configured=self._prereleases_configured,
+ )
def _combine_literals(
self,
@@ -621,6 +653,8 @@ class VersionRange:
*,
intersect: bool,
admit_arbitrary: bool,
+ prereleases: bool | None,
+ prereleases_configured: bool | None,
) -> VersionRange:
"""Resolve admit/reject for ``self & other`` or ``self | other``."""
admits: set[str] = set()
@@ -639,6 +673,8 @@ class VersionRange:
admit=frozenset(admits),
reject=frozenset(rejects),
admit_arbitrary=admit_arbitrary,
+ prereleases=prereleases,
+ prereleases_configured=prereleases_configured,
)
def _matches_literal(self, literal: str) -> bool:
@@ -814,13 +850,11 @@ class VersionRange:
)
result = result.intersection(operand)
- result._restamp(
+ return result._with_policy(
resolved=specifier_set.prereleases,
configured=specifier_set._prereleases,
)
- return result
-
@property
def is_empty(self) -> bool:
"""``True`` if no version or string satisfies this range.Matter of taste, up to you.
Everything else looks good to me.
I agree this is a better approach, fixed. |
VersionRange classVersionRange class
|
Thanks so much @henryiii for the review. I'll leave this open for a few days given this adds such a large new public API surface to give any other maintainer chance to object, but I do plan to merge early next week if I don't hear anything. |
This supersedes #1182 with a smaller, more focused implementation.
SpecifierandSpecifierSetalready run their PEP 440 matching through a shared internal range engine (_ranges.py). A specifier becomes a list of bound intervals, and membership, filtering, and intersection all run on those intervals. That is the state on main today.This PR exposes that capability as a public set-algebra object.
VersionRangeis a second consumer of the same engine: it reuses the engine's bound construction, intersection, membership test, filtering, and pre-release handling, and adds the two operations only it needs, union and complement, plus a canonical interval form.SpecifierSetgets one new method,to_range(), to hand its bounds over. Nothing else onSpecifierorSpecifierSetchanges.#1182 went the other direction: make
VersionRangethe core and turnSpecifier/SpecifierSetinto wrappers around it. That was a larger change to the existing types; sharing the engine keeps this one additive.New public APIs
A new module
packaging.rangeswith a single public class,VersionRange.A
VersionRangeis the set ofVersionvalues matched by aSpecifierSet, held as a union of disjoint intervals on the PEP 440 ordering, and closed under intersection, union, and complement. Set operations answer overlap, subset, and complement questions directly, without parsing strings or iterating versions.VersionRangehas no public constructor (VersionRange()raisesTypeError); it is not a PEP 440 object, just a predicate onVersion. Build one from a specifier set, or with a factory:Set algebra. Each returns a new range; operands are not mutated.
Membership and filtering, mirroring
SpecifierSet, including PEP 440 pre-release handling:Plus
is_empty, and the standard==,hash, andrepr.The only addition to an existing type is
SpecifierSet.to_range(). There is noSpecifier.to_range(); build aSpecifierSetfirst if you need one.Design choices
A new class, not new methods on
SpecifierSet.SpecifierSetis not closed under union or complement. PEP 440 has no syntax for the strict singleton{V}(==Valso matchesV+local), nor for the inclusive upper bound that complementing>Vproduces. Adding set algebra toSpecifierSetwould return values no specifier string can represent.singletonis strict.singleton("1.5")is exactly{1.5}and does not match1.5+local, unlike==1.5. A range and a singleVersionare different things, so the factory is named rather than implicit.Pre-release policy travels with the range. The configured and resolved pre-release policy of the originating
SpecifierSetcarries onto the range and through the algebra, socontainsandfiltermatch the originating set exactly. Combining two ranges requires the same configured policy.===survives the algebra. A range built from a===specifier keeps the literal-string admission, so it still supports membership and set operations.===matches a string verbatim rather than a version set, so its complement is one-way.Equality is substitutability. Two ranges are equal only when they behave the same under
containsandfilter, so==covers the pre-release policy and===admission, not just the version set. Ranges are stored in one canonical interval form, so==andhashare well-defined. Because==is stricter than matching the same versions, set relations use the algebra: subset is(a & ~b).is_empty, disjoint is(a & b).is_empty. There is a worked example in the docs. I would be open to removing==equality in this initial version if it's considered too confusing.Where I use it in nab
nab is a fresh resolver I am building on the PubGrub algorithm. PubGrub represents every fact as a term: a package plus a set of allowed versions. The solver intersects, unions, complements, and tests those sets on every step, so it needs the set algebra to be public.
nab stays version-type agnostic behind a small protocol, and
packaging.ranges.VersionRangeis the PEP 440 implementation that satisfies it:The bridge from PEP 508 is
SpecifierSet.to_range(). Each requirement's specifier becomes a range and folds into the package's accumulated constraint by intersection, withfull()as the identity:A term and its operations map straight onto the algebra:
The subset and disjoint tests use the algebra, not
==. As noted under Design choices,==also compares the pre-release policy and===admission, which PubGrub does not care about, so two ranges with the same versions can compare unequal. The algebra keeps the relations correct under those flags, which is what the solver's progress argument needs.nab selects candidates with
filter(), which does the PEP 440 selection including pre-releases, so nab does not reimplement that or carry its own pre-release flags.