Skip to content

feat(ranges): clip pre-release opt-in to bounds to prevent pre-release leaks#1311

Open
notatallshaw wants to merge 3 commits into
pypa:mainfrom
notatallshaw:ranges-prerelease-clip
Open

feat(ranges): clip pre-release opt-in to bounds to prevent pre-release leaks#1311
notatallshaw wants to merge 3 commits into
pypa:mainfrom
notatallshaw:ranges-prerelease-clip

Conversation

@notatallshaw

Copy link
Copy Markdown
Member

Depends on #1306; its difference-policy guard is the first commit here until #1306 merges, then rebase it away.

On main the pre-release opt-in is a single whole-range flag: once any operand names a pre-release, a union force-admits pre-releases across the whole range, including versions no operand asked for. This reworks the opt-in into a per-specifier region clipped to the bounds, so it only ever covers the versions a specifier actually requested.

>>> from packaging.specifiers import SpecifierSet
>>> 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']
# on main: ['1.5b1', '2.0b1', '2.5'], leaking 1.5b1 (only >=2.0b1 opted in)

The leak that closed #1304 is the same overflow past a cap: >=2.0b1,<3 opts pre-releases in only below 3, but that opt-in rode the union onto a higher range.

>>> c = SpecifierSet(">=3.5,<4").to_range()    # names no pre-release
>>> d = SpecifierSet(">=2.0b1,<3").to_range()  # opts in only below 3
>>> list((c | d).filter(["3.6b1", "3.7"]))
['3.7']
# on main and the #1304 attempt: ['3.6b1', '3.7'], leaking 3.6b1 (no operand opted it in)

Combining ranges still opts in the versions each operand named, because & and | treat the opt-in the way PEP 440 treats a comma-joined specifier set. Exclusion is different: removing or complementing a pre-release-naming range never carries its opt-in into the result. On main the a & ~b spelling leaks it; here it agrees with a - b:

>>> req, excl = SpecifierSet(">=1.0").to_range(), SpecifierSet(">=2.0b1").to_range()
>>> list((req - excl).filter(["1.9", "2.0a1"]))
['1.9']
>>> list((req & ~excl).filter(["1.9", "2.0a1"]))
['1.9']
# on main this second line gives ['1.9', '2.0a1'], leaking the excluded 2.0a1

The cost is that ~~ no longer round-trips. Because an exclusion drops the opt-in, complementing twice keeps the versions but not the opt-in, and there is no way to both stop the leak and keep ~~r == r:

>>> r = SpecifierSet(">=2.0b1").to_range()
>>> list(r.filter(["2.0b1", "2.5"]))
['2.0b1', '2.5']
>>> list((~~r).filter(["2.0b1", "2.5"]))    # same versions, opt-in gone
['2.5']
# on main ~~r still admits 2.0b1

Ranges built with conflicting explicit prereleases= policies still cannot be combined, and difference now raises on a mismatch too (per #1306), so a - b and a & ~b always agree.

A few textbook identities like ~~r == r no longer hold once an opt-in is involved; the new docs "Limits of the model" section lists them with examples, and tests pin them so the leak cannot quietly come back. SpecifierSet output is unchanged.

@notatallshaw notatallshaw force-pushed the ranges-prerelease-clip branch from 144dc35 to 8be7bde Compare July 2, 2026 15:41
@notatallshaw notatallshaw changed the title feat(ranges): clip pre-release opt-in to bounds to prevent leaks feat(ranges): clip pre-release opt-in to bounds to pre-release prevent leaks Jul 2, 2026
@notatallshaw notatallshaw changed the title feat(ranges): clip pre-release opt-in to bounds to pre-release prevent leaks feat(ranges): clip pre-release opt-in to bounds to prevent pre-release leaks Jul 2, 2026
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