Skip to content

feat: add public PEP 440 compliance VersionRange class - #1267

Merged
notatallshaw merged 5 commits into
pypa:mainfrom
notatallshaw:minimal-version-range
Jun 24, 2026
Merged

notatallshaw merged 5 commits into
pypa:mainfrom
notatallshaw:minimal-version-range

Conversation

@notatallshaw

@notatallshaw notatallshaw commented Jun 18, 2026 •

Copy link
Copy Markdown
Member

This supersedes #1182 with a smaller, more focused implementation.

Specifier and SpecifierSet already 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. VersionRange is 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. SpecifierSet gets one new method, to_range(), to hand its bounds over. Nothing else on Specifier or SpecifierSet changes.

#1182 went the other direction: make VersionRange the core and turn Specifier / SpecifierSet into 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.ranges with a single public class, VersionRange.

A VersionRange is the set of Version values matched by a SpecifierSet, 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.

VersionRange has no public constructor (VersionRange() raises TypeError); it is not a PEP 440 object, just a predicate on Version. Build one from a specifier set, or with a factory:

SpecifierSet(">=1.0,<2.0").to_range()   # the usual entry point
VersionRange.full()                     # every version
VersionRange.empty()                    # no version
VersionRange.singleton("1.5")           # the strict set {1.5}

Set algebra. Each returns a new range; operands are not mutated.

a.intersection(b)   # a & b
a.union(b)          # a | b
a.complement()      # ~a

Membership and filtering, mirroring SpecifierSet, including PEP 440 pre-release handling:

"1.5" in r
r.contains("1.5", prereleases=None, installed=None)
r.filter(["0.9", "1.5", "2.0"])

Plus is_empty, and the standard ==, hash, and repr.

The only addition to an existing type is SpecifierSet.to_range(). There is no Specifier.to_range(); build a SpecifierSet first if you need one.

Design choices

A new class, not new methods on SpecifierSet. SpecifierSet is not closed under union or complement. PEP 440 has no syntax for the strict singleton {V} (==V also matches V+local), nor for the inclusive upper bound that complementing >V produces. Adding set algebra to SpecifierSet would return values no specifier string can represent.

singleton is strict. singleton("1.5") is exactly {1.5} and does not match 1.5+local, unlike ==1.5. A range and a single Version are 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 SpecifierSet carries onto the range and through the algebra, so contains and filter match 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 contains and filter, so == covers the pre-release policy and === admission, not just the version set. Ranges are stored in one canonical interval form, so == and hash are 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.VersionRange is the PEP 440 implementation that satisfies it:

# nab-resolver: types.py
class RangeProtocol(Protocol[VersionType_contra]):
    @classmethod
    def empty(cls) -> Self: ...
    @classmethod
    def full(cls) -> Self: ...
    @classmethod
    def singleton(cls, version: VersionType_contra) -> Self: ...
    @property
    def is_empty(self) -> bool: ...
    def __contains__(self, version: VersionType_contra, /) -> bool: ...
    def __and__(self, other: object) -> Self: ...   # intersection
    def __or__(self, other: object) -> Self: ...    # union
    def __invert__(self) -> Self: ...               # complement

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, with full() as the identity:

# nab-python: resolve.py
previous = ranges.get(name, VersionRange.full())
ranges[name] = previous & req.specifier.to_range()

A term and its operations map straight onto the algebra:

# nab-resolver: types.py (Term)
def satisfies(self, assignment):
    if self._positive:                              # assignment subset of constraint
        return (assignment & ~self.constraint).is_empty
    return (assignment & self.constraint).is_empty  # assignment disjoint from constraint

def intersect(self, other):
    # not(A) and not(B) == not(A | B)
    # positive and not(negative) == positive & ~negative
    ...

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.

@notatallshaw notatallshaw changed the title Add public VersionRange class Add public PEP 440 compliance VersionRange class Jun 18, 2026
@notatallshaw
notatallshaw force-pushed the minimal-version-range branch from b8ba5d8 to 178b624 Compare June 18, 2026 01:07
@Jackenmen

Jackenmen commented Jun 18, 2026 •

