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
3 changes: 3 additions & 0 deletions docs/source/pcapkit/utilities/functools.rst
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,6 @@ Error Handling Utilities
.. autofunction:: pcapkit.utilities.exceptions.stacklevel

.. autofunction:: pcapkit.utilities.warnings.warn

.. autoclass:: pcapkit.utilities.warnings.hold_warnings
:members: discard, holding
7 changes: 7 additions & 0 deletions docs/source/pcapkit/utilities/warnings.rst
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,13 @@ The counts are the same in development mode as outside it:
the log record carries, never how many records there are. Constructing a warning
class is not an act of reporting one -- it emits nothing and has no side effects.

A next layer captured shorter than its length hint is parsed on trial, and
replaced with :class:`~pcapkit.protocols.misc.raw.Raw` when its header was not
captured. The trial's warnings are held back by
:class:`~pcapkit.utilities.warnings.hold_warnings`: a replaced layer's are dropped
on both channels, since they describe a layer the result does not have, and a kept
layer's are reported as above when the trial ends (:issue:`1580`).

Silencing pcapkit Warnings
--------------------------

Expand Down
48 changes: 42 additions & 6 deletions pcapkit/protocols/protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@
from pcapkit.utilities.exceptions import (BaseError, ProtocolError, ProtocolNotFound,
ProtocolNotImplemented, RegistryError, StructError,
UnsupportedCall)
from pcapkit.utilities.warnings import RegistryWarning, UnknownFieldWarning, warn
from pcapkit.utilities.warnings import RegistryWarning, UnknownFieldWarning, hold_warnings, warn

if TYPE_CHECKING:
from enum import IntEnum as StdlibEnum
Expand Down Expand Up @@ -389,6 +389,31 @@ def _check_construction_keywords(cls: 'type', kwargs: 'dict[str, Any]',
UnknownFieldWarning)


def _short_of_hint(klass: 'Type[ProtocolBase]', captured: 'int') -> 'bool':
"""Tell whether ``captured`` octets may be too few for a ``klass`` header.

Args:
klass: Next layer protocol class.
captured: Octets captured for it.

Returns:
Whether a next layer of ``klass`` parsed from ``captured`` octets may be
replaced with :class:`~pcapkit.protocols.misc.raw.Raw` by
:meth:`ProtocolBase._parse_next_layer`, and so has its warnings held.

The hint is read from the class, before there is an instance to ask, which
is enough for every protocol shipped: each returns a constant. A hint that
needs an instance raises here instead, and the layer counts as short, which
only means its warnings are held until the parse is known to be kept.

"""
try:
hint = klass.__length_hint__(cast('ProtocolBase', None))
except Exception: # pylint: disable=broad-except
return True
return hint is not None and captured < hint


class ProtocolMeta(abc.ABCMeta):
"""Metaclass of :class:`ProtocolBase`, and so of every protocol class.

Expand Down Expand Up @@ -2283,13 +2308,24 @@ def _parse_next_layer(klass: 'Type[ProtocolBase]', file_: 'bytes', length: 'int'
form rather than the shortest. A next layer that reports no hint is
kept as parsed.

"""
next_ = klass(file_, length, **kwargs) # type: ignore[abstract]
The warnings of a parse that may be replaced are held until it is
known whether it is. They are dropped when it is, since they describe
a header the result does not have -- ``packet length < 0`` twice for
an Ethernet frame of 10 octets (:issue:`1580`) -- and reported as the
parse ends when it is kept, still inside the guard that catches them
under an ``error`` filter. A layer kept with its header cut short
still warns. A layer captured at least as long as its hint cannot be
replaced, and is parsed holding nothing, even inside one that holds.

