Skip to content

fix(spec): let ~=X.Y match later minor versions - #159

Open
darrenhuai wants to merge 2 commits into
tox-dev:mainfrom
darrenhuai:fix/compatible-release-upper-bound
Open

darrenhuai wants to merge 2 commits into
tox-dev:mainfrom
darrenhuai:fix/compatible-release-upper-bound

Conversation

@darrenhuai

@darrenhuai darrenhuai commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

With 3.9, 3.13, 3.14 and 3.15 installed, a two-part compatible release finds nothing on main:

>>> from python_discovery import SimpleSpecifier, get_interpreter
>>> SimpleSpecifier.from_string("~=3.10").contains("3.13.7")
False
>>> get_interpreter("~=3.10", cache=None)
>>> get_interpreter(">=3.10,<4", cache=None).version_str
'3.14.3'

PEP 440 defines ~=3.10 as >=3.10, ==3.*, and the docs list ~= as supported and link that spec, so the first lookup should find 3.14.3 like the explicit range does. virtualenv -p "~=3.10" goes through the same call and fails the same way.

SimpleVersion.from_string pads every release to three parts, so ~=3.10 is stored as (3, 10, 0). _check_compatible_release built its upper bound by dropping the last part of that padded tuple and bumping the one before it, which gives 3.11. That's only right for a three-part spec like ~=3.12.0, the only form the tests checked against the upper bound. PythonInfo.satisfies (used by get_interpreter and iter_interpreters), the uv store filter and PythonSpec.satisfies with a full X.Y.Z spec all go through this method, so they skipped 3.11 and newer for ~=3.10, and 3.13 and newer for ~=3.12.

The fix compares all but the last release part the spec actually wrote. It takes that count from version_str.count("."), as #48 did in _satisfies_version_specifier, and checks the prefix the way _check_wildcard already does for ==X.*. Checking the prefix rather than building a bound also stops ~=3.12.0 from accepting 3.13.0a1, which used to slip in because that pre-release sorts below 3.13.0. The dead len(release) >= 2 branch and its two pragmas go away.

This does change what some specs pick. On this machine get_interpreter("~=3.13") returned 3.13.7 on main and now returns 3.14.3. Anyone who used ~=3.12 to mean 3.12 only now gets later 3.x too; PEP 440 spells that ~=3.12.0 or ==3.12.*.

A bare ~=3 isn't valid PEP 440, but it was accepted before, and SimpleSpecifierSet silently drops entries that fail to parse, so rejecting it would turn ~=3 into a set that matches everything. I kept it lenient and it now means ==3.*, which is what PythonInfo.satisfies already did with it. Before this change SimpleSpecifier.contains said 3.13.7 was outside ~=3 while satisfies said a 3.13.7 interpreter matched.

The new test_specifier_contains rows check ~=3.10 against 3.9.25, 3.13.7, 4.0.0 and 4.0.0a1, ~=3.13rc1 against 3.14.0, and ~=3.12.0 against 3.13.0a1, and the ~=3 test, test_satisfies_version_specifier_prerelease and the uv probe-order test get matching cases. Seven of them fail on main. The 4.0.0 rows pin the ==3.* ceiling and the 3.9.25 row keeps the lower bound covered. Taking the part count from the padded release, dropping the max(..., 1) floor, going back to a computed upper bound, or removing the candidate < self.version check each breaks at least one of them.

Windows and Linux (under coverage) show the same failures as main, _specifier.py is fully covered, and ruff and ty (3.9 and 3.15) report nothing new.

SimpleVersion pads every release to three parts, and the compatible
release check built its upper bound by dropping the last part of that
padded tuple. That is only right when the specifier itself has three
parts. For ~=3.10 it gave >=3.10,<3.11 instead of the >=3.10,==3.* that
PEP 440 defines, so get_interpreter("~=3.10") returned None on a machine
whose Pythons are all 3.11 or newer, and the uv store filter and
PythonSpec.satisfies with a full X.Y.Z spec skipped the same
interpreters. virtualenv -p "~=3.10" fails the same way.

The check now compares every release part the specifier wrote except
the last, the same prefix match the wildcard check uses. Comparing the
prefix also stops ~=3.12.0 from accepting 3.13.0a1, which sorted below
the old 3.13 bound. A bare ~=3 is not valid PEP 440, but it was
accepted before, so it keeps working and now means ==3.* in both
SimpleSpecifier.contains and PythonInfo.satisfies, which used to
disagree on it.

Anyone who used ~=3.12 to mean 3.12 only now gets later 3.x releases
too; PEP 440 spells that ~=3.12.0 or ==3.12.*.

This branch has not been deployed

No deployments
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