Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 33 additions & 6 deletions .github/workflows/unit-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -104,10 +104,16 @@ jobs:
ANNOTATION_LEVEL: ${{ matrix.ktlo && 'warning' || 'error' }}
strategy:
fail-fast: false
# #1660: .github/python-versions.toml's supported CPython tier, through
# the `python-versions` job, as the `python-version` axis; it blocks
# through `required-checks` (#1633). `include` marks one leg
# `coverage: true`. The KTLO legs are `test-ktlo`'s (#1738).
# #1660: .github/python-versions.toml's supported tier, CPython's then
# PyPy's, through the `python-versions` job, as the `python-version`
# axis; it blocks through `required-checks` (#1633). `include` marks one
# leg `coverage: true`. The KTLO legs are `test-ktlo`'s (#1738).
#
# #1958, the owner's ruling E: every implementation runs the same `ALL`
# tier, so PyPy's legs run it as every CPython leg but `latest` does,
# here and in `integration`. A supported PyPy leg is one more leg of a
# job `required-checks` needs, so it blocks with no context of its own
# for the ruleset to name, and a KTLO one is `test-ktlo`'s.
#
# Exactly one leg runs under coverage, so measuring it adds no leg to
# the queue. It is `latest`, the newest supported version, like every
Expand Down Expand Up @@ -151,7 +157,9 @@ jobs:
# clean installs on 3.10-3.14 (manylinux wheels, no build step). pycrate
# is pure Python but its distribution is large (~238MB, see the NGAP
# extra's comment in pyproject.toml) -- still cheap in wall time, just not
# "a second" cheap.
# "a second" cheap. On PyPy 8.0, which setup-python's `pypy3.11` is,
# PyPI has no Linux wheel of cryptography, so pip builds it with the
# image's Rust and libssl-dev (#1958).
#
# PyPCAPFile (pypcapfile) is deliberately NOT here. It used to be kept off
# every job in this file: on 3.10/3.11 (where its
Expand Down Expand Up @@ -250,9 +258,23 @@ jobs:
# import no pcapkit, so coverage loses nothing. Both lists are asked of
# tests/_tiers.py at run time rather than listed, as `integration` asks
# for its selection.
#
# #2005: on PyPy an xdist worker keeps what each test leaves behind, so
# its RSS grows with every test it runs, never in a few heavy modules:
# at `-n 4` the `ALL` tier took each worker to 5.4-5.9 GB and them all
# past 20 GB, measured on PyPy 8.0.1, where a 14 GB cap OOM-killed them
# and a 3 GB `PYPY_GC_MAX` raised MemoryError instead. A smaller `-n`
# only hands each worker more tests. So on a PyPy leg util/pytest_shards.py
# runs pytest once per directory `SHARDS` names, then once more over the
# rest, ignoring each of them, and every run starts its workers afresh:
# the largest, with tests/protocols, peaked at 2.6 GB a worker and 8.5 GB
# in all, and the tier took 383 s rather than 493 s. Each run but the
# last writes a JUnit file of its own, beside junit.xml; the step fails
# if any run did.
env:
COVERAGE: ${{ matrix.coverage }}
LATEST: ${{ matrix.python-version == needs.python-versions.outputs.latest }}
SHARDS: ${{ startsWith(matrix.python-version, 'pypy') && 'tests/protocols tests/corekit' || '' }}
run: |
if [ "$LATEST" = "true" ]; then
if ! skipped=$(python -c "from tests._tiers import project_pin_paths; print(' '.join(project_pin_paths()))"); then
Expand All @@ -275,6 +297,9 @@ jobs:
# Logs every measured process and its parent, for the check below.
COVERAGE_DEBUG=pid,process COVERAGE_DEBUG_FILE="$PWD/coverage-processes.log" \
python util/cap_file_size.py python -m coverage run --rcfile=.github/coverage.toml -m pytest "$@"
elif [ -n "$SHARDS" ]; then
# #2005: #1963's script, on the leg's interpreter, under the one cap.
python util/cap_file_size.py python util/pytest_shards.py "$SHARDS" -- python -m pytest "$@"
else
python util/cap_file_size.py python -m pytest "$@"
fi
Expand Down Expand Up @@ -331,13 +356,15 @@ jobs:
# newest push runs. One artifact per leg, so the name carries the version.
# `continue-on-error` because the file is only diagnostic: an artifact
# service outage must not fail a leg that `Required checks passed` needs.
# The glob takes a PyPy leg's JUnit file per run too (#2005); a CPython
# leg writes junit.xml alone.
- name: Upload test timings
if: ${{ !cancelled() && steps.install.outcome == 'success' }}
continue-on-error: true
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: junit-unit-${{ matrix.python-version }}
path: ${{ runner.temp }}/junit.xml
path: ${{ runner.temp }}/junit*.xml
retention-days: 14