"""
captured = min(length, len(file_))
length_hint = getattr(next_, '__length_hint__', None)
hint = None if length_hint is None else length_hint()
if hint is not None and captured < hint and captured < next_.length:
with hold_warnings(_short_of_hint(klass, captured)) as held:
next_ = klass(file_, length, **kwargs) # type: ignore[abstract]

length_hint = getattr(next_, '__length_hint__', None)
hint = None if length_hint is None else length_hint()
held.discard = hint is not None and captured < hint and captured < next_.length
if held.discard:
from pcapkit.protocols.misc.raw import Raw # isort: skip # pylint: disable=import-outside-toplevel
next_ = Raw(file_, length, **kwargs)
return next_
Expand Down
176 changes: 175 additions & 1 deletion pcapkit/utilities/warnings.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,15 +51,28 @@
``pcapkit`` logger is configured separately, e.g. with
``logging.getLogger('pcapkit').setLevel(logging.ERROR)``.

The one exception is a parse :mod:`pcapkit` runs on trial: :class:`hold_warnings`
holds back its warnings, on both channels, until the parse is kept, and drops
them if it is discarded (:issue:`1580`).

"""
import contextvars
import inspect
import warnings
from typing import TYPE_CHECKING

from pcapkit.utilities.exceptions import stacklevel as stacklevel_calculator
from pcapkit.utilities.logging import VERBOSE, get_logger

if TYPE_CHECKING:
from typing import Optional, Type, Union
from types import FrameType, TracebackType
from typing import List, Optional, Tuple, Type, Union

from typing_extensions import Self

#: A warning held back by :class:`hold_warnings`: its message, its category,
#: and the frame it is attributed to, if that frame could be found.
HeldWarning = Tuple[Union[str, Warning], Type[Warning], Optional[FrameType]]

