From 676ad2304316f6cbc2bfb32ecece3a73f31a2703 Mon Sep 17 00:00:00 2001 From: Jarry Shaw Date: Thu, 22 Oct 2026 16:45:40 -0400 Subject: [PATCH] build!: require Python 3.8+ and drop the BPC conversion (#2051) The owner ruled on #2040 that 1.5.0 is a full turn to Python 3.8+; the bpc-* backport tools are no longer maintained. - pyproject.toml: requires-python ">=3.8"; drop the bpc-* and pathlib2 requirements and the markers constant from 3.8 on (scapy's >= '3.7'). - setup.py: drop refactor() and the build commands that only ran it. - pcapkit: drop the version gates 3.8 can no longer take, in compat.py and the py37/py38 flags elsewhere; the compat names stay exported. - vermin.ini targets 3.8. - CI: drop the 3.7 KTLO legs, and empty coverage-comment.yml's EXPECTED, as the pypy3.10 legs install since #2040's cryptography cap. - Docs and comments that stated 3.6/3.7 support or the conversion, and the 1.5.0 changelog entries, CHANGELOG.md regenerated. - Tests: retarget the 3.7 pins; pin the floor, no bpc-* requirement and no constant marker; the updater does not re-add a dropped version. Affected test files pass, run one by one. Closes #2040 --- .github/python-versions.toml | 1 - .github/workflows/coverage-comment.yml | 22 +-- .github/workflows/lint.yml | 10 +- .github/workflows/python-compatibility.yml | 11 +- .github/workflows/unit-tests.yml | 12 +- CHANGELOG.md | 2 + CONTRIBUTING.md | 2 +- Makefile | 4 +- Pipfile | 4 +- README.md | 5 +- SECURITY.md | 8 +- conda/pypcapkit/meta.yaml | 13 -- docs/source/changelog/1.5.0.rst | 12 ++ .../conventions/documentation.rst | 4 +- .../conventions/sentinel-convention.rst | 15 +- docs/source/contributing/pep.rst | 4 +- docs/source/contributing/workflows.rst | 34 ++-- docs/source/index.rst | 35 ++-- docs/source/pcapkit/utilities/index.rst | 26 +-- pcapkit/corekit/infoclass.py | 6 +- pcapkit/foundation/engines/pcap_ct.py | 2 +- pcapkit/protocols/link/arp.py | 10 +- pcapkit/protocols/link/ethernet.py | 11 +- pcapkit/protocols/misc/pcap/frame.py | 5 +- pcapkit/protocols/misc/pcapng.py | 19 +-- pcapkit/protocols/protocol.py | 6 +- pcapkit/toolkit/pypcapfile.py | 9 +- pcapkit/utilities/compat.py | 114 +------------ pyproject.toml | 66 ++------ setup.py | 151 +----------------- tests/corekit/test_sentinel_exports_unit.py | 26 ++- tests/project/test_build_leftovers_1773.py | 144 +---------------- .../test_docs_internal_refs_1940_unit.py | 3 +- .../project/test_ktlo_legs_stay_green_1672.py | 14 +- tests/project/test_pyshark_encap_map.py | 6 +- .../project/test_python_matrix_tiers_1633.py | 137 ++++++++-------- .../test_python_versions_update_1660.py | 9 ++ .../test_requires_python_floor_2051.py | 84 ++++++++++ tests/project/test_setup.py | 84 ++-------- .../test_stdlib_feature_probes_1709_unit.py | 7 +- .../test_unit_tests_push_reports_2030.py | 72 +++++---- tests/protocols/link/test_link_unit.py | 9 +- tests/protocols/misc/test_pcapng_unit.py | 7 +- tests/protocols/test_protocol_make_unit.py | 4 +- tests/test_support_helpers.py | 2 +- tests/test_tier_guard.py | 6 +- tests/utilities/_harness.py | 2 +- tests/utilities/test_compat.py | 119 +++++--------- util/apt_packages.py | 2 +- util/pip_install.py | 2 +- util/update_python_versions.py | 2 +- vermin.ini | 2 +- 52 files changed, 432 insertions(+), 934 deletions(-) create mode 100644 tests/project/test_requires_python_floor_2051.py diff --git a/.github/python-versions.toml b/.github/python-versions.toml index 162b9eca7f..a16537a562 100644 --- a/.github/python-versions.toml +++ b/.github/python-versions.toml @@ -13,7 +13,6 @@ latest = "3.15" [versions.cpython] -"3.7" = { tier = "ktlo", runner = "ubuntu-22.04" } "3.8" = { tier = "ktlo", runner = "ubuntu-24.04" } "3.9" = { tier = "ktlo", runner = "ubuntu-24.04" } "3.10" = { tier = "ktlo" } diff --git a/.github/workflows/coverage-comment.yml b/.github/workflows/coverage-comment.yml index 992a60fb1b..77275accf8 100644 --- a/.github/workflows/coverage-comment.yml +++ b/.github/workflows/coverage-comment.yml @@ -210,25 +210,9 @@ jobs: # the step that failed. Matched on the title, not the message, which # #2041 rewords. Anything else in these jobs, a failed tests step # among it, still makes a green run `warning`. - EXPECTED = { - # #1633, #1672: no pytest>=8 for 3.7, so its KTLO legs stop at install. - 'Python 3.7 (KTLO)': ( - ('WARNING', 'install failed on Python 3.7 (KTLO, non-blocking)'), - ('FAILURE', ''), - ('WARNING', 'pcapkit did not install on Python 3.7 (KTLO, non-blocking)'), - ), - 'Integration Python 3.7 (KTLO)': ( - ('WARNING', 'install failed on Python 3.7 (KTLO, non-blocking)'), - ('FAILURE', ''), - ('WARNING', 'pcapkit did not install on Python 3.7 (KTLO, non-blocking)'), - ), - # #1633, #1672: pypy3.10's KTLO leg stops at install too, until #2040. - 'Python pypy3.10 (KTLO)': ( - ('WARNING', 'install failed on Python pypy3.10 (KTLO, non-blocking)'), - ('FAILURE', ''), - ('WARNING', 'pcapkit did not install on Python pypy3.10 (KTLO, non-blocking)'), - ), - } + # None today: the 3.7 legs that stopped at install went with #2051, and + # pypy3.10's installs since #2040's cryptography cap. + EXPECTED = {} def expected(job, note): """Whether ``note``, an annotation of ``job``, is one :data:`EXPECTED` lists.""" diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index dc875f110e..ee62270ef5 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -181,10 +181,12 @@ jobs: # `vermin-ci`, not `vermin`: that target redirects the report into temp/, # out of this log. Same flags, via $(VERMIN_FLAGS). # - # Blocking needs a decision, not a cleanup, and not CI's: vermin.ini's - # `targets = 3.6` and `requires-python`'s `>=3.6, <4` both sit below the - # code's real floor, 3.11 (`enum.StrEnum`, `enum.show_flag_values`). - # Raising either belongs in its own change. + # Blocking needs a decision, not a cleanup, and not CI's. vermin.ini's + # `targets = 3.8` matches `requires-python` (#2051), but vermin still + # reports 3.11: `--no-parse-comments` ignores the `# novermin` markers on + # compat.py's version-gated imports (`enum.StrEnum`, + # `enum.show_flag_values`), and evaluated annotations put some + # `pcapkit.foundation` modules at 3.9. - name: vermin continue-on-error: true run: | diff --git a/.github/workflows/python-compatibility.yml b/.github/workflows/python-compatibility.yml index c3e4529856..6428926fb6 100644 --- a/.github/workflows/python-compatibility.yml +++ b/.github/workflows/python-compatibility.yml @@ -49,9 +49,8 @@ jobs: # 23497679's required contexts. A KTLO leg is suffixed ` (KTLO)`, so no # required context can match it, and its steps carry `continue-on-error`, # as in unit-tests.yml's `test` job: job-level, a failed leg's check run - # concluded `failure`, so every commit showed red (#1672). 3.7 fails "Verify - # package" by design. No 3.6: setup-python builds it only for retired - # Ubuntu images. + # concluded `failure`, so every commit showed red (#1672). Nothing below + # `requires-python`'s 3.8 runs (#2051). name: Compat Python ${{ matrix.python-version }}${{ matrix.ktlo && ' (KTLO)' || '' }} needs: [python-versions] runs-on: ${{ matrix.os || 'ubuntu-latest' }} @@ -87,9 +86,9 @@ jobs: LEG: Python ${{ matrix.python-version }}${{ matrix.ktlo && ' (KTLO, non-blocking)' || '' }} run: python util/pip_install.py -e . - # One annotation saying why, so a failed leg -- such as 3.7's, where the - # install succeeds and the sources do not compile -- is legible from the - # run summary; a warning on a KTLO leg (#1672). Each title escapes `,` as + # One annotation saying why, so a failed leg -- an install that succeeds + # with sources that do not compile -- is legible from the run summary; a + # warning on a KTLO leg (#1672). Each title escapes `,` as # `%2C`: the runner splits a command's properties on `,`, so the KTLO # title would stop at "(KTLO" (actions/runner # src/Runner.Common/ActionCommand.cs:100, at 5f0b3ddcc). diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 06adb8e464..fb2933f81b 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -123,8 +123,8 @@ jobs: # PyPCAPFile is not here either: #751's `engine-tests` and # `pypcap-parity` jobs cover its gated methods. # - # #1633: a KTLO leg that cannot install -- such as 3.7 -- fails fast and - # once: util/pip_install.py writes one annotation naming pip's last ERROR + # #1633: a KTLO leg that cannot install fails fast and once: + # util/pip_install.py writes one annotation naming pip's last ERROR # line, and every later step needs `steps.install.outcome == 'success'`, # so it is skipped rather than failing against a missing package. The # install needs its own interpreter: if setup-python failed, the image's @@ -309,11 +309,9 @@ jobs: # rest, so the Unit Tests run its `workflow_run` consumers read stays green. # # A version `ubuntu-latest` cannot provide carries its `runner` from the - # file as `os`: 3.8 and 3.9 need ubuntu-24.04, and 3.7 ubuntu-22.04, the - # newest image setup-python builds them for. 3.7 cannot install -- the `test` - # extra's `pytest>=8` needs 3.8 -- so the leg stops at its install step. - # ubuntu-22.04 is deprecated (actions/runner-images#14254); a brownout fails - # the leg before any step runs, which only the job-level guard contains. + # file as `os`: 3.8 and 3.9 need ubuntu-24.04, the newest image + # setup-python builds them for. A brownout of a deprecated image fails the + # leg before any step runs, which only the job-level guard contains. test-ktlo: name: Python ${{ matrix.python-version }} (KTLO) if: ${{ inputs.gate-only != true && needs.changes.outputs.code != 'false' }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 141ae0626f..99bbd6b552 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -355,6 +355,7 @@ This resolves [#548](https://github.com/JarryShaw/PyPCAPKit/issues/548), which r - `pcapkit` no longer configures logging at import. It installs a `NullHandler` and sets no level, so verbosity is inherited from the application instead of being seized by whichever library was imported second; the old stderr handler stays as the `PCAPKIT_DEVMODE` opt-in. Consequences: the previous behaviour is `configure(logging.INFO, stream=sys.stderr)`; the registry and extractor `info` calls became `debug`, so those messages are invisible even at `INFO`; and the handler is no longer `logger.handlers[0]`. `verbose=` output stays on stdout and is not logging ([#384](https://github.com/JarryShaw/PyPCAPKit/pull/384)). - each warning is reported once per channel, and `pcapkit` no longer inserts a `simplefilter('ignore', ...)` at the front of the process-global `warnings.filters` ([#362](https://github.com/JarryShaw/PyPCAPKit/issues/362)--[#364](https://github.com/JarryShaw/PyPCAPKit/issues/364), [#390](https://github.com/JarryShaw/PyPCAPKit/pull/390)). The application's own filter therefore wins, which is the point and also the sharp edge: under `-W error`, or pytest's `filterwarnings = error`, a pcapkit warning that used to be suppressed will raise. Suppress them deliberately with `warnings.filterwarnings('ignore', category=BaseWarning)`. `quiet=True` now means no record at any level and no longer sets `sys.tracebacklimit`, and the `pcapkit.utilities.warnings.DEVMODE` re-export is gone; its home is `pcapkit.utilities.logging`. +- `pcapkit.utilities.compat` exports `pathlib`, `ModuleNotFoundError`, `collections.abc.Collection` and `functools.cached_property()` as the standard library has them: their fallbacks for interpreters below 3.8 are removed with that floor ([#2051](https://github.com/JarryShaw/PyPCAPKit/issues/2051), [#2053](https://github.com/JarryShaw/PyPCAPKit/pull/2053)). #### Fixed @@ -397,6 +398,7 @@ This resolves [#548](https://github.com/JarryShaw/PyPCAPKit/issues/548), which r #### Changed +- **a breaking change to** `requires-python`: it is `>=3.8`, so pip refuses an older interpreter at install, as "requires a different Python". The sources use 3.8 syntax, and the `bpc-*` backport tools that converted it for 3.6 and 3.7 are no longer maintained, so the conversion goes: the `bpc-*` requirements, `refactor()` and the `setup.py` build commands that ran it. The 3.7 KTLO (*keep the lights on*) legs go with it, and `coverage-comment.yml` lists no install failure as by design any more, which closes [#2040](https://github.com/JarryShaw/PyPCAPKit/issues/2040) ([#2051](https://github.com/JarryShaw/PyPCAPKit/issues/2051), [#2053](https://github.com/JarryShaw/PyPCAPKit/pull/2053)). - renames with no compatibility alias left behind: `examples/sample` and `examples/samples` -- one letter apart, holding different things -- are now `examples/captures` and `examples/generators`. - the README is a landing page now, and Markdown rather than reStructuredText. `README.rst` became a much shorter `README.md`, keeping what a reader arriving from PyPI or a search result needs (what the library is, why it exists rather than Scapy or DPKT, how to install it, a worked example, where the documentation lives). **Module Structure**, **Engine Comparison**, **Engine support by Python version**, **Test Environment**, **Test Results** and **Installation Notes** were already duplicated in fuller form in `docs/source/index.rst`, so they are linked rather than restated; the blocks that existed nowhere else moved: **Testing** is now `docs/source/contributing/testing.rst`, and the `pipenv` and `make setup` block joined the Installation section of `docs/source/index.rst`. Requested by the project owner, and a deliberate exception to the reStructuredText convention, for the README only, since its renderers are GitHub and PyPI rather than Sphinx. `setup.py` reads `README.md` and declares `text/markdown`, and `MANIFEST.in` gains `include README.md`, which is belt-and-braces rather than load-bearing: setuptools' `sdist` ships a `README.md` regardless. Of the `include` lines only `CHANGELOG.md` is load-bearing, measured in the [#631](https://github.com/JarryShaw/PyPCAPKit/pull/631) entry below. `examples/benchmark/Dockerfile` copies `README.md`, while the `README.md` that `examples/benchmark/benchmark.py` names is the benchmark suite's own ([#619](https://github.com/JarryShaw/PyPCAPKit/pull/619)). - `CODE_OF_CONDUCT.md` moves from Contributor Covenant 1.4 to 3.0, at the owner's request. The text is the canonical 3.0 Markdown fetched from https://www.contributor-covenant.org/version/3/0/code_of_conduct/code_of_conduct.md rather than transcribed. Some things needed deciding. 3.0's `[NOTE` placeholders are filled or removed: the reporting channel names `jarryshaw@icloud.com` (the contact 1.4 carried and `SECURITY.md` uses as email fallback) plus GitHub's report-abuse form for a report about the maintainer, which a single-maintainer project cannot otherwise cover; the enforcement placeholder is an instruction to the adopter and is removed. 3.0's plural "Community Moderators" become the singular maintainer, this repository having no moderators. The enforcement ladder (Warning, Temporarily Limited Activities, Temporary Suspension, Permanent Ban) is a suggestion and is **kept**, each rung mapping to a lever one person holds on GitHub. 3.0 is CC BY-SA 4.0 where 1.4 carried no licence notice, so the attribution names version 3.0, links the permanent `version/3/0/` URL, carries the CC BY-SA 4.0 notice, indicates changes were made as BY requires, and says the share-alike term covers this document only (the code remains BSD-3-Clause, `LICENSE` untouched). The ladder renders as nested list items, checked against GitHub's Markdown API; [#613](https://github.com/JarryShaw/PyPCAPKit/pull/613) had to repair the 1.4 file when a stray list marker collapsed it into one nested item ([#624](https://github.com/JarryShaw/PyPCAPKit/pull/624)). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b27e6d2024..4b4358cecd 100755 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -168,7 +168,7 @@ rewrites the regenerated constants, not as a check, so nothing verifies import o request. One trap: `make vermin` redirects its report into `temp/vermin.txt` rather than to your terminal, -and vermin exits 1 because `vermin.ini` sets `targets = 3.6` against the 3.11 minimum it detects. +and vermin exits 1 because `vermin.ini` sets `targets = 3.8` against the 3.11 minimum it detects. Make gives up at the redirect with `make: *** [vermin] Error 1`, the report left in the file and the line that would have opened it never reached. `make vermin-ci` runs the same flags to stdout, which is why CI uses it and why it is easier to read at a desk. Running the lot before you push still saves a diff --git a/Makefile b/Makefile index 1d794abe37..8be2c038a5 100644 --- a/Makefile +++ b/Makefile @@ -179,8 +179,8 @@ vermin: # Make at the redirect, with the report left unread in the file and the line # that would have opened it never reached -- useless as a log. # This one writes to stdout and lets vermin's failure propagate, which is what -# `targets = 3.6` in vermin.ini is for: vermin exits non-zero when the target is -# not met, and the code's real floor is 3.11. +# `targets = 3.8` in vermin.ini is for: vermin exits non-zero when the target is +# not met, and it detects 3.11. vermin-ci: $(RUN) vermin pcapkit $(VERMIN_FLAGS) pcapkit diff --git a/Pipfile b/Pipfile index 23c6ad1787..0db42c4fef 100644 --- a/Pipfile +++ b/Pipfile @@ -14,8 +14,8 @@ tbtrim = "~=0.3.1" pypcapkit = {editable = true,path = "."} pyshark = "*" dpkt = "*" -# The floor and marker match the Scapy extra in pyproject.toml (#1572). -scapy = {version = ">=2.6.0",markers = "python_version >= '3.7'"} +# The floor matches the Scapy extra in pyproject.toml (#1572). +scapy = ">=2.6.0" cryptography = "*" # NB: ``pypcap`` is deliberately absent. It builds a C extension against # libpcap, so listing it here would break ``pipenv install --dev`` -- and even diff --git a/README.md b/README.md index e57fb812d5..1ce1c3e2c9 100644 --- a/README.md +++ b/README.md @@ -13,9 +13,8 @@ Unlike popular PCAP extractors such as [Scapy](https://scapy.net), Where that depth is not needed, the same interface also drives third-party extraction engines. -The whole project declares **Python 3.6** or later. CI supports 3.11 to 3.15, and -still tests the end-of-life 3.7 to 3.10 without letting a failure block anything; -3.7 cannot import the package yet. +The whole project requires **Python 3.8** or later. CI supports 3.11 to 3.15, and +still tests the end-of-life 3.8 to 3.10 without letting a failure block anything. ## Installation diff --git a/SECURITY.md b/SECURITY.md index 9868b1d024..998f68e78f 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -13,11 +13,9 @@ lines are not patched -- upgrading is the fix. | 0.x | :x: | no longer patched | Supported interpreters are the test matrix's blocking tier: CPython 3.11 -through 3.15. `pyproject.toml` declares `requires-python = ">=3.6, <4"` through the -`bpc-*` source conversion, but 3.7 to 3.10 are best-effort (tested, never blocking) -and below 3.8 is intent rather than something that works -- see the note above -`requires-python` in `pyproject.toml`. A report that only reproduces outside the tested range is still -welcome; it may be fixed by raising the floor rather than by patching. +through 3.15. `pyproject.toml` declares `requires-python = ">=3.8"`, but 3.8 to 3.10 are +best-effort (tested, never blocking). A report that only reproduces outside the tested range is +still welcome; it may be fixed by raising the floor rather than by patching. ## Reporting a Vulnerability diff --git a/conda/pypcapkit/meta.yaml b/conda/pypcapkit/meta.yaml index 8bdf8f441f..81befad125 100644 --- a/conda/pypcapkit/meta.yaml +++ b/conda/pypcapkit/meta.yaml @@ -26,18 +26,12 @@ requirements: - pip - setuptools>=62.3.0 - # # version compatibility - # - bpc-f2format - # - bpc-poseur - # - bpc-walrus - # - dictdumper~=0.8.0 # for formatted output # - chardet # for bytes decode # - aenum # for const types # - tbtrim>=0.2.1 # for refined exceptions # # version compatibility - # - pathlib2>=2.3.2 # - typing-extensions # - emoji @@ -51,18 +45,12 @@ requirements: - pip - setuptools>=62.3.0 - # # version compatibility - # - bpc-f2format - # - bpc-poseur - # - bpc-walrus - # - dictdumper~=0.8.0 # for formatted output # - chardet # for bytes decode # - aenum # for const types # - tbtrim>=0.2.1 # for refined exceptions # # version compatibility - # - pathlib2>=2.3.2 # - typing-extensions # - emoji @@ -80,7 +68,6 @@ requirements: # - tbtrim>=0.2.1 # for refined exceptions # # version compatibility - # - pathlib2>=2.3.2 # - typing-extensions # - emoji diff --git a/docs/source/changelog/1.5.0.rst b/docs/source/changelog/1.5.0.rst index 5dbf472157..fe7cd486a8 100644 --- a/docs/source/changelog/1.5.0.rst +++ b/docs/source/changelog/1.5.0.rst @@ -2741,6 +2741,10 @@ Changed means no record at any level and no longer sets ``sys.tracebacklimit``, and the ``pcapkit.utilities.warnings.DEVMODE`` re-export is gone; its home is :mod:`pcapkit.utilities.logging`. +* :mod:`pcapkit.utilities.compat` exports :mod:`pathlib`, :exc:`ModuleNotFoundError`, + :class:`collections.abc.Collection` and :func:`functools.cached_property` as the + standard library has them: their fallbacks for interpreters below 3.8 are removed + with that floor (:issue:`2051`, :pr:`2053`). Fixed ~~~~~ @@ -2951,6 +2955,14 @@ Added Changed ~~~~~~~ +* **a breaking change to** ``requires-python``: it is ``>=3.8``, so pip refuses an + older interpreter at install, as "requires a different Python". The sources use 3.8 + syntax, and the ``bpc-*`` backport tools that converted it for 3.6 and 3.7 are no + longer maintained, so the conversion goes: the ``bpc-*`` requirements, + ``refactor()`` and the ``setup.py`` build commands that ran it. The 3.7 KTLO (*keep + the lights on*) legs go with it, and ``coverage-comment.yml`` lists no install + failure as by design any more, which closes :issue:`2040` (:issue:`2051`, + :pr:`2053`). * renames with no compatibility alias left behind: ``examples/sample`` and ``examples/samples`` -- one letter apart, holding different things -- are now ``examples/captures`` and ``examples/generators``. diff --git a/docs/source/contributing/conventions/documentation.rst b/docs/source/contributing/conventions/documentation.rst index c906695c17..2bf7277f99 100644 --- a/docs/source/contributing/conventions/documentation.rst +++ b/docs/source/contributing/conventions/documentation.rst @@ -320,8 +320,6 @@ Format and Mechanics :ref:`sentinel-convention`'s table names it rather than any of the modules that use them, which do not re-export them (:issue:`1744`). * **A verbatim upstream port is exempt from these conventions.** - :ref:`sentinel-convention` already carries the case that settled it, the - :class:`~pcapkit.utilities.compat.cached_property` backport in - :file:`pcapkit/utilities/compat.py`: the value of a + :ref:`sentinel-convention` carries the reason: the value of a vendored copy is that it can still be diffed against upstream, and a house-style rewrite destroys that in exchange for nothing a reader ever sees. diff --git a/docs/source/contributing/conventions/sentinel-convention.rst b/docs/source/contributing/conventions/sentinel-convention.rst index a1cf4f1291..d5b90509a3 100644 --- a/docs/source/contributing/conventions/sentinel-convention.rst +++ b/docs/source/contributing/conventions/sentinel-convention.rst @@ -102,14 +102,13 @@ appears in a signature, in :func:`help` output and in a traceback. Compare what identity ``__eq__`` and is exactly as safe as ``object()``. That argument appeared in an early draft of :mod:`pcapkit.corekit.enum` and was wrong. -**Ported code is exempt.** ``_NOT_FOUND = object()`` at -:file:`pcapkit/utilities/compat.py`, line 73, sits inside the -:class:`~pcapkit.utilities.compat.cached_property` -backport taken for interpreters below 3.8, which tracks CPython's own -:mod:`functools` implementation down to that name. It is **not** to be converted: the -value of a vendored backport is that it can still be diffed against upstream, and a -house-style rewrite destroys that in exchange for a sentinel nobody outside the backport -ever sees. The rule above is for sentinels this package writes itself. +**Ported code is exempt.** A verbatim upstream port keeps its upstream's sentinels +as written: the value of a vendored copy is that it can still be diffed against +upstream, and a house-style rewrite destroys that in exchange for a sentinel nobody +outside the port ever sees. The rule above is for sentinels this package writes +itself. The case that settled it, ``_NOT_FOUND = object()`` in the +:class:`~pcapkit.utilities.compat.cached_property` backport for interpreters below +3.8, went with that backport when 1.5.0 raised the floor to 3.8 (:issue:`2051`). Per-Sentinel Dunder Methods ~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/docs/source/contributing/pep.rst b/docs/source/contributing/pep.rst index d71434a5cb..0f7faf6427 100644 --- a/docs/source/contributing/pep.rst +++ b/docs/source/contributing/pep.rst @@ -587,8 +587,8 @@ Test Cases **Largely done.** There is a systematic test suite under ``tests/``, and it runs in CI against Python 3.11 through 3.15, per -``.github/workflows/unit-tests.yml``, and against the end-of-life 3.7 through 3.10 -on legs whose failures block nothing; 3.7's stop at the install. +``.github/workflows/unit-tests.yml``, and against the end-of-life 3.8 through 3.10 +on legs whose failures block nothing. The module count is measurable rather than worth writing down: .. code-block:: shell diff --git a/docs/source/contributing/workflows.rst b/docs/source/contributing/workflows.rst index 7b7b0fad94..f68e718e66 100644 --- a/docs/source/contributing/workflows.rst +++ b/docs/source/contributing/workflows.rst @@ -694,19 +694,19 @@ grepping every workflow file for its name: - ``unit-tests.yml`` * - ``Compat Python 3.11`` - job ``compatibility``, matrix leg ``3.11`` - - ``python-compatibility.yml``, ``.github/python-versions.toml:20`` + - ``python-compatibility.yml``, ``.github/python-versions.toml:19`` * - ``Compat Python 3.12`` - job ``compatibility``, matrix leg ``3.12`` - - ``python-compatibility.yml``, ``.github/python-versions.toml:21`` + - ``python-compatibility.yml``, ``.github/python-versions.toml:20`` * - ``Compat Python 3.13`` - job ``compatibility``, matrix leg ``3.13`` - - ``python-compatibility.yml``, ``.github/python-versions.toml:22`` + - ``python-compatibility.yml``, ``.github/python-versions.toml:21`` * - ``Compat Python 3.14`` - job ``compatibility``, matrix leg ``3.14`` - - ``python-compatibility.yml``, ``.github/python-versions.toml:23`` + - ``python-compatibility.yml``, ``.github/python-versions.toml:22`` * - ``Compat Python 3.15`` - job ``compatibility``, matrix leg ``3.15`` - - ``python-compatibility.yml``, ``.github/python-versions.toml:24`` + - ``python-compatibility.yml``, ``.github/python-versions.toml:23`` That is the list after `#1633 `__, which added ``Compat Python 3.15`` and removed ``Compat Python 3.10``. Moving a version @@ -716,8 +716,8 @@ between the tiers below means editing the ruleset in the same change. ``required-checks`` job in ``unit-tests.yml``. ``Compat Python`` is defined by one, the ``name:`` of the ``compatibility`` job in ``python-compatibility.yml``, whose ``matrix`` comes from :file:`.github/python-versions.toml`: the required legs -are its supported tier (``python-versions.toml:20-24``), and its KTLO legs -(``python-versions.toml:16-19``) have names ending in ``(KTLO)``, so no required +are its supported tier (``python-versions.toml:19-23``), and its KTLO legs +(``python-versions.toml:16-18``) have names ending in ``(KTLO)``, so no required context matches one. The comment above ``required-checks`` mentions the string without defining it. @@ -746,7 +746,7 @@ Python versions are tiered -- supported and KTLO (`#1660 `__, below) -- in :file:`.github/python-versions.toml` (see `Python Versions`_). The **supported** tier, 3.11-3.15, blocks: a failed leg fails ``Required checks passed`` or its own -required ``Compat`` context. The **KTLO** (*keep the lights on*) tier -- end-of-life 3.7-3.10, still built and +required ``Compat`` context. The **KTLO** (*keep the lights on*) tier -- end-of-life 3.8-3.10, still built and tested -- never blocks, is never a required context, and never shows a commit or a pull request red (`#1672 `__). In ``unit-tests.yml`` the KTLO legs are jobs of their own since `#1738 `__: ``test-ktlo``, @@ -774,7 +774,7 @@ with supported legs. Every job holding KTLO legs works the same way: report the same way. * Never job-level ``continue-on-error`` alone: it passes the *run*, but the leg's check run still concludes ``failure``, which showed every commit red while - ``Compat Python 3.7 (KTLO)`` carried it. ``unit-tests.yml``'s KTLO jobs + the ``Compat Python 3.7 (KTLO)`` leg carried it. ``unit-tests.yml``'s KTLO jobs carry it on top of the step-level guards, as a backstop for the failures no step contains, as ``experimental.yml``'s job does; it "prevents a workflow run from failing when a job fails" (`Workflow syntax @@ -814,16 +814,10 @@ the kills into ``MemoryError``\ s. Each run but the last writes a JUnit file of own beside ``junit.xml``, which ``Upload test timings`` takes too, and the step fails if any run did. -3.7 can neither install the ``test`` extra nor import the package, so each 3.7 leg -fails one step, with a warning saying why -- ``test`` and ``integration`` at the -install step, the ``Compat`` leg at ``Verify package`` -- and concludes -``success``. They run on ``ubuntu-22.04``, which retires on 2027-04-17 after -brownouts from 2027-03-23 (actions/runner-images#14254). A brownout fails a -``test-ktlo`` or ``integration-ktlo`` leg before any step runs, where no step-level -``continue-on-error`` reaches; since `#1738 `__ that fails nothing gating, but the leg's -check run reads red, so they must still go before then (`#1660 `__). 3.6 is -in no matrix: setup-python builds it only for retired Ubuntu images. Nor are -3.7-3.9 in ``engine-tests``, which installs every interpreter on +Nothing below ``requires-python``'s 3.8 is in any matrix (`#2051 +`__): pip refuses such an +interpreter at install, so a leg there could only prove the refusal. Nor are +3.8 and 3.9 in ``engine-tests``, which installs every interpreter on ``ubuntu-latest``, where setup-python has no Ubuntu 26.04 build of any of them. Nor are they in ``pypcap-parity``, which also runs on ``ubuntu-latest``; none of them was measured on that job. @@ -1239,7 +1233,7 @@ file-size cap every test command of ``unit-tests.yml`` takes. :file:`util/regenerate_samples.py` is standard library only but runs on the leg's interpreter, since the generator it runs imports ``pcapkit``, and so does :file:`util/pip_install.py`, since it installs into that interpreter; it runs on -every version the matrices hold, 3.7 and the experimental tier included. :file:`util/apt_packages.py` runs on +every version the matrices hold, the experimental tier included. :file:`util/apt_packages.py` runs on ``/usr/bin/python3``, as the apt shell needed no interpreter of the leg's, and runs under ``sudo`` the commands that shell ran. Its ``plan`` and ``install`` both read the job's ``APT_PACKAGES``, so the cache key always describes the set installed. diff --git a/docs/source/index.rst b/docs/source/index.rst index bd848a7a23..f606bf6860 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -11,9 +11,8 @@ analysis. .. important:: - The whole project declares **Python 3.6** or later. CI supports 3.11 to 3.15, and - still tests the end-of-life 3.7 to 3.10 without letting a failure block anything; - 3.7 cannot import the package. + The whole project requires **Python 3.8** or later. CI supports 3.11 to 3.15, and + still tests the end-of-life 3.8 to 3.10 without letting a failure block anything. .. .. contents:: .. :depth: 2 @@ -245,32 +244,16 @@ Installation .. note:: - The sources themselves use 3.8 syntax; the ``bpc-walrus``/``bpc-poseur`` - backport tools in :file:`setup.py` convert it at install time, which is what - makes the **3.6** lower bound possible. Measured: 3.9 and 3.8 import and - extract straight from source with no conversion needed, and 3.7 needs the - conversion. - -.. warning:: - - **The conversion is blocked by an upstream bug**, so below 3.8 the declaration - is intent rather than something that works. ``bpc-poseur`` 0.4.3.post1 crashes - on positional-only parameters declared on a *method* rather than a plain - function, and exits 0 so the build does not notice; such methods -- including - those on :class:`~pcapkit.corekit.io.SeekableReader` and others across - the protocols, foundation, dumpkit and corekit modules -- then survive into the - installed package and ``import pcapkit`` fails with :exc:`SyntaxError`. - - It is a one-line fix upstream -- ``poseur.py:744`` passes ``cls_ctx=name.name`` - where ``name`` is already a parso ``Name`` and wants ``.value`` -- and with it - applied, ``walrus`` then ``poseur`` produce a file Python 3.7 parses cleanly. + The sources use 3.8 syntax, so ``requires-python`` is ``>=3.8`` and pip + refuses an older interpreter. Before 1.5.0 the ``bpc-*`` backport tools + converted it at install time; they are no longer maintained, and the + conversion is gone (:issue:`2051`). .. note:: - 3.7 to 3.10 are end-of-life and best-effort: CI builds and tests them, but a - failure there blocks nothing, and on 3.7 the import fails as described above. - Individual *engines* also stop earlier than the library does; see - `Engine prerequisites`_. + 3.8 to 3.10 are end-of-life and best-effort: CI builds and tests them, but a + failure there blocks nothing. Individual *engines* also stop earlier than the + library does; see `Engine prerequisites`_. Run the following to install the current version from PyPI: diff --git a/docs/source/pcapkit/utilities/index.rst b/docs/source/pcapkit/utilities/index.rst index a0d0a43426..f16849acc7 100644 --- a/docs/source/pcapkit/utilities/index.rst +++ b/docs/source/pcapkit/utilities/index.rst @@ -32,14 +32,6 @@ following objects and functions: * - Compatibility Target - Minimal Required Version - * - :mod:`pathlib` - - Python 3.5 - * - :exc:`ModuleNotFoundError` - - Python 3.6 - * - :class:`collections.abc.Collection` - - Python 3.6 - * - :func:`functools.cached_property` - - Python 3.8 * - :class:`collections.abc.Mapping[KT, VT] ` - Python 3.9 * - :class:`tuple[T, ...] ` @@ -67,6 +59,11 @@ following objects and functions: * - :class:`types.UnionType`, or :data:`None` below it - Python 3.10 +It also exports :mod:`pathlib`, :exc:`ModuleNotFoundError`, +:class:`collections.abc.Collection` and :func:`functools.cached_property` as the +standard library has them. Their fallbacks were for interpreters below Python 3.8, +which 1.5.0 no longer installs on (:issue:`2051`). + .. function:: final(f) :func:`typing.final` from Python 3.11 on, and ``typing_extensions.final`` @@ -93,11 +90,14 @@ following objects and functions: The interpreter's own default thread exception hook, even when :func:`threading.excepthook` has been replaced: - :data:`threading.__excepthook__` from Python 3.10 on, ``_thread._excepthook`` - on 3.8 and 3.9, and :data:`None` below 3.8, which has no thread exception hook. + :data:`threading.__excepthook__` from Python 3.10 on, and ``_thread._excepthook`` + on 3.8 and 3.9. .. class:: cached_property(func) - :func:`functools.cached_property` from Python 3.8 on. Below 3.8 it is a backport - of CPython's own implementation, kept verbatim so that it can be diffed against - upstream. + :func:`functools.cached_property` itself. + + .. versionchanged:: 1.5.0 + + No longer a backport below Python 3.8, which 1.5.0 no longer installs on + (:issue:`2051`). diff --git a/pcapkit/corekit/infoclass.py b/pcapkit/corekit/infoclass.py index d7d80ee2ea..8b36ba90d3 100644 --- a/pcapkit/corekit/infoclass.py +++ b/pcapkit/corekit/infoclass.py @@ -1006,8 +1006,7 @@ class MultiInfo(_MultiInfo[KT, VT], MultiDict[KT, VT]): __iter__ = MultiDict.__iter__ # type: ignore[assignment] __len__ = MultiDict.__len__ __contains__ = MultiDict.__contains__ - if sys.version_info >= (3, 8): # pragma: no branch -- dict reversal is Python 3.8 and up - __reversed__ = MultiDict.__reversed__ + __reversed__ = MultiDict.__reversed__ __hash__ = None # type: ignore[assignment] __repr__ = MultiDict.__repr__ __str__ = MultiDict.__str__ @@ -1099,8 +1098,7 @@ class OrderedMultiInfo(_MultiInfo[KT, VT], OrderedMultiDict[KT, VT]): __iter__ = OrderedMultiDict.__iter__ # type: ignore[assignment] __len__ = OrderedMultiDict.__len__ __contains__ = OrderedMultiDict.__contains__ - if sys.version_info >= (3, 8): # pragma: no branch -- dict reversal is Python 3.8 and up - __reversed__ = OrderedMultiDict.__reversed__ + __reversed__ = OrderedMultiDict.__reversed__ __eq__ = OrderedMultiDict.__eq__ __ne__ = _ne __hash__ = None diff --git a/pcapkit/foundation/engines/pcap_ct.py b/pcapkit/foundation/engines/pcap_ct.py index 164787c6ec..d03c0009d1 100644 --- a/pcapkit/foundation/engines/pcap_ct.py +++ b/pcapkit/foundation/engines/pcap_ct.py @@ -209,7 +209,7 @@ def unsupported_reason(cls) -> 'Optional[str]': 1.11.0b29 installs and reads :file:`examples/captures/in.pcap` identically on both, so this engine covers the whole range the project supports. The floor in the ``PCAP_CT`` extra's markers exists only because - :file:`pyproject.toml` advertises ``requires-python >= 3.6``, and on + :file:`pyproject.toml` advertises ``requires-python >= 3.8``, and on an interpreter below 3.10 the distributions simply will not be installed -- which the import test then reports correctly on its own. diff --git a/pcapkit/protocols/link/arp.py b/pcapkit/protocols/link/arp.py index e8c6e25b64..e1b54291e5 100644 --- a/pcapkit/protocols/link/arp.py +++ b/pcapkit/protocols/link/arp.py @@ -40,8 +40,6 @@ """ import ipaddress import re -import sys -import textwrap from typing import TYPE_CHECKING, cast from pcapkit.const.arp.hardware import Hardware as Enum_Hardware @@ -69,9 +67,6 @@ __all__ = ['ARP', 'InARP'] -# check Python version -py38 = ((version_info := sys.version_info).major >= 3 and version_info.minor >= 8) - # Ethernet address pattern, of any number of octets (``hlen`` is not fixed at 6) PAT_MAC_ADDR = re.compile(rb'(?i)(?:[0-9a-f]{2}(?:[:-][0-9a-f]{2})*)?') @@ -370,10 +365,7 @@ def _read_addr_resolve(self, addr: 'bytes', htype: 'int') -> 'str': """ if htype == Enum_Hardware.Ethernet: # Ethernet - if py38: - _addr = addr.hex(':') - else: - _addr = ':'.join(textwrap.wrap(addr.hex(), 2)) + _addr = addr.hex(':') else: _addr = addr.hex() return _addr diff --git a/pcapkit/protocols/link/ethernet.py b/pcapkit/protocols/link/ethernet.py index f42574abde..ae1525399d 100644 --- a/pcapkit/protocols/link/ethernet.py +++ b/pcapkit/protocols/link/ethernet.py @@ -26,8 +26,6 @@ """ import re -import sys -import textwrap from typing import TYPE_CHECKING from pcapkit.const.reg.ethertype import EtherType as Enum_EtherType @@ -49,9 +47,6 @@ __all__ = ['Ethernet'] -# check Python version -py38 = ((version_info := sys.version_info).major >= 3 and version_info.minor >= 8) - # Ethernet address pattern PAT_MAC_ADDR = re.compile(rb'(?i)(?:[0-9a-f]{2}[:-]){5}[0-9a-f]{2}') @@ -225,11 +220,7 @@ def _read_mac_addr(self, addr: 'bytes') -> 'str': Colon (``:``) separated *hex* encoded MAC address. """ - if py38: - _addr = addr.hex(':') - else: - _addr = ':'.join(textwrap.wrap(addr.hex(), 2)) - return _addr + return addr.hex(':') def _make_mac_addr(self, addr: 'str | bytes | bytearray') -> 'bytes': """Make MAC address. diff --git a/pcapkit/protocols/misc/pcap/frame.py b/pcapkit/protocols/misc/pcap/frame.py index 994da73fa7..00343734b1 100644 --- a/pcapkit/protocols/misc/pcap/frame.py +++ b/pcapkit/protocols/misc/pcap/frame.py @@ -63,9 +63,6 @@ __all__ = ['Frame'] -# check Python version -py37 = ((version_info := sys.version_info).major >= 3 and version_info.minor >= 7) - class Frame(ProtocolBase[Data_Frame, Schema_Frame], schema=Schema_Frame, data=Data_Frame): @@ -600,7 +597,7 @@ def _make_timestamp(self, timestamp: 'Optional[float | Decimal | dt_type | int]' """ with localcontext(prec=64): if timestamp is None: - if py37 and nanosecond: + if nanosecond: timestamp = decimal.Decimal(time.time_ns()) / 1_000_000_000 else: timestamp = decimal.Decimal(time.time()) diff --git a/pcapkit/protocols/misc/pcapng.py b/pcapkit/protocols/misc/pcapng.py index 3e72a165e8..4deb01a0db 100644 --- a/pcapkit/protocols/misc/pcapng.py +++ b/pcapkit/protocols/misc/pcapng.py @@ -51,7 +51,6 @@ import re import struct import sys -import textwrap import time from typing import TYPE_CHECKING, cast, overload @@ -258,10 +257,6 @@ _FlagsEnum = TypeVar('_FlagsEnum', 'PacketDirection', 'PacketReception') _S = TypeVar('_S', bound='Schema | bytes') -# check Python version -py37 = ((version_info := sys.version_info).major >= 3 and version_info.minor >= 7) -py38 = ((version_info := sys.version_info).major >= 3 and version_info.minor >= 8) - # Ethernet address pattern PAT_MAC_ADDR = re.compile(rb'(?i)(?:[0-9a-f]{2}[:-]){5}[0-9a-f]{2}') # EUI address pattern @@ -2040,7 +2035,7 @@ def _make_timestamp(self, timestamp: 'Optional[float | Decimal | dt_type | int]' """ with localcontext(prec=64): if timestamp is None: - if py37 and self.nanosecond: + if self.nanosecond: timestamp = decimal.Decimal(time.time_ns()) / 1_000_000_000 else: timestamp = decimal.Decimal(time.time()) @@ -2064,11 +2059,7 @@ def _read_mac_addr(self, addr: 'bytes') -> 'str': Colon (``:``) separated *hex* encoded MAC address. """ - if py38: - _addr = addr.hex(':') - else: - _addr = ':'.join(textwrap.wrap(addr.hex(), 2)) - return _addr + return addr.hex(':') def _read_eui_addr(self, addr: 'bytes') -> 'str': """Read EUI address. @@ -2080,11 +2071,7 @@ def _read_eui_addr(self, addr: 'bytes') -> 'str': Colon (``:``) separated *hex* encoded EUI address. """ - if py38: - _addr = addr.hex(':') - else: - _addr = ':'.join(textwrap.wrap(addr.hex(), 2)) - return _addr + return addr.hex(':') def _make_mac_addr(self, addr: 'str | bytes | bytearray') -> 'bytes': """Make MAC address. diff --git a/pcapkit/protocols/protocol.py b/pcapkit/protocols/protocol.py index 82f2bcfb21..22be926a7d 100644 --- a/pcapkit/protocols/protocol.py +++ b/pcapkit/protocols/protocol.py @@ -36,7 +36,6 @@ import shutil import string import struct -import sys import textwrap import types import urllib.parse @@ -105,11 +104,10 @@ def _make_index_calls(func: 'Callable[..., Any]') -> 'tuple[tuple[int, int, str] tree = ast.parse(textwrap.dedent(''.join(lines))) except (OSError, TypeError, SyntaxError): return () - # NOTE: ``end_lineno`` is new in Python 3.8, where ``ast.parse`` always sets - # it on a call; below that a call's last line is not known, only its first. + # NOTE: ``ast.parse`` always sets ``end_lineno`` on a call. return tuple( (node.lineno + start - 1, - (cast('int', node.end_lineno) if sys.version_info >= (3, 8) else node.lineno) + start - 1, + cast('int', node.end_lineno) + start - 1, node.args[0].id) for node in ast.walk(tree) if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) diff --git a/pcapkit/toolkit/pypcapfile.py b/pcapkit/toolkit/pypcapfile.py index 76cc07b94e..42cab3defa 100644 --- a/pcapkit/toolkit/pypcapfile.py +++ b/pcapkit/toolkit/pypcapfile.py @@ -44,8 +44,6 @@ import functools import ipaddress import struct -import sys -import textwrap from typing import TYPE_CHECKING from pcapkit.const.reg.ethertype import EtherType as Enum_EtherType @@ -79,11 +77,6 @@ 'ipv4_reassembly', 'ipv6_reassembly', 'tcp_reassembly', 'tcp_traceflow', ] -#: Whether :meth:`bytes.hex` supports the ``sep`` argument (Python 3.8+), as used -#: below by :func:`_parse_mac_address` -- mirrors the same guard in -#: :meth:`pcapkit.protocols.link.ethernet.Ethernet._read_mac_addr`. -py38 = ((version_info := sys.version_info).major >= 3 and version_info.minor >= 8) - #: IPv4 **DF** (don't fragment) bit, within the three-bit flags field. IPV4_FLAG_DF = 0b010 @@ -211,7 +204,7 @@ def _parse_mac_address(value: 'Any') -> 'str': raise ProtocolError(f'invalid PyPCAPFile MAC address: {value!r}') raw = bytes(value) if len(raw) == 6: - return raw.hex(':') if py38 else ':'.join(textwrap.wrap(raw.hex(), 2)) + return raw.hex(':') try: return raw.decode('ascii') except UnicodeDecodeError as error: diff --git a/pcapkit/utilities/compat.py b/pcapkit/utilities/compat.py index cf5720c747..bae7f1046f 100644 --- a/pcapkit/utilities/compat.py +++ b/pcapkit/utilities/compat.py @@ -1,10 +1,14 @@ # -*- coding: utf-8 -*- +import pathlib import sys +from builtins import ModuleNotFoundError +from collections.abc import Collection +from functools import cached_property from typing import TYPE_CHECKING if TYPE_CHECKING: - from typing import Any, Callable, Iterator, Optional, Type, Union + from typing import Any, Iterator, Optional __all__ = [ # functions @@ -42,104 +46,6 @@ sys.modules['aenum._common'].NoneType = type(None) # type: ignore[attr-defined] import aenum # pylint: disable=unused-import -if sys.version_info < (3, 6): - class ModuleNotFoundError(ImportError): # pylint: disable=redefined-builtin - """Module not found.""" -else: - from builtins import ModuleNotFoundError - -if sys.version_info <= (3, 5): - from collections.abc import Container, Iterable, Sized # pylint: disable=unused-import - - def _check_methods(C: 'Type[Any]', *methods: 'str') -> 'bool | Any': - mro = C.__mro__ - for method in methods: - for B in mro: # pragma: no branch - if method in B.__dict__: - if B.__dict__[method] is None: - return NotImplemented - break - else: - return NotImplemented - return True - - class Collection(Sized, Iterable, Container): # pylint: disable=abstract-method - - __slots__ = () - - @classmethod - def __subclasshook__(cls, C: 'Type[Any]') -> 'bool | Any': - if cls is Collection: - return _check_methods(C, "__len__", "__iter__", "__contains__") - return NotImplemented # pragma: no cover -else: - from collections.abc import Collection - -if sys.version_info <= (3, 4): # pragma: no cover - import pathlib2 as pathlib # pylint: disable=import-error -else: - import pathlib - -# functools.cached_property added in 3.8 -if sys.version_info < (3, 8): - from threading import RLock - from typing import Generic, TypeVar # isort: split - - _T = TypeVar("_T") - _S = TypeVar("_S") - - _NOT_FOUND = object() - - class cached_property(Generic[_T]): - def __init__(self, func: 'Callable[[Any], _T]') -> 'None': - self.func = func # type: Callable[[Any], _T] - self.attrname = None # type: Optional[str] - self.__doc__ = func.__doc__ - self.lock = RLock() - - def __set_name__(self, owner: 'Type[Any]', name: 'str') -> 'None': - if self.attrname is None: - self.attrname = name - elif name != self.attrname: - raise TypeError( - "Cannot assign the same cached_property to two different names " - f"({self.attrname!r} and {name!r})." - ) - - def __get__(self, instance: 'Optional[_S]', - owner: 'Optional[Type[Any]]' = None) -> 'Union[cached_property[_T], _T]': - if instance is None: - return self - if self.attrname is None: # pragma: no cover - raise TypeError( - "Cannot use cached_property instance without calling __set_name__ on it.") - try: - cache = instance.__dict__ - except AttributeError: # not all objects have __dict__ (e.g. class defines slots) - msg = ( - f"No '__dict__' attribute on {type(instance).__name__!r} " - f"instance to cache {self.attrname!r} property." - ) - raise TypeError(msg) from None - val = cache.get(self.attrname, _NOT_FOUND) - if val is _NOT_FOUND: - with self.lock: - # check if another thread filled cache while we awaited lock - val = cache.get(self.attrname, _NOT_FOUND) - if val is _NOT_FOUND: # pragma: no branch - val = self.func(instance) - try: - cache[self.attrname] = val - except TypeError: # pragma: no cover - msg = ( - f"The '__dict__' attribute on {type(instance).__name__!r} instance " - f"does not support item assignment for caching {self.attrname!r} property." - ) - raise TypeError(msg) from None - return val -else: - from functools import cached_property - if sys.version_info < (3, 9): from typing import Dict, List, Mapping, Tuple else: @@ -273,18 +179,14 @@ def show_flag_values(value: 'IntFlag') -> 'list[int]': # whatever :data:`threading.excepthook` holds when this module loads, keeps it the # real default on 3.8 and 3.9 even when a host application, or an earlier loaded # copy of :mod:`pcapkit.utilities.exceptions`, has already replaced the hook -- -# as it is on 3.10 and later. 3.6 and 3.7 have no thread exception hook at all, -# so the name is :data:`None` there rather than a failed import; nothing on those -# interpreters calls it. +# as it is on 3.10 and later. # # The stdlib name is not reused as it stands, unlike every other name here: a # bare ``__excepthook__`` imported into a module reads as # :data:`sys.__excepthook__`, which takes three arguments rather than one, and # :mod:`pcapkit.utilities.exceptions` uses both. ``default_`` is the word that # module's docstrings already use for this object. -if sys.version_info < (3, 8): - default_threading_excepthook = None -elif sys.version_info < (3, 10): +if sys.version_info < (3, 10): from _thread import _excepthook as default_threading_excepthook else: from threading import __excepthook__ as default_threading_excepthook # novermin @@ -308,7 +210,7 @@ def get_annotations(obj: 'type') -> 'dict[str, Any]': # The :pep:`604` union type, i.e. the origin of ``int | None``, which is the # same object as :data:`typing.Union` from 3.14. Below 3.10 only # :data:`typing.Union` spells a union, so the name is :data:`None` there rather -# than a failed import, as ``default_threading_excepthook`` is below 3.8. +# than a failed import. if sys.version_info < (3, 10): UnionType = None else: diff --git a/pyproject.toml b/pyproject.toml index 1942907b73..710440e390 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,11 +5,6 @@ requires = [ # 61.0.0 and 62.2.0 read it as ``*``, and ship only the files one directory # down from it. "setuptools>=62.3.0", - - # version compatibility - "bpc-f2format; python_version < '3.6'", - "bpc-poseur; python_version < '3.8'", - "bpc-walrus; python_version < '3.8'", ] build-backend = "setuptools.build_meta" @@ -26,40 +21,18 @@ maintainers = [ { name="Jarry Shaw" }, ] license = { text="BSD 3-Clause License" } -# Kept at 3.6, which the ``bpc-*`` requirements above are what make possible: the -# sources use 3.8 syntax (walrus, positional-only parameters) and ``refactor()`` in -# setup.py converts it at install time. Measured, so that the state of that is on -# the record rather than assumed: -# -# 3.9.25, 3.8.20 import and extract straight from source, no conversion needed -# 3.7.16 SyntaxError from source, as expected -- this is what the -# ``bpc-*`` conversion exists for +# 3.8 is the floor (#2051): the sources use 3.8 syntax (assignment expressions, +# positional-only parameters) and nothing converts them any more, since the +# ``bpc-*`` backport tools are no longer maintained. No upper bound: there is no +# Python 4 for one to exclude, and a cap here is copied into the metadata of +# every project that depends on this one. # -# **The conversion path has an upstream bug.** ``bpc-poseur`` 0.4.3.post1 -# crashes on any positional-only parameter that is on a *method* rather than a -# plain function -- ``AttributeError: 'Name' object has no attribute 'name'`` at -# ``poseur.py:744`` in ``_process_classdef``, which does ``cls_ctx=name.name`` -# where ``name`` is already a parso ``Name`` leaf and wants ``.value``. It then -# **exits 0**, so ``refactor()``'s ``check_call`` cannot see the failure, and the -# positional-only parameters in ``pcapkit/corekit/io.py`` survive into the -# installed package -- making ``import pcapkit`` fail with ``SyntaxError`` on an -# interpreter below 3.8. -# -# Verified that this is the *only* blocker: with that one line changed to -# ``name.value``, ``walrus`` followed by ``poseur`` converts ``io.py`` into -# something Python 3.7 parses cleanly. So this is an upstream fix to follow, not a -# reason to withdraw the declaration. Until it lands, sub-3.8 support is intent -# rather than something that works. -# -# Independently, CI sorts versions into tiers (#1633), declared in .github/python-versions.toml +# CI sorts versions into tiers (#1633), declared in .github/python-versions.toml # (#1660), which a tier change edits. **3.11 through 3.15** are supported and -# blocking. **3.7 through 3.10** are KTLO: end-of-life, still built and tested, never -# blocking, and a failure there is a warning on a green leg (#1672). Each 3.7 leg -# fails a step by design: the import fails as above, and the ``test`` extra's -# ``pytest>=8`` needs 3.8. 3.6 is not run at all: setup-python -# builds it only for retired Ubuntu images, and the build needs ``setuptools>=62.3`` -# besides. The ``PCAP_CT`` extra needs 3.10 regardless. -requires-python = ">=3.6, <4" +# blocking. **3.8 through 3.10** are KTLO: end-of-life, still built and tested, +# never blocking, and a failure there is a warning on a green leg (#1672). The +# ``PCAP_CT`` extra needs 3.10 regardless. +requires-python = ">=3.8" description = "PyPCAPKit: comprehensive network packet analysis library" readme = { file = "README.md", content-type = "text/markdown" } keywords = [ "network", "pcap", "packet" ] @@ -77,7 +50,7 @@ classifiers = [ "Operating System :: OS Independent", "Programming Language :: Python :: 3", # The supported tier only (#1633). A version classifier is a claim of - # support, which the KTLO tier (3.7-3.10) is by definition not: its tests + # support, which the KTLO tier (3.8-3.10) is by definition not: its tests # may fail, as a warning that blocks nothing. ``requires-python`` above is # what keeps those versions installable. "Programming Language :: Python :: 3.11", @@ -100,13 +73,7 @@ dependencies = [ "aenum", # for const types "tbtrim>=0.2.1", # for refined exceptions - # PyBPC - "bpc-f2format; python_version < '3.6'", - "bpc-poseur; python_version < '3.8'", - "bpc-walrus; python_version < '3.8'", - # version compatibility - "pathlib2>=2.3.2; python_version == '3.4'", "typing-extensions; python_version < '3.11'", ] @@ -169,8 +136,7 @@ DPKT = [ "dpkt" ] # ``if_tsresol`` is lost after a comment option without a newline, so a # nanosecond interface reads as microseconds, and ``packet2dict`` reports DNS # records and field order differently. 2.6.0, the next release, fixes them all. -# It needs Python 3.7, hence the marker, as ``PCAP_CT``'s below. -Scapy = [ "scapy>=2.6.0; python_version >= '3.7'" ] +Scapy = [ "scapy>=2.6.0" ] PyShark = [ "pyshark" ] # pypcap ships no wheels, so this compiles a C extension against libpcap and # needs both its headers and its shared library present; deliberately kept out @@ -222,8 +188,8 @@ PyPCAP = [ "pypcap; python_version < '3.12'" ] # 1.3.0b3 declares ``Requires-Python: <4.0.0,>=3.9.0`` and ``libpcap`` # 1.11.0b29 declares ``<4.0.0,>=3.10.0``, so 3.10 is the binding floor. The # marker is load-bearing rather than decorative, because this project's own -# ``requires-python`` is ``>=3.6, <4`` (see the note above it): without the -# marker, ``pip install pypcapkit[PCAP_CT]`` on a 3.6-3.9 interpreter would try +# ``requires-python`` is ``>=3.8`` (see the note above it): without the +# marker, ``pip install pypcapkit[PCAP_CT]`` on a 3.8 or 3.9 interpreter would try # to resolve two distributions that exclude it and fail the whole install, where # with it the extra simply resolves to nothing there. CI installs it only in # engine-tests' PCAP_CT cell, on 3.10 upwards, so the marker excludes none of those. @@ -293,8 +259,8 @@ all = [ # and it is not a place to add ``pypcap``/``pcap-ct`` just to silence a # finding CI has already decided to carry as advisory. dev = [ - # scapy: the same floor and marker as the Scapy extra above, #1572 - "dpkt", "scapy>=2.6.0; python_version >= '3.7'", "pyshark", + # scapy: the same floor as the Scapy extra above, #1572 + "dpkt", "scapy>=2.6.0", "pyshark", # same marker and reasoning as the PyPCAPFile extra above and the old # ``all``: on 3.12+ the package installs cleanly and is then unusable, so # the marker keeps the resolver from fetching something that could never diff --git a/setup.py b/setup.py index 02dade1c03..312b17d611 100644 --- a/setup.py +++ b/setup.py @@ -1,15 +1,13 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- -import logging -import os import sys -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from distutils.cmd import Command - from typing import Any, Type +# Python 2 only, not ``requires-python``'s 3.8 (#2051). pip builds the metadata +# with this file before it compares ``requires-python``, so a 3.8 guard here would +# replace pip's "requires a different Python" with a traceback. A Python 2 that +# runs this file directly would otherwise reach ``setup()`` with a setuptools too +# old to read ``pyproject.toml``. if sys.version_info[0] <= 2: raise OSError("PyPCAPKit does not support Python 2!") @@ -18,139 +16,6 @@ except ImportError: raise ImportError("setuptools is required to install PyPCAPKit!") -# get logger -logger = logging.getLogger("pcapkit.setup") -formatter = logging.Formatter( - fmt="[%(levelname)s] %(asctime)s - %(message)s", datefmt="%m/%d/%Y %I:%M:%S %p" -) -handler = logging.StreamHandler(sys.stderr) -handler.setFormatter(formatter) -logger.addHandler(handler) - - -def refactor(path: "str") -> "None": - """Refactor code.""" - import subprocess # nosec: B404 - - if sys.version_info < (3, 6): - try: - subprocess.check_call( # nosec - [sys.executable, "-m", "f2format", "--no-archive", path] - ) - except subprocess.CalledProcessError as error: - logger.error( - "Failed to perform f-string backport compiling. " - "Please consider manually install `bpc-f2format` and try again." - ) - sys.exit(error.returncode) - - if sys.version_info < (3, 8): - try: - subprocess.check_call( # nosec - [sys.executable, "-m", "walrus", "--no-archive", path] - ) - except subprocess.CalledProcessError as error: - logger.error( - "Failed to perform assignment expression backport compiling. " - "Please consider manually install `bpc-walrus` and try again." - ) - sys.exit(error.returncode) - - try: - subprocess.check_call( # nosec - [sys.executable, "-m", "poseur", "--no-archive", path] - ) - except subprocess.CalledProcessError as error: - logger.error( - "Failed to perform positional-only parameter backport compiling. " - "Please consider manually install `bpc-poseur` and try again." - ) - sys.exit(error.returncode) - - -cmdclass = {} # type: dict[str, Type[Command]] - -try: - from setuptools.command.sdist import sdist - - class pcapkit_sdist(sdist): - """Modified sdist to run PyBPC conversion.""" - - def make_release_tree( - self, base_dir: "str", *args: "Any", **kwargs: "Any" - ) -> "None": - super(pcapkit_sdist, self).make_release_tree(base_dir, *args, **kwargs) - logger.info("running sdist") - - # PyBPC compatibility enforcement - refactor(os.path.join(base_dir, "pcapkit")) - - cmdclass["sdist"] = pcapkit_sdist -except ImportError: - logger.warning( - "setuptools version is too old to support PyBPC conversion in sdist." - ) - -try: - from setuptools.command.build_py import build_py - - class pcapkit_build_py(build_py): - """Modified build_py to run PyBPC conversion.""" - - def build_package_data(self) -> "None": - super(pcapkit_build_py, self).build_package_data() - logger.info("running build_py") - - # PyBPC compatibility enforcement - refactor(os.path.join(self.build_lib, "pcapkit")) - - cmdclass["build_py"] = pcapkit_build_py -except ImportError: - logger.warning( - "setuptools version is too old to support PyBPC conversion in build_py." - ) - -try: - from setuptools.command.develop import develop - - class pcapkit_develop(develop): - """Modified develop to run PyBPC conversion.""" - - def run(self) -> "None": # type: ignore[override] - super(pcapkit_develop, self).run() - logger.info("running develop") - - # PyBPC compatibility enforcement - refactor(os.path.join(self.install_lib, "pcapkit")) - - cmdclass["develop"] = pcapkit_develop -except ImportError: - logger.warning( - "setuptools version is too old to support PyBPC conversion in develop." - ) - -try: - from setuptools.command.install import install - - class pcapkit_install(install): - """Modified install to run PyBPC conversion.""" - - def run(self) -> "None": - super(pcapkit_install, self).run() - logger.info("running install") - - # PyBPC compatibility enforcement - refactor(os.path.join(self.install_lib, "pcapkit")) # type: ignore[arg-type] - - cmdclass["install"] = pcapkit_install -except ImportError: - logger.warning( - "setuptools version is too old to support PyBPC conversion in install." - ) - -# No bdist_wheel override: ``bdist_wheel`` runs ``build_py`` and ``install`` -# above, so the tree it archives is already converted before the wheel is -# written, and ``dist_dir`` holds only the finished wheels (#1773). - -# The long description is ``pyproject.toml``'s static ``readme`` (#1968). -setup(cmdclass=cmdclass) +# Everything is declared in ``pyproject.toml``. The long description is its +# static ``readme`` (#1968). +setup() diff --git a/tests/corekit/test_sentinel_exports_unit.py b/tests/corekit/test_sentinel_exports_unit.py index 92ef46b013..f68a6ba014 100644 --- a/tests/corekit/test_sentinel_exports_unit.py +++ b/tests/corekit/test_sentinel_exports_unit.py @@ -63,12 +63,11 @@ now pins that privacy under the new name, since the mechanical underscore signal it used to double-check is gone. -One sentinel is deliberately **not** held to any of this: ``_NOT_FOUND = object()`` -at :file:`pcapkit/utilities/compat.py`, line 73, inside the ``cached_property`` -backport for interpreters below 3.8. It is ported code and exempt, and -:meth:`SentinelPopulationTests.test_the_vendored_bare_object_sentinel_is_left_alone` -pins that as an exemption rather than leaving it to read as an oversight against the -"why a class and not ``object()``" rule. +Ported code is deliberately **not** held to any of this. The case that settled it, +``_NOT_FOUND = object()`` in :file:`pcapkit/utilities/compat.py`'s ``cached_property`` +backport for interpreters below 3.8, went with that backport (#2051); +:meth:`SentinelPopulationTests.test_the_vendored_bare_object_sentinel_went_with_its_backport` +pins that, and the exemption stays in the conventions doc as a rule. On the tree before this change, four of these fail: @@ -497,20 +496,19 @@ def test_conventions_doc_carves_out_the_vendored_bare_object(self) -> 'None': """"Why a Class and Not ``object()``" read as a blanket rule with no exception.""" section = _sentinel_section() + self.assertIn('**Ported code is exempt.**', section) self.assertIn('_NOT_FOUND', section) - self.assertIn('pcapkit/utilities/compat.py', section) - def test_the_vendored_bare_object_sentinel_is_left_alone(self) -> 'None': - """The carve-out, asserted against the source it carves out. + def test_the_vendored_bare_object_sentinel_went_with_its_backport(self) -> 'None': + """The carve-out's case is gone with the 3.8 floor (#2051). - Read rather than imported: the backport is inside ``if sys.version_info < - (3, 8):``, so on any interpreter that runs this suite the branch is dead and - ``_NOT_FOUND`` is never bound. A test that imported it would pass - vacuously. + Read rather than imported, as the backport it sat in was never bound on + an interpreter that runs this suite. """ source = (ROOT / 'pcapkit' / 'utilities' / 'compat.py').read_text(encoding='utf-8') - self.assertRegex(source, r'(?m)^\s+_NOT_FOUND = object\(\)$') + self.assertNotIn('_NOT_FOUND', source) + self.assertRegex(source, r'(?m)^from functools import cached_property$') def test_the_sentinel_table_has_a_row_per_sentinel_and_no_more(self) -> 'None': """Counted from the table itself, so a fifth sentinel cannot be half-added.""" diff --git a/tests/project/test_build_leftovers_1773.py b/tests/project/test_build_leftovers_1773.py index 1ff5b3aa3e..3f6195de92 100644 --- a/tests/project/test_build_leftovers_1773.py +++ b/tests/project/test_build_leftovers_1773.py @@ -1,10 +1,6 @@ # -*- coding: utf-8 -*- """Pins for the build and packaging leftovers fixed in #1773. -* Every PyBPC conversion in :file:`setup.py` lands in a package tree before a - wheel exists, so none of them is a no-op on ``dist_dir``. Measured by building - a real wheel of a stub package with :file:`setup.py`'s own ``cmdclass``. -* Each backport's failure message names the feature that tool back-ports. * ``make profile`` reaches a Makefile that exists, and every ``prune`` in :file:`MANIFEST.in` names a directory the repository tracks or ignores. Every other line there changes what the template selects (#1794). @@ -16,22 +12,17 @@ from __future__ import annotations -import importlib.util import json -import logging import pathlib import re import shutil import subprocess # nosec: B404 import sys -import tempfile import textwrap -import types import unittest -from unittest import mock -#: Test tier (#1958): checks what a stub build through setup.py leaves behind; every -#: leg's install runs setup.py for real. +#: Test tier (#1958): reads the Makefile and MANIFEST.in, and asks setuptools what +#: the manifest template selects. __tier__ = 'LATEST' ROOT = pathlib.Path(__file__).resolve().parents[2] @@ -51,137 +42,6 @@ def is_history(path: 'str') -> 'bool': return any(part.lower().startswith('changelog') for part in pathlib.PurePosixPath(path).parts) -#: Builds a wheel of a one-module stub package with setup.py's ``cmdclass``, -#: ``refactor`` replaced by a recorder, and writes what it saw as JSON. -WHEEL_DRIVER = textwrap.dedent(''' - import importlib.util, json, os, sys, zipfile - from unittest import mock - - import setuptools - from setuptools.dist import Distribution - - root, work, report = sys.argv[1:4] - captured = {} - with mock.patch.object(setuptools, 'setup', lambda **kwargs: captured.update(kwargs)): - spec = importlib.util.spec_from_file_location('project_setup', os.path.join(root, 'setup.py')) - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - - os.chdir(work) - os.makedirs('pcapkit') - with open(os.path.join('pcapkit', '__init__.py'), 'w') as file: - file.write('VALUE = 1\\n') - dist_dir = os.path.join(work, 'dist') - calls = [] - - def record(path): - init = os.path.join(path, '__init__.py') - wheels = os.path.isdir(dist_dir) and [n for n in os.listdir(dist_dir) if n.endswith('.whl')] - calls.append({'path': os.path.relpath(os.path.abspath(path), work), - 'package': os.path.isfile(init), 'after_wheel': bool(wheels)}) - if os.path.isfile(init): - with open(init, 'a') as file: - file.write('CONVERTED = True\\n') - - module.refactor = record - dist = Distribution({'name': 'probe', 'version': '0', 'packages': ['pcapkit'], - 'cmdclass': captured['cmdclass']}) - dist.script_name = 'setup.py' - dist.script_args = ['-q', 'bdist_wheel', '--dist-dir', dist_dir] - dist.parse_command_line() - dist.run_commands() - wheels = [n for n in os.listdir(dist_dir) if n.endswith('.whl')] - with zipfile.ZipFile(os.path.join(dist_dir, wheels[0])) as archive: - shipped = archive.read('pcapkit/__init__.py').decode() - with open(report, 'w') as file: - json.dump({'calls': calls, 'shipped': shipped}, file) -''') - - -class WheelConversionTests(unittest.TestCase): - """A wheel is archived from a converted tree, and nothing converts ``dist_dir``.""" - - @classmethod - def setUpClass(cls) -> None: - with tempfile.TemporaryDirectory() as work: - report = pathlib.Path(work) / 'report.json' - project = pathlib.Path(work) / 'project' - project.mkdir() - result = subprocess.run([sys.executable, '-c', WHEEL_DRIVER, # nosec - str(ROOT), str(project), str(report)], - capture_output=True, text=True, timeout=300) - if result.returncode != 0: - raise AssertionError(f'wheel build failed:\n{result.stdout}\n{result.stderr}') - cls.report = json.loads(report.read_text(encoding='utf-8')) - - def test_a_conversion_runs_during_the_wheel_build(self) -> None: - self.assertTrue(self.report['calls']) - - def test_every_conversion_targets_an_existing_package_tree(self) -> None: - for call in self.report['calls']: - with self.subTest(path=call['path']): - self.assertTrue(call['package'], f"refactor() pointed at {call['path']}, " - 'which holds no package') - - def test_no_conversion_runs_after_the_wheel_is_written(self) -> None: - for call in self.report['calls']: - with self.subTest(path=call['path']): - self.assertFalse(call['after_wheel'], f"refactor({call['path']}) ran after " - 'the wheel was written, so it cannot change it') - - def test_the_wheel_ships_the_converted_tree(self) -> None: - self.assertIn('CONVERTED = True', self.report['shipped']) - - -class BackportMessageTests(unittest.TestCase): - """Each backport's failure names the feature its tool back-ports.""" - - def load_setup(self) -> 'types.ModuleType': - fakes = {'setuptools': types.ModuleType('setuptools')} - fakes['setuptools'].setup = lambda **kwargs: None # type: ignore[attr-defined] - for name in ('sdist', 'build_py', 'develop', 'install'): - module = types.ModuleType(f'setuptools.command.{name}') - setattr(module, name, type(name, (), {})) - fakes[module.__name__] = module - logger = logging.getLogger('pcapkit.setup') - handlers = logger.handlers[:] - self.addCleanup(setattr, logger, 'handlers', handlers) - with mock.patch.dict(sys.modules, fakes): - spec = importlib.util.spec_from_file_location('project_setup_1773', ROOT / 'setup.py') - assert spec is not None and spec.loader is not None - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module - - def failure_message(self, tool: 'str') -> 'str': - module = self.load_setup() - - def check_call(argv: 'list[str]') -> 'int': - if argv[2] == tool: - raise subprocess.CalledProcessError(2, argv) - return 0 - - with mock.patch.object(module, 'sys') as fake_sys, \ - mock.patch('subprocess.check_call', side_effect=check_call), \ - self.assertLogs('pcapkit.setup', 'ERROR') as logs: - fake_sys.executable = sys.executable - fake_sys.version_info = (3, 5) - fake_sys.exit.side_effect = SystemExit - with self.assertRaises(SystemExit): - module.refactor('pcapkit') - self.assertEqual(len(logs.output), 1) - return logs.output[0] - - def test_each_failure_names_its_own_feature(self) -> None: - for tool, feature in (('f2format', 'f-string'), - ('walrus', 'assignment expression'), - ('poseur', 'positional-only parameter')): - with self.subTest(tool=tool): - message = self.failure_message(tool) - self.assertIn(f'{feature} backport', message) - self.assertIn(f'bpc-{tool}', message) - - class MakefileTests(unittest.TestCase): """``make profile`` reaches a Makefile that exists.""" diff --git a/tests/project/test_docs_internal_refs_1940_unit.py b/tests/project/test_docs_internal_refs_1940_unit.py index 302b69dd73..6f814c6e0b 100644 --- a/tests/project/test_docs_internal_refs_1940_unit.py +++ b/tests/project/test_docs_internal_refs_1940_unit.py @@ -102,7 +102,8 @@ ':class:`~pcapkit.const.ftp.command.CommandType`'), ('docs/source/contributing/conventions/sentinel-convention.rst', '``NoDefaultType``', ':class:`~pcapkit.corekit.sentinels.NoDefaultType`'), - ('docs/source/contributing/conventions/documentation.rst', '``cached_property``', + # Moved here from documentation.rst with the backport it named (#2051). + ('docs/source/contributing/conventions/sentinel-convention.rst', '``cached_property``', ':class:`~pcapkit.utilities.compat.cached_property`'), ('docs/source/contributing/releasing.rst', '``pcapkit.__version__``', ':data:`pcapkit.__version__`'), diff --git a/tests/project/test_ktlo_legs_stay_green_1672.py b/tests/project/test_ktlo_legs_stay_green_1672.py index ad1f97aea3..d1d361b87c 100644 --- a/tests/project/test_ktlo_legs_stay_green_1672.py +++ b/tests/project/test_ktlo_legs_stay_green_1672.py @@ -48,7 +48,7 @@ from typing import TYPE_CHECKING, NamedTuple from tests.project.test_python_matrix_tiers_1633 import (EXPERIMENTAL_GUARD, EXPERIMENTAL_JOB, KTLO_GUARD, - KTLO_LEGS, LEG_37, TITLE_37, annotation_level, + KTLO_LEGS, LEG_KTLO, TITLE_KTLO, annotation_level, is_experimental, is_ktlo, is_nonblocking, job, leg_label, legs, titles, workflow, workflow_commands) @@ -426,7 +426,7 @@ def test_no_step_a_ktlo_leg_runs_writes_an_error(self) -> None: self.assertIn((name, job_id), LEVELLED) def summary(self, steps: 'dict[str, str]') -> 'tuple[subprocess.CompletedProcess[str], str]': - """``KTLO summary`` on 3.7, given each step's ``outcome``; its result and job summary.""" + """``KTLO summary`` on a KTLO leg, given each step's ``outcome``; its result and job summary.""" return self.run_summary(summary_step(job('unit-tests.yml', 'test'))['run'], steps) def run_summary(self, script: 'str', steps: 'dict[str, str]', @@ -437,23 +437,23 @@ def run_summary(self, script: 'str', steps: 'dict[str, str]', for key, outcome in steps.items()} page = self.tmp / 'summary.md' page.write_text('', encoding='utf-8') - env = {'LEG': LEG_37, 'STEPS': json.dumps(context, indent=2), 'GITHUB_STEP_SUMMARY': str(page)} + env = {'LEG': LEG_KTLO, 'STEPS': json.dumps(context, indent=2), 'GITHUB_STEP_SUMMARY': str(page)} result = bash(script, env, self.tmp) if inline else run_summary(script, env) return result, page.read_text(encoding='utf-8') def test_the_summary_warns_once_per_failed_step(self) -> None: def warning(step: 'str') -> 'str': - return (f'::warning title={step} failed on {TITLE_37}::KTLO legs never block (#1633), so this job ' + return (f'::warning title={step} failed on {TITLE_KTLO}::KTLO legs never block (#1633), so this job ' f"still succeeds (#1672); see the {step} step's log.") def line(step: 'str') -> 'str': - return f'- {LEG_37}: step `{step}` failed; non-blocking, so this job still succeeds.\n' + return f'- {LEG_KTLO}: step `{step}` failed; non-blocking, so this job still succeeds.\n' result, page = self.summary({'checkout': 'success', 'python': 'success', 'install': 'failure', 'parallelism': 'skipped', 'tests': 'skipped'}) self.assertEqual(result.returncode, 0, result.stderr) self.assertEqual(workflow_commands(result.stdout), [warning('install')]) - self.assertEqual(titles(result.stdout), [f'install failed on {LEG_37}']) + self.assertEqual(titles(result.stdout), [f'install failed on {LEG_KTLO}']) self.assertEqual(page, line('install')) result, page = self.summary({'checkout': 'failure', 'python': 'failure', 'install': 'skipped'}) @@ -466,7 +466,7 @@ def test_the_summary_is_quiet_when_nothing_failed(self) -> None: self.assertEqual(result.returncode, 0, result.stderr) self.assertEqual(workflow_commands(result.stdout), []) self.assertEqual(page, '') - self.assertIn(f'{LEG_37}: no step failed.', result.stdout) + self.assertIn(f'{LEG_KTLO}: no step failed.', result.stdout) def test_the_inline_release_copy_reports_as_the_script_does(self) -> None: """``pypi``'s ``KTLO summary`` keeps its shell (#1967), so it must not drift from the script.""" diff --git a/tests/project/test_pyshark_encap_map.py b/tests/project/test_pyshark_encap_map.py index 0a150c62c4..9960999f1e 100644 --- a/tests/project/test_pyshark_encap_map.py +++ b/tests/project/test_pyshark_encap_map.py @@ -287,12 +287,12 @@ def make_tmp_dir(case: unittest.TestCase) -> pathlib.Path: :meth:`unittest.TestCase.enterContext` would do this in one line, but it is Python 3.11+ and this repository supports back to - ``requires-python = ">=3.6, <4"`` (:file:`pyproject.toml`) -- the oldest + ``requires-python = ">=3.8"`` (:file:`pyproject.toml`) -- the oldest CI leg to run the suite is ``Python 3.8 (KTLO)``, where ``enterContext`` does not exist at all (``AttributeError``). ``TemporaryDirectory.cleanup`` plus :meth:`~unittest.TestCase.addCleanup` is the same "clean up after the - test regardless of outcome" guarantee, built from APIs both present since - 3.6. + test regardless of outcome" guarantee, built from APIs 3.8 already has + (``cleanup`` since 3.2, ``addCleanup`` since 3.1). """ tmp = tempfile.TemporaryDirectory() diff --git a/tests/project/test_python_matrix_tiers_1633.py b/tests/project/test_python_matrix_tiers_1633.py index 82118f140c..0d7ba9caa8 100644 --- a/tests/project/test_python_matrix_tiers_1633.py +++ b/tests/project/test_python_matrix_tiers_1633.py @@ -7,9 +7,9 @@ * **Supported** -- 3.11 to 3.15, in every matrix (unit tests, compatibility, conda, PyPI), blocking as before. * **KTLO** -- end-of-life versions that still build and test (3.10 wherever it - ran, plus 3.7, 3.8 and 3.9 in the test matrices), never blocking: not CI, not + ran, plus 3.8 and 3.9 in the test matrices), never blocking: not CI, not a release, not a required context. -* ``requires-python`` stays ``>=3.6, <4``. +* ``requires-python`` stayed ``>=3.6, <4``, until #2051 raised it to ``>=3.8``. A KTLO leg can block in more ways than one, so each is pinned on its own: a job that ``required-checks`` or ``release_status`` aggregates through one @@ -23,13 +23,12 @@ every KTLO leg conclude ``success``, Compat's included, and report a failed step as a warning; :mod:`tests.project.test_ktlo_legs_stay_green_1672` pins that. -3.7 joined the tier afterwards, at the owner's request, and cannot install or -import pcapkit: the ``test`` extra's ``pytest>=8`` needs 3.8, and the sources use -3.8 syntax. So each of its legs fails a step by design, and -:class:`TestPython37IsKTLO` pins that they fail fast and say why, at warning level --- the install and verify steps are executed with ``python`` stubbed, and each -annotation is parsed as the runner parses it. 3.6 stays out: setup-python builds -it only for retired images. +3.7 joined the tier afterwards, at the owner's request, and left it with #2051, +whose ``>=3.8`` pip enforces at install, so no leg runs below that floor. A KTLO +leg that cannot install or compile still fails fast and says why, at warning +level, and :class:`TestAKTLOLegFailsFast` pins that -- the install and verify +steps are executed with ``python`` stubbed, and each annotation is parsed as the +runner parses it. Since #1660 the tiers are declared once, in :file:`.github/python-versions.toml`, and every matrix is read from it, so the expectations below are read from it @@ -77,8 +76,7 @@ def tier(impl: 'str', name: 'str') -> 'tuple[str, ...]': SUPPORTED = tier('cpython', 'supported') KTLO = tier('cpython', 'ktlo') #: The image each version needs where ``ubuntu-latest`` cannot provide it: -#: setup-python has no Ubuntu 26.04 build of 3.7-3.9, and ubuntu-22.04, 3.7's, is -#: deprecated and retires 2027-04-17. +#: setup-python has no Ubuntu 26.04 build of 3.8 or 3.9. PINNED_OS = {v: entry['runner'] for v, entry in TIERS['versions']['cpython'].items() if 'runner' in entry} #: The KTLO versions a job pinned to ``ubuntu-latest`` runs. KTLO_ON_LATEST = tuple(v for v in KTLO if v not in PINNED_OS) @@ -127,16 +125,17 @@ def tier(impl: 'str', name: 'str') -> 'tuple[str, ...]': ('python-compatibility.yml', 'compatibility', KTLO), ('create-release.yml', 'pypi', KTLO_ON_LATEST + tier('pypy', 'ktlo')), ) -#: The jobs 3.7 joins (#1633), each as (workflow, job, install-or-verify step). -LEGACY_37 = ( +#: The jobs a KTLO leg that cannot install or compile stops in, each as +#: (workflow, job, install-or-verify step). +KTLO_STEPS = ( ('unit-tests.yml', 'test-ktlo', 'Install package and test dependencies'), ('unit-tests.yml', 'integration-ktlo', 'Install package, test and generator dependencies'), ('python-compatibility.yml', 'compatibility', 'Verify package'), ) #: Claims the tier split made false, each worded as it was somewhere before #1633, -#: or before 3.7 joined the KTLO tier. A denylist: it catches these phrasings -#: coming back and examines no others. +#: or while 3.7 was in the KTLO tier, before #2051. A denylist: it catches these +#: phrasings coming back and examines no others. RETIRED_CLAIMS = re.compile('|'.join(( r'3\.15 as an allowed-to-fail leg', r'3\.15 runs allowed-to-fail', @@ -145,17 +144,15 @@ def tier(impl: 'str', name: 'str') -> 'tuple[str, ...]': r'CI covers \**3\.10', r'runs on 3\.10 through 3\.1[45]', r'repository supports 3\.10', - r'matrix floor is 3\.(?:10|8)', - r'3\.6 and 3\.7 are (?:in no matrix|left out|not run)', - r'KTLO tier \(3\.8-3\.10\)', - r'3\.8 through 3\.10\** are KTLO', - r'end-of-life (?:versions that still run, )?3\.8(?:-| to | through )3\.10', - r'the 3\.8-3\.10 legs', - r'KTLO legs 3\.8-3\.10', - r'3\.8 to 3\.10 are best-effort', - r'3\.8, 3\.9 and 3\.10 are end-of-life', - r"CI's range starts at 3\.8", - r'oldest tested leg is ``Python 3\.8', + r'matrix floor is 3\.10', + # #2051: 3.7 is below requires-python, and in no matrix. + r'KTLO tier \(3\.7-3\.10\)', + r'3\.7 through 3\.10\** are KTLO', + r'end-of-life (?:versions that still run, )?3\.7(?:-| to | through )3\.10', + r'3\.7 to 3\.10 are best-effort', + r'3\.7 cannot import', + r'3\.7 fails "Verify', + r'declares \**Python 3\.6', r"3\.7's legs are red by design", # #1672: no KTLO leg shows red, Compat's included, and a failed KTLO step's # own annotation is a warning. @@ -268,10 +265,11 @@ def test_the_supported_tier_is_what_the_classifiers_list(self) -> None: listed = re.findall(r'"Programming Language :: Python :: (3\.\d+)"', text) self.assertEqual(listed, list(SUPPORTED)) - def test_requires_python_is_unchanged(self) -> None: - """The owner's ruling: KTLO keeps old versions installable, it does not raise the floor.""" + def test_requires_python_is_the_oldest_ktlo_version(self) -> None: + """#2051 raised the floor to 3.8, the oldest version a KTLO leg runs.""" text = (ROOT / 'pyproject.toml').read_text(encoding='utf-8') - self.assertEqual(re.findall(r'(?m)^requires-python = (.+)$', text), ['">=3.6, <4"']) + self.assertEqual(re.findall(r'(?m)^requires-python = (.+)$', text), ['">=3.8"']) + self.assertEqual(min(KTLO, key=lambda v: tuple(map(int, v.split('.')))), '3.8') def test_ci_timing_pairs_the_coverage_leg_with_supported_legs_only(self) -> None: spec = importlib.util.spec_from_file_location('ci_timing_1633', ROOT / 'util' / 'ci_timing.py') @@ -497,20 +495,20 @@ def test_no_conda_leg_is_ktlo_and_315_leaves_base_python_alone(self) -> None: 'ERROR: Could not find a version that satisfies the requirement pytest>=8; ' 'extra == "test" (from pypcapkit[crypto,dpkt,ngap,test]) (from versions: 7.4.4)\n' 'ERROR: No matching distribution found for pytest>=8; extra == "test"') -LEG_37 = 'Python 3.7 (KTLO, non-blocking)' -#: :data:`LEG_37` as a ``title=`` property must carry it: ``,`` escaped (see :func:`runner_parse`). -TITLE_37 = 'Python 3.7 (KTLO%2C non-blocking)' -#: The 3.7 leg's matrix entry, and a supported leg's, as :meth:`TestPython37IsKTLO.run_step` takes them. -KTLO_37 = {'python-version': '3.7', 'ktlo': True} +LEG_KTLO = 'Python 3.8 (KTLO, non-blocking)' +#: :data:`LEG_KTLO` as a ``title=`` property must carry it: ``,`` escaped (see :func:`runner_parse`). +TITLE_KTLO = 'Python 3.8 (KTLO%2C non-blocking)' +#: A KTLO leg's matrix entry, and a supported leg's, as :meth:`TestAKTLOLegFailsFast.run_step` takes them. +KTLO_LEG = {'python-version': '3.8', 'ktlo': True} SUPPORTED_312 = {'python-version': '3.12'} #: Every KTLO leg's install step, as (workflow, job, step): it needs setup-python's ``python`` (#1672). INSTALLS = ( - LEGACY_37[0], LEGACY_37[1], + KTLO_STEPS[0], KTLO_STEPS[1], ('unit-tests.yml', 'pypcap-parity', 'Install package, test and generator dependencies'), ('python-compatibility.yml', 'compatibility', 'Install package'), ) -#: The install steps of the 3.7 legs, each running util/pip_install.py (#1967). -INSTALLS_37 = (LEGACY_37[0], LEGACY_37[1], INSTALLS[3]) +#: The install steps of :data:`KTLO_STEPS`, each running util/pip_install.py (#1967). +INSTALLS_KTLO = (KTLO_STEPS[0], KTLO_STEPS[1], INSTALLS[3]) def workflow_commands(output: 'str') -> 'list[str]': @@ -531,8 +529,8 @@ def runner_parse(line: 'str') -> 'Optional[tuple[str, dict[str, str], str]]': properties end at the first ``::`` after the leading one (``:69``), and are split on every ``,`` (``:100``, ``propertiesStr.Split(new[] { ',' }, ...)``) *before* ``%2C`` becomes a comma (``:30``, ``:106``). A piece holding no - ``=`` is dropped without a word, so ``title=on 3.7 (KTLO, non-blocking)`` - renders as "on 3.7 (KTLO". + ``=`` is dropped without a word, so ``title=on 3.8 (KTLO, non-blocking)`` + renders as "on 3.8 (KTLO". """ message = line.lstrip() @@ -562,20 +560,25 @@ def titles(output: 'str') -> 'list[str]': return [command[1].get('title', '') for command in parsed if command is not None] -class TestPython37IsKTLO(unittest.TestCase): - """3.7 runs, non-blocking, and stops at the first failure with one warning.""" +class TestAKTLOLegFailsFast(unittest.TestCase): + """A KTLO leg runs non-blocking, and stops at the first failure with one warning. + + Its inputs are what 3.7.16 printed while 3.7 had legs: pip refusing the ``test`` + extra, and compileall failing on 3.8 syntax. + + """ def setUp(self) -> None: if shutil.which('bash') is None: # pragma: no cover self.skipTest('bash is needed to run the workflow shell') - tmp = tempfile.TemporaryDirectory(prefix='pcapkit-1633-37-') # pylint: disable=consider-using-with + tmp = tempfile.TemporaryDirectory(prefix='pcapkit-1633-ktlo-') # pylint: disable=consider-using-with self.addCleanup(tmp.cleanup) self.tmp = pathlib.Path(tmp.name) def run_step(self, name: 'str', job_id: 'str', step_name: 'str', stub: 'str', env: 'dict[str, str]', leg: 'Optional[dict[str, Any]]' = None, ) -> 'subprocess.CompletedProcess[str]': - """Step ``step_name`` on ``leg`` (3.7's by default) under its shell, ``python`` stubbed. + """Step ``step_name`` on ``leg`` (:data:`KTLO_LEG` by default) under its shell, ``python`` stubbed. ``LEG`` and the job's ``ANNOTATION_LEVEL`` get the values their expressions take on that leg; ``-eo pipefail`` is what ``shell: bash`` runs, and ``-e`` @@ -583,7 +586,7 @@ def run_step(self, name: 'str', job_id: 'str', step_name: 'str', stub: 'str', the stub is its ``pip``. """ - leg = KTLO_37 if leg is None else leg + leg = KTLO_LEG if leg is None else leg spec = job(name, job_id) step = next(step for step in spec['steps'] if step.get('name') == step_name) self.assertIn(step.get('shell'), ('bash', None)) @@ -610,20 +613,18 @@ def run_step(self, name: 'str', job_id: 'str', step_name: 'str', stub: 'str', RUNNER_TEMP=str(self.tmp), **env), cwd=ROOT, capture_output=True, text=True, check=False, timeout=60) - def test_37_is_a_ktlo_leg_of_exactly_the_three_test_matrices(self) -> None: - """On ubuntu-22.04; not engine-tests or pypcap-parity (``ubuntu-latest`` only), nor a release leg.""" - for name, job_id, _ in LEGACY_37: - spec = job(name, job_id) - with self.subTest(workflow=name, job=job_id): - found = [leg for leg in legs(name, job_id) if leg['python-version'] == '3.7'] - self.assertEqual(found, [{'python-version': '3.7', 'ktlo': True, 'os': 'ubuntu-22.04'}]) - self.assertTrue(spec['name'].endswith((' (KTLO)', "${{ matrix.ktlo && ' (KTLO)' || '' }}"))) - joined = {(name, job_id) for name, job_id, _ in MATRICES + KTLO_LEGS - if '3.7' in [leg['python-version'] for leg in legs(name, job_id)]} - engines = [versions for _, versions in engine_inputs()] - if '3.7' in ' '.join(engines).split(): - joined.add(('unit-tests.yml', 'engine-tests')) - self.assertEqual(joined, {(name, job_id) for name, job_id, _ in LEGACY_37}) + def test_no_leg_runs_below_requires_python(self) -> None: + """#2051: pip refuses an interpreter below 3.8, so a leg there could only prove that.""" + def below(version: 'str') -> 'bool': + return bool(re.fullmatch(r'3\.\d+', version)) and tuple(map(int, version.split('.'))) < (3, 8) + + jobs = {(name, job_id) for name, job_id, *_ in MATRICES + KTLO_LEGS + KTLO_STEPS} + found = [f'{name} {job_id}: {leg["python-version"]}' for name, job_id in sorted(jobs) + for leg in legs(name, job_id) if below(str(leg['python-version']))] + found += [f'unit-tests.yml engine-tests: {version}' for _, versions in engine_inputs() + for version in versions.split() if below(version)] + self.assertEqual(found, []) + self.assertNotIn('3.7', TIERS['versions']['cpython']) def test_a_leg_that_cannot_install_stops_there(self) -> None: """The install needs setup-python's interpreter, and every later step needs the install. @@ -652,29 +653,29 @@ def test_a_leg_that_cannot_install_stops_there(self) -> None: self.assertIn("steps.install.outcome == 'success'", condition) def test_a_failed_install_fails_once_naming_pips_reason(self) -> None: - for name, job_id, install in INSTALLS_37: + for name, job_id, install in INSTALLS_KTLO: with self.subTest(job=job_id): result = self.run_step(name, job_id, install, PIP_STUB, {'PIP_OUTPUT': PIP_37, 'PIP_STATUS': '1'}) self.assertNotEqual(result.returncode, 0) self.assertEqual(workflow_commands(result.stdout + result.stderr), [ - f'::warning title=pcapkit did not install on {TITLE_37}::No matching distribution found ' + f'::warning title=pcapkit did not install on {TITLE_KTLO}::No matching distribution found ' 'for pytest>=8; extra == "test". The rest of this leg is skipped.']) - self.assertEqual(titles(result.stdout), [f'pcapkit did not install on {LEG_37}']) + self.assertEqual(titles(result.stdout), [f'pcapkit did not install on {LEG_KTLO}']) self.assertEqual((self.tmp / 'pip-install.log').read_text(encoding='utf-8'), PIP_37 + '\n') def test_an_install_failure_without_an_error_line_is_still_named(self) -> None: for output, reason in (('Killed', "pip failed without an ERROR line; see this step's log"), ('ERROR: 100% of the index timed out', '100%25 of the index timed out')): with self.subTest(output=output): - result = self.run_step('unit-tests.yml', 'test', LEGACY_37[0][2], PIP_STUB, + result = self.run_step('unit-tests.yml', 'test', KTLO_STEPS[0][2], PIP_STUB, {'PIP_OUTPUT': output, 'PIP_STATUS': '1'}) self.assertNotEqual(result.returncode, 0) self.assertEqual(workflow_commands(result.stdout), [ - f'::warning title=pcapkit did not install on {TITLE_37}::{reason}. ' + f'::warning title=pcapkit did not install on {TITLE_KTLO}::{reason}. ' 'The rest of this leg is skipped.']) def test_a_clean_install_passes_without_an_annotation(self) -> None: - for name, job_id, install in INSTALLS_37: + for name, job_id, install in INSTALLS_KTLO: with self.subTest(job=job_id): result = self.run_step(name, job_id, install, PIP_STUB, {'PIP_OUTPUT': 'Successfully installed pypcapkit-1.5.0', 'PIP_STATUS': '0'}) @@ -684,10 +685,10 @@ def test_a_clean_install_passes_without_an_annotation(self) -> None: def test_the_compatibility_leg_names_its_first_compile_error(self) -> None: cases = ( ({'COMPILE_OUTPUT': COMPILE_37, 'COMPILE_STATUS': '1', 'IMPORT_STATUS': '0'}, - [f'::warning title=pcapkit does not compile on {TITLE_37}::2 modules fail to compile, the first at ' + [f'::warning title=pcapkit does not compile on {TITLE_KTLO}::2 modules fail to compile, the first at ' 'File "pcapkit/const/ftp/command.py", line 286; see this step\'s log.']), ({'COMPILE_OUTPUT': '', 'COMPILE_STATUS': '0', 'IMPORT_STATUS': '1'}, - [f"::warning title=pcapkit does not import on {TITLE_37}::See the traceback in this step's log."]), + [f"::warning title=pcapkit does not import on {TITLE_KTLO}::See the traceback in this step's log."]), ({'COMPILE_OUTPUT': '', 'COMPILE_STATUS': '0', 'IMPORT_STATUS': '0'}, []), ) # type: tuple[tuple[dict[str, str], list[str]], ...] for env, expected in cases: @@ -697,7 +698,7 @@ def test_the_compatibility_leg_names_its_first_compile_error(self) -> None: self.assertEqual(bool(result.returncode), bool(expected), result.stderr) self.assertEqual(workflow_commands(result.stdout), expected) for title in titles(result.stdout): - self.assertTrue(title.endswith(f' on {LEG_37}'), title) + self.assertTrue(title.endswith(f' on {LEG_KTLO}'), title) if env['COMPILE_STATUS'] != '0': self.assertIn('SyntaxError: invalid syntax', result.stdout) @@ -706,7 +707,7 @@ def test_a_supported_leg_fails_the_same_steps_at_error_level(self) -> None: cases = [(name, job_id, install, PIP_STUB, {'PIP_OUTPUT': PIP_37, 'PIP_STATUS': '1'}, '::error title=pcapkit did not install on Python 3.12::No matching distribution found ' 'for pytest>=8; extra == "test". The rest of this leg is skipped.') - for name, job_id, install in INSTALLS_37] + for name, job_id, install in INSTALLS_KTLO] cases.append(('python-compatibility.yml', 'compatibility', 'Verify package', VERIFY_STUB, {'COMPILE_OUTPUT': '', 'COMPILE_STATUS': '0', 'IMPORT_STATUS': '1'}, "::error title=pcapkit does not import on Python 3.12::See the traceback in this step's log.")) diff --git a/tests/project/test_python_versions_update_1660.py b/tests/project/test_python_versions_update_1660.py index bd520a8007..f6a557aa91 100644 --- a/tests/project/test_python_versions_update_1660.py +++ b/tests/project/test_python_versions_update_1660.py @@ -546,6 +546,15 @@ def test_an_end_of_life_version_the_file_does_not_list_is_not_added(self) -> Non self.assertNotIn('3.6', result.entries) self.assertNotIn('3.5', result.entries) + def test_a_dropped_version_with_builds_left_is_not_re_added(self) -> None: + """#2051 dropped 3.7 by hand, below ``requires-python``, while setup-python still builds it.""" + sources = add_build(as_of_the_file(snapshot()), '3.7.17', True, (('linux', '24.04', 'x64'),)) + current = {version: entry for version, entry in frozen()['versions']['cpython'].items() + if version != '3.7'} + result = updater().compute_cpython(current, context(sources)) + self.assertNotIn('3.7', result.entries) + self.assertIn('3.8', result.entries) + def test_each_implementation_is_one_entry(self) -> None: """Another implementation is one more source and entry here, and its spelling in python_versions.py. diff --git a/tests/project/test_requires_python_floor_2051.py b/tests/project/test_requires_python_floor_2051.py new file mode 100644 index 0000000000..86cf54a4af --- /dev/null +++ b/tests/project/test_requires_python_floor_2051.py @@ -0,0 +1,84 @@ +# -*- coding: utf-8 -*- +"""#2051: 1.5.0 requires Python 3.8, and nothing converts the sources any more. + +The owner's ruling on #2040 raised ``requires-python`` to ``>=3.8`` and dropped the +``bpc-*`` backport tools, which are no longer maintained. Pinned here: + +* ``requires-python`` is floored at 3.8, and at nothing else; +* no requirement in :file:`pyproject.toml` names a ``bpc-`` distribution; +* no requirement carries a ``python_version`` marker that reads the same on every + version from the floor up, as ``scapy``'s ``>= '3.7'`` did; +* :file:`vermin.ini` targets the floor. + +""" + +from __future__ import annotations + +import pathlib +import re +import unittest + +try: + import tomllib +except ImportError: # pragma: no cover + tomllib = None # type: ignore[assignment] + +#: Test tier (#1958): reads pyproject.toml and vermin.ini, the same on every leg. +__tier__ = 'LATEST' + +ROOT = pathlib.Path(__file__).resolve().parents[2] + +#: The floor #2051 set. +FLOOR = (3, 8) + +#: A ``python_version`` comparison in a requirement's marker. +MARKER = re.compile(r'''python_version\s*(<=|>=|==|!=|<|>)\s*['"](\d+)\.(\d+)['"]''') + + +def constant_from_floor(operator: 'str', version: 'tuple[int, int]') -> 'bool': + """Whether ``python_version version`` reads the same on every version from :data:`FLOOR`.""" + if operator in ('<', '>='): + return version <= FLOOR + return version < FLOOR + + +class RequiresPythonFloorTests(unittest.TestCase): + """``requires-python``, the requirements and vermin agree on 3.8.""" + + def setUp(self) -> None: + if tomllib is None: # pragma: no cover + self.skipTest('tomllib needs Python 3.11') + self.pyproject = tomllib.loads((ROOT / 'pyproject.toml').read_text(encoding='utf-8')) + + def requirements(self) -> 'list[tuple[str, str]]': + """Every requirement string, with where it is declared.""" + project = self.pyproject['project'] + found = [('build-system.requires', text) for text in self.pyproject['build-system']['requires']] + found += [('project.dependencies', text) for text in project['dependencies']] + for extra, texts in project['optional-dependencies'].items(): + found += [(f'project.optional-dependencies.{extra}', text) for text in texts] + return found + + def test_requires_python_is_floored_at_3_8(self) -> None: + clauses = [clause.strip() for clause in self.pyproject['project']['requires-python'].split(',')] + floors = [clause for clause in clauses if clause.startswith('>')] + self.assertEqual(floors, ['>=3.8']) + + def test_no_bpc_requirement_remains(self) -> None: + stale = [f'{where}: {text}' for where, text in self.requirements() + if re.match(r'\s*bpc-', text, re.IGNORECASE)] + self.assertEqual(stale, []) + + def test_no_marker_is_constant_from_the_floor(self) -> None: + constant = [f'{where}: {text}' for where, text in self.requirements() + for operator, major, minor in MARKER.findall(text) + if constant_from_floor(operator, (int(major), int(minor)))] + self.assertEqual(constant, []) + + def test_vermin_targets_the_floor(self) -> None: + text = (ROOT / 'vermin.ini').read_text(encoding='utf-8') + self.assertEqual(re.findall(r'(?m)^targets = (.+)$', text), ['3.8']) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/project/test_setup.py b/tests/project/test_setup.py index 59dbad72d2..f0e28b76ea 100644 --- a/tests/project/test_setup.py +++ b/tests/project/test_setup.py @@ -2,7 +2,6 @@ import importlib.util import pathlib -import subprocess import sys import types import unittest @@ -25,27 +24,12 @@ def fake_setup(*args, **kwargs): captured['kwargs'] = kwargs fake_setuptools.setup = fake_setup - sys.modules['setuptools'] = fake_setuptools - - command_specs = { - 'setuptools.command.sdist': 'sdist', - 'setuptools.command.build_py': 'build_py', - 'setuptools.command.develop': 'develop', - 'setuptools.command.install': 'install', - } - - loaded = ['setuptools'] - for module_name, class_name in command_specs.items(): - module = types.ModuleType(module_name) - module.__dict__[class_name] = type(class_name, (), {}) - sys.modules[module_name] = module - loaded.append(module_name) - - spec = importlib.util.spec_from_file_location('project_setup', ROOT / 'setup.py') - module = importlib.util.module_from_spec(spec) - assert spec is not None and spec.loader is not None - spec.loader.exec_module(module) - return module, captured, loaded + with mock.patch.dict(sys.modules, {'setuptools': fake_setuptools}): + spec = importlib.util.spec_from_file_location('project_setup', ROOT / 'setup.py') + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module, captured def test_setup_passes_no_long_description(self) -> None: """``pyproject.toml``'s static ``readme`` is the long description (#1968). @@ -54,57 +38,19 @@ def test_setup_passes_no_long_description(self) -> None: description supplied here leaves ``readme`` dynamic and the stub's build failing. """ - _, captured, loaded = self._load_setup_module() - for name in loaded + ['project_setup']: - sys.modules.pop(name, None) - + _, captured = self._load_setup_module() self.assertNotIn('long_description', captured['kwargs']) self.assertNotIn('long_description_content_type', captured['kwargs']) - def test_refactor_runs_no_backports_on_modern_python(self) -> None: - module, _, loaded = self._load_setup_module() - try: - with mock.patch('subprocess.check_call', autospec=True) as check_call: - module.refactor('pcapkit') - finally: - for name in loaded + ['project_setup']: - sys.modules.pop(name, None) - - check_call.assert_not_called() - - def test_refactor_runs_required_backports_for_old_python(self) -> None: - module, _, loaded = self._load_setup_module() - try: - with mock.patch.object(module, 'sys') as fake_sys, \ - mock.patch('subprocess.check_call', autospec=True) as check_call: - fake_sys.executable = sys.executable - fake_sys.version_info = (3, 5) - fake_sys.exit.side_effect = AssertionError('should not exit') + def test_setup_overrides_no_build_command(self) -> None: + """No ``cmdclass``: the commands only ever ran the BPC conversion (#2051). - module.refactor('pcapkit') - finally: - for name in loaded + ['project_setup']: - sys.modules.pop(name, None) - - commands = [call.args[0][2] for call in check_call.call_args_list] - self.assertEqual(commands, ['f2format', 'walrus', 'poseur']) - - def test_refactor_exits_when_backport_command_fails(self) -> None: - module, _, loaded = self._load_setup_module() - try: - with mock.patch.object(module, 'sys') as fake_sys, \ - mock.patch('subprocess.check_call', autospec=True, side_effect=subprocess.CalledProcessError(3, 'f2format')): - fake_sys.executable = sys.executable - fake_sys.version_info = (3, 5) - fake_sys.exit.side_effect = SystemExit(3) - - with self.assertRaises(SystemExit) as error: - module.refactor('pcapkit') - finally: - for name in loaded + ['project_setup']: - sys.modules.pop(name, None) - - self.assertEqual(error.exception.code, 3) + Nothing converts the 3.8 syntax any more, so a build command overridden + here has nothing left to add to setuptools' own. + """ + module, captured = self._load_setup_module() + self.assertEqual(captured, {'args': (), 'kwargs': {}}) + self.assertFalse(hasattr(module, 'refactor')) if __name__ == '__main__': diff --git a/tests/project/test_stdlib_feature_probes_1709_unit.py b/tests/project/test_stdlib_feature_probes_1709_unit.py index a022b5d05f..c7b2db9df9 100644 --- a/tests/project/test_stdlib_feature_probes_1709_unit.py +++ b/tests/project/test_stdlib_feature_probes_1709_unit.py @@ -52,9 +52,10 @@ ('pcapkit/vendor/default.py', "getattr(sys, 'stdin', None)"), } -#: Attributes that only some supported interpreters define on their objects, and -#: the version that added them. A literal probe for one is a version probe on any -#: receiver, which is why these are matched by name alone. +#: Attributes an older interpreter lacked on its objects, and the version that +#: added them. A literal probe for one is a version probe on any receiver, which +#: is why these are matched by name alone; from the 3.8 floor (#2051) it is a +#: dead one as well. VERSIONED = { 'end_lineno': (3, 8), # ast node end positions 'end_col_offset': (3, 8), diff --git a/tests/project/test_unit_tests_push_reports_2030.py b/tests/project/test_unit_tests_push_reports_2030.py index 6d7af47e67..c4a8475a50 100644 --- a/tests/project/test_unit_tests_push_reports_2030.py +++ b/tests/project/test_unit_tests_push_reports_2030.py @@ -92,22 +92,22 @@ def check_runs(*runs: 'tuple[str, str, list[tuple[str, ...]]]', more: 'bool' = F KTLO = ('Python 3.8 (KTLO)', 'SUCCESS', [('WARNING', 'tests failed on Python 3.8 (KTLO, non-blocking)'), ('FAILURE', '')]) -#: The annotations main's run writes by design, as the step's ``EXPECTED`` lists them. -BY_DESIGN = { +#: The annotations main's run writes by design, as the step's ``EXPECTED`` lists them: +#: none since #2051 dropped the 3.7 legs and #2040's cap let pypy3.10 install. +BY_DESIGN = {} # type: dict[str, tuple[tuple[str, str], ...]] +#: The list as it stood for pypy3.10's install failure, before #2040, which the +#: tests of the matching itself install in place of :data:`BY_DESIGN`. +LISTED = { job: (('WARNING', f'install failed on {leg} (KTLO, non-blocking)'), ('FAILURE', ''), ('WARNING', f'pcapkit did not install on {leg} (KTLO, non-blocking)')) - for job, leg in (('Python 3.7 (KTLO)', 'Python 3.7'), ('Integration Python 3.7 (KTLO)', 'Python 3.7'), - ('Python pypy3.10 (KTLO)', 'Python pypy3.10')) + for job, leg in (('Python pypy3.10 (KTLO)', 'Python pypy3.10'),) } #: Unit Tests run 39718608950's jobs with any annotation but a notice, as the #: check-suite query returned them on 2026-10-22, and one job with a notice alone. +#: Its 3.7 legs are left out: #2051 dropped them, and their entries in +#: ``EXPECTED`` with them. REAL_KTLO = "KTLO legs never block (#1633), so this job still succeeds (#1672); see the install step's log." REAL_RUN = ( - ('Python 3.7 (KTLO)', 'SUCCESS', [ - ('WARNING', 'install failed on Python 3.7 (KTLO, non-blocking)', REAL_KTLO), - ('FAILURE', '', 'Process completed with exit code 1.'), - ('WARNING', 'pcapkit did not install on Python 3.7 (KTLO, non-blocking)', - 'No matching distribution found for pytest>=8; extra == "test". The rest of this leg is skipped.')]), ('Python pypy3.10 (KTLO)', 'SUCCESS', [ ('WARNING', 'install failed on Python pypy3.10 (KTLO, non-blocking)', REAL_KTLO), ('FAILURE', '', 'Process completed with exit code 1.'), @@ -115,11 +115,6 @@ def check_runs(*runs: 'tuple[str, str, list[tuple[str, ...]]]', more: 'bool' = F "pip failed without an ERROR line; see this step's log. The rest of this leg is skipped."), ('NOTICE', '', '"The ubuntu-latest label will migrate to Ubuntu 26 beginning October 19, 2026. For more ' 'information, see https://github.com/actions/runner-images/issues/14748"')]), - ('Integration Python 3.7 (KTLO)', 'SUCCESS', [ - ('WARNING', 'install failed on Python 3.7 (KTLO, non-blocking)', REAL_KTLO), - ('FAILURE', '', 'Process completed with exit code 1.'), - ('WARNING', 'pcapkit did not install on Python 3.7 (KTLO, non-blocking)', - 'No matching distribution found for pytest>=8; extra == "test". The rest of this leg is skipped.')]), GREEN, ) @@ -226,10 +221,15 @@ def write_launcher(directory: 'pathlib.Path', name: 'str', script: 'str') -> Non launcher.chmod(0o755) -def run_step(name: 'str', cwd: 'pathlib.Path', env: 'dict[str, str]') -> 'subprocess.CompletedProcess[str]': - """Step ``name``'s ``run:`` as a bash step runs it.""" +def run_step(name: 'str', cwd: 'pathlib.Path', env: 'dict[str, str]', + expected: 'Optional[dict[str, Any]]' = None) -> 'subprocess.CompletedProcess[str]': + """Step ``name``'s ``run:`` as a bash step runs it, with ``EXPECTED`` replaced by ``expected`` if given.""" script = cwd.parent / 'step.sh' - script.write_text(named(name)['run'], encoding='utf-8') + source = named(name)['run'] + if expected is not None: + assert source.count('\nEXPECTED = {}\n') == 1, 'the step no longer reads EXPECTED = {}' + source = source.replace('\nEXPECTED = {}\n', f'\nEXPECTED = {expected!r}\n') + script.write_text(source, encoding='utf-8') return subprocess.run([str(BASH), '--noprofile', '--norc', '-eo', 'pipefail', str(script)], # nosec: B603 cwd=cwd, env=env, capture_output=True, text=True, check=False, timeout=120) @@ -253,7 +253,8 @@ def setUp(self) -> None: self.output = self.root / 'output' def report(self, conclusion: 'str' = 'success', answer: 'Optional[object]' = None, - branch: 'str' = 'main', **extra: 'str') -> 'tuple[dict[str, str], str, str]': + branch: 'str' = 'main', expected: 'Optional[dict[str, Any]]' = None, + **extra: 'str') -> 'tuple[dict[str, str], str, str]': """The step's outputs, its report and its stdout, for a run that ended ``conclusion``.""" shutil.rmtree(self.work / 'report', ignore_errors=True) self.output.write_text('', encoding='utf-8') @@ -264,7 +265,7 @@ def report(self, conclusion: 'str' = 'success', answer: 'Optional[object]' = Non if answer is not None: self.answer.write_text(answer if isinstance(answer, str) else json.dumps(answer), encoding='utf-8') env['FAKE_SUITE_ANSWER'] = str(self.answer) - result = run_step('Write the run report', self.work, env) + result = run_step('Write the run report', self.work, env, expected) self.assertEqual(result.returncode, 0, result.stdout + result.stderr) outputs = dict(line.split('=', 1) for line in self.output.read_text(encoding='utf-8').splitlines()) return outputs, (self.work / REPORT).read_text(encoding='utf-8'), result.stdout @@ -304,13 +305,20 @@ def test_a_green_run_whose_ktlo_leg_warned_is_a_warning(self) -> None: def test_the_by_design_list_is_pinned(self) -> None: """``EXPECTED`` holds the install failures main's KTLO legs write on every run, and nothing else.""" source = named('Write the run report')['run'] - start = source.index('EXPECTED = {') - end = source.index('\n}\n', start) + 2 - self.assertEqual(ast.literal_eval(source[start + len('EXPECTED = '):end]), BY_DESIGN) + script = source.split("<<'PY'\n", 1)[1].split('\nPY\n', 1)[0] + [value] = [node.value for node in ast.parse(script).body if isinstance(node, ast.Assign) + and [getattr(target, 'id', None) for target in node.targets] == ['EXPECTED']] + self.assertEqual(ast.literal_eval(value), BY_DESIGN) + + def test_with_nothing_listed_the_old_install_failures_warn(self) -> None: + """Run 39718608950's pypy3.10 install failure is no longer by design (#2040).""" + outputs, report, _ = self.report('success', check_runs(*REAL_RUN)) + self.assertEqual(outputs['status'], 'warning') + self.assertIn('- `Python pypy3.10 (KTLO)`: `install failed on Python pypy3.10 (KTLO, non-blocking)`', report) def test_mains_by_design_annotations_leave_a_green_run_green(self) -> None: """Run 39718608950's annotations, as the API returned them: ``success``, and none listed.""" - outputs, report, _ = self.report('success', check_runs(*REAL_RUN)) + outputs, report, _ = self.report('success', check_runs(*REAL_RUN), expected=LISTED) self.assertEqual(outputs['status'], 'success') self.assertEqual(report, ( '### Unit Tests push run\n\n' @@ -319,22 +327,23 @@ def test_mains_by_design_annotations_leave_a_green_run_green(self) -> None: def test_a_reworded_message_still_matches(self) -> None: """The title is matched, not the message, which #2041 rewords.""" - reworded = [(level, title, 'something else entirely') for level, title, _ in REAL_RUN[1][2]] - outputs, _, _ = self.report('success', check_runs(('Python pypy3.10 (KTLO)', 'SUCCESS', reworded))) + reworded = [(level, title, 'something else entirely') for level, title, _ in REAL_RUN[0][2]] + outputs, _, _ = self.report('success', check_runs(('Python pypy3.10 (KTLO)', 'SUCCESS', reworded)), + expected=LISTED) self.assertEqual(outputs['status'], 'success') def test_anything_else_still_warns(self) -> None: """An annotation the list does not hold, beside the by-design ones or in their place, is a warning.""" failed = 'FAILED tests/project/test_x.py::TestX::test_y - AssertionError' cases = { - 'a failed test on a by-design job': ('Python 3.7 (KTLO)', 'WARNING', failed), + 'a failed test on a by-design job': ('Python pypy3.10 (KTLO)', 'WARNING', failed), 'a failed tests step on a by-design job': ( - 'Python 3.7 (KTLO)', 'WARNING', 'tests failed on Python 3.7 (KTLO, non-blocking)'), - 'a titled failure on a by-design job': ('Integration Python 3.7 (KTLO)', 'FAILURE', 'Something broke'), + 'Python pypy3.10 (KTLO)', 'WARNING', 'tests failed on Python pypy3.10 (KTLO, non-blocking)'), + 'a titled failure on a by-design job': ('Python pypy3.10 (KTLO)', 'FAILURE', 'Something broke'), 'a by-design title at another level': ( 'Python pypy3.10 (KTLO)', 'FAILURE', 'install failed on Python pypy3.10 (KTLO, non-blocking)'), 'a by-design title on another job': ( - 'Python 3.8 (KTLO)', 'WARNING', 'install failed on Python 3.7 (KTLO, non-blocking)'), + 'Python 3.8 (KTLO)', 'WARNING', 'install failed on Python pypy3.10 (KTLO, non-blocking)'), 'an install failure on a leg not listed': ( 'Python 3.8 (KTLO)', 'WARNING', 'install failed on Python 3.8 (KTLO, non-blocking)'), } @@ -346,7 +355,7 @@ def test_anything_else_still_warns(self) -> None: else: runs.append((job, 'SUCCESS', [(level, title, 'extra')])) with self.subTest(case=case): - outputs, report, _ = self.report('success', check_runs(*runs)) + outputs, report, _ = self.report('success', check_runs(*runs), expected=LISTED) self.assertEqual(outputs['status'], 'warning') [line] = [line for line in report.splitlines() if line.startswith('- ')] self.assertEqual(line, f'- `{job}`: `{title}`') @@ -359,12 +368,11 @@ def test_a_job_that_failed_without_failing_the_run_is_a_warning(self) -> None: def test_a_failed_run_names_its_jobs(self) -> None: answer = check_runs(*REAL_RUN, KTLO, ('Project tests', 'FAILURE', [('FAILURE', '')]), ('Required checks passed', 'FAILURE', []), ('Python 3.13', 'TIMED_OUT', [])) - outputs, report, _ = self.report('failure', answer) + outputs, report, _ = self.report('failure', answer, expected=LISTED) self.assertEqual(outputs['status'], 'failure') self.assertIn('**Did not pass:**\n\n- `Project tests` (failure)\n- `Required checks passed` (failure)\n' '- `Python 3.13` (timed_out)\n', report) self.assertIn('- `Python 3.8 (KTLO)`: ', report) - self.assertNotIn('Python 3.7', report) self.assertNotIn('pypy3.10', report) def test_unreadable_jobs_keep_the_conclusion(self) -> None: diff --git a/tests/protocols/link/test_link_unit.py b/tests/protocols/link/test_link_unit.py index 7527a9c147..61ef109f32 100644 --- a/tests/protocols/link/test_link_unit.py +++ b/tests/protocols/link/test_link_unit.py @@ -540,9 +540,7 @@ def test_ethernet_properties_read_make_and_mac_helpers(self) -> None: self.assertEqual(schema.src, b'\x11\x22\x33\x44\x55\x66') self.assertEqual(schema.type, EtherType.Internet_Protocol_version_6) - with mock.patch('pcapkit.protocols.link.ethernet.py38', False): - self.assertEqual(maker._read_mac_addr(b'\xaa\xbb\xcc\xdd\xee\xff'), - 'aa:bb:cc:dd:ee:ff') + self.assertEqual(maker._read_mac_addr(b'\xaa\xbb\xcc\xdd\xee\xff'), 'aa:bb:cc:dd:ee:ff') with self.assertRaises(ProtocolError): maker._make_mac_addr('not-a-mac') @@ -582,9 +580,8 @@ def read_operation(oper: int) -> tuple[str, str]: arp = object.__new__(ARP) self.assertEqual(arp._read_addr_resolve(b'\x01\x02', Hardware.Experimental_Ethernet), '0102') - with mock.patch('pcapkit.protocols.link.arp.py38', False): - self.assertEqual(arp._read_addr_resolve(b'\x00\x11\x22\x33\x44\x55', Hardware.Ethernet), - '00:11:22:33:44:55') + self.assertEqual(arp._read_addr_resolve(b'\x00\x11\x22\x33\x44\x55', Hardware.Ethernet), + '00:11:22:33:44:55') self.assertEqual(arp._read_proto_resolve(b'\x20\x01\x0d\xb8' + b'\x00' * 12, EtherType.Internet_Protocol_version_6), ip_address('2001:db8::')) diff --git a/tests/protocols/misc/test_pcapng_unit.py b/tests/protocols/misc/test_pcapng_unit.py index 55f5669ae7..4e889c76d4 100644 --- a/tests/protocols/misc/test_pcapng_unit.py +++ b/tests/protocols/misc/test_pcapng_unit.py @@ -595,10 +595,9 @@ def fromtimestamp(cls, *args): instant = datetime.datetime.fromtimestamp(6, datetime.timezone.utc) self.assertEqual(pcapng._make_timestamp(instant, interface_id=0), (0, 4_000_000)) - with mock.patch('pcapkit.protocols.misc.pcapng.py38', False): - self.assertEqual(pcapng._read_mac_addr(b'\x00\x01\x02\x03\x04\x05'), '00:01:02:03:04:05') - self.assertEqual(pcapng._read_eui_addr(bytes.fromhex('023456fffe789abc')), - '02:34:56:ff:fe:78:9a:bc') + self.assertEqual(pcapng._read_mac_addr(b'\x00\x01\x02\x03\x04\x05'), '00:01:02:03:04:05') + self.assertEqual(pcapng._read_eui_addr(bytes.fromhex('023456fffe789abc')), + '02:34:56:ff:fe:78:9a:bc') proto_map = PCAPNG.__dict__['__proto__'] block_map = PCAPNG.__dict__['__block__'] diff --git a/tests/protocols/test_protocol_make_unit.py b/tests/protocols/test_protocol_make_unit.py index 9ac649e4e1..86fb44b045 100644 --- a/tests/protocols/test_protocol_make_unit.py +++ b/tests/protocols/test_protocol_make_unit.py @@ -23,7 +23,6 @@ """ import importlib -import sys import unittest import unittest.mock from typing import TYPE_CHECKING @@ -247,8 +246,7 @@ def make_split(self: 'Any', a: 'Any', a_namespace: 'Any' = None) -> 'Any': namespace=a_namespace) first = make_split.__code__.co_firstlineno + 1 - last = first + 1 if sys.version_info >= (3, 8) else first - self.assertEqual(module._make_index_calls(make_split), ((first, last, 'a'),)) + self.assertEqual(module._make_index_calls(make_split), ((first, first + 1, 'a'),)) if __name__ == '__main__': diff --git a/tests/test_support_helpers.py b/tests/test_support_helpers.py index a58f9a6cfb..8938ff3676 100644 --- a/tests/test_support_helpers.py +++ b/tests/test_support_helpers.py @@ -484,7 +484,7 @@ def test_each_raise_re_arms_the_alarm_for_one_second(self) -> None: expire(signal.SIGALRM, None) self.assertEqual(signal.alarm(0), 1) - @unittest.skipUnless(sys.implementation.name == 'cpython' and sys.version_info >= (3, 8), + @unittest.skipUnless(sys.implementation.name == 'cpython', 'needs a finaliser run on the last reference, and sys.unraisablehook') def test_a_raise_a_finaliser_drops_is_raised_again(self) -> None: """#1892: the first raise lands in a finaliser and is lost; the repeat is not. diff --git a/tests/test_tier_guard.py b/tests/test_tier_guard.py index e1ddd022c4..752b59109d 100644 --- a/tests/test_tier_guard.py +++ b/tests/test_tier_guard.py @@ -1504,12 +1504,12 @@ def test_scapy_is_floored_at_the_first_release_main_passes_on(self) -> None: On 2.5.0 ``main`` reads a PCAP-NG nanosecond interface as microseconds and ``packet2dict`` reports DNS records and field order differently; 2.6.0, the - next release, fixes all three. The marker is ``PCAP_CT``'s courtesy: 2.6.0 - needs Python 3.7, above this project's ``requires-python`` floor. + next release, fixes all three. Unmarked: 2.6.0 needs Python 3.7, below this + project's ``requires-python`` floor (#2051). """ declared = _dependency_gates.declared_requirements() - floor = "scapy>=2.6.0; python_version >= '3.7'" + floor = 'scapy>=2.6.0' self.assertEqual([requirement.text for requirement in declared['Scapy']], [floor]) self.assertIn(floor, [requirement.text for requirement in declared['dev']]) diff --git a/tests/utilities/_harness.py b/tests/utilities/_harness.py index a382117e85..b977d20d70 100644 --- a/tests/utilities/_harness.py +++ b/tests/utilities/_harness.py @@ -6,7 +6,7 @@ * :func:`capture` -- collect every record a :class:`logging.Logger` emits, at any level, without also printing it. :mod:`unittest.TestCase.assertNoLogs` would do half of this but only exists on Python 3.10+, and this project declares support - from 3.6. + from 3.8. * :func:`bootstrap` -- (re)load the :mod:`pcapkit` core modules with :envvar:`PCAPKIT_DEVMODE` forced on or off. The flag is read once, at import time, so a test that cares about it has to control the environment *before* the diff --git a/tests/utilities/test_compat.py b/tests/utilities/test_compat.py index 7ec21e9265..1e8c63c343 100644 --- a/tests/utilities/test_compat.py +++ b/tests/utilities/test_compat.py @@ -6,8 +6,10 @@ supports. Only one of the two can run on any given interpreter, so the only way to test the other is to lie to the module about which interpreter it is on -- :meth:`CompatTests.load_compat_as` executes the file a second time with -:data:`sys.version_info` faked to ``(3, 5)``. ``default_threading_excepthook`` -has a third, for 3.8 and 3.9 only, which a fake ``(3, 9)`` reaches instead. +:data:`sys.version_info` faked to ``(3, 8)``, the oldest interpreter +``requires-python`` admits (#2051), which takes every fallback at once. A fake +``(3, 9)`` checks the identity ``default_threading_excepthook``'s 3.8 and 3.9 +branch rests on. That lie is not free, and containing its cost is most of what this module is about. A fake version is process-global for as long as it is installed, so *any* @@ -51,6 +53,8 @@ """ from __future__ import annotations +import _thread +import contextlib import decimal import enum import importlib @@ -58,6 +62,7 @@ import subprocess import sys import textwrap +import threading import types import unittest import urllib.parse @@ -115,12 +120,13 @@ #: #: The list is **read off** :file:`pcapkit/utilities/compat.py`, not arrived at by #: running the test until it stopped complaining, and that distinction is the -#: point of having it. Every import statement on a branch reachable at ``(3, 5)``: -#: :mod:`collections.abc` (line 34), :mod:`pathlib` (63), :mod:`threading` (67), -#: :mod:`typing` (4, 68, 126), :mod:`urllib.parse` (137, 138), :mod:`aenum` (173), -#: :mod:`typing_extensions` (191, 245), :mod:`contextlib` (196), :mod:`decimal` -#: (197), :mod:`enum` (222). A fake ``(3, 9)`` reaches three more: :mod:`builtins` -#: (31), :mod:`functools` (123) and :mod:`_thread` (270). +#: point of having it. Every module imported at ``(3, 8)``: :mod:`builtins`, +#: :mod:`collections.abc`, :mod:`functools`, :mod:`pathlib` and :mod:`typing`, +#: which no version gate guards any more (#2051), and, behind the gates that +#: remain, :mod:`urllib.parse`, :mod:`aenum`, :mod:`typing_extensions`, +#: :mod:`contextlib`, :mod:`decimal`, :mod:`enum` and :mod:`_thread`. +#: :mod:`threading` stays on the list because :meth:`CompatTests.load_compat_as` +#: reads :data:`threading.excepthook` for PyPy's stand-in ``_thread._excepthook``. #: #: Four of those never actually surface, and *why* is the point. Three -- #: :mod:`collections.abc`, :mod:`contextlib` and :mod:`pathlib` -- are imported at @@ -146,14 +152,10 @@ #: already owes them (issue #674). #: #: Excluding them is safe for a reason that does not extend to the third-party -#: modules this file *does* check, and the distinction is the point. Four -#: :mod:`pcapkit` modules do memoise the interpreter version at import time in the -#: same shape :mod:`aenum` does -- ``py37``/``py38`` in -#: :mod:`pcapkit.protocols.misc.pcap.frame`, :mod:`pcapkit.protocols.misc.pcapng`, -#: :mod:`pcapkit.protocols.link.ethernet` and :mod:`pcapkit.protocols.link.arp`. -#: None of them is reachable from the branches faked here, and if one became -#: reachable it would still be harmless: the region is restored around every -#: test, so the next import recomputes the value from source. A third-party +#: modules this file *does* check, and the distinction is the point. Were a +#: :mod:`pcapkit` module to memoise the interpreter version at import time, as +#: :mod:`aenum` does, it would still be harmless: the region is restored around +#: every test, so the next import recomputes the value from source. A third-party #: module is never purged, which is exactly why its cache is permanent and why the #: assertion is aimed there. OWN_MODULE_PREFIX = 'pcapkit' @@ -285,7 +287,7 @@ def load_compat_as(self, module_name: str, version: 'tuple[int, int]') -> 'objec module_name: Dotted name to execute the file under. Must not be the name the real module holds, or the faked module would be bound over it. - version: The :data:`sys.version_info` to fake. ``(3, 5)`` takes + version: The :data:`sys.version_info` to fake. ``(3, 8)`` takes every fallback at once. Returns: @@ -297,7 +299,13 @@ def load_compat_as(self, module_name: str, version: 'tuple[int, int]') -> 'objec importlib.import_module(name) before = frozenset(sys.modules) - with mock.patch.object(sys, 'version_info', version): + with contextlib.ExitStack() as stack: + stack.enter_context(mock.patch.object(sys, 'version_info', version)) + if not hasattr(_thread, '_excepthook'): + # PyPy's _thread has none, which the 3.8 and 3.9 branch imports; + # no tier runs a PyPy before 3.10 (#1958), so a stand-in will do. + stack.enter_context(mock.patch.object(_thread, '_excepthook', threading.excepthook, + create=True)) compat = load_module(module_name, 'pcapkit/utilities/compat.py') leaked = sorted( @@ -417,7 +425,7 @@ def test_default_threading_excepthook_is_the_hook_the_interpreter_started_with(s import sys import threading - STARTUP = threading.excepthook if sys.version_info >= (3, 8) else None + STARTUP = threading.excepthook def host_hook(args): pass @@ -440,12 +448,12 @@ def test_faking_the_version_leaves_aenum_reading_the_real_one(self) -> None: """The regression test for issue #687. Named rather than folded into - :meth:`test_python35_fallback_implementations`, because the invariant it + :meth:`test_python38_fallback_implementations`, because the invariant it pins has nothing to do with the shims: it is about what faking a version around a real ``import`` costs whatever else is in the process. Self-contained on purpose: it installs the fake itself rather than relying - on :meth:`test_python35_fallback_implementations` having run first. Under + on :meth:`test_python38_fallback_implementations` having run first. Under :mod:`unittest` a class's methods run in alphabetical order and under :program:`pytest` in definition order, so which of the two goes first is a property of their names and their position in this file -- neither of @@ -462,6 +470,10 @@ def test_faking_the_version_leaves_aenum_reading_the_real_one(self) -> None: here costs two lines and no ``import pcapkit``, which keeps this module in the unit tier (see :mod:`tests._tiers`). + It fakes ``(3, 5)`` rather than ``(3, 8)``, the oldest supported: this + module takes the same branches at both, and only below 3.6 does a + poisoned :mod:`aenum` also fail the closing lines, not just the readings. + """ purge_modules(['pcapkit.utilities.compat_py35_probe']) self.load_compat_as('pcapkit.utilities.compat_py35_probe', (3, 5)) @@ -484,12 +496,10 @@ class TransportProtocolLike(aenum.IntFlag): self.assertEqual(TransportProtocolLike.TCP.value, 6) - def test_python35_fallback_implementations(self) -> None: - purge_modules(['pcapkit.utilities.compat_py35']) - compat = self.load_compat_as('pcapkit.utilities.compat_py35', (3, 5)) + def test_python38_fallback_implementations(self) -> None: + purge_modules(['pcapkit.utilities.compat_py38']) + compat = self.load_compat_as('pcapkit.utilities.compat_py38', (3, 8)) - self.assertTrue(issubclass(compat.ModuleNotFoundError, ImportError)) - self.assertIsNone(compat.default_threading_excepthook) self.assertIsNone(compat.UnionType) # The reader infoclass and protocol used below 3.10 (#1692, #1709): the @@ -502,63 +512,6 @@ class Annotated: self.assertIs(compat.get_annotations(Annotated), Annotated.__dict__['__annotations__']) - class CollectionLike: - def __len__(self): return 0 - def __iter__(self): return iter(()) - def __contains__(self, item): return False - - class BrokenCollection: - __len__ = None - def __iter__(self): return iter(()) - def __contains__(self, item): return False - - class BaseCollection: - def __len__(self): return 0 - def __iter__(self): return iter(()) - def __contains__(self, item): return False - - class DerivedCollection(BaseCollection): - pass - - class MissingCollection: - def __len__(self): return 0 - def __iter__(self): return iter(()) - - self.assertTrue(compat.Collection.__subclasshook__(CollectionLike)) - self.assertTrue(compat.Collection.__subclasshook__(DerivedCollection)) - self.assertEqual(compat.Collection.__subclasshook__(BrokenCollection), NotImplemented) - self.assertEqual(compat.Collection.__subclasshook__(MissingCollection), NotImplemented) - - class Demo: - def __init__(self) -> None: - self.calls = 0 - - @compat.cached_property - def value(self) -> int: - self.calls += 1 - return 10 - - demo = Demo() - self.assertEqual(demo.value, 10) - self.assertEqual(demo.value, 10) - self.assertEqual(demo.calls, 1) - self.assertIs(Demo.__dict__['value'].__get__(None, Demo), Demo.__dict__['value']) - self.assertEqual(Demo.__dict__['value'].__get__(demo, Demo), 10) - - prop = compat.cached_property(lambda self: 1) - prop.__set_name__(Demo, 'first') - prop.__set_name__(Demo, 'first') - with self.assertRaises(TypeError): - prop.__set_name__(Demo, 'second') - - class SlotDemo: - __slots__ = () - - prop = compat.cached_property(lambda self: 1) - prop.__set_name__(SlotDemo, 'value') - with self.assertRaises(TypeError): - prop.__get__(SlotDemo(), SlotDemo) - original_prec = decimal.getcontext().prec with compat.localcontext(prec=9) as ctx: self.assertEqual(ctx.prec, 9) diff --git a/util/apt_packages.py b/util/apt_packages.py index c3288a4f94..a4f10b9740 100644 --- a/util/apt_packages.py +++ b/util/apt_packages.py @@ -50,7 +50,7 @@ running as root in a process group of its own, ends with apt. It is given no option, so uutils' ``timeout`` on Ubuntu 26.04 behaves as GNU's (#2020). The step's ``timeout-minutes`` stays the hang detector. The script is standard library -only, and runs on every interpreter the matrices hold, 3.7 included. +only, and runs on every interpreter the matrices hold. """ diff --git a/util/pip_install.py b/util/pip_install.py index 0ed1eb6f90..03d047c470 100644 --- a/util/pip_install.py +++ b/util/pip_install.py @@ -30,7 +30,7 @@ so every later step, which waits on the install's ``outcome``, is skipped. An ``ANNOTATION_LEVEL`` other than ``error`` or ``warning`` exits 2 before pip runs. The script is standard library only, and runs on every interpreter the matrices -hold, 3.7 and the experimental tier included. +hold, the experimental tier included. """ diff --git a/util/update_python_versions.py b/util/update_python_versions.py index 7212aafad7..e7e96a8ba8 100644 --- a/util/update_python_versions.py +++ b/util/update_python_versions.py @@ -784,7 +784,7 @@ class Implementation(NamedTuple): def entry_line(version: 'str', entry: 'dict[str, str]', comment: 'str' = '') -> 'str': - """One version's line, as the file spells it: ``"3.7" = { tier = "ktlo", runner = "ubuntu-22.04" }``.""" + """One version's line, as the file spells it: ``"3.8" = { tier = "ktlo", runner = "ubuntu-24.04" }``.""" fields = [f'tier = {json.dumps(entry["tier"])}'] if 'runner' in entry: fields.append(f'runner = {json.dumps(entry["runner"])}') diff --git a/vermin.ini b/vermin.ini index de4c5cca8f..1e0deb4d41 100644 --- a/vermin.ini +++ b/vermin.ini @@ -28,7 +28,7 @@ verbose = 3 # detected. # # Examples: -targets = 3.6 +targets = 3.8 ### Concurrent processing ### # Use N concurrent processes to detect and analyze files. Defaults to 0, meaning all cores