# #1672: the KTLO leg's report, and the last step of every KTLO matrix
Expand Down
2 changes: 1 addition & 1 deletion Pipfile
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ beautifulsoup4 = {extras = ["html5lib"],version = "*"}
requests = {extras = ["socks"],version = "*"}
# The markers match the test extra in pyproject.toml (#1894).
psutil = {version = "*",markers = "platform_python_implementation == 'CPython'"}
lxml = {version = "*",markers = "platform_python_implementation != 'GraalVM'"}
lxml = {version = "*",markers = "platform_python_implementation == 'CPython'"}
markdown-it-py = "*"
autopep8 = "*"
bandit = "*"
Expand Down
4 changes: 2 additions & 2 deletions docs/source/contributing/releasing.rst
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ Editing ``__version__`` by hand without also moving :file:`CITATION.cff`'s
(``:494-514``) asserts the two agree, and ``create-release.yml``'s
``unit-tests`` job (``:581-597``) calls ``unit-tests.yml`` with
``gate-only: true``, which runs the **full** suite rather than the tiered
subset an ordinary push runs (its ``gate`` job, ``unit-tests.yml:1771-1846``).
subset an ordinary push runs (its ``gate`` job, ``unit-tests.yml:1798-1873``).
That gate runs whenever a release will -- a moved ``__version__`` is exactly
what makes ``version_check``'s evidence read "not yet published" -- and every
publishing job requires it to have succeeded (``:621,808,974,1210``), so a
Expand All @@ -62,7 +62,7 @@ Either run the script, or move both fields by hand in the same commit.
This is the **only** edit a person makes to get a release started -- the tag,
the Release, and every upload are the workflow's job from here. Other
checks run unconditionally on the same path, though none is a step in *this*
process: the ``changelog`` job (``unit-tests.yml:1614-1640``) fails outright if
process: the ``changelog`` job (``unit-tests.yml:1641-1667``) fails outright if
``CHANGELOG.md`` has drifted from its source entry under
:file:`docs/source/changelog/`, and the release-body step warns, without
failing, if that entry's heading still reads "unreleased"
Expand Down
50 changes: 39 additions & 11 deletions docs/source/contributing/workflows.rst
Original file line number Diff line number Diff line change
Expand Up @@ -376,7 +376,7 @@ the same commit several times over. It also skips ``changes``,
Checks`_ below), which are gated out by their own ``if:`` rather than by having
already run. ``changelog`` carries no
``if:`` at all and runs on every path regardless -- deliberately, per its own
comment (``unit-tests.yml:1602-1608``): ``create-release.yml`` feeds
comment (``unit-tests.yml:1629-1635``): ``create-release.yml`` feeds
``CHANGELOG.md`` to the GitHub Release body, so the release path is exactly
where a drifted file must not go unchecked. Confirmed on run `36210743295
<https://github.com/JarryShaw/PyPCAPKit/actions/runs/36210743295>`__ (a
Expand Down Expand Up @@ -689,7 +689,7 @@ grepping every workflow file for its name:
- Where
* - ``Required checks passed``
- job ``required-checks``
- ``unit-tests.yml:1937``
- ``unit-tests.yml:1964``
* - ``Compat Python 3.11``
- job ``compatibility``, matrix leg ``3.11``
- ``python-compatibility.yml:60``, ``.github/python-versions.toml:20``
Expand All @@ -711,22 +711,22 @@ which added ``Compat Python 3.15`` and removed ``Compat Python 3.10``. Moving a
between the tiers below means editing the ruleset in the same change.

