diff --git a/docs/source/contributing/conventions/process.rst b/docs/source/contributing/conventions/process.rst index c876b987fe..276307e2ed 100644 --- a/docs/source/contributing/conventions/process.rst +++ b/docs/source/contributing/conventions/process.rst @@ -25,10 +25,13 @@ On the tree, in :file:`pyproject.toml`: .. code-block:: toml cli = [ "emoji" ] - crypto = [ "cryptography>=3.4" ] + crypto = [ + "cryptography>=3.4; platform_python_implementation != 'PyPy' or python_version >= '3.11'", + "cryptography>=3.4,<46; platform_python_implementation == 'PyPy' and python_version < '3.11'", + ] NGAP = [ "pycrate" ] - all = [ "emoji", "cryptography>=3.4", "pycrate" ] + all = [ "emoji", "cryptography>=3.4; platform_python_implementation != 'PyPy' or python_version >= '3.11'", "cryptography>=3.4,<46; platform_python_implementation == 'PyPy' and python_version < '3.11'", "pycrate" ] So ``all`` is exactly the union of those extras, written out as literals rather than referenced. diff --git a/docs/source/contributing/workflows.rst b/docs/source/contributing/workflows.rst index bb5b201170..2d7f1e9442 100644 --- a/docs/source/contributing/workflows.rst +++ b/docs/source/contributing/workflows.rst @@ -792,7 +792,10 @@ so it adds no context to the ruleset. No ``Compat``, ``engine-tests`` or ``pypcap-parity`` leg runs PyPy. On PyPy 8.0, which setup-python's ``pypy3.11`` is, PyPI has no Linux wheel of |cryptography|_, so the install step builds it with the image's Rust and ``libssl-dev``, and none of ``lxml``, so the ``test`` -extra carries ``lxml`` on CPython only. +extra carries ``lxml`` on CPython only. On ``pypy3.10`` the ``crypto`` and ``all`` +extras cap |cryptography|_ below 46, as 45.0.7 is the newest release with a Linux +``pp310`` wheel and the newest release's sdist does not build there (`#2040 +`__). On PyPy an xdist worker's RSS grows with every test it runs, rather than in a few heavy modules, so a PyPy leg's ``Run unit tests`` hands its pytest command to diff --git a/pyproject.toml b/pyproject.toml index 519ccf858c..1942907b73 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -124,7 +124,18 @@ pcapkit-vendor = "pcapkit.vendor.__main__:main" # for CLI display cli = [ "emoji" ] # for ESP payload decryption, c.f. pcapkit.protocols.internet.esp -crypto = [ "cryptography>=3.4" ] +# +# Capped below 46 on PyPy older than 3.11 (#2040), and nowhere else, which the +# complementary marker on the first line keeps. 45.0.7 is the newest release +# with a manylinux ``pp310`` wheel: 46.0.0 through 46.0.3 ship ``pp310`` wheels +# for macOS x86_64 and Windows only, and 46.0.4 onwards none at all. Without the +# cap, pip on a Linux PyPy 3.10 builds the newest release's sdist, and that +# build stops at PyO3's minimum supported version, 3.11. ``all`` below repeats +# both lines. +crypto = [ + "cryptography>=3.4; platform_python_implementation != 'PyPy' or python_version >= '3.11'", + "cryptography>=3.4,<46; platform_python_implementation == 'PyPy' and python_version < '3.11'", +] # for NGAP decoding, c.f. pcapkit.protocols.application.ngap. Counted among # ``all``'s *core addons* by the owner's ruling on #910 -- everything needed for # the library itself to work at full functionality -- even though the @@ -260,7 +271,14 @@ vendor = [ "requests[socks]", "beautifulsoup4[html5lib]", "pycrate" ] # only as pre-releases, and ``all`` should not be how somebody ends up with a # beta they did not ask for. Install either explicitly: ``pip install # pypcapkit[PyPCAP]`` or ``pip install pypcapkit[PCAP_CT]``. -all = [ "emoji", "cryptography>=3.4", "pycrate" ] +# +# The two ``cryptography`` lines are the ``crypto`` extra's, cap included (#2040). +all = [ + "emoji", + "cryptography>=3.4; platform_python_implementation != 'PyPy' or python_version >= '3.11'", + "cryptography>=3.4,<46; platform_python_implementation == 'PyPy' and python_version < '3.11'", + "pycrate", +] # Not for an end user -- for CI, which has a different need entirely: pylint, # mypy and Sphinx's autodoc all resolve imports against whatever is installed, # so narrowing ``all`` above would otherwise have made every one of diff --git a/tests/project/test_pypy310_cryptography_cap_2040.py b/tests/project/test_pypy310_cryptography_cap_2040.py new file mode 100644 index 0000000000..e103dd3b9c --- /dev/null +++ b/tests/project/test_pypy310_cryptography_cap_2040.py @@ -0,0 +1,100 @@ +# -*- coding: utf-8 -*- +"""#2040: the ``crypto`` and ``all`` extras cap ``cryptography`` on PyPy below 3.11, and only there. + +45.0.7 is the newest ``cryptography`` with a manylinux ``pp310`` wheel. Without +the cap pip on the ``pypy3.10`` leg builds the newest release's sdist, which +fails, so the leg never runs a test. Each environment below gets exactly one live +``cryptography`` requirement from each extra, read from :file:`pyproject.toml` +and evaluated with :mod:`packaging.markers`: capped on PyPy 3.10, uncapped on +PyPy 3.11 and on CPython 3.10. + +""" + +from __future__ import annotations + +import pathlib +import unittest + +from packaging.requirements import Requirement +from packaging.version import Version + +try: + import tomllib +except ImportError: # pragma: no cover + tomllib = None # type: ignore[assignment] + +#: Test tier (#1958): reads pyproject.toml, which is the same on every interpreter. +__tier__ = 'LATEST' + +ROOT = pathlib.Path(__file__).resolve().parents[2] +PYPROJECT = ROOT / 'pyproject.toml' + +#: The newest release with a manylinux ``pp310`` wheel, and the first without one. +LAST_PP310 = Version('45.0.7') +FIRST_WITHOUT = Version('46.0.0') +#: The release ``pypy3.10`` resolved to before the cap, whose sdist fails there. +NEWEST = Version('50.0.2') + + +def env(implementation: 'str', python: 'str') -> 'dict[str, str]': + """A Linux x86_64 marker environment for ``implementation`` at ``python``.""" + return { + 'implementation_name': implementation.lower(), + 'platform_python_implementation': implementation, + 'python_version': python, + 'python_full_version': f'{python}.0', + 'implementation_version': f'{python}.0', + 'sys_platform': 'linux', 'platform_system': 'Linux', 'os_name': 'posix', + 'platform_machine': 'x86_64', 'platform_release': '', 'platform_version': '', + 'extra': '', + } + + +ENVS = { + 'PyPy 3.10': env('PyPy', '3.10'), + 'PyPy 3.11': env('PyPy', '3.11'), + 'CPython 3.10': env('CPython', '3.10'), +} + + +@unittest.skipIf(tomllib is None, 'tomllib, which reads pyproject.toml, needs Python 3.11+') +class TestCryptographyCap(unittest.TestCase): + """Which ``cryptography`` each extra asks for, per interpreter.""" + + extras = {} # type: dict[str, list[str]] + + @classmethod + def setUpClass(cls) -> 'None': + cls.extras = tomllib.loads(PYPROJECT.read_text(encoding='utf-8'))['project']['optional-dependencies'] + + def live(self, extra: 'str', name: 'str') -> 'Requirement': + """The one ``cryptography`` requirement of ``extra`` live on ``ENVS[name]``.""" + found = [requirement for requirement in map(Requirement, self.extras[extra]) + if requirement.name == 'cryptography' + and (requirement.marker is None or requirement.marker.evaluate(ENVS[name]))] + self.assertEqual(len(found), 1, + f'{extra} has {len(found)} live cryptography requirements on {name}, ' + f'not one: {[str(requirement) for requirement in found]}') + return found[0] + + def test_pypy310_is_capped_at_the_last_release_with_a_pp310_wheel(self) -> 'None': + for extra in ('crypto', 'all'): + with self.subTest(extra=extra): + specifier = self.live(extra, 'PyPy 3.10').specifier + self.assertIn(LAST_PP310, specifier) + self.assertIn(Version('3.4'), specifier) + self.assertNotIn(FIRST_WITHOUT, specifier) + self.assertNotIn(NEWEST, specifier) + + def test_every_other_interpreter_stays_uncapped(self) -> 'None': + for extra in ('crypto', 'all'): + for name in ('PyPy 3.11', 'CPython 3.10'): + with self.subTest(extra=extra, env=name): + specifier = self.live(extra, name).specifier + self.assertIn(NEWEST, specifier) + self.assertIn(Version('3.4'), specifier) + self.assertNotIn(Version('3.3'), specifier) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_tier_guard.py b/tests/test_tier_guard.py index b64150f731..e1ddd022c4 100644 --- a/tests/test_tier_guard.py +++ b/tests/test_tier_guard.py @@ -1428,8 +1428,11 @@ def test_the_extras_read_back_what_pyproject_declares(self) -> None: """Spot checks on the extras the rest of this suite reasons about.""" declared = _dependency_gates.declared_requirements() - self.assertEqual([requirement.text for requirement in declared['crypto']], - ['cryptography>=3.4']) + # Two lines since #2040: the second caps PyPy below 3.11. + self.assertEqual( + [requirement.text for requirement in declared['crypto']], + ["cryptography>=3.4; platform_python_implementation != 'PyPy' or python_version >= '3.11'", + "cryptography>=3.4,<46; platform_python_implementation == 'PyPy' and python_version < '3.11'"]) self.assertTrue(_dependency_gates.provided_by(declared['NGAP'], 'pycrate')) self.assertTrue(_dependency_gates.provided_by(declared['cli'], 'emoji')) # The `test` extra carries requests and bs4 (#507) but not html5lib.