Version Ranges

A VersionRange is the set of Version values accepted by a SpecifierSet, viewed as intervals on the PEP 440 ordering. It supports intersection, union, complement, and difference, so tooling that combines many requirements, such as a resolver, can work on the intervals directly.

Added in version 26.3.

Usage

>>> from packaging.ranges import VersionRange
>>> from packaging.specifiers import SpecifierSet
>>> from packaging.version import Version
>>> # Build a range from a specifier set
>>> r = SpecifierSet(">=1.0,<2.0").to_range()
>>> r
<VersionRange '[1.0, 2.0.dev0)'>
>>> Version("1.5") in r
True
>>> Version("2.0") in r
False
>>> # Combine ranges with set algebra
>>> a = SpecifierSet(">=1.0").to_range()
>>> b = SpecifierSet("<2.0").to_range()
>>> a & b == r
True
>>> # The union covers either side, leaving any gap between them
>>> u = SpecifierSet("<1.0").to_range() | SpecifierSet(">=2.0").to_range()
>>> Version("0.5") in u
True
>>> Version("1.5") in u
False
>>> # The complement is every other version
>>> Version("0.5") in ~r
True
>>> # Filter an iterable of versions
>>> list(r.filter(["0.9", "1.5", "2.0"]))
['1.5']
>>> # An unsatisfiable set produces the empty range
>>> SpecifierSet(">=2.0,<1.0").to_range().is_empty
True
>>> # Convert back to a SpecifierSet when possible
>>> str(r.to_specifier_set())
'<2.0,>=1.0'

Pre-releases

A specifier that names a pre-release, such as >=2.0b1, opts in pre-releases only for the versions it asks for. That opt-in region is carried as ranges are combined, so a union with a plain range keeps the pre-releases it named without admitting every pre-release below them:

>>> a = SpecifierSet(">=1.0").to_range()
>>> b = SpecifierSet(">=2.0b1").to_range()
>>> list((a | b).filter(["1.5b1", "2.0b1", "2.5"]))
['2.0b1', '2.5']

2.0b1 is admitted because >=2.0b1 asked for it; 1.5b1 is not, since the opt-in never came from >=1.0.

The opt-in is also clipped to the range’s own bounds, so combining ranges never force-admits a pre-release outside every operand’s request. >=2.0b1,<3 opts pre-releases in only below 3, so unioning it with an unrelated higher range does not admit a pre-release up in that range:

>>> capped = SpecifierSet(">=2.0b1,<3").to_range()
>>> other = SpecifierSet(">=3.5,<4").to_range()
>>> list((capped | other).filter(["3.6b1", "3.7"]))
['3.7']

Neither operand admits 3.6b1: >=3.5,<4 names no pre-release and >=2.0b1,<3 opts in only below 3.

Set difference

a - b is set difference: the versions in a but not b. It agrees with a & ~b on the version set and the opt-in region, so subtracting a pre-release-naming range does not leak its pre-releases into the result:

>>> base = SpecifierSet(">=1.0").to_range()
>>> excluded = SpecifierSet(">=2.0b1").to_range()
>>> list((base - excluded).filter(["1.9", "2.0a1"]))
['1.9']
>>> (base - excluded) == (base & ~excluded)
True

Comparing ranges

Equality on a VersionRange is structural: it compares the bounds, the === admit/reject literals, the arbitrary-string flag, the configured pre-release policy, and the opt-in region, not only the version set. Equal ranges therefore behave identically under VersionRange.contains() and VersionRange.filter(). The guarantee is one-directional: ==1.0 and >=1.0,<1.0.post0.dev0 compare unequal (the second carries an opt-in region), yet no pre-release exists in that window, so they filter identically.

For set relations use VersionRange.is_subset(), VersionRange.is_superset(), and VersionRange.is_disjoint() rather than comparing intersections by hand. Intersection can change the opt-in region without changing the version set, so the textbook subset test a & b == a can report a false negative. Each method compares the version sets directly, so it is not affected by that opt-in difference:

>>> from packaging.specifiers import SpecifierSet
>>> a = SpecifierSet(">=1.0").to_range()
>>> b = SpecifierSet(">=1.0a1").to_range()
>>> # Every version >=1.0 is also >=1.0a1, so a is a subset of b. But b
>>> # opts pre-releases in, so ``a & b`` and ``a`` differ in the opt-in region:
>>> a & b == a
False
>>> a.is_subset(b)
True
>>> b.is_superset(a)
True
>>> a.is_disjoint(b)
False
>>> # The opt-in difference is observable: a & b force-admits pre-releases
>>> # in b's region that plain a filters out
>>> list(a.filter(["2.0a1", "2.5"]))
['2.5']
>>> list((a & b).filter(["2.0a1", "2.5"]))
['2.0a1', '2.5']

Like VersionRange.intersection(), VersionRange.union(), and VersionRange.difference(), these predicates require both operands to share the same configured pre-release policy and raise ValueError otherwise.

Because the operations refuse to mix policies, a range’s version set is well-defined only under its own configured policy, and set relations stay sound only while that policy is held fixed. Reinterpreting a range, or a SpecifierSet recovered from one, under a different prereleases value is therefore unsound: under prereleases=False the ranges of <=1.0,!=1.0 and <1.0 accept the same releases (they differ only in 1.0’s pre-releases, which the policy excludes), but that equivalence is gone once the policy changes.