``Required checks passed`` is defined by exactly one job
(``unit-tests.yml:1937``). ``Compat Python`` is defined by one, the
(``unit-tests.yml:1964``). ``Compat Python`` is defined by one, the
``compatibility`` job in ``python-compatibility.yml`` (``:60``), whose matrix
(``:74``) 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
context matches one. A comment at
``unit-tests.yml:1849`` mentions the string without defining it.
``unit-tests.yml:1876`` mentions the string without defining it.

``Required checks passed`` is an aggregate, not a single check run. The ruleset
requires it and the ``Compat Python 3.11``-``3.15`` legs, nothing else, as
``unit-tests.yml``'s own comment (``unit-tests.yml:1848-1858``) records. It
``unit-tests.yml``'s own comment (``unit-tests.yml:1875-1885``) records. It
``needs:`` the gating jobs of ``unit-tests.yml`` -- ``test``, ``integration``,
``engine-tests`` and ``pypcap-parity``, plus ``project-tests``, the one run of
the project pins, and ``changes`` for the docs-only path -- and checks each
result explicitly
(``unit-tests.yml:1936-1992``). Rulesets match check names literally, with no
(``unit-tests.yml:1963-2019``). Rulesets match check names literally, with no
wildcard, so requiring the matrix cells one by one would mean editing the
ruleset whenever a matrix changes; the aggregate keeps the required list fixed.
It accepts the gating jobs as ``skipped`` only on a docs-only pull request, where
Expand All @@ -735,7 +735,7 @@ It accepts the gating jobs as ``skipped`` only on a docs-only pull request, wher
``python-compatibility.yml``, and a job cannot ``needs:`` a job in another
workflow file. This job runs on Unit Tests' own ``push``/``pull_request``
triggers; the ``gate-only: true`` reusable calls documented above skip it via
``if: ${{ always() && inputs.gate-only != true }}`` (``unit-tests.yml:1939``),
``if: ${{ always() && inputs.gate-only != true }}`` (``unit-tests.yml:1966``),
so it is never produced -- and never expected -- on those paths.

.. _ktlo-tier:
Expand All @@ -759,7 +759,7 @@ and keeps its legs' check names, ``Python 3.10 (KTLO)`` and so on. In
with supported legs. Every job holding KTLO legs works the same way:

* Every step that can fail on a KTLO leg has an ``id`` and
``continue-on-error: ${{ matrix.ktlo == true }}`` (``unit-tests.yml:367-384``),
``continue-on-error: ${{ matrix.ktlo == true }}`` (``unit-tests.yml:394-411``),
unless it is masked on every leg, as the timings uploads are. Its ``outcome`` is
then ``failure`` but its ``conclusion`` ``success``, so the job, its check run
and ``required-checks`` read ``success``. A step that needs an earlier one's work
Expand All @@ -785,6 +785,31 @@ runner's ``Process completed with exit code N.`` for a ``run:`` step, and an
action's own. And `#1634 <https://github.com/JarryShaw/PyPCAPKit/issues/1634>`__'s tag check still blocks the ``pypi`` KTLO legs: it fails
every leg alike, and masked, the leg would go on to publish.

PyPy is tiered the same way. ``test`` and ``integration`` run its supported
versions after CPython's, and ``test-ktlo`` and ``integration-ktlo`` its KTLO ones,
each leg running the ``ALL`` test tier (`#1958
<https://github.com/JarryShaw/PyPCAPKit/issues/1958>`__, see `Test Tiers`_). A
supported PyPy leg blocks through ``Required checks passed``, which needs its job,
so it adds no context to the ruleset. No ``Compat``, ``engine-tests`` or
``pypcap-parity`` leg runs PyPy. On PyPy 8.0, which setup-python's ``pypy3.11``
is, PyPI has no Linux wheel of |cryptography|_, so the install step builds it
with the image's Rust and ``libssl-dev``, and none of ``lxml``, so the ``test``
extra carries ``lxml`` on CPython only.

