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
45 changes: 20 additions & 25 deletions tests/_dependency_gates.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@
"""Which CI job installs the dependency each ``HAS_*`` gate asks for.

:mod:`tests._tiers` answers "may this module read that capture". This module
answers the neighbouring question that nothing asked before: **when a test is
gated on an optional dependency, does the job whose selection reaches that test
actually install it?**
answers the neighbouring question: **when a test is gated on an optional
dependency, does the job whose selection reaches that test actually install
it?**

The reason it needs asking is that the failure mode is silence. A
``@unittest.skipUnless(HAS_CRYPTO, ...)`` whose dependency nobody installs does
Expand Down Expand Up @@ -740,8 +740,8 @@ def _top_level_providers(module: 'str') -> 'tuple[str, ...]':
unrecognised one is the worst answer available; #779 is what that cost, and
:func:`_disqualified_providers` below is the message shape this follows.

Unchanged for a mapped module: the same tuple the subscript returned, so the
lookup is a diagnostic improvement and not a semantic one.
For a mapped module it is the same tuple a subscript returns, so only the
failure for an unmapped one differs from a bare lookup.

"""
top_level = module.partition('.')[0]
Expand All @@ -762,14 +762,13 @@ def _top_level_providers(module: 'str') -> 'tuple[str, ...]':
def module_providers(module: 'str') -> 'frozenset[str]':
"""Every distribution (or :data:`CORE`) :data:`MODULE_PROVIDERS` says could ship ``module``.

Unlike :func:`extras_providing`, which this deliberately does not touch,
keyed on the dotted path *exactly as given* when :data:`MODULE_PROVIDERS`
has a dedicated entry for it -- ``pcap._pcap`` resolves to ``('pcap-ct',)``
alone rather than falling back to plain ``pcap``'s two-wide entry. Falls
back to the top-level package otherwise, the same rule
:func:`extras_providing` always uses, which is what makes an unmapped
module a failure there rather than a silent pass here too (the fallback goes
through :func:`_top_level_providers`, so it raises the same
Unlike :func:`extras_providing`, keyed on the dotted path *exactly as given*
when :data:`MODULE_PROVIDERS` has a dedicated entry for it -- ``pcap._pcap``
resolves to ``('pcap-ct',)`` alone rather than falling back to plain
``pcap``'s two-wide entry. Falls back to the top-level package otherwise,
the same rule :func:`extras_providing` always uses, which is what makes an
unmapped module a failure there rather than a silent pass here too (the
fallback goes through :func:`_top_level_providers`, so it raises the same
:exc:`AssertionError` naming the module that that function's own lookup
does).

Expand Down Expand Up @@ -886,9 +885,8 @@ def _probed_modules(node: 'ast.AST', names: 'dict[str, tuple[str, ...]]',
cannot tell them apart -- it would report ``HAS_PYPCAP`` as needing a module
whose presence makes it false.

``want_negated`` picks which side this call collects: :data:`False` (the
default, and every call site before :func:`module_flag_exclusions` existed)
yields the positively-required modules, as before. :data:`True` yields the
``want_negated`` picks which side this call collects: :data:`False`, the
default, yields the positively-required modules. :data:`True` yields the
mirror image -- the modules a negated probe asks to be absent -- which is
what :func:`module_flag_exclusions` asks for. A module is never yielded by
both calls on the same expression: exactly one of ``negated == want_negated``
Expand Down Expand Up @@ -1228,11 +1226,9 @@ def _engine_matrix_variants(name: 'str', section: 'str') -> 'Optional[tuple[Job,

This also, separately, asserts that the workflow's own ``engine:`` matrix
list agrees with the ``case "$ENGINE" in ...`` arms it is supposed to
describe: earlier versions of this function built :class:`Job` entries
purely from the case arms and never looked at the ``engine:`` list at
all, so deleting a cell from that list (the ordinary way to retire one)
left this guard still crediting the retired engine with coverage it no
longer has, silently.
describe: built purely from the case arms, the :class:`Job` entries would
go on crediting an engine deleted from that list (the ordinary way to
retire a cell) with coverage it does not have, silently.

"""
code = '\n'.join(line for line in section.splitlines() if not line.strip().startswith('#'))
Expand Down Expand Up @@ -1570,8 +1566,8 @@ def ambiguous_satisfactions(
This is the check that notices, scoped to exactly the names
:data:`MUTUALLY_EXCLUSIVE_IMPORTS` declares genuinely contested --
deliberately *not* every name :data:`MODULE_PROVIDERS` lists more than one
requirement string for for. ``html5lib`` also has two entries there, and
is not this: both name the same one distribution reached two ways, not two
requirement string for. ``html5lib`` also has two entries there, and is
not this: both name the same one distribution reached two ways, not two
distributions that could each independently win the import, so scoping on
``len(MODULE_PROVIDERS[...]) > 1`` instead would false-positive on it the
moment some job gained the ``vendor`` extra.
Expand Down Expand Up @@ -1615,8 +1611,7 @@ def ambiguous_satisfactions(
# not for every gate this job reaches: an excluded module with
# no MODULE_PROVIDERS entry of its own raises here (see
# _disqualified_providers), and a flag with nothing to do with
# a contested name must never pay for that -- see this
# function's own history (#762 round 3).
# a contested name must never pay for that (#762).
excluded_modules = exclusions.get((gate.module, gate.flag), frozenset())
disqualified = _disqualified_providers(excluded_modules)

Expand Down
14 changes: 7 additions & 7 deletions tests/_support.py
Original file line number Diff line number Diff line change
Expand Up @@ -400,8 +400,8 @@ def load_module(module_name: str, relative_path: str,

Both halves of that write to :data:`sys.modules` -- the stub parents, and
``module_name`` itself -- so both are taken back off again when ``test``
finishes, via :func:`restore_modules_after`. Before issue #674 they were not,
and test modules left a bare ``pcapkit`` bound for whatever ran next.
finishes, via :func:`restore_modules_after`. Without that, a test module
leaves a bare ``pcapkit`` bound for whatever runs next (issue #674).

Args:
module_name: Dotted name to execute the file under, e.g.
Expand Down Expand Up @@ -587,8 +587,8 @@ class objects, and their creation churns the C-level ``_abc_impl`` caches

:func:`abc._reset_caches` is a CPython internal (present on both the C
``_abc`` and pure-python ``_py_abc`` backends); if a future runtime drops
it this degrades to the previous, occasionally-flaky behaviour rather than
erroring.
it this degrades to skipping the reset, which is occasionally flaky,
rather than erroring.
"""
reset = getattr(abc, '_reset_caches', None)
if reset is None: # pragma: no cover
Expand Down Expand Up @@ -748,9 +748,9 @@ def _running_test_case() -> 'Optional[unittest.TestCase]':
same frame, so one handed some other, finished :class:`~unittest.TestCase`
would otherwise win the walk -- and a cleanup registered on a test that has
already finished is never run, which is the original leak with an extra step
and no error to show for it. Measured before the check was added: a ten-name
leak, silently. Skipping the idle candidate lets the walk continue to the
method frame above, which is the running test.
and no error to show for it. Measured without the check: a ten-name leak,
silently. Skipping the idle candidate lets the walk continue to the method
frame above, which is the running test.

Returns:
The running test, or :data:`None` when the caller is not inside a running
Expand Down
32 changes: 16 additions & 16 deletions tests/_tiers.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,21 +81,22 @@
check sees the name however it was computed, e.g. ``sample_path(sample)`` in a
parametrised loop, but only once the call is reached.

What neither catches, deliberately: a unit-tier module that opens a capture
without going through :func:`~tests._support.sample_path` at all, e.g. by joining
What neither catches, deliberately: a unit-tier module that opens a capture without
going through :func:`~tests._support.sample_path` at all, e.g. by joining
:file:`examples/captures/` and a name itself. No *unit-tier* module does this:
:file:`tests/protocols/misc/test_pcapng_unit.py` was the last one, and
reads ``sample_path('dhcp_big_endian.pcapng')`` inside the ``try``/``except
FileNotFoundError`` idiom :func:`explain` recommends. Only the *unit* tier is
claimed there, deliberately: fixture-tier modules join the directory freely and
are entitled to, e.g. :file:`tests/protocols/test_option_coverage_runtime.py`,
which imports :data:`SAMPLE_ROOT` from here and joins names onto it -- legal,
because that tier runs only once the fixtures exist. The one unit-tier module that
touches :data:`SAMPLE_ROOT` at all, :file:`tests/test_tier_guard.py`, stats
:file:`in.pcap` directly -- which is committed -- and separately loops over every
name :func:`committed_captures` reports, each committed by construction, since that
is git's own tracked listing rather than a name the test made up. But nothing stops
the next unit-tier module from opening a *generated* capture that way, and the shape
:file:`tests/protocols/misc/test_pcapng_unit.py`, for one, reads
``sample_path('dhcp_big_endian.pcapng')`` inside the
``try``/``except FileNotFoundError`` idiom :func:`explain` recommends. Only the
*unit* tier is claimed there, deliberately: fixture-tier modules join the directory
freely and are entitled to, e.g.
:file:`tests/protocols/test_option_coverage_runtime.py`, which imports
:data:`SAMPLE_ROOT` from here and joins names onto it -- legal, because that tier
runs only once the fixtures exist. The one unit-tier module that touches
:data:`SAMPLE_ROOT` at all, :file:`tests/test_tier_guard.py`, stats :file:`in.pcap`
directly -- which is committed -- and separately loops over every name
:func:`committed_captures` reports, each committed by construction, since that is
git's own tracked listing rather than a name the test made up. But nothing stops the
next unit-tier module from opening a *generated* capture that way, and the shape
stays invisible here.

Flagging it in general was considered and rejected, with a measurement behind the
Expand Down Expand Up @@ -1729,8 +1730,7 @@ def fixture_tier_paths(tier: 'Optional[str]' = None) -> 'tuple[str, ...]':
:func:`skip_idiom_modules` directly, so that pulling in one skip-idiom test
does not also pull in every unrelated method the same module happens to
hold. Without that, the skip-idiom modules would add every one of their
tests back into this selection -- well over a hundred -- to reach the
handful that actually need it.
tests back into this selection to reach the handful that actually need it.

This is deliberately *not* "the complement of :func:`is_unit_tier`"
expressed as a single predicate the way :func:`is_unit_tier` itself is: the
Expand Down
8 changes: 4 additions & 4 deletions tests/cli/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,10 +59,10 @@
#: Wider than :data:`tests._support.ISOLATED_PREFIXES` by ``'emoji'``, which is
#: the one stand-in here that is not part of :mod:`pcapkit`.
#: :func:`tests.conftest.restore_module_table` covers only the default prefixes,
#: so the faked ``emoji`` was not merely unrestored but *unmasked* as well: it
#: survived even a normal :program:`pytest` run, leaving whatever imported
#: :mod:`emoji` next with a :class:`~types.SimpleNamespace` carrying nothing but
#: ``emojize`` -- or, from the last test here, a class whose ``emojize`` raises
#: so a faked ``emoji`` left unrestored is *unmasked* as well: it survives even
#: a normal :program:`pytest` run, leaving whatever imports :mod:`emoji` next
#: with a :class:`~types.SimpleNamespace` carrying nothing but ``emojize`` --
#: or, from the last test here, a class whose ``emojize`` raises
#: :exc:`UnicodeEncodeError`.
ISOLATED = ISOLATED_PREFIXES + ('emoji',)

Expand Down
47 changes: 24 additions & 23 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -286,21 +286,21 @@ def restore_module_table() -> 'Iterator[None]':
:meth:`~unittest.TestCase.setUpClass` runs *before* this fixture's first
entry for the class and a class-level cleanup runs *after* its last exit, so
either can plant exactly the kind of leak the paragraphs above describe
without this fixture's old, entry-scoped restore ever seeing it -- an
entry-scoped snapshot faithfully preserves whatever a ``setUpClass`` purge
already did, rather than undoing it, and a class-level cleanup that purges
again has no later exit here to be caught by at all. See
:func:`_pin_module_snapshot` for why the fix is to restore to a fixed target
instead, both before the test and after it, rather than to change what is
snapshotted and when: doing it on both sides means neither a ``setUpClass``
purge nor a class-exit purge survives past the next test's own entry,
regardless of which one planted it or whether the class in between reimports
anything. This is the same "leaning on a neighbour" trap the paragraph above
already rejects, generalised: healing a class-level leak by restoring before
the *next* test runs is the guard doing its own job on a fixed schedule, not
an accident of collection order -- unlike relying on some *other* class's own
purge to happen to reimport first, which is exactly the accident #720's
reproduction shows failing half the time.
without an entry-scoped restore ever seeing it -- an entry-scoped snapshot
faithfully preserves whatever a ``setUpClass`` purge already did, rather than
undoing it, and a class-level cleanup that purges again has no later exit
here to be caught by at all. See :func:`_pin_module_snapshot` for why the fix
is to restore to a fixed target instead, both before the test and after it,
rather than to change what is snapshotted and when: doing it on both sides
means neither a ``setUpClass`` purge nor a class-exit purge survives past the
next test's own entry, regardless of which one planted it or whether the
class in between reimports anything. This is the same "leaning on a
neighbour" trap the paragraph above already rejects, generalised: healing a
class-level leak by restoring before the *next* test runs is the guard doing
its own job on a fixed schedule, not an accident of collection order --
unlike relying on some *other* class's own purge to happen to reimport first,
which is exactly the accident #720's reproduction shows failing half the
time.

Yields:
Nothing; the region is already restored by the time control passes to
Expand Down Expand Up @@ -476,9 +476,10 @@ def pytest_collection_modifyitems(session: 'pytest.Session', config: 'pytest.Con
# pytest-xdist 3.8.0/pytest 9.1.1: setting `shouldfail` first is what the
# controller already knows how to shut a worker down on cleanly, no
# crash-item assertion, and the message still comes through -- as
# ``xdist.dsession.Interrupted: <message>`` rather than ``ERROR:
# <message>``, which is the one visible difference from the serial path.
# A no-op outside a worker, so serial behaviour is unchanged.
# ``xdist.dsession.Interrupted: <message>`` rather than
# ``ERROR: <message>``, which is the one visible difference from the
# serial path. A no-op outside a worker, where the ``UsageError`` alone
# ends the run.
if hasattr(config, 'workerinput'):
session.shouldfail = message
raise pytest.UsageError(message)
Expand Down Expand Up @@ -629,11 +630,11 @@ def _tier_guard_lock(exclusive: 'bool') -> 'Iterator[None]':
has to poll (see :data:`TIER_GUARD_LOCK_POLLS`), they do (#1977). A second
lock file, the gate, gives the writer priority: a writer holds the gate
exclusively while it waits for the lock, and a reader passes through the
gate, shared and released at once, before asking for the lock. Readers
hold the gate only for that instant, so the writer gets it almost at once,
and the readers already past it then drain. The kernel drops both locks
with a dying process, so no crash can leave the gate shut. Every wait is
unbounded, as before: proceeding without the lock would reopen the race.
gate, shared and released at once, before asking for the lock. Readers hold
the gate only for that instant, so the writer gets it almost at once, and
the readers already past it then drain. The kernel drops both locks with a
dying process, so no crash can leave the gate shut. Every wait is
unbounded: proceeding without the lock would reopen the race.

Args:
exclusive: :data:`True` for the writer's hold, :data:`False` for a
Expand Down
15 changes: 7 additions & 8 deletions tests/corekit/test_decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ def unpack(cls, data, length=None, packet=None):
def _demo_schema_for_call_shapes(self):
"""A ``DemoSchema`` whose ``unpack`` records what ``prepare`` bound.

Shared by the five call-shape tests below, one per row of `#444
Shared by the call-shape tests below, one per row of `#444
<https://github.com/JarryShaw/PyPCAPKit/issues/444>`__'s reproduction
table: without the fix, ``prepare`` subscripts ``args[2]``/``args[3]``
unconditionally, so only the three-positional shape works and every
Expand Down Expand Up @@ -170,7 +170,7 @@ def test_prepare_accepts_data_and_positional_length(self) -> None:
self._assert_call_shape_result(DemoSchema.unpack(b'payload', 7))

def test_prepare_accepts_data_length_and_packet_positionally(self) -> None:
"""``unpack(data, length, packet)`` -- the one shape that already worked."""
"""``unpack(data, length, packet)`` -- the shape that never needed the fix."""
DemoSchema = self._demo_schema_for_call_shapes()
self._assert_call_shape_result(DemoSchema.unpack(b'payload', 7, {}))

Expand Down Expand Up @@ -267,9 +267,8 @@ def unpack(cls, data, length=None, packet=None):

def test_prepare_still_raises_eof_for_a_truncated_stream(self) -> None:
"""A file-like stream already exhausted, with no declared length, is
the frame reader's genuine end-of-capture case and must still raise --
confirming the #458 fix only changes the *declared*-zero case below,
not this one.
the frame reader's genuine end-of-capture case and must still raise:
only the *declared*-zero case below unpacks (#458).

"""
class DemoSchema:
Expand Down Expand Up @@ -397,7 +396,7 @@ def unpack(cls, data, length=None, packet=None):
# ``self.__header__.get_payload()``, because that is what
# ``ProtocolBase._get_payload`` is -- a wrapper over the schema's accessor.
# ``beholder`` goes through the wrapper rather than the schema directly, so
# that the two protocols which override it (SCTP, PCAP-NG) recover from a
# that the protocols which override it, SCTP and PCAP-NG, recover from a
# next-layer failure at all; see
# ``test_beholder_uses_the_protocol_payload_accessor_not_the_schema``.
##########################################################################
Expand Down Expand Up @@ -472,8 +471,8 @@ def test_beholder_forwards_the_protocol_number_as_an_alias(self) -> None:
The success path in ``_import_next_layer`` passes ``alias=proto``, so a
payload that reached :class:`Raw` because nothing was registered on its
number is still labelled with that number. Omitting it from the failure
path made *registering* a protocol produce less informative output than
leaving the number unregistered.
path would make *registering* a protocol produce less informative
output than leaving the number unregistered.

"""
Raw, _ = self._payload_stand_ins()
Expand Down
Loading
Loading