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
1 change: 1 addition & 0 deletions docs/source/pcapkit/corekit/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ inherits from.
io
module
multidict
packet
protochain
sentinels
version
40 changes: 40 additions & 0 deletions docs/source/pcapkit/corekit/packet.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
Deferred Packet
===============

.. module:: pcapkit.corekit.packet

:mod:`pcapkit.corekit.packet` contains
:class:`~pcapkit.corekit.packet.DeferredPacket`, the mixin that lets an
:class:`~pcapkit.corekit.infoclass.Info` subclass hold its ``packet`` field as
a placeholder and resolve it on first read. It is shared by the reassembled
datagrams of :mod:`pcapkit.foundation.reassembly` and the traced flows of
:mod:`pcapkit.foundation.traceflow`, whose ``packet`` is a second parse of the
payload and a reassembly of the stream respectively.

A subclass is declared ``class X(DeferredPacket, Info)``, lists ``packet`` in
its ``__additional__``, and names its placeholder class in
:attr:`~pcapkit.corekit.packet.DeferredPacket.__deferred__`;
:meth:`~pcapkit.corekit.packet.DeferredPacket.__init_subclass__` refuses one
that does not. Listing ``packet`` in ``__additional__`` is what makes the field
lazy: :class:`~pcapkit.corekit.infoclass.Info` then stores it under a mangled
key, so reading it reaches ``__getattr__``, while ``dict(x)``,
:meth:`~pcapkit.corekit.packet.DeferredPacket.to_dict` and iteration still
report it as ``packet``.

Resolution happens **at most once**: the placeholder is called with no
arguments, and its result replaces it, so every later read returns that same
object.

.. autoclass:: pcapkit.corekit.packet.DeferredPacket
:no-members:
:show-inheritance:

.. attribute:: __deferred__

Placeholder class the ``packet`` field may hold, set by each subclass. A
value of this class is resolved by calling it with no arguments; an
instance of any other class, :data:`None` included, is not.

.. automethod:: __init_subclass__
.. automethod:: __analyse__
.. automethod:: to_dict
4 changes: 1 addition & 3 deletions docs/source/pcapkit/foundation/reassembly/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,4 @@ Auxiliary Data
:members:
:show-inheritance:

.. autoclass:: pcapkit.foundation.reassembly.data.data.DeferredPacket
:members:
:show-inheritance:
``DeferredPacket`` is re-exported here from :mod:`pcapkit.corekit.packet`; see :doc:`/pcapkit/corekit/packet`.
4 changes: 1 addition & 3 deletions docs/source/pcapkit/foundation/traceflow/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,4 @@ Auxiliary Data
:members:
:show-inheritance:

.. autoclass:: pcapkit.foundation.traceflow.data.data.DeferredPacket
:members:
:show-inheritance:
``DeferredPacket`` is re-exported here from :mod:`pcapkit.corekit.packet`; see :doc:`/pcapkit/corekit/packet`.
172 changes: 172 additions & 0 deletions pcapkit/corekit/packet.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
# -*- coding: utf-8 -*-
"""Deferred Packet
===================

.. module:: pcapkit.corekit.packet

:mod:`pcapkit.corekit.packet` contains
:class:`~pcapkit.corekit.packet.DeferredPacket`, the mixin that lets an
:class:`~pcapkit.corekit.infoclass.Info` subclass hold its ``packet`` field as
a placeholder and resolve it on first read. A reassembled datagram's ``packet``
is a second parse of its payload, and a traced flow's is a reassembly of its
stream; most callers want neither, so both are postponed until somebody reads
them.

A subclass keeps a three-part contract, which
:meth:`DeferredPacket.__init_subclass__` checks at class creation:

* It is declared ``class X(DeferredPacket, Info)``, mixin first, so the methods
here take precedence over :class:`~pcapkit.corekit.infoclass.Info`'s own.
* It lists ``packet`` in its ``__additional__``. That is what makes the field
lazy at all: :class:`~pcapkit.corekit.infoclass.Info` stores a field named
there under a mangled key and maps it back on the way out, so ``packet``
never lands in :attr:`~object.__dict__` under its own name. Reading it
therefore reaches :meth:`DeferredPacket.__getattr__`, where the placeholder
can be resolved, while ``dict(x)``, :meth:`DeferredPacket.to_dict` and
iteration still report the field as ``packet``.
* It names its placeholder class in
:attr:`~DeferredPacket.__deferred__`. A value of that class is called with no
arguments to produce the real ``packet``; any other value already is one.

Resolution happens **at most once**: the result replaces the placeholder in
:attr:`~object.__dict__`, so every later read returns that same object and the
placeholder is never called again.

This module imports nothing from :mod:`pcapkit.foundation` or
:mod:`pcapkit.protocols`. Each placeholder class stays with the subsystem that
creates it, and only the subclass knows which one it holds.

"""
from typing import TYPE_CHECKING