On PyPy an xdist worker's RSS grows with every test it runs, rather than in a few
heavy modules, so a PyPy leg's ``Run unit tests`` hands its pytest command to
:file:`util/pytest_shards.py`, as GraalPy's leg does (see below), which runs it once per
directory its ``SHARDS`` names, ``tests/protocols`` and then ``tests/corekit``, and once
more over the rest, ignoring each (`#2005 <https://github.com/JarryShaw/PyPCAPKit/issues/2005>`__).
Each run starts its workers afresh. Measured on PyPy 8.0.1 at ``-n 4``, one run
over the whole ``ALL`` tier took each worker to 5.4-5.9 GB and the workers together
past 20 GB, beyond the runner's 16 GB; split, the largest run peaked at 2.6 GB a
worker and 8.5 GB in all, and the tier took 383 s rather than 493 s. A smaller
``-n`` would only hand each worker more tests, and a ``PYPY_GC_MAX`` heap cap turns
the kills into ``MemoryError``\ s. Each run but the last writes a JUnit file of its
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
Expand Down Expand Up @@ -1049,7 +1074,7 @@ Which legs run which tier:
- ``ALL`` and ``LATEST``, less the project pins
* - ``project-tests``
- the project pins
* - ``test`` off ``latest``, ``test-ktlo``, ``experimental``
* - ``test`` off ``latest``, PyPy's among them, ``test-ktlo``, ``experimental``
- ``ALL``
* - ``integration`` on ``latest``
- ``ALL`` and ``LATEST``
Expand Down Expand Up @@ -1177,14 +1202,17 @@ does; each has tests under :file:`tests/project/` that run it.
- ``Report coverage`` in ``unit-tests.yml``'s ``test``, on the coverage leg.
* - :file:`util/pytest_shards.py`
- ``Run unit tests`` in ``experimental.yml``'s ``experimental``, on GraalPy's leg
(`#1963 <https://github.com/JarryShaw/PyPCAPKit/issues/1963>`__).
(`#1963 <https://github.com/JarryShaw/PyPCAPKit/issues/1963>`__), and in
``unit-tests.yml``'s ``test`` and ``test-ktlo``, on PyPy's legs (`#2005
<https://github.com/JarryShaw/PyPCAPKit/issues/2005>`__).

The summaries run on the image's ``/usr/bin/python3`` rather than the leg's
interpreter, which may have failed to set up or be too broken to run anything, so
they need only the standard library, as ``classify_changes.py`` does on the
runner's ``python3``. :file:`util/pytest_shards.py` runs on ``/usr/bin/python3``
too, standard library only, so on GraalPy's leg the leg's interpreter runs pytest
alone. Both ``changes`` jobs check out on every event, since the script is in the
alone; on PyPy's legs the leg's interpreter runs the script as well, under the
file-size cap every test command of ``unit-tests.yml`` takes. Both ``changes`` jobs check out on every event, since the script is in the
tree. ``create-release.yml``'s ``pypi`` job keeps its own copy of
the KTLO summary's shell until its shell moves to scripts too; a project pin runs
that copy and the script on the same steps and requires the same output.
Expand Down
18 changes: 18 additions & 0 deletions pcapkit/utilities/compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,24 @@
'Mapping', 'Tuple', 'List', 'Dict', 'TypeAlias', 'UnionType',
]

