Skip to content

Add a public PEP 440 compliant VersionRange API#1182

Draft
notatallshaw wants to merge 4 commits into
pypa:mainfrom
notatallshaw:public-pep440-version-range
Draft

Add a public PEP 440 compliant VersionRange API#1182
notatallshaw wants to merge 4 commits into
pypa:mainfrom
notatallshaw:public-pep440-version-range

Conversation

@notatallshaw
Copy link
Copy Markdown
Member

@notatallshaw notatallshaw commented May 2, 2026

On top of #1120, promote the internal range helpers to a public VersionRange class in packaging.ranges. And push all the PEP 440 handling into VersionRange, so Specifier and SpecifierSet become light wrappers around it.

A VersionRange represents the set of Version values matched by a Specifier or SpecifierSet as a union of disjoint intervals on the PEP 440 version ordering, and is closed under intersection, union, and complement.

Set operations on a VersionRange answer overlap, subset, and complement questions directly, without parsing strings or iterating versions.

A resolver implementation can now defer all its PEP 440 logic to packaging. I've focused on PubGrub as a real use case, but the same applies to other algorithms.

Why a new class instead of extending SpecifierSet

SpecifierSet is not closed under union or complement:

  • PEP 440 has no syntax for the strict singleton {V} (==V also matches V+local).
  • PEP 440 has no syntax for the inclusive upper bound produced by complementing >V.

So intersection / union / complement on SpecifierSet would produce values that no SpecifierSet string can represent. Adding those methods to SpecifierSet would require a partial API (returning None for the unsupported cases) and would break the existing contract that a SpecifierSet round-trips through its string form.

A separate VersionRange keeps SpecifierSet's contract intact, and is itself closed under intersection, union, and complement. Conversion back to SpecifierSet via to_specifier_set() / to_specifier_sets() returns None when the range cannot be expressed.

New methods on existing types

  • Specifier.to_range() -> VersionRange
  • SpecifierSet.to_range() -> VersionRange

Convenience wrappers around the VersionRange.from_* classmethod factories. Results are cached on the specifier instance.

I was a bit unsure on the name or if these are needed, we could pick one of:

  • spec.range()
  • spec.to_range()
  • spec.version_range()
  • spec.to_version_range()
  • VersionRange.from_specifier_set(spec)

VersionRange constructors:

VersionRange has no direct constructor: it isn't a PEP 440 object, just a more general predicate on Version. Construct one with a factory, or build a SpecifierSet first and call .to_range() on it.

  • VersionRange.from_specifier(specifier): range for a Specifier.
  • VersionRange.from_specifier_set(specifier_set): range for a SpecifierSet (the intersection of every specifier in the set).
  • VersionRange.empty(): the empty range
  • VersionRange.full(): the unbounded range
  • VersionRange.singleton(version): the strict singleton {version} (does not match version+local)

Singleton is a borrowed term from PubGrub/uv, I'm not tied to it, but whatever is chosen it should not be confused with an actual Version as a Version is not a range.

Calling VersionRange() directly raises TypeError.

Set algebra

  • intersection(other) / &
  • union(other) / |
  • complement() / ~

All return a new range; operands are not mutated.

Membership and filtering

  • version in range: membership; mirrors SpecifierSet.__contains__.
  • filter(iterable, key=None, prereleases=None): yield items whose version falls in the range; mirrors SpecifierSet.filter, including PEP 440 pre-release buffering.

Conversion back to SpecifierSet

  • to_specifier_set() -> SpecifierSet | None: a single SpecifierSet whose to_range() yields the same range, or None if no such set exists. Redundant specifiers are dropped during the round-trip.
  • to_specifier_sets() -> tuple[SpecifierSet, ...] | None: a tuple whose union equals the range, or None if no such tuple exists. Looser than to_specifier_set(); useful for disjoint unions of intervals that no single SpecifierSet can express.

Properties

  • is_empty: True when no version satisfies the range.
  • is_prerelease_only: True when every match is a pre-release. Used by SpecifierSet.is_unsatisfiable.

Standard protocol

__contains__, __bool__, __eq__, __hash__, __repr__, plus pickle support via __reduce__.

Tests and docs

I've added full test coverage plus significant property-based testing. In particular I've added property-based testing of PubGrub invariants to ensure that this object can be used in PubGrub-like algorithms.

@notatallshaw notatallshaw force-pushed the public-pep440-version-range branch from fee7bb1 to 536a8a7 Compare May 2, 2026 20:25
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.

1 participant