from pcapkit.utilities.exceptions import InfoError

if TYPE_CHECKING:
from typing import Any, Callable, ClassVar, Type

__all__ = ['DeferredPacket']


class DeferredPacket:
"""Resolves a deferred ``packet`` field on first read.

A mixin for :class:`~pcapkit.corekit.infoclass.Info` subclasses; see
:mod:`pcapkit.corekit.packet` for the contract a subclass keeps. Reading
``packet`` -- as an attribute, as ``x['packet']``, or through
:meth:`to_dict`, :func:`str` or :func:`repr` -- resolves a placeholder once
and keeps the result. ``'packet' in x``, :func:`len` and iteration report the
field by name without resolving it.

It is shared by
:class:`pcapkit.foundation.reassembly.data.ip.Datagram`,
:class:`pcapkit.foundation.reassembly.data.tcp.Datagram` and
:class:`pcapkit.foundation.traceflow.data.tcp.Index`, which differ only in
the placeholder class each names in :attr:`__deferred__`
(:issue:`1516`). It is still importable from where it lived before:
:mod:`pcapkit.foundation.reassembly.data`,
:mod:`pcapkit.foundation.reassembly.data.data` and
:mod:`pcapkit.foundation.traceflow.data.data`.

"""

if TYPE_CHECKING:
#: Placeholder class the ``packet`` field may hold, set by each subclass.
#: A value of this class is resolved by calling it with no arguments;
#: an instance of any other class, :data:`None` included, is not.
__deferred__: 'ClassVar[Type[Callable[[], Any]]]'

# NOTE: the ``super()`` calls below are suppressed for both checkers. They are
# undefined *on this mixin*, which is what a mixin is -- the base arrives at
# the point of use, where every subclass is declared
# ``class X(DeferredPacket, Info)`` and :class:`~pcapkit.corekit.infoclass.Info`
# supplies all three. Neither mypy nor pylint can see that from here, and
# pylint calls it an *error* rather than a warning.

def __init_subclass__(cls, /, *args: 'Any', **kwargs: 'Any') -> 'None':
"""Refuse a subclass that does not keep the contract.

Either omission would otherwise fail silently, and only on a read.
Without ``packet`` in ``__additional__`` the field lands in
:attr:`~object.__dict__` under its own name, :meth:`__getattr__` is never
reached, and the placeholder itself comes back. Without
:attr:`__deferred__`, :meth:`__analyse__` raises :exc:`AttributeError`
from inside :meth:`__getattr__`, which ``getattr(x, 'packet', None)``
takes for a missing attribute and answers with :data:`None`.

Args:
*args: Arbitrary positional arguments.
**kwargs: Arbitrary keyword arguments in class definition.

Raises:
InfoError: If ``cls`` does not list ``packet`` in its
``__additional__``, or does not name a class in its
:attr:`__deferred__`.

"""
super().__init_subclass__(*args, **kwargs)

if 'packet' not in getattr(cls, '__additional__', ()):
raise InfoError(f"{cls.__name__}: 'packet' is not listed in __additional__, "
'so reading it would never resolve the placeholder')
if not isinstance(getattr(cls, '__deferred__', None), type):
raise InfoError(f"{cls.__name__}: __deferred__ does not name the placeholder class "
"that 'packet' may hold")

def __analyse__(self) -> 'Any':
"""Resolve the ``packet`` field, at most once.

A placeholder, i.e. an instance of :attr:`__deferred__`, is called with
no arguments and its result replaces it in :attr:`~object.__dict__`. Any
other value is returned as it is.

Returns:
The resolved ``packet``, which may be :data:`None` when the subclass
stored that instead of a placeholder.

"""
key = self.__map__.get('packet', 'packet')
value = self.__dict__[key]
if isinstance(value, self.__deferred__):
value = value()
self.__dict__[key] = value
return value