# PyPy 8.0 binds ``NoneType`` in :mod:`builtins`, which CPython does not, and
# its own stdlib relies on it (``_sqlite3``, which aenum imports). aenum 3.1.17's
# ``_common`` binds a ``NoneType`` of its own only on a ``NameError``, so there it
# binds none, and the ``from ._common import *`` in aenum's ``__init__`` fails on
# the name ``__all__`` lists: ``AttributeError: module 'aenum._common' has no
# attribute 'NoneType'``. That left pcapkit unimportable on PyPy 8.0 (#1958).
# ``aenum._common`` itself ran to its end and stays in :data:`sys.modules`, so
# binding the name there and importing aenum again completes it, leaving
# :mod:`builtins` as PyPy has it. This module runs before any of pcapkit's that
# imports aenum: pcapkit/__init__.py reaches it through
# pcapkit.utilities.exceptions, ahead of pcapkit.corekit.
if sys.implementation.name == 'pypy':
try:
import aenum # pylint: disable=unused-import
except AttributeError:
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."""
Expand Down
12 changes: 7 additions & 5 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -379,11 +379,13 @@ test = [
# saved README. A pure-Python wheel; unpinned, like the entries above.
"markdown-it-py",
# The strict XML parser util/ci_timing.py reads JUnit with and
# util/pyshark_encap_map.py reads PDML with (#1894). PyPI has wheels for
# CPython and PyPy but none for GraalPy, which reports its implementation as
# ``GraalVM``; the tests that parse skip there, and the scripts do not fall
# back to a laxer parser. Unpinned, like the entries above.
"lxml; platform_python_implementation != 'GraalVM'",
# util/pyshark_encap_map.py reads PDML with (#1894). CPython only, like
# psutil: PyPI has no wheel for GraalPy, nor for PyPy 8.0, which
# setup-python's ``pypy3.11`` is, and the image has no libxml2 headers to
# build one (#1958). The tests that parse skip there, all of them on
# ``latest`` alone, and the scripts do not fall back to a laxer parser.
# Unpinned, like the entries above.
"lxml; platform_python_implementation == 'CPython'",
]

# setuptools specific
Expand Down
10 changes: 6 additions & 4 deletions tests/_dependency_gates.py
Original file line number Diff line number Diff line change
Expand Up @@ -262,10 +262,12 @@
# The strict XML parser ``HAS_LXML`` asks for (#1894): util/ci_timing.py
# reads JUnit and util/pyshark_encap_map.py reads PDML with it, and neither
# falls back to a laxer parser. The ``test`` extra carries it with a
# ``platform_python_implementation != 'GraalVM'`` marker, because PyPI has
# no wheel for GraalPy, so the gate skips on experimental.yml's GraalPy leg.
# That is no :data:`DEPENDENCY_GATE_EXCLUSIONS` entry: this module does not
# model markers and does not read experimental.yml, so every job it reads
# ``platform_python_implementation == 'CPython'`` marker, because PyPI has
# no wheel for GraalPy or PyPy 8.0, so the gate skips on experimental.yml's
# GraalPy leg and on unit-tests.yml's PyPy legs (#1958). That is no
# :data:`DEPENDENCY_GATE_EXCLUSIONS` entry: this module does not model
# markers, every module gated on it is ``LATEST``, which no PyPy leg runs,
# and this module does not read experimental.yml, so every job it reads
# satisfies the gate, and an entry would describe a gap that is not there.
'lxml': ('lxml',),
}
Expand Down
13 changes: 13 additions & 0 deletions tests/corekit/test_sentinel_py_abc_1960_unit.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@
trusting the block. :class:`CModuleTests` pins that, under the C module, the
names let through for ``_py_abc`` are still refused.

Neither runs on PyPy (#1958). Its built-in ``_abc`` keeps ``_py_abc``'s caches
on the class, so a sentinel type there holds both names, and it imports
``_collections_abc`` at startup even under ``-S``, so a child that blocks
``_abc`` builds :mod:`abc` anew under ABCs already made with the old one, and
``import pcapkit`` fails with a metaclass conflict.

"""

from __future__ import annotations
Expand All @@ -38,6 +44,11 @@
#: interpreter.
__tier__ = 'ALL'

#: PyPy's ``_abc`` is no C module and keeps the caches on the class (#1958).
ON_PYPY = sys.implementation.name == 'pypy'
#: Why both classes skip there.
PYPY_REASON = "PyPy's built-in _abc keeps _py_abc's caches on the class, and the fallback cannot be forced"

#: Repository root, put first on the child interpreter's path so that it
#: imports this tree rather than whatever :mod:`pcapkit` is installed.
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
Expand Down Expand Up @@ -145,6 +156,7 @@ def _expected(instance_name: 'str | None') -> 'dict[str, str]':
return probes


@unittest.skipIf(ON_PYPY, PYPY_REASON)
class PyABCFallbackTests(unittest.TestCase):
"""Every sentinel type behaves under ``_py_abc`` as it does under ``_abc``."""

Expand Down Expand Up @@ -181,6 +193,7 @@ def test_every_probe_behaves_as_under_the_c_module(self) -> 'None':
self.assertEqual(actual[probe], outcome)


@unittest.skipIf(ON_PYPY, PYPY_REASON)
class CModuleTests(unittest.TestCase):
"""Under the C ``_abc`` module, the names let through for ``_py_abc`` stay refused."""

Expand Down
Loading
Loading