Copy link
Copy Markdown
Contributor

Is it expected for to_specifier_set() to return None here?

SpecifierSet(">=3.8.dev0,<3.14", prereleases=True).to_range().to_specifier_set()

I thought a round-trip would always work, but I assume explicit prerelease=True makes that not the case?

In case you wonder about the use case, I'm working with version specifiers defined in markers (e.g. python_version / python_full_version), which always allow pre-releases (see c5f1db9). I assume to_specifier_set() wants to generate a specifier set that can be safely stringified and parsed without an explicit prereleases kwarg, but it would be nice if I could get it to just generate a SpecifierSet with prereleases explicitly set.

Edit: SpecifierSet(">=3.8,<3.14", prereleases=True).to_range().to_specifier_set() does work and returns back SpecifierSet(">=3.8,<3.14", prereleases=True) with prereleases set, so I guess it's not about the stringification bit.

@notatallshaw

Copy link
Copy Markdown
Member Author

Is it expected for to_specifier_set() to return None here?

Nope, it's expected we can always round trip specifier_set.to_range().to_specifier_set(), but it's a very tricky problem and there's lots of edge cases, I'll take a look now!

@Jackenmen

Copy link
Copy Markdown
Contributor

Since I actually found this while looking into another oddness, I guess I should mention it too.
I've seen a case where the output from to_specifier_set() is not wrong, but I think is not expressed in the simplest form (which I figure might be relevant since this PR helps solve #776 as well):

>>> 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 SpecifierSet("<3.14", prereleases=True). It's not a simplification that can be made without prereleases=True though.

@notatallshaw

Copy link
Copy Markdown
Member Author

I've seen a case where the output from to_specifier_set() is not wrong, but I think is not expressed in the simplest form (which I figure might be relevant since this PR helps solve #776 as well):

>>> 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 SpecifierSet("<3.14", prereleases=True). It's not a simplification that can be made without prereleases=True though.

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.

@notatallshaw

notatallshaw commented Jun 18, 2026 •

Copy link
Copy Markdown
Member Author

SpecifierSet(">=3.8.dev0,<3.14", prereleases=True).to_range().to_specifier_set()

This should be fixed now, but .to_specifier_set() now has so much machinery associated with it, I wonder if it would be easier to move it to a follow up PR?

I'll leave it up to likely reviewer @henryiii , if I remove .to_specifier_set() from this PR then ranges.py will likely be about half the size.

Edit: I changed my mind.

@notatallshaw

Copy link
Copy Markdown
Member Author

Yeah, I decided this PR is meant to be a minimal VersionRange API where we can review the API surface, not review complex functions, so I split .to_specifier_set() out into a separate PR: #1270. This makes range.py less than a thousand lines, and largely doc strings.

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.

@henryiii

Copy link
Copy Markdown
Contributor

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 AFTER_POSTS gaps are treated as non-empty
src/packaging/_ranges.py:444

intersect_ranges() relies on _range_is_empty(), but _range_is_empty() only compares boundary ordering. This leaves impossible synthetic gaps marked non-empty, e.g. SpecifierSet(">1.0a1,<1.0a2.dev0").to_range() reports is_empty == False even though no valid PEP 440 version can satisfy it.

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 BoundaryKind.AFTER_POSTS up to the next valid successor boundary, and add regression tests for unsatisfiable intersections plus union/complement canonicalization around these boundaries.

@notatallshaw

notatallshaw commented Jun 19, 2026 •

Copy link
Copy Markdown
Member Author

I ran a review with GPT 5.5 (below) while doing something else. I'll try to review it myself soon.

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()
True

This is correct, these specifiers produce identical results for both contains and filter.

@henryiii henryiii left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread CHANGELOG.rst Outdated
@notatallshaw

Copy link
Copy Markdown
Member Author

t 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 agree this is a better approach, fixed.

@henryiii henryiii changed the title Add public PEP 440 compliance VersionRange class feat: add public PEP 440 compliance VersionRange class Jun 20, 2026
@notatallshaw

Copy link
Copy Markdown
Member Author

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants