diff --git a/docs/source/pcapkit/corekit/index.rst b/docs/source/pcapkit/corekit/index.rst index c1670d2734..6ccbad3b7e 100644 --- a/docs/source/pcapkit/corekit/index.rst +++ b/docs/source/pcapkit/corekit/index.rst @@ -25,6 +25,7 @@ inherits from. io module multidict + packet protochain sentinels version diff --git a/docs/source/pcapkit/corekit/packet.rst b/docs/source/pcapkit/corekit/packet.rst new file mode 100644 index 0000000000..18c76c9b79 --- /dev/null +++ b/docs/source/pcapkit/corekit/packet.rst @@ -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 diff --git a/docs/source/pcapkit/foundation/reassembly/index.rst b/docs/source/pcapkit/foundation/reassembly/index.rst index 32056e1dc5..27425415db 100644 --- a/docs/source/pcapkit/foundation/reassembly/index.rst +++ b/docs/source/pcapkit/foundation/reassembly/index.rst @@ -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`. diff --git a/docs/source/pcapkit/foundation/traceflow/index.rst b/docs/source/pcapkit/foundation/traceflow/index.rst index f5c7cc5926..924401911d 100644 --- a/docs/source/pcapkit/foundation/traceflow/index.rst +++ b/docs/source/pcapkit/foundation/traceflow/index.rst @@ -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`. diff --git a/pcapkit/corekit/packet.py b/pcapkit/corekit/packet.py new file mode 100644 index 0000000000..a42a816c4a --- /dev/null +++ b/pcapkit/corekit/packet.py @@ -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 diff --git a/pcapkit/foundation/reassembly/data/data.py b/pcapkit/foundation/reassembly/data/data.py index fad0ea8813..bd59205202 100644 --- a/pcapkit/foundation/reassembly/data/data.py +++ b/pcapkit/foundation/reassembly/data/data.py @@ -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 @@ -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. @@ -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.""" diff --git a/pcapkit/foundation/reassembly/data/ip.py b/pcapkit/foundation/reassembly/data/ip.py index c61d9d764b..9ec767db55 100644 --- a/pcapkit/foundation/reassembly/data/ip.py +++ b/pcapkit/foundation/reassembly/data/ip.py @@ -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__ = [ @@ -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:`` diff --git a/pcapkit/foundation/reassembly/data/tcp.py b/pcapkit/foundation/reassembly/data/tcp.py index 0eed7391a4..b4e1ee53f6 100644 --- a/pcapkit/foundation/reassembly/data/tcp.py +++ b/pcapkit/foundation/reassembly/data/tcp.py @@ -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__ = [ @@ -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]' diff --git a/pcapkit/foundation/traceflow/data/data.py b/pcapkit/foundation/traceflow/data/data.py index 29e7754fd9..75dea4724d 100644 --- a/pcapkit/foundation/traceflow/data/data.py +++ b/pcapkit/foundation/traceflow/data/data.py @@ -4,6 +4,7 @@ from typing import TYPE_CHECKING from pcapkit.corekit.infoclass import Info, info_final +from pcapkit.corekit.packet import DeferredPacket __all__ = ['TraceFlowData', 'Deferred', 'DeferredPacket', 'FrameRecord'] @@ -65,84 +66,6 @@ def __call__(self) -> 'tuple[TCP_Datagram, ...]': return self.reassembly.datagram -class DeferredPacket: - """Resolves a :class:`Deferred` ``packet`` field on first read. - - The reading half of the arrangement above, and the counterpart of - :class:`pcapkit.foundation.reassembly.data.data.DeferredPacket`. - - 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 - named there 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 reassembly can run, while - ``dict(index)``, :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[tuple[TCP_Datagram, ...]]': - """Resolve a deferred reassembly, at most once. - - Returns: - The flow's reassembled datagrams, or :data:`None` when the tracer was - not asked to analyse the application layer. - - """ - 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 reassembled flow. - 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 reassembly 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:`Index` into :obj:`dict`. - - Returns: - The flow's fields, with ``packet`` reassembled 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 - - class FrameRecord(dict): """A frame as a third-party engine's flow tracing adapter reports it. diff --git a/pcapkit/foundation/traceflow/data/tcp.py b/pcapkit/foundation/traceflow/data/tcp.py index 6511799903..f1b082a2a9 100644 --- a/pcapkit/foundation/traceflow/data/tcp.py +++ b/pcapkit/foundation/traceflow/data/tcp.py @@ -4,7 +4,8 @@ from typing import TYPE_CHECKING, Generic, TypeVar from pcapkit.corekit.infoclass import Info, info_final -from pcapkit.foundation.traceflow.data.data import Deferred, DeferredPacket +from pcapkit.corekit.packet import DeferredPacket +from pcapkit.foundation.traceflow.data.data import Deferred from pcapkit.utilities.compat import Tuple __all__ = ['BufferID', 'Packet', 'Buffer', 'Index'] @@ -158,8 +159,11 @@ class Index(DeferredPacket, Info): """ #: Listing ``packet`` here is what makes :attr:`packet` lazy -- see - #: :class:`~pcapkit.foundation.traceflow.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 #: Output filename if exists. fpout: 'Optional[str]' diff --git a/tests/corekit/test_deferred_packet_1516_unit.py b/tests/corekit/test_deferred_packet_1516_unit.py new file mode 100644 index 0000000000..39d9363588 --- /dev/null +++ b/tests/corekit/test_deferred_packet_1516_unit.py @@ -0,0 +1,270 @@ +# -*- coding: utf-8 -*- +"""GitHub issue #1516: one :class:`~pcapkit.corekit.packet.DeferredPacket` for +reassembly and flow tracing. + +There used to be two copies of the mixin, in +``pcapkit/foundation/reassembly/data/data.py`` and +``pcapkit/foundation/traceflow/data/data.py``. Their method bodies were +AST-identical, and the one thing that differed was which module-level +``Deferred`` the ``isinstance`` test named. The merged class takes that +difference as the ``__deferred__`` hook each subclass sets, so a model resolves +its own placeholder class and nothing else. + +:class:`DeferredPacketTests` pins the shared class against a placeholder of its +own, so it holds whatever either subsystem later does with the mixin. +:class:`WiringTests` pins the three real models. + +Each test imports what it needs itself, rather than the module importing it at +the top. On a tree without :mod:`pcapkit.corekit.packet`, every test then fails +on its own instead of the module failing to collect. + +""" + +import ast +import importlib +import pathlib +import types +import unittest + +#: The module under test, read as source by the import-layering check. +MODULE = pathlib.Path(__file__).resolve().parents[2] / 'pcapkit' / 'corekit' / 'packet.py' + +#: The three models, as ``(module, class, module holding its placeholder)``. +MODELS = ( + ('pcapkit.foundation.reassembly.data.ip', 'Datagram', 'pcapkit.foundation.reassembly.data.data'), + ('pcapkit.foundation.reassembly.data.tcp', 'Datagram', 'pcapkit.foundation.reassembly.data.data'), + ('pcapkit.foundation.traceflow.data.tcp', 'Index', 'pcapkit.foundation.traceflow.data.data'), +) + + +class Placeholder: + """Counts its own calls, and answers with a fixed result.""" + + def __init__(self, result: 'object') -> 'None': + self.result = result + self.calls = 0 + + def __call__(self) -> 'object': + self.calls += 1 + return self.result + + +class Unrelated: + """Callable as well, but not the class a holder names.""" + + def __init__(self) -> 'None': + self.calls = 0 + + def __call__(self) -> 'object': + self.calls += 1 + return 'must not be called' + + +def holder_class() -> 'type': + """A fresh model that keeps the contract, holding :class:`Placeholder`.""" + from pcapkit.corekit.infoclass import Info, info_final + from pcapkit.corekit.packet import DeferredPacket + + @info_final + class Holder(DeferredPacket, Info): + __additional__ = ['packet'] + __deferred__ = Placeholder + + name: 'str' + packet: 'object' + + return Holder + + +class DeferredPacketTests(unittest.TestCase): + """The shared class, against a placeholder of the test's own.""" + + def setUp(self) -> 'None': + self.result = {'parsed': True} + self.placeholder = Placeholder(self.result) + self.holder = holder_class()(name='x', packet=self.placeholder) + + def test_construction_does_not_resolve(self) -> 'None': + self.assertEqual(self.placeholder.calls, 0) + self.assertNotIn('packet', self.holder.__dict__) + self.assertIs(self.holder.__dict__[self.holder.__map__['packet']], self.placeholder) + + def test_reading_packet_resolves_exactly_once(self) -> 'None': + first = self.holder.packet + self.assertIs(first, self.result) + self.assertEqual(self.placeholder.calls, 1) + + # every later read, by whatever route, returns the same object without + # calling the placeholder again + self.assertIs(self.holder.packet, first) + self.assertIs(self.holder['packet'], first) + self.assertIs(self.holder.to_dict()['packet'], first) + self.assertIs(dict(self.holder)['packet'], first) + str(self.holder) + repr(self.holder) + self.assertEqual(self.placeholder.calls, 1) + + def test_views_report_the_field_by_name_without_resolving(self) -> 'None': + self.assertIn('packet', self.holder) + self.assertEqual(list(self.holder), ['name', 'packet']) + self.assertEqual(list(self.holder.keys()), ['name', 'packet']) + self.assertEqual(len(self.holder), 2) + self.assertEqual(self.placeholder.calls, 0) + + def test_every_reader_resolves_once_and_reports_packet_by_name(self) -> 'None': + readers = { + 'to_dict': lambda holder: holder.to_dict()['packet'], + 'dict': lambda holder: dict(holder)['packet'], + 'getitem': lambda holder: holder['packet'], + 'get': lambda holder: holder.get('packet'), + 'items': lambda holder: dict(holder.items())['packet'], + } + for (reader, read) in readers.items(): + with self.subTest(reader=reader): + placeholder = Placeholder(self.result) + holder = holder_class()(name='x', packet=placeholder) + self.assertIs(read(holder), self.result) + self.assertEqual(placeholder.calls, 1) + + for render in (str, repr): + with self.subTest(reader=render.__name__): + placeholder = Placeholder(self.result) + holder = holder_class()(name='x', packet=placeholder) + text = render(holder) + self.assertIn("packet={'parsed': True}", text) + self.assertNotIn('Placeholder', text) + self.assertEqual(placeholder.calls, 1) + + def test_only_the_named_placeholder_class_is_resolved(self) -> 'None': + """``__deferred__`` is a hook on the class, not a test for "callable".""" + unrelated = Unrelated() + function = types.SimpleNamespace(calls=0) + + def callback() -> 'None': + function.calls += 1 + + for value in (unrelated, callback, None, ('a', 'b')): + with self.subTest(value=type(value).__name__): + holder = holder_class()(name='x', packet=value) + self.assertIs(holder.packet, value) + self.assertIs(holder.to_dict()['packet'], value) + self.assertEqual(unrelated.calls, 0) + self.assertEqual(function.calls, 0) + + def test_an_unknown_attribute_still_raises_without_resolving(self) -> 'None': + self.assertFalse(hasattr(self.holder, 'nope')) + with self.assertRaises(AttributeError): + self.holder.nope # pylint: disable=pointless-statement + self.assertEqual(self.placeholder.calls, 0) + + def test_a_subclass_must_list_packet_in_additional(self) -> 'None': + from pcapkit.corekit.infoclass import Info + from pcapkit.corekit.packet import DeferredPacket + from pcapkit.utilities.exceptions import InfoError + + with self.assertRaisesRegex(InfoError, r"Unlisted: 'packet' is not listed in __additional__"): + class Unlisted(DeferredPacket, Info): # pylint: disable=unused-variable + __deferred__ = Placeholder + packet: 'object' + + def test_a_subclass_must_name_its_placeholder_class(self) -> 'None': + from pcapkit.corekit.infoclass import Info + from pcapkit.corekit.packet import DeferredPacket + from pcapkit.utilities.exceptions import InfoError + + with self.assertRaisesRegex(InfoError, r'Unnamed: __deferred__ does not name the placeholder class'): + class Unnamed(DeferredPacket, Info): # pylint: disable=unused-variable + __additional__ = ['packet'] + packet: 'object' + + with self.assertRaisesRegex(InfoError, r'Instance: __deferred__ does not name the placeholder class'): + class Instance(DeferredPacket, Info): # pylint: disable=unused-variable + __additional__ = ['packet'] + __deferred__ = Placeholder(None) + packet: 'object' + + def test_the_module_imports_nothing_from_foundation_or_protocols(self) -> 'None': + """``corekit`` sits below both, so the mixin may not reach up to either.""" + tree = ast.parse(MODULE.read_text(encoding='utf-8')) + imported = [] + for node in ast.walk(tree): + if isinstance(node, ast.Import): + imported.extend(alias.name for alias in node.names) + elif isinstance(node, ast.ImportFrom): + self.assertEqual(node.level, 0, f'relative import at line {node.lineno}') + imported.append(node.module or '') + self.assertTrue(imported) + for name in imported: + with self.subTest(module=name): + self.assertFalse(name.startswith(('pcapkit.foundation', 'pcapkit.protocols')), name) + + +class WiringTests(unittest.TestCase): + """The three real models share the one class, each with its own placeholder.""" + + def test_every_model_shares_the_one_class_mixin_first(self) -> 'None': + from pcapkit.corekit.packet import DeferredPacket + + for (module, name, deferred_module) in MODELS: + with self.subTest(model=f'{module}.{name}'): + cls = getattr(importlib.import_module(module), name) + self.assertIs(cls.__mro__[1], DeferredPacket) + self.assertIn('packet', cls.__additional__) + self.assertIs(cls.__deferred__, importlib.import_module(deferred_module).Deferred) + + def test_the_old_import_paths_resolve_to_the_one_class(self) -> 'None': + """The name was public at all three before the move, and still is. + + Each path is the class object itself rather than a copy of it, so the + two old definitions really are gone and nothing can diverge again. + + """ + from pcapkit.corekit.packet import DeferredPacket + + self.assertEqual(DeferredPacket.__module__, 'pcapkit.corekit.packet') + for module in ('pcapkit.foundation.reassembly.data', + 'pcapkit.foundation.reassembly.data.data', + 'pcapkit.foundation.traceflow.data.data'): + with self.subTest(module=module): + imported = importlib.import_module(module) + self.assertIs(imported.DeferredPacket, DeferredPacket) + self.assertIn('DeferredPacket', imported.__all__) + + def test_each_model_resolves_its_own_placeholder_and_not_the_other(self) -> 'None': + from pcapkit.corekit.packet import DeferredPacket + from pcapkit.foundation.reassembly.data.data import Completion + from pcapkit.foundation.reassembly.data.data import Deferred as ReassemblyDeferred + from pcapkit.foundation.reassembly.data.ip import Datagram as IP_Datagram + from pcapkit.foundation.reassembly.data.tcp import Datagram as TCP_Datagram + from pcapkit.foundation.traceflow.data.data import Deferred as TraceFlowDeferred + from pcapkit.foundation.traceflow.data.tcp import Index + + def reassembly() -> 'ReassemblyDeferred': + return ReassemblyDeferred(lambda proto, payload: ('parsed', proto, payload), 'proto', b'payload') + + def traceflow() -> 'TraceFlowDeferred': + return TraceFlowDeferred(types.SimpleNamespace(datagram=('reassembled',))) # type: ignore[arg-type] + + def datagram(cls: 'type', packet: 'object') -> 'DeferredPacket': + return cls(completed=Completion.COMPLETE, id=None, index=(1,), header=b'', + payload=b'payload', packet=packet, conflict=()) + + def index(packet: 'object') -> 'DeferredPacket': + return Index(fpout=None, index=(1,), label='label', forward=(1,), reverse=(), packet=packet) + + cases = ( + ('ip', lambda packet: datagram(IP_Datagram, packet), reassembly, traceflow, + ('parsed', 'proto', b'payload')), + ('tcp', lambda packet: datagram(TCP_Datagram, packet), reassembly, traceflow, + ('parsed', 'proto', b'payload')), + ('trace', index, traceflow, reassembly, ('reassembled',)), + ) + for (label, build, own, other, expected) in cases: + with self.subTest(model=label): + self.assertEqual(build(own()).packet, expected) + foreign = other() + self.assertIs(build(foreign).packet, foreign) + + +if __name__ == '__main__': + unittest.main()