__all__ = [
'warn',
Expand All @@ -85,6 +98,12 @@
#: :data:`pcapkit.utilities.logging.logger`.
logger = get_logger(__name__)

#: The innermost open :class:`hold_warnings` block, or :data:`None` outside any.
#: A context variable rather than a global, so that a parse in another thread or
#: task is neither held nor released by this one.
_HELD = contextvars.ContextVar(
'pcapkit_held_warnings', default=None) # type: contextvars.ContextVar[Optional[hold_warnings]]


def warn(message: 'Union[str, Warning]', category: 'Type[Warning]',
stacklevel: 'Optional[int]' = None) -> 'None':
Expand Down Expand Up @@ -112,6 +131,11 @@ def warn(message: 'Union[str, Warning]', category: 'Type[Warning]',
:mod:`pcapkit.utilities.warnings` for the emission model in full, and for
how to silence either channel.

Note:
Inside a :class:`hold_warnings` block the warning is held back instead,
with the frame it is attributed to, and reported from there only if the
block keeps it.

"""
if stacklevel is None:
# Computed here, so the level is already relative to *this* frame, which
Expand All @@ -126,11 +150,161 @@ def warn(message: 'Union[str, Warning]', category: 'Type[Warning]',
# whole exercise exists to skip past.
stacklevel += 1

block = _open_block(_HELD.get())
if block is not None and block.holding:
# Level 1 is this frame, so the frame named is ``stacklevel - 1`` out.
target = inspect.currentframe()
for _ in range(stacklevel - 1):
if target is None:
break
target = target.f_back
block._held.append((message, category, target)) # pylint: disable=protected-access
del target
return

logger.warning(message, exc_info=VERBOSE, stack_info=VERBOSE,
stacklevel=stacklevel)
warnings.warn(message, category, stacklevel)


def _open_block(block: 'Optional[hold_warnings]') -> 'Optional[hold_warnings]':
"""The innermost open block from ``block`` outwards.

Args:
block: Block to start from, usually the one :data:`_HELD` names.

Returns:
``block``, or the nearest block enclosing it that is still open. A block
is left closed but still named only when it was left out of order, or in
another context, where it could not unset itself in this one.

"""
while block is not None and not block._open: # pylint: disable=protected-access
block = block._outer # pylint: disable=protected-access
return block


def _replay(message: 'Union[str, Warning]', category: 'Type[Warning]',
target: 'Optional[FrameType]') -> 'None':
"""Report a warning :class:`hold_warnings` held back, as :func:`warn` would have.

Args:
message: Warning message.
category: Warning category.
target: Frame the warning was attributed to when it was held.

The warning is attributed to ``target`` again, found by walking out from this
frame. ``target`` lies outside the parse that held it whenever its level came
from :func:`~pcapkit.utilities.exceptions.stacklevel`, and so is still on the
stack; were it not, the level is computed afresh. It is reported here and
now, not held again by an enclosing block.

"""
level = 1 # this frame, as both consumers below count it
frame = inspect.currentframe()
while frame is not None and frame is not target:
frame = frame.f_back
level += 1
found = frame is not None
del frame
if not found:
level = stacklevel_calculator()

logger.warning(message, exc_info=VERBOSE, stack_info=VERBOSE, stacklevel=level)
warnings.warn(message, category, level)


class hold_warnings: # pylint: disable=invalid-name
"""Hold back the warnings a parse reports until it is known to be kept.

Args:
hold: Hold the warnings reported inside the block. When false, they are
reported at once instead, even inside an enclosing block that holds.

Every :func:`warn` call inside the block is held, with the frame it is
attributed to. On leaving the block they are reported in order, with the
same message and category and attributed to the same frame on both
channels, unless :attr:`discard` was set, in which case they are dropped. A
block left by an :exc:`Exception` reports them whatever :attr:`discard`
says; one left by any other :exc:`BaseException` -- :exc:`KeyboardInterrupt`,
:exc:`SystemExit`, :exc:`GeneratorExit` -- drops them, so that a filter
turning them into errors cannot replace it.

The line named is the frame's line when the block is left. For the frame
:func:`~pcapkit.utilities.exceptions.stacklevel` names -- the caller of
:mod:`pcapkit`, still inside that call -- it is the line named when held.

Blocks nest, and each reports what it keeps when it is left, never handing
it to an enclosing block. Under a filter turning warnings into errors, the
error is then raised where the block that kept them is left -- the end of
the trial parse, inside the guard it was raised in when nothing was held.

:meth:`ProtocolBase._parse_next_layer
<pcapkit.protocols.protocol.ProtocolBase._parse_next_layer>` uses this for the
trial parse it replaces with :class:`~pcapkit.protocols.misc.raw.Raw` when the
header was not captured: the warnings describe a layer the result does not
have (:issue:`1580`).

Note:
Nothing here touches :data:`warnings.filters` or a module's
``__warningregistry__``; :func:`warnings.catch_warnings` would, which is
what made unrelated warnings re-fire up to v1.4.1 (see
:class:`BaseWarning`).

A block left while one opened inside it is still open -- a generator
suspended inside its own block -- stays in place for that one, which
steps over it when it is left. While it is suspended, though, the
generator's block holds its caller's warnings too: a context variable
follows the thread, not the frame.

"""

__slots__ = ('discard', 'holding', '_held', '_outer', '_token', '_open')

def __init__(self, hold: 'bool' = True) -> 'None':
#: Drop the held warnings on leaving the block, rather than report them.
self.discard = False
#: Whether warnings reported inside the block are held.
self.holding = hold

self._held = [] # type: List[HeldWarning]
self._outer = None # type: Optional[hold_warnings]
self._token = None # type: Optional[contextvars.Token[Optional[hold_warnings]]]
self._open = False

def __enter__(self) -> 'Self':
self._held = []
self._outer = _HELD.get()
self._token = _HELD.set(self)
self._open = True
return self

def __exit__(self, exc_type: 'Optional[Type[BaseException]]',
exc_value: 'Optional[BaseException]',
traceback: 'Optional[TracebackType]') -> 'None':
token, self._token = self._token, None
# a block opened inside this one and still open -- a suspended generator's
# -- stays in place, and steps over this one when it is left in turn
innermost = _open_block(_HELD.get()) is self
self._open = False
if innermost and token is not None:
try:
_HELD.reset(token)
except ValueError: # left in a copy of the context it was entered in
_HELD.set(self._outer)
outer = _open_block(_HELD.get())
if outer is not _HELD.get():
_HELD.set(outer)

held, self._held = self._held, []
if self.discard and exc_type is None:
return
if exc_type is not None and not issubclass(exc_type, Exception):
return
for message, category, target in held:
_replay(message, category, target)


##############################################################################
# BaseWarning (abc of warnings) session.
##############################################################################
Expand Down
Loading
Loading