🌐 US-Proxy
class="logged-out env-production page-responsive" style="word-wrap: break-word;" >
Skip to content

Apply the whole Requires-Python specifier set in Wheel.support_py - #3369

Merged
gaborbernat merged 3 commits into
pypa:mainfrom
pasmud:gsz/support-py-requires-python-specifiers
Oct 1, 2026
Merged

gaborbernat merged 3 commits into
pypa:mainfrom
pasmud:gsz/support-py-requires-python-specifiers

Conversation

@pasmud

@pasmud pasmud commented Oct 1, 2026

Copy link
Copy Markdown
Contributor

Thanks for contributing, make sure you address all the checklists (for details on how see development documentation)

  • ran the linter to address style issues (tox -e fix)
  • wrote descriptive pull request text
  • ensured there are test(s) validating the fix
  • added news fragment in docs/changelog folder
  • updated/extended the documentation
  • for changes to activation scripts, pyvenv.cfg, wheel downloads, app-data or release workflows: explained in the
    PR text which outside input the change handles, such as a path, a prompt or a downloaded file, and how it stops that
    input from running as code. See the
    security scope.

The bug

Wheel.support_py() in src/virtualenv/seed/wheels/util.py parsed the Requires-Python value out of a wheel's
METADATA by hand. It recognised six operators, !=, ==, <=, >=, < and >, and it compared the target
version as a tuple truncated to two components.

Two things go wrong as a result:

>>> Wheel(...'~=3.9').support_py("4.0")
True            # ~= matched no operator, so the requirement was skipped entirely
>>> Wheel(...'>=3.9.1').support_py("3.9")
True            # ">=3.9.1" compared as (3, 9), so 3.9.0 passed

discover_wheels() calls support_py() to decide which wheel in a folder suits the interpreter being created, so a
wheel that does not support that Python can be offered. A Requires-Python value that is not a plain comparison also
raised, because the parser tried to int() text such as 9; python_version<'4'.