def __getattr__(self, name: 'str') -> 'Any':
# NOTE: reached only for names absent from ``__dict__``, which ``packet``
# always is -- see ``__additional__`` in the module docstring. Everything
# else has to raise, or a typo would silently answer with the resolved
# ``packet``.
if name != 'packet':
raise AttributeError(f'{type(self).__name__!r} object has no attribute {name!r}')
return self.__analyse__()

def __getitem__(self, name: 'str') -> 'Any':
if name == 'packet':
return self.__analyse__()
return super().__getitem__(name) # type: ignore[misc] # pylint: disable=no-member

def __contains__(self, name: 'object') -> 'bool':
# NOTE: ``Mapping.__contains__`` answers by fetching the value, which
# would resolve the placeholder merely to decide that the field exists.
# ``packet`` is a declared field, so it is always there.
return name == 'packet' or super().__contains__(name) # type: ignore[misc] # pylint: disable=no-member

def __str__(self) -> 'str':
self.__analyse__()
return super().__str__()

def __repr__(self) -> 'str':
self.__analyse__()
return super().__repr__()

def to_dict(self) -> 'dict[str, Any]':
"""Convert the instance into :obj:`dict`.

Returns:
The fields, with ``packet`` resolved if it had not been read yet --
a :obj:`dict` holding the placeholder would leak an implementation
detail into what is meant to be plain data.

"""
self.__analyse__()
return super().to_dict() # type: ignore[misc] # pylint: disable=no-member
89 changes: 5 additions & 84 deletions pcapkit/foundation/reassembly/data/data.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,13 @@

from pcapkit.corekit.enum import EnumLookup
from pcapkit.corekit.infoclass import Info, info_final
from pcapkit.corekit.packet import DeferredPacket
from pcapkit.utilities.compat import StrEnum, auto

__all__ = ['ReassemblyData', 'Completion', 'Deferred', 'DeferredPacket']

if TYPE_CHECKING:
from typing import Any, Callable, Optional
from typing import Callable, Optional

from pcapkit.const.reg.transtype import TransType
from pcapkit.foundation.reassembly.data.ip import Datagram as IP_Datagram
Expand Down Expand Up @@ -124,9 +125,9 @@ class Deferred:
:file:`http.pcap` pass against 1117.

Holding the call here defers it to the first read of
:attr:`Datagram.packet`, so a caller that wants the parsed payload still gets
exactly the object an eager call would produce, and one that does not never
pays for it.
:attr:`Datagram.packet`, where :class:`~pcapkit.corekit.packet.DeferredPacket`
runs it, so a caller that wants the parsed payload still gets exactly the
object an eager call would produce, and one that does not never pays for it.

Args:
analyze: The analyser to call, i.e.
Expand Down Expand Up @@ -155,86 +156,6 @@ def __call__(self) -> 'ProtocolBase':
return self.analyze(self.proto, self.payload)


class DeferredPacket:
"""Resolves a :class:`Deferred` ``packet`` field on first read.

A reassembled datagram's ``packet`` is the parsed form of the payload it just
reassembled, and both reassemblers can hand a :class:`Deferred` in its place.
This carries the reading half of that arrangement, so the two ``Datagram``
models share it rather than each declaring it.

A subclass has to list ``packet`` in its ``__additional__``. That is what makes
the field lazy at all: :class:`~pcapkit.corekit.infoclass.Info` stores a field
whose name is a *builtin* name under a mangled key and maps it back on the way
out, so ``packet`` never lands in :attr:`~object.__dict__` itself -- which
routes reading it through :meth:`__getattr__`, where the deferred analysis can
run, while ``dict(datagram)``, :meth:`to_dict` and iteration still report the
field under its own name.

"""

# NOTE: the ``super()`` calls below are suppressed for both checkers. They are
# undefined *on this mixin*, which is what a mixin is -- the base arrives at
# the point of use, where every subclass is declared
# ``class X(DeferredPacket, Info)`` and :class:`~pcapkit.corekit.infoclass.Info`
# supplies all three. Neither mypy nor pylint can see that from here, and
# pylint calls it an *error* rather than a warning.