Different specifiers that denote the same range, opt-in region included, canonicalize to one form, so they compare equal. >1.0a1 excludes 1.0a1’s post-releases per PEP 440, so its smallest member is 1.0a2.dev0, exactly the set of >=1.0a2.dev0:

>>> r1 = SpecifierSet(">1.0a1").to_range()
>>> r2 = SpecifierSet(">=1.0a2.dev0").to_range()
>>> r1 == r2
True
>>> third = SpecifierSet("<2.0").to_range()
>>> (r1 & third) == (r2 & third)
True

The opt-in region is also part of equality. <1.0.post0.dev0 and <=1.0 cover the same versions, but the first autodetects an opt-in region from its .dev bound, so it admits pre-releases by default, while the second does not; they are not substitutable and compare unequal:

>>> SpecifierSet("<1.0.post0.dev0").to_range() == SpecifierSet("<=1.0").to_range()
False

Recovering a specifier set

VersionRange.to_specifier_set() converts a range back to a SpecifierSet. Specifier syntax cannot express every range, so it returns the simplest set whose own to_range() reproduces the range, or None when no single set does:

>>> str(SpecifierSet(">=1.0,<2.0").to_range().to_specifier_set())
'<2.0,>=1.0'
>>> # A disjoint union usually has no single-set spelling
>>> a = SpecifierSet("<1").to_range()
>>> b = SpecifierSet(">=2").to_range()
>>> (a | b).to_specifier_set() is None
True
>>> # unless the gap between the pieces is expressible as exclusions
>>> u = SpecifierSet("==1.*").to_range() | SpecifierSet("==3.*").to_range()
>>> str(u.to_specifier_set())
'!=0.*,!=2.*,<4'
>>> # The strict singleton has no spelling: ==1.5 would also match 1.5+local
>>> VersionRange.singleton("1.5").to_specifier_set() is None
True
>>> # Neither does the full range built by algebra: it opts no pre-release
>>> # in, while its only spelling, >=0.dev0, opts them all in
>>> r = SpecifierSet(">=2").to_range()
>>> (r | ~r).to_specifier_set() is None
True
>>> # full() itself admits arbitrary strings, which the empty set spells
>>> str(VersionRange.full().to_specifier_set())
''

The recovery also caps how many != exclusions it will write out, returning None past the cap; without it, some ranges would convert to pathologically large sets that are slow to build and to use:

>>> far = SpecifierSet("==1.*").to_range() | SpecifierSet("==1000000.*").to_range()
>>> far.to_specifier_set() is None
True

Limits of the model

The pre-release opt-in is not itself a set. PEP 440 opts a whole specifier set in when any one specifier names a pre-release, so & is requirement conjunction (a comma-merge of the two specifier sets), not plain intersection, on the opt-in. No model can keep that parity with SpecifierSet, exclusion soundness (an exclusion grants no opt-in), and an involutive complement at the same time. This model keeps parity and soundness, so complement drops the opt-in and a few Boolean identities do not hold once an operand carries one.

A double complement therefore erases the opt-in rather than restoring it. ~~r keeps r’s versions but not its eager pre-releases:

>>> b = SpecifierSet(">=2.0b1").to_range()
>>> list(b.filter(["2.0b1", "2.5"]))
['2.0b1', '2.5']
>>> list((~~b).filter(["2.0b1", "2.5"]))
['2.5']
>>> ~~b == b
False

The erasure is still well behaved: ~~~r == ~r, both De Morgan laws hold, and r & ~r is empty.

>>> ~~~b == ~b
True
>>> (b & ~b).is_empty
True

Because complement drops the opt-in while union keeps it, b | full() carries b’s opt-in, which the full range (with no eager pre-release) does not, so it does not collapse to VersionRange.full(). For the same reason union no longer distributes over intersection, and the absorption laws do not hold, when an operand carries an opt-in:

>>> a = SpecifierSet(">=1.0").to_range()
>>> b | VersionRange.full() == VersionRange.full()
False
>>> a & (a | b) == a
False

Intersection still distributes over union, a - b agrees with a & ~b on the version set and the opt-in region, and every law holds on the version set as usual. The opt-in region makes the identities above differ only when a specifier named a pre-release.

The arbitrary-string flag has corners of its own. SpecifierSet("") matches even strings that are not PEP 440 versions, so its range, VersionRange.full(), admits them too; pass admit_arbitrary=False for the versions-only full range. Only those ranges and === literals admit such strings: combining ranges never grants an admission the operands did not have, so ~r stays version-only for any version-only r. The flag rides along where that is harmless, keeping ~~full() == full() and union idempotent, but an intersection or difference that shrinks the bounds does not remember it:

>>> f = VersionRange.full()
>>> "wat" in f
True
>>> "wat" in VersionRange.full(admit_arbitrary=False)
False
>>> ~~f == f
True
>>> (~f | ~f) == ~f
True
>>> (f & ~f) == VersionRange.empty()
True
>>> (f & ~f) == ~f
False
>>> d = f - SpecifierSet(">=1.0").to_range()
>>> "wat" in (d | SpecifierSet(">=0.5").to_range())
False
>>> d == f & ~SpecifierSet(">=1.0").to_range()
True

Reference