The code dates back to the periodic update feature (#1841, 2020) and had almost no coverage: two tests, one asserting
a wheel with no requirement is compatible and one asserting a bundled wheel rejects 3.3.

The change

support_py() now hands the value to packaging.specifiers.SpecifierSet, which is the same move
as_version_tuple() makes with packaging.version.Version in #3361. The whole set is applied, so every clause has to
hold and every component of the version is compared. packaging is already a runtime dependency, added in #3361, so
this adds no new dependency.

The change is 18 lines out and 1 line in.

Tests

Four new tests, all failing on unmodified main (a6bdda0) and passing with the fix.

$ tox -e py -- tests/unit/seed/wheels/test_wheels_util.py -p no:randomly

Before, with src/virtualenv/seed/wheels/util.py reverted to main:

E       AssertionError: assert True is False
E        +  where True = support_py('4.0')
E        +    where support_py = Wheel(.../pip-9.1-py3-none-any.whl).support_py

E       AssertionError: assert True is False
E        +  where True = support_py('3.10')
E        +    where support_py = Wheel(.../pip-9.2-py3-none-any.whl).support_py

E       AssertionError: assert True is False
E        +  where True = support_py('3.9')
E        +    where support_py = Wheel(.../pip-9.3-py3-none-any.whl).support_py

E       AssertionError: assert True is False
E        +  where True = support_py('3.10')
E        +    where support_py = Wheel(.../pip-9.4-py3-none-any.whl).support_py

FAILED tests/unit/seed/wheels/test_wheels_util.py::test_support_py_honours_compatible_release
FAILED tests/unit/seed/wheels/test_wheels_util.py::test_support_py_compatible_release_keeps_patch_prefix
FAILED tests/unit/seed/wheels/test_wheels_util.py::test_support_py_compares_all_version_components
FAILED tests/unit/seed/wheels/test_wheels_util.py::test_support_py_every_requirement_must_hold
========================= 4 failed, 9 passed in 0.52s =========================

After:

============================= 13 passed in 0.41s =============================

I also compared support_py() against packaging.specifiers.SpecifierSet directly over a matrix of real-world
requirements (~=3.9, ~=3.10.1, >=3.9.1, ==3.9.*, !=3.10.*,>=3.9, >=2.7, !=3.0.*, !=3.1.*, <=3.9) against
Python 2.7, 3.8, 3.9, 3.10 and 3.14. Before the change 8 of those comparisons disagreed with packaging; after it,
0 do.

Wider selections, all passing:

tox -e py -- tests/unit/seed/ -p no:randomly                        152 passed
tox -e py -- -k "seed or bundle or periodic or acquire or discover"  175 passed, 356 deselected

Linter and type checks:

tox -e fix        -> OK, all 16 hooks passed
tox -e type       -> All checks passed!
tox -e type-3.9   -> All checks passed!

Seeding still works end to end. All 14 bundled wheels (pip and setuptools for 3.9 through 3.16) are still accepted for
their target version, and a freshly created environment reports
pip 26.2.1 from .../lib/python3.14/site-packages/pip (python 3.14).

Full suite

tox -e py on this branch: 10 failed, 485 passed, 43 skipped. The same run on unmodified main: 10 failed,
481 passed, 43 skipped. The difference is the four new tests, nothing else. All 10 failures reproduce on unmodified
main and are artifacts of this sandbox rather than of this change:

  • tests/unit/test_util.py (2): the safe_delete tests rely on directory permissions denying an unlink, which does
    not happen for uid 0. This container runs as root.
  • tests/unit/activation/test_bash.py::test_bash (8): bash renders the \$ prompt escape as # for uid 0 and as $
    for any other user, so the expected prompt text does not match. Confirmed by running the generated activate script
    as root and as uid 65534: (x#y) versus (x$y).

fish, csh and tcsh are installed here, so those activator tests ran. powershell and nu are not installed, so
the PowerShell and Nushell activator tests skip. CI runs them, so a green local run does not guarantee a green CI run
for those files.

Security scope

The outside input here is the Requires-Python field of a wheel's METADATA, read out of the zip. That is data, and
it is passed to packaging for evaluation rather than interpreted by virtualenv. Nothing on this path is executed,
and no new input is read. Previously an unusual value could raise out of the parser; now packaging rejects it
instead, which is a more predictable failure.

pasmud and others added 3 commits October 1, 2026 15:35
Wheel.support_py() parsed the Requires-Python value by hand. It knew only
!=, ==, <=, >=, < and >, so a ~= compatible release matched nothing and was
silently ignored, and it compared only the first two components of a version,
so >=3.9.1 was read as >=3.9. discover_wheels() calls this to decide which
seed wheel suits the target interpreter, so a wheel that does not support it
could be offered.

Hand the value to packaging.specifiers.SpecifierSet instead, the same way
as_version_tuple() now uses packaging.version. The whole set is applied, so
every clause has to hold and every component is compared.

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
SpecifierSet raises on a value such as ">=3.6.*", which the old parser
skipped, so one malformed wheel in app-data or an extra search dir
aborted environment creation. Skip such a wheel with a warning, as pip
does. Read Requires-Python with HeaderParser, so a lowercase or folded
header counts and a line in the description body does not. Callers pass
major.minor, so say so instead of claiming every component is compared.

Fold the support_py tests into tables built from a wheel fixture instead
of a mocked ZipFile, cover discover_wheels, and rename the changelog
fragment to this PR's number with the contributor credit. Order util.py
so callers precede what they call, mark its constants Final, and replace
the suppressed magic number and the impossible None guard in from_path.
@gaborbernat
gaborbernat force-pushed the gsz/support-py-requires-python-specifiers branch from 1523df3 to 87e2c1a Compare October 1, 2026 22:11
@gaborbernat
gaborbernat merged commit 40d94e3 into pypa:main Oct 1, 2026
76 checks passed
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.

2 participants