Skip to content

Commit 536a8a7

Browse files
committed
feat: add public VersionRange API
1 parent f3819fb commit 536a8a7

11 files changed

Lines changed: 4233 additions & 478 deletions

docs/index.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ The ``packaging`` library uses calendar-based versioning (``YY.N``).
2525

2626
version
2727
specifiers
28+
ranges
2829
markers
2930
licenses
3031
requirements

docs/ranges.rst

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
Ranges
2+
======
3+
4+
.. versionadded:: 26.3
5+
6+
A :class:`~packaging.ranges.VersionRange` represents the set of
7+
:class:`~packaging.version.Version` values matched by a
8+
:class:`~packaging.specifiers.Specifier` or
9+
:class:`~packaging.specifiers.SpecifierSet`. Unlike a
10+
:class:`~packaging.specifiers.SpecifierSet`, ranges are closed under
11+
intersection, union, and complement, so questions like "do these two
12+
constraints overlap?" or "is this constraint a subset of that one?"
13+
reduce to direct set operations.
14+
15+
Constructing a range
16+
--------------------
17+
18+
Build a range from a :class:`Specifier` or :class:`SpecifierSet`
19+
using :meth:`~Specifier.to_range`:
20+
21+
.. doctest::
22+
23+
>>> from packaging.ranges import VersionRange
24+
>>> from packaging.specifiers import Specifier, SpecifierSet
25+
>>> r = SpecifierSet(">=1.0,<2.0").to_range()
26+
>>> "1.5" in r
27+
True
28+
>>> "2.0" in r
29+
False
30+
31+
The classmethods :meth:`VersionRange.from_specifier` and
32+
:meth:`VersionRange.from_specifier_set` produce the same results and
33+
are useful when only a :class:`VersionRange` reference is in scope.
34+
35+
Three factories return common identity ranges:
36+
37+
.. doctest::
38+
39+
>>> VersionRange.empty().is_empty
40+
True
41+
>>> "1.5" in VersionRange.full()
42+
True
43+
>>> "1.0" in VersionRange.singleton("1.0")
44+
True
45+
46+
Calling ``VersionRange()`` directly raises :exc:`TypeError`; use one
47+
of the factories above.
48+
49+
Set algebra
50+
-----------
51+
52+
:class:`VersionRange` supports intersection, union, and complement
53+
via the :meth:`~VersionRange.intersection`,
54+
:meth:`~VersionRange.union`, and :meth:`~VersionRange.complement`
55+
methods, or the ``&``, ``|``, and ``~`` operator aliases. Every
56+
operation returns a new range; operands are not mutated.
57+
58+
.. doctest::
59+
60+
>>> ge1 = SpecifierSet(">=1.0").to_range()
61+
>>> lt2 = SpecifierSet("<2.0").to_range()
62+
>>> "1.5" in (ge1 & lt2)
63+
True
64+
>>> "2.5" in (ge1 | lt2)
65+
True
66+
>>> # Double-complement is the original range.
67+
>>> ~~ge1 == ge1
68+
True
69+
>>> # A range and its complement are always disjoint.
70+
>>> bool(ge1 & ~ge1)
71+
False
72+
73+
Set operations answer overlap and subset questions directly:
74+
75+
.. doctest::
76+
77+
>>> a = SpecifierSet(">=1.0,<2.0").to_range()
78+
>>> b = SpecifierSet(">=1.5,<3.0").to_range()
79+
>>> # Do these constraints overlap?
80+
>>> bool(a & b)
81+
True
82+
>>> # Is *a* entirely contained in *b*?
83+
>>> (a & b) == a
84+
False
85+
>>> narrow = SpecifierSet(">=1.0,<1.5").to_range()
86+
>>> wide = SpecifierSet(">=1.0,<2.0").to_range()
87+
>>> (narrow & wide) == narrow
88+
True
89+
90+
Membership and filtering
91+
------------------------
92+
93+
``in`` and :meth:`~VersionRange.filter` mirror :class:`SpecifierSet`'s
94+
:meth:`~SpecifierSet.__contains__` and :meth:`~SpecifierSet.filter`,
95+
including the PEP 440 pre-release behaviour: with
96+
``prereleases=None`` (the default), pre-releases are buffered and
97+
emitted only when the iterable contains no in-range final release.
98+
99+
.. doctest::
100+
101+
>>> from packaging.version import Version
102+
>>> r = SpecifierSet(">=1.0,<2.0").to_range()
103+
>>> "1.5" in r
104+
True
105+
>>> Version("1.5") in r
106+
True
107+
>>> list(r.filter(["0.9", "1.5", "2.0"]))
108+
['1.5']
109+
110+
Converting back to a SpecifierSet
111+
---------------------------------
112+
113+
:meth:`~VersionRange.to_specifier_set` returns a single
114+
:class:`SpecifierSet` whose :meth:`~SpecifierSet.to_range` yields the
115+
same range, or ``None`` if no such single set exists. Redundant
116+
specifiers are dropped, which makes the round-trip a useful
117+
normalisation step:
118+
119+
.. doctest::
120+
121+
>>> r = SpecifierSet(">=1.0,<2.0,!=1.5").to_range()
122+
>>> str(r.to_specifier_set())
123+
'!=1.5,<2.0,>=1.0'
124+
>>> # ``>2`` is subsumed by ``>=3``; ``!=1.0`` is outside ``>=3``.
125+
>>> str(SpecifierSet("!=1.0,>2,>=3").to_range().to_specifier_set())
126+
'>=3'
127+
128+
PEP 440 specifier sets are not closed under union, so the disjoint
129+
union of two intervals returns ``None``;
130+
:meth:`~VersionRange.to_specifier_sets` returns one
131+
:class:`SpecifierSet` per interval:
132+
133+
.. doctest::
134+
135+
>>> r = (
136+
... SpecifierSet(">=1.0,<2.0").to_range()
137+
... | SpecifierSet(">=3.0,<4.0").to_range()
138+
... )
139+
>>> r.to_specifier_set() is None
140+
True
141+
>>> [str(s) for s in r.to_specifier_sets()]
142+
['<2.0,>=1.0', '<4.0,>=3.0']
143+
144+
The empty range round-trips through ``SpecifierSet("<0")`` (``<0``
145+
excludes the smallest possible PEP 440 version, ``0.dev0``):
146+
147+
.. doctest::
148+
149+
>>> VersionRange.empty().to_specifier_set() == SpecifierSet("<0")
150+
True
151+
152+
Reference
153+
---------
154+
155+
.. autoclass:: packaging.ranges.VersionRange
156+
:members:
157+
:special-members: __contains__, __bool__, __eq__, __hash__, __repr__

0 commit comments

Comments
 (0)