From f177f1ada19227b438e90cb8500dd35c82466775 Mon Sep 17 00:00:00 2001 From: Jarry Shaw Date: Tue, 3 Nov 2026 01:39:41 -0500 Subject: [PATCH] fix(dumpkit): dump a key renamed for clashing with a builtin under its own name (#2733) Info stores a key named like one of its builtin names, such as items or a reassembled datagram's packet, under a renamed key, and finalisation adds every builtin name to __excluded__. repr and str match __excluded__ against the renamed key and write the field; the dumps matched it against the name iterated, so every json, plist and tree dump left the field out, of the info and of its to_dict() export alike. - corekit.infoclass: _dump_excluded gives an Info's __excluded__ less each non-dunder key it renamed. to_dict() records that as the export's __excluded__, so an export dumps the field too. - dumpkit: excluded() takes _dump_excluded for an Info, so fields() and the nested-export branch of object_hook write the field under its own name. - An excluded name, a dunder key __excluded__ names, a dunder key iteration hides and a renamed dunder key stay out. - The dump resolves a deferred packet through indexing, once, as to_dict(), str and repr do; constructing, iterating and len() leave it deferred. - fields() writes a protocol object, such as that packet, as its info, as a frame's layers are written, at the top level and nested. Written by its attributes it carried its BytesIO's address, so a datagram dumped differently in each process. The new tests fail on ab7be2fb0; the protocol and fresh-process ones also fail on the first revision of this fix. dumpkit, corekit, foundation and project pass module by module. Closes #2733 --- pcapkit/corekit/infoclass.py | 42 ++- pcapkit/dumpkit/common.py | 51 ++- .../test_renamed_field_dump_2733_unit.py | 346 ++++++++++++++++++ 3 files changed, 422 insertions(+), 17 deletions(-) create mode 100644 tests/dumpkit/test_renamed_field_dump_2733_unit.py diff --git a/pcapkit/corekit/infoclass.py b/pcapkit/corekit/infoclass.py index c480c0c290..1654b26a03 100644 --- a/pcapkit/corekit/infoclass.py +++ b/pcapkit/corekit/infoclass.py @@ -377,7 +377,10 @@ class Info(ReduceAsProtocol2, Mapping[str, VT], Generic[VT], metaclass=InfoMeta) #: List of names of keys left out of ``repr``, ``str`` and the dumps of #: :mod:`pcapkit.dumpkit` -- metadata such as ``__next_type__``, which #: :meth:`to_dict` may still carry, see :attr:`__included__`. - #: Finalisation adds the names in :attr:`__builtin__`. + #: Finalisation adds the names in :attr:`__builtin__`, though ``repr``, + #: ``str`` and the dumps write a field renamed for clashing with one of + #: them, such as a reassembled datagram's ``packet``, under its own name + #: (:issue:`2733`). __excluded__: 'list[str]' = [] #: List of names of dunder keys that iterating an instance, ``len()`` and #: :meth:`to_dict` carry -- metadata such as ``__next_type__``, which a @@ -771,9 +774,16 @@ def to_dict(self) -> 'OrderedMultiDict[str, VT]': :attr:`__excluded__`, and records those as its own ``__excluded__`` for the dumpers to leave out (:issue:`2003`). + .. versionchanged:: 1.6.0 + + The ``__excluded__`` it records leaves out a field renamed for + clashing with a builtin name, such as a reassembled datagram's + ``packet``, so the dumpers write that field under its own name + (:issue:`2733`). + """ dict_ = _OrderedMultiDict() # type: _OrderedMultiDict[str, Any] - dict_.__excluded__ = self.__excluded__ + dict_.__excluded__ = _dump_excluded(self) for (key, value) in self.__items_multi(): out_key = self.__map_reverse__.get(key, key) if isinstance(value, Info): @@ -827,8 +837,9 @@ class _OrderedMultiDict(OrderedMultiDict[KT, VT]): __ne__ = _ne #: Names of the keys the dumpers leave out: the :attr:`Info.__excluded__` - #: of the info exported, kept by copies, deep copies and pickles; none for - #: an export of a multi-mapping (:issue:`2003`). + #: of the info exported, less the fields it renamed for clashing with a + #: builtin name (:issue:`2733`), kept by copies, deep copies and pickles; + #: none for an export of a multi-mapping (:issue:`2003`). __excluded__ = () # type: Collection[str] def __repr__(self) -> 'str': @@ -862,6 +873,29 @@ def _restore_export(cls: 'Type[_OrderedMultiDict]', pairs: 'list[tuple[Any, Any] return new +def _dump_excluded(info: 'Info') -> 'Collection[str]': + """Return the names of the keys of ``info`` the dumps leave out. + + Args: + info: A field model, i.e. an :class:`Info` that is no multi-mapping. + + Returns: + Its :attr:`Info.__excluded__`, less each field it renamed for clashing + with a builtin name. Finalisation adds every such name to + :attr:`Info.__excluded__`, and the dumps see a key under its own name + rather than as renamed, so they would otherwise leave out every renamed + field, a reassembled datagram's ``packet`` among them (:issue:`2733`). + ``repr`` and ``str`` see the renamed key, and write the field. A + renamed dunder key stays left out, being metadata. + + """ + renamed = info.__map__ + if not renamed: + return info.__excluded__ + return [name for name in info.__excluded__ + if name not in renamed or (name.startswith('__') and name.endswith('__'))] + + class _MultiInfo(Info[VT], Generic[KT, VT]): """An :class:`Info` that holds its values as a multi-mapping. diff --git a/pcapkit/dumpkit/common.py b/pcapkit/dumpkit/common.py index 4e9e6b33b4..bda2c704a9 100644 --- a/pcapkit/dumpkit/common.py +++ b/pcapkit/dumpkit/common.py @@ -28,9 +28,10 @@ import dictdumper.plist import dictdumper.tree -from pcapkit.corekit.infoclass import Info +from pcapkit.corekit.infoclass import Info, _dump_excluded from pcapkit.corekit.multidict import MultiDict, OrderedMultiDict from pcapkit.corekit.sentinels import NoValueType, NullType +from pcapkit.protocols.protocol import ProtocolBase from pcapkit.protocols.schema.schema import Schema from pcapkit.utilities import config from pcapkit.utilities.exceptions import UnsupportedCall @@ -357,6 +358,16 @@ def make_dumper(output: 'Type[ABCDumper]') -> 'Type[ABCDumper]': kept (:issue:`1257`), and a string carrying a character XML forbids is written as ```` (:issue:`1262`). + .. versionchanged:: 1.6.0 + + A field an :class:`~pcapkit.corekit.infoclass.Info` renamed for + clashing with a builtin name, such as a reassembled datagram's + ``packet``, is written under its own name rather than left out, and + reading it resolves a deferred ``packet`` as indexing does. A protocol + object, such as that ``packet``, is written as its + :attr:`~pcapkit.protocols.protocol.Protocol.info`, as a frame is, + rather than by its attributes (:issue:`2733`). + """ # NOTE: :class:`~dictdumper.plist.PLIST` -- which is also what # :attr:`Extractor.__output__ ` @@ -416,21 +427,27 @@ def excluded(info: 'Info | MultiDict[Any, Any]') -> 'Collection[Any]': The :attr:`~pcapkit.corekit.infoclass.Info.__excluded__` of a field model, or of the field model an export was made from -- metadata such as ``__next_type__``, which only a rebuild reads - (:issue:`2003`). Empty for an option list, whose keys are its + (:issue:`2003`) -- less the fields it renamed for clashing with a + builtin name, which are written under their own names + (:issue:`2733`). Empty for an option list, whose keys are its options. """ - if isinstance(info, Info) and isinstance(info, MultiDict): - return () + if isinstance(info, Info): + if isinstance(info, MultiDict): + return () + return _dump_excluded(info) return getattr(info, '__excluded__', ()) - def fields(info: 'Info | MultiDict[Any, Any]') -> 'dict[Any, Any]': + def fields(info: 'Info | MultiDict[Any, Any] | ProtocolBase') -> 'dict[Any, Any]': """Return the fields of ``info`` as the :class:`dict` the writer is handed. Args: info: The fields, as an :class:`~pcapkit.corekit.infoclass.Info` or as the :class:`~pcapkit.corekit.multidict.OrderedMultiDict` its - :meth:`~pcapkit.corekit.infoclass.Info.to_dict` returns. + :meth:`~pcapkit.corekit.infoclass.Info.to_dict` returns, or a + protocol object, whose fields are those of its + :attr:`~pcapkit.protocols.protocol.Protocol.info`. Returns: One entry per field, in order, with its key unchanged -- the writer @@ -443,8 +460,11 @@ def fields(info: 'Info | MultiDict[Any, Any]') -> 'dict[Any, Any]': written. The keys :func:`excluded` names are left out. """ - skip = excluded(info) - pairs = [(key, val) for key, val in info.items(multi=True) if key not in skip] + # NOTE: a protocol object, as a reassembled datagram's ``packet``, is + # written as its info, as a frame is, never by its attributes (#2733). + model = info.info if isinstance(info, ProtocolBase) else info # type: Info | MultiDict[Any, Any] + skip = excluded(model) + pairs = [(key, val) for key, val in model.items(multi=True) if key not in skip] counts = collections.Counter(key for key, _ in pairs) result = {} # type: dict[Any, Any] for key, val in pairs: @@ -454,7 +474,7 @@ def fields(info: 'Info | MultiDict[Any, Any]') -> 'dict[Any, Any]': # NOTE: read by indexing, so that a model overriding it is # honoured -- the traceflow and reassembly models resolve a # deferred ``packet`` there. - result[key] = info[key] + result[key] = model[key] return result #: Whether the writer is handed the fields of an @@ -484,12 +504,13 @@ def __call__(self, value: 'Any', name: 'Optional[str]' = None) -> 'Any': :class:`~pcapkit.corekit.multidict.MultiDict` its :meth:`~pcapkit.corekit.infoclass.Info.to_dict` returns, is written as the :class:`dict` :func:`fields` makes of it, so - that a repeated field keeps every value. The writer walks + that a repeated field keeps every value; a protocol object + is written as that of its info. The writer walks the block it is handed without calling :meth:`object_hook` on it, only on what it holds. """ - if isinstance(value, (Info, MultiDict)): + if isinstance(value, (Info, MultiDict, ProtocolBase)): value = fields(value) return super().__call__(value, name) @@ -526,14 +547,18 @@ def object_hook(self, o: 'Any') -> 'Any': the option lists an :class:`~pcapkit.corekit.infoclass.Info` holds. An :class:`~pcapkit.corekit.infoclass.Info` itself becomes the :class:`dict` of its fields that :func:`fields` - returns, holding every value of a repeated field. + returns, holding every value of a repeated field, and so does a + protocol object, as its + :attr:`~pcapkit.protocols.protocol.Protocol.info`, rather + than by its attributes, which hold such per-process detail as + the address of its file object (:issue:`2733`). """ if isinstance(o, decimal.Decimal): result = str(o) # type: Any elif isinstance(o, datetime.timedelta): result = o.total_seconds() - elif isinstance(o, Info) and not isinstance(o, MultiDict): + elif isinstance(o, ProtocolBase) or (isinstance(o, Info) and not isinstance(o, MultiDict)): # NOTE: a MultiInfo or OrderedMultiInfo, though an Info, is # an option list, and is written by the branches below. result = fields(o) diff --git a/tests/dumpkit/test_renamed_field_dump_2733_unit.py b/tests/dumpkit/test_renamed_field_dump_2733_unit.py new file mode 100644 index 0000000000..51d1756877 --- /dev/null +++ b/tests/dumpkit/test_renamed_field_dump_2733_unit.py @@ -0,0 +1,346 @@ +# -*- coding: utf-8 -*- +"""A field an :class:`~pcapkit.corekit.infoclass.Info` renamed for clashing with a builtin name is dumped. + +GitHub issue #2733. :class:`Info` stores a key named like one of its builtin +names, such as ``items`` or a reassembled datagram's ``packet``, under a renamed +key, and finalisation puts the name in ``__excluded__``. The dumps matched +``__excluded__`` against the name the key is iterated by, so every ``json``, +``plist`` and ``tree`` dump left such a field out, of the info and of its export +alike. The dumps write each under its own name, and leave out an excluded name, a +dunder key ``__excluded__`` names, a dunder key iteration hides and a renamed +dunder key. + +A deferred ``packet`` is resolved by the dump, once, as indexing resolves it: the +dump reads the field, and ``to_dict()`` resolves it for an export as well, so the +info and its export dump alike. Nothing before the dump resolves it. A resolved +``packet`` is a protocol object, which the dumps write as its ``info``, as they +write a frame's layers: written by its attributes, it carried the address of its +file object, so the same datagram dumped differently in each process. + +Every case builds its input in memory and reads no capture. :mod:`pcapkit` is +imported inside each test, after :func:`~tests._support.reimport_once_per_class`, +or in a fresh interpreter for :class:`FreshProcessDumpTests`. + +""" + +import importlib +import itertools +import json +import os +import plistlib +import re +import subprocess # nosec: B404 +import sys +import tempfile +import unittest +from ipaddress import ip_address + +from tests._support import reimport_once_per_class, scale_timeout + +#: Repository root, the working directory of a fresh interpreter. +ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +#: Builds a reassembled IPv4 and TCP datagram in memory and dumps both through +#: each writer into the directory ``argv[1]``; prints where :mod:`pcapkit` is from. +SCRIPT = r''' +import os +import struct +import sys +from ipaddress import ip_address + +import dictdumper + +import pcapkit +from pcapkit.const.reg.transtype import TransType +from pcapkit.dumpkit.common import make_dumper +from pcapkit.foundation.reassembly.data.ip import Packet as IP_Packet +from pcapkit.foundation.reassembly.data.tcp import Packet as TCP_Packet +from pcapkit.foundation.reassembly.ipv4 import IPv4 +from pcapkit.foundation.reassembly.tcp import TCP + +SRC, DST = ip_address('192.0.2.1'), ip_address('198.51.100.2') +UDP = struct.pack('!HHHH', 1234, 9, 32, 0) + bytes(range(24)) +REQUEST = b'GET / HTTP/1.1\r\nHost: example\r\n\r\n' + + +def ipv4(): + reasm = IPv4() + for (offset, chunk, mf) in ((0, UDP[:16], True), (16, UDP[16:], False)): + reasm(IP_Packet(bufid=(SRC, DST, 0x1234, TransType.UDP), num=1 + offset // 16, fo=offset, ihl=20, + mf=mf, tl=20 + len(chunk), header=bytes([0x45]) + bytes(19), + payload=bytearray(chunk), timestamp=1000.0)) + datagram, = reasm.datagram + return datagram + + +def tcp(): + reasm = TCP() + reasm(TCP_Packet((SRC, 50000, DST, 80), 1, 500, 1, False, True, False, 5, 1, 5, b'', + bytearray(REQUEST), 1000.0)) + datagram, = reasm.datagram + return datagram + + +for writer in ('JSON', 'PLIST', 'Tree'): + dumper = make_dumper(getattr(dictdumper, writer))(os.path.join(sys.argv[1], writer)) + dumper(ipv4(), name='ipv4') + dumper(tcp(), name='tcp') +print(pcapkit.__file__) +''' + +#: The :mod:`dictdumper` writers the extractor's ``json``, ``plist``/``xml`` and +#: ``tree``/``text``/``txt`` formats map to. +WRITERS = ('JSON', 'PLIST', 'Tree') + +#: Block name every dump is written under. +NAME = 'x' + +#: A top-level key of a ``tree`` block, as the writer spells its line. +TREE_KEY = re.compile(r'^ \|-- (\S+)', re.MULTILINE) + + +class DumpTestCase(unittest.TestCase): + """Writes a value through each writer and reads the block back.""" + + def setUp(self) -> None: + reimport_once_per_class(self) + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + self.tmp = tmp.name + self.count = itertools.count() + + def block(self, writer: 'str', value: 'object') -> 'object': + """Write ``value`` through ``make_dumper(dictdumper.)``; return the block. + + A ``json`` or ``plist`` block is the mapping read back, a ``tree`` block + its text. + + """ + dictdumper = importlib.import_module('dictdumper') + common = importlib.import_module('pcapkit.dumpkit.common') + + path = os.path.join(self.tmp, f'{writer}-{next(self.count)}.out') + common.make_dumper(getattr(dictdumper, writer))(path)(value, name=NAME) + if writer == 'JSON': + with open(path, encoding='utf-8') as file: + return json.load(file)[NAME] + if writer == 'PLIST': + with open(path, 'rb') as file: + return plistlib.load(file)[NAME] + with open(path, encoding='utf-8') as file: + return file.read() + + def keys(self, writer: 'str', value: 'object') -> 'list[str]': + """The top-level keys of the block ``writer`` makes of ``value``, in order.""" + block = self.block(writer, value) + if isinstance(block, str): + return TREE_KEY.findall(block) + return list(block) # type: ignore[call-overload] + + +class RenamedFieldTests(DumpTestCase): + """A field named like a builtin name of :class:`Info`.""" + + def bag(self) -> 'object': + from pcapkit.corekit.infoclass import Info, info_final + + @info_final + class Bag(Info): + name: 'str' + items: 'int' + + return Bag(name='x', items=1) + + def test_a_field_named_items_is_dumped_under_its_name(self) -> None: + bag = self.bag() + self.assertIn('items', bag.__map__) + self.assertIn('items', bag.__excluded__) + for writer in WRITERS: + for (label, value) in (('info', bag), ('export', bag.to_dict())): + with self.subTest(writer=writer, value=label): + self.assertEqual(self.keys(writer, value), ['name', 'items']) + block = self.block(writer, value) + if isinstance(block, str): + self.assertIn(' |-- items -> 1\n', block) + else: + self.assertEqual(block['items'], 1) # type: ignore[index] + + def test_a_nested_renamed_field_is_dumped_too(self) -> None: + """An info nested in an info, and an export nested in an export.""" + from pcapkit.corekit.infoclass import Info + + inner = Info(name='x', items=1, keys=2) + outer = Info(inner=inner) + self.assertEqual(self.block('JSON', outer), {'inner': {'name': 'x', 'items': 1, 'keys': 2}}) + # A nested export is an option list's shape, one single-key mapping per entry. + self.assertEqual(self.block('JSON', outer.to_dict()), + {'inner': [{'name': 'x'}, {'items': 1}, {'keys': 2}]}) + + +class HiddenKeyTests(DumpTestCase): + """The keys the dumps leave out, held beside a renamed field.""" + + def model(self) -> 'object': + from pcapkit.corekit.infoclass import Info + + class Model(Info): + __included__ = ['__meta__'] + __excluded__ = ['__meta__', 'secret'] + + return Model.from_dict([('name', 'x'), ('items', 1), ('secret', 2), ('__meta__', 3), + ('__hidden__', 4), ('__doc__', 5)]) + + def test_excluded_and_dunder_keys_stay_out_beside_a_renamed_field(self) -> None: + """``secret`` is excluded, ``__meta__`` excluded metadata, ``__hidden__`` + a dunder iteration hides, and ``__doc__`` a dunder renamed as ``items`` + is, so the export carries it. + + """ + model = self.model() + self.assertEqual(sorted(model.__map__), ['__doc__', 'items']) + self.assertIn('__doc__', [key for (key, _) in model.to_dict().items(multi=True)]) + for writer in WRITERS: + for (label, value) in (('info', model), ('export', model.to_dict())): + with self.subTest(writer=writer, value=label): + self.assertEqual(self.keys(writer, value), ['name', 'items']) + + def test_the_export_records_every_left_out_name_but_the_renamed_field(self) -> None: + export = self.model().to_dict() + self.assertNotIn('items', export.__excluded__) + for name in ('secret', '__meta__', '__doc__', '__map__'): + with self.subTest(name=name): + self.assertIn(name, export.__excluded__) + + +class Counting: + """Stands in for an analysis; counts its calls, and answers ``'parsed'``.""" + + def __init__(self) -> None: + self.calls = 0 + + def __call__(self, *args: 'object') -> 'str': + self.calls += 1 + return 'parsed' + + @property + def datagram(self) -> 'str': + """What a traced flow's placeholder reads of its reassembler.""" + return self() + + +class DeferredPacketDumpTests(DumpTestCase): + """A deferred ``packet`` is dumped, resolved once and only by the dump.""" + + @staticmethod + def tcp_datagram() -> 'object': + """One complete TCP datagram, its ``packet`` deferred.""" + from pcapkit.foundation.reassembly.data.tcp import Packet + from pcapkit.foundation.reassembly.tcp import TCP + + reasm = TCP() + reasm(Packet((ip_address('192.0.2.1'), 50000, ip_address('198.51.100.2'), 65535), + 1, 500, 1, False, True, False, 5, 1, 5, b'', bytearray(b'hello'), 1000.0)) + datagram, = reasm.datagram + return datagram + + @staticmethod + def held(model: 'object') -> 'object': + """``model``'s stored ``packet``, without resolving it.""" + return model.__dict__[model.__map__['packet']] # type: ignore[attr-defined] + + def test_a_reassembled_tcp_datagram_dumps_its_packet(self) -> None: + from pcapkit.foundation.reassembly.data.data import Deferred + + for writer in WRITERS: + for label in ('info', 'export'): + with self.subTest(writer=writer, value=label): + datagram = self.tcp_datagram() + self.assertIn('packet', datagram.__excluded__) + self.assertIsInstance(self.held(datagram), Deferred) + + value = datagram if label == 'info' else datagram.to_dict() + block = self.block(writer, value) + keys = TREE_KEY.findall(block) if isinstance(block, str) else list(block) + self.assertEqual(keys, list(datagram)) + self.assertIn('packet', keys) + + # The dump wrote the parse, and kept it in place of the placeholder. + packet = self.held(datagram) + self.assertNotIsInstance(packet, Deferred) + self.assertIs(datagram['packet'], packet) + if not isinstance(block, str): + self.assertEqual(block['packet'], self.block(writer, {'packet': packet})['packet']) + + def test_the_dump_resolves_a_deferred_packet_once(self) -> None: + """Every model with a deferred ``packet``, IP, TCP and DTLS reassembly's and a traced flow's.""" + from pcapkit.foundation.reassembly.data.data import Completion + from pcapkit.foundation.reassembly.data.data import Deferred as ReassemblyDeferred + from pcapkit.foundation.reassembly.data.dtls import Datagram as DTLS_Datagram + 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 datagram(cls: 'type', counting: 'Counting') -> 'object': + return cls(completed=Completion.COMPLETE, id=None, index=(1,), header=b'', payload=b'payload', + packet=ReassemblyDeferred(counting, 'proto', b'payload'), conflict=()) + + builders = { + 'ip': lambda counting: datagram(IP_Datagram, counting), + 'tcp': lambda counting: datagram(TCP_Datagram, counting), + 'dtls': lambda counting: DTLS_Datagram( + completed=Completion.COMPLETE, id=None, index=(1,), message_seq=0, msg_type=None, length=7, + body=b'payload', packet=ReassemblyDeferred(counting, 'proto', b'payload')), + 'trace': lambda counting: Index(fpout=None, index=(1,), label='label', forward=(1,), reverse=(), + packet=TraceFlowDeferred(counting)), # type: ignore[arg-type] + } + for (label, build) in builders.items(): + with self.subTest(model=label): + counting = Counting() + model = build(counting) + self.assertIn('packet', model) + self.assertEqual(len(model), len(list(model))) + self.assertEqual(counting.calls, 0) + + self.assertEqual(self.block('JSON', model)['packet'], 'parsed') # type: ignore[index] + self.assertEqual(counting.calls, 1) + for writer in WRITERS: + self.assertIn('packet', self.keys(writer, model)) + self.assertEqual(counting.calls, 1) + + def test_a_resolved_packet_is_dumped_as_its_info(self) -> None: + datagram = self.tcp_datagram() + packet = datagram['packet'] + self.assertEqual(self.block('JSON', datagram)['packet'], self.block('JSON', packet.info)) # type: ignore[index] + # A protocol object handed to the writer itself is written the same way. + self.assertEqual(self.block('JSON', packet), self.block('JSON', packet.info)) + + +class FreshProcessDumpTests(unittest.TestCase): + """The same datagrams, dumped in two fresh interpreters, give the same octets.""" + + def dump(self) -> 'dict[str, bytes]': + """Run :data:`SCRIPT` in a fresh interpreter; return each writer's octets.""" + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + proc = subprocess.run([sys.executable, '-c', SCRIPT, tmp.name], # nosec: B603 + cwd=ROOT, capture_output=True, text=True, timeout=scale_timeout(120), check=False) + self.assertEqual(proc.returncode, 0, proc.stderr) + self.assertEqual(os.path.dirname(os.path.dirname(proc.stdout.strip())), ROOT) + octets = {} + for writer in WRITERS: + with open(os.path.join(tmp.name, writer), 'rb') as file: + octets[writer] = file.read() + return octets + + def test_each_writer_dumps_a_reassembled_datagram_identically(self) -> None: + first, second = self.dump(), self.dump() + for writer in WRITERS: + with self.subTest(writer=writer): + self.assertIn(b'packet', first[writer]) + self.assertNotIn(b' object at 0x', first[writer]) + self.assertEqual(first[writer], second[writer]) + + +if __name__ == '__main__': + unittest.main()