def __analyse__(self) -> 'Optional[ProtocolBase]':
"""Resolve a deferred analysis, at most once.

Returns:
Parsed payload, or :data:`None` for a datagram reported as the tuple
of its received runs.

"""
key = self.__map__.get('packet', 'packet')
value = self.__dict__[key]
if isinstance(value, Deferred):
value = value()
self.__dict__[key] = value
return value

def __getattr__(self, name: 'str') -> 'Any':
# NOTE: reached only for names absent from ``__dict__``, which ``packet``
# always is -- see ``__additional__`` above. Everything else has to raise,
# or a typo would silently answer with a parsed payload.
if name != 'packet':
raise AttributeError(f'{type(self).__name__!r} object has no attribute {name!r}')
return self.__analyse__()

def __getitem__(self, name: 'str') -> 'Any':
if name == 'packet':
return self.__analyse__()
return super().__getitem__(name) # type: ignore[misc] # pylint: disable=no-member

def __contains__(self, name: 'object') -> 'bool':
# NOTE: ``Mapping.__contains__`` answers by fetching the value, which
# would run the deferred analysis merely to decide that the field exists.
# ``packet`` is a declared field, so it is always there.
return name == 'packet' or super().__contains__(name) # type: ignore[misc] # pylint: disable=no-member

def __str__(self) -> 'str':
self.__analyse__()
return super().__str__()

def __repr__(self) -> 'str':
self.__analyse__()
return super().__repr__()

def to_dict(self) -> 'dict[str, Any]':
"""Convert :class:`Datagram` into :obj:`dict`.

Returns:
The datagram's fields, with ``packet`` analysed if it had not been
read yet -- a :obj:`dict` holding a :class:`Deferred` would leak an
implementation detail into what is meant to be plain data.

"""
self.__analyse__()
return super().to_dict() # type: ignore[misc] # pylint: disable=no-member


@info_final
class ReassemblyData(Info):
"""Data storage for reassembly."""
Expand Down
7 changes: 6 additions & 1 deletion pcapkit/foundation/reassembly/data/ip.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
from typing import TYPE_CHECKING, Generic, TypeVar

from pcapkit.corekit.infoclass import Info, info_final
from pcapkit.foundation.reassembly.data.data import Completion, Deferred, DeferredPacket
from pcapkit.corekit.packet import DeferredPacket
from pcapkit.foundation.reassembly.data.data import Completion, Deferred
from pcapkit.utilities.compat import Tuple

__all__ = [
Expand Down Expand Up @@ -86,7 +87,11 @@ class Datagram(DeferredPacket, Info, Generic[_AT]):
#: itself -- which routes reading it through :meth:`__getattr__`, where a
#: :class:`Deferred` analysis can be run, while ``dict(datagram)``,
#: :meth:`to_dict` and iteration still report the field under its own name.
#: See :class:`~pcapkit.corekit.packet.DeferredPacket`.
__additional__ = ['packet']
#: Placeholder class :attr:`packet` may hold, for
#: :class:`~pcapkit.corekit.packet.DeferredPacket` to resolve.
__deferred__ = Deferred

#: How completely the datagram was reassembled, and why reassembly stopped.
#: Only :attr:`Completion.COMPLETE` is truthy, so ``if datagram.completed:``
Expand Down
8 changes: 6 additions & 2 deletions pcapkit/foundation/reassembly/data/tcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
from typing import TYPE_CHECKING, Generic, TypeVar

from pcapkit.corekit.infoclass import Info, info_final
from pcapkit.foundation.reassembly.data.data import Completion, Deferred, DeferredPacket
from pcapkit.corekit.packet import DeferredPacket
from pcapkit.foundation.reassembly.data.data import Completion, Deferred
from pcapkit.utilities.compat import Tuple

__all__ = [
Expand Down Expand Up @@ -92,8 +93,11 @@ class Datagram(DeferredPacket, Info, Generic[_AT]):
#: as it would for a :obj:`bool`.
completed: 'Completion'
#: Listing ``packet`` here is what makes it lazy -- see
#: :class:`~pcapkit.foundation.reassembly.data.data.DeferredPacket`.
#: :class:`~pcapkit.corekit.packet.DeferredPacket`.
__additional__ = ['packet']
#: Placeholder class :attr:`packet` may hold, for
#: :class:`~pcapkit.corekit.packet.DeferredPacket` to resolve.
__deferred__ = Deferred

#: Original packet identifier.
id: 'DatagramID[_AT]'
Expand Down
Loading
Loading