diff --git a/pcapkit/corekit/fields/ipaddress.py b/pcapkit/corekit/fields/ipaddress.py index 98703f99fb..8c1f8b6a7a 100644 --- a/pcapkit/corekit/fields/ipaddress.py +++ b/pcapkit/corekit/fields/ipaddress.py @@ -4,6 +4,7 @@ import abc import contextlib import ipaddress +import re from typing import TYPE_CHECKING, Generic, TypeVar, cast from pcapkit.corekit.fields.field import NO_VALUE, Field @@ -347,6 +348,191 @@ class _IPInterfaceField(_IPField[_IT]): """ +class _RawMaskIPv4Interface(ipaddress.IPv4Interface): + """IPv4 interface whose mask is not a contiguous netmask. + + Args: + address: Dotted address and mask, as ``'a.b.c.d/m.m.m.m'`` or + ``'a.b.c.d/0xXXXXXXXX'``; either way the mask is read literally. + + :class:`ipaddress.IPv4Interface` can only hold a prefix length, so it + reads a hostmask-shaped mask such as ``0.0.0.255`` as ``/24`` and rejects + a mask such as ``255.0.255.0``. This class keeps the mask octets as given + in :attr:`netmask`, so :meth:`IPv4InterfaceField.pre_process` writes them + back unchanged. + + :func:`str`, :func:`repr` and :mod:`pickle` use the ``address/mask`` + form, which :meth:`IPv4InterfaceField.pre_process` packs back to the same + octets. The mask is dotted, except when it is hostmask-shaped (ones, then + zeros, read from the low bit): :func:`ipaddress.ip_interface` would read + such a dotted mask as a hostmask, so it is written as eight hex digits + instead, e.g. ``10.0.0.1/0x000000ff``. The stdlib rejects that hex form, + so it never means anything else there. + + Equality and ordering use the mask too. An instance sorts as the stdlib + sorts ``address/32``, then by mask, so the order stays total when mixed + with :class:`ipaddress.IPv4Interface` values. + + Such a mask has no prefix length, so :attr:`network`, :attr:`with_prefixlen` + and the prefix length all describe the address alone (``/32``); only + :attr:`netmask`, :attr:`hostmask`, :attr:`with_netmask` and + :attr:`with_hostmask` carry the mask. :attr:`with_netmask` is always + dotted, so it is not the round-trip form; :func:`str` is. + + """ + + def __init__(self, address: 'str') -> 'None': + addr, _, mask = address.partition('/') + super().__init__(addr) + self.netmask = _parse_literal_mask(mask) + + @property + def hostmask(self) -> 'IPv4Address': + """Bitwise inverse of :attr:`netmask`.""" + return ipaddress.IPv4Address(int(self.netmask) ^ 0xFFFFFFFF) + + def __str__(self) -> 'str': + if _is_hostmask(self.netmask): + return f'{self.ip}/0x{int(self.netmask):08x}' + return f'{self.ip}/{self.netmask}' + + def __eq__(self, other: 'object') -> 'bool': + if not isinstance(other, ipaddress.IPv4Interface): + return NotImplemented + return self.ip == other.ip and self.netmask == other.netmask + + def __hash__(self) -> 'int': + return hash((int(self.ip), int(self.netmask))) + + def __lt__(self, other: 'Any') -> 'bool': + if not isinstance(other, ipaddress.IPv4Interface): + return ipaddress.IPv4Interface.__lt__(self, other) + return _interface_order(self) < _interface_order(other) + + def __le__(self, other: 'Any') -> 'bool': + if not isinstance(other, ipaddress.IPv4Interface): + return ipaddress.IPv4Interface.__le__(self, other) + return _interface_order(self) <= _interface_order(other) + + def __gt__(self, other: 'Any') -> 'bool': + if not isinstance(other, ipaddress.IPv4Interface): + return ipaddress.IPv4Interface.__gt__(self, other) + return _interface_order(self) > _interface_order(other) + + def __ge__(self, other: 'Any') -> 'bool': + if not isinstance(other, ipaddress.IPv4Interface): + return ipaddress.IPv4Interface.__ge__(self, other) + return _interface_order(self) >= _interface_order(other) + + +def _is_netmask(mask: 'IPv4Address') -> 'bool': + """Tell whether ``mask`` is a contiguous netmask (leading ones, then zeros). + + Args: + mask: Dotted mask. + + Returns: + :data:`True` for ``0.0.0.0`` through ``255.255.255.255`` with no gap + in the ones, :data:`False` otherwise. + + """ + inverse = int(mask) ^ 0xFFFFFFFF + return not inverse & (inverse + 1) + + +def _is_hostmask(mask: 'IPv4Address') -> 'bool': + """Tell whether ``mask`` is hostmask-shaped (leading zeros, then ones). + + Args: + mask: Dotted mask. + + Returns: + :data:`True` when :func:`ipaddress.ip_interface` would accept the + dotted ``mask`` as a hostmask, :data:`False` otherwise. + + """ + value = int(mask) + return not value & (value + 1) + + +#: Hex spelling of a literal mask, as :func:`str` writes a hostmask-shaped one. +_HEX_MASK = re.compile(r'0[xX][0-9a-fA-F]{8}') + + +def _parse_literal_mask(text: 'str') -> 'IPv4Address': + """Read a mask literally, from eight hex digits or a dotted quad. + + Args: + text: ``0xXXXXXXXX`` or ``m.m.m.m``. + + Returns: + The mask as given, never reinterpreted as a hostmask. + + Raises: + ValueError: If ``text`` is neither form. + + """ + if _HEX_MASK.fullmatch(text) is not None: + return ipaddress.IPv4Address(int(text, 16)) + return ipaddress.IPv4Address(text) + + +def _interface_from_mask(ip: 'IPv4Address', mask: 'IPv4Address') -> 'IPv4Interface': + """Build the interface for an address and a literal mask. + + Args: + ip: Interface address. + mask: Mask, taken as given. + + Returns: + A stdlib :class:`~ipaddress.IPv4Interface` when ``mask`` is a + contiguous netmask, otherwise a :class:`_RawMaskIPv4Interface`. + + """ + if _is_netmask(mask): + return ipaddress.IPv4Interface(f'{ip}/{mask}') + return _RawMaskIPv4Interface(f'{ip}/{mask}') + + +def _literal_interface(value: 'str') -> 'Optional[IPv4Interface]': + """Read an ``address/mask`` string with the mask taken literally. + + Args: + value: Interface string that :func:`ipaddress.ip_interface` rejected. + + Returns: + The interface, or :obj:`None` if ``value`` is not an IPv4 address + followed by a dotted or ``0xXXXXXXXX`` mask. + + """ + addr, sep, mask = value.partition('/') + if not sep or ('.' not in mask and _HEX_MASK.fullmatch(mask) is None): + return None + try: + return _interface_from_mask(ipaddress.IPv4Address(addr), _parse_literal_mask(mask)) + except ValueError: + return None + + +def _interface_order(value: 'IPv4Interface') -> 'tuple[int, int, int, int]': + """Sort key for an IPv4 interface, consistent with ``==``. + + Args: + value: IPv4 interface, either from the stdlib or a + :class:`_RawMaskIPv4Interface`. + + Returns: + The stdlib order (network address, netmask, address), with a third + element that is ``0`` for a stdlib interface and ``1 + mask`` for a + :class:`_RawMaskIPv4Interface`, whose stdlib network is + ``address/32``. + + """ + if isinstance(value, _RawMaskIPv4Interface): + return int(value.ip), 0xFFFFFFFF, 1 + int(value.netmask), int(value.ip) + return int(value.network.network_address), int(value.network.netmask), 0, int(value.ip) + + class IPv4InterfaceField(_IPInterfaceField[ipaddress.IPv4Interface]): """IPv4 interface value for protocol fields. @@ -383,14 +569,28 @@ def pre_process(self, value: 'IPv4Interface | bytes | int | str', packet: 'dict[ :func:`_reject_bool`), is not a valid IP interface, or is the wrong IP version for this field. + Notes: + A string means what :func:`ipaddress.ip_interface` says it + means, so ``'10.0.0.1/0.0.0.255'`` is the hostmask spelling of + ``/24``. Only a string the stdlib rejects is read with its mask + taken literally: a dotted mask that is neither a netmask nor a + hostmask, such as ``'10.0.0.1/255.0.255.0'``, or eight hex + digits, such as ``'10.0.0.1/0x000000ff'``. Those are the forms + :func:`str` gives a :class:`_RawMaskIPv4Interface`. + """ _reject_bool(value, 'invalid IP interface') if isinstance(value, ipaddress.IPv4Interface): val = value else: - with _reraise_as_field_value_error('invalid IP interface'): - parsed = ipaddress.ip_interface(value) + try: + parsed = ipaddress.ip_interface(value) # type: IPv4Interface | IPv6Interface + except ValueError as error: + literal = _literal_interface(value) if isinstance(value, str) else None + if literal is None: + raise FieldValueError(f'invalid IP interface: {error}') from error + parsed = literal if not isinstance(parsed, ipaddress.IPv4Interface): raise FieldValueError(f'IP version mismatch: {parsed.version} != {self.version}') val = parsed @@ -410,18 +610,21 @@ def post_process(self, value: 'bytes', packet: 'dict[str, Any]') -> 'IPv4Interfa Processed field value. Raises: - FieldValueError: If the trailing four octets are not a valid - dotted netmask, or if the resulting interface is the wrong IP - version for this field. The leading four octets cannot - actually fail here -- they are always exactly 4 octets, fixed - by this field's length, and any such octet string is a valid - address -- but the conversion is still wrapped for - consistency with the rest of this module. + FieldValueError: If the resulting interface is the wrong IP + version for this field. The eight octets cannot actually fail + to convert here -- they are always exactly 8 octets, fixed by + this field's length, any four octets are a valid address, and + a mask that is not a netmask is kept as given -- but the + conversion is still wrapped for consistency with the rest of + this module. Notes: The trailing four octets are a dotted netmask, as written by :meth:`pre_process` -- not a prefix length as in - :meth:`IPv6InterfaceField.post_process`. + :meth:`IPv6InterfaceField.post_process`. When they are not a + contiguous netmask, the value is a :class:`_RawMaskIPv4Interface` + that keeps them as given, rather than an interface the stdlib + would reinterpret (``0.0.0.255`` as the hostmask of ``/24``). """ with _reraise_as_field_value_error('invalid IPv4 address'): @@ -429,7 +632,7 @@ def post_process(self, value: 'bytes', packet: 'dict[str, Any]') -> 'IPv4Interfa mask = ipaddress.IPv4Address(value[4:]) with _reraise_as_field_value_error('invalid IPv4 interface'): - val = ipaddress.ip_interface(f'{ip}/{mask}') + val = _interface_from_mask(ip, mask) if not isinstance(val, ipaddress.IPv4Interface): raise FieldValueError(f'IP version mismatch: {val.version} != {self.version}') return val diff --git a/pcapkit/corekit/multidict.py b/pcapkit/corekit/multidict.py index 74c4317b27..464c58b97d 100644 --- a/pcapkit/corekit/multidict.py +++ b/pcapkit/corekit/multidict.py @@ -497,6 +497,18 @@ def __eq__(self, other: 'Any') -> 'bool': __hash__ = None # type: ignore[assignment] + def deepcopy(self, memo: 'Optional[dict]' = None) -> 'OrderedMultiDict[_KT, _VT]': + """Return a deep copy of this object. + + Notes: + :meth:`MultiDict.deepcopy` rebuilds from :meth:`to_dict` with + ``flat=False``, which groups the values by key. This rebuilds from + ``items(multi=True)`` instead, so the copy keeps the insertion + order of interleaved keys. :meth:`__deepcopy__` reaches it too. + + """ + return self.__class__(copy.deepcopy(list(self.items(multi=True)), memo=memo)) + def __reduce_ex__(self, protocol: 'SupportsIndex') -> 'tuple[type, tuple[list[tuple[_KT, _VT]]]]': return type(self), (list(self.items(multi=True)),) diff --git a/tests/corekit/test_fields_ipaddress.py b/tests/corekit/test_fields_ipaddress.py index 6d8aacdbe2..1a99485a15 100644 --- a/tests/corekit/test_fields_ipaddress.py +++ b/tests/corekit/test_fields_ipaddress.py @@ -606,30 +606,24 @@ def test_switch_backed_address_makers_reject_a_bool(self) -> None: locator_set=[{'ip': int(True)}]).pack().hex(), '00c1001800000400000000000000000000000000000000000000000100000000') - def test_ipv4_interface_post_process_rejects_a_non_contiguous_netmask(self) -> None: - """``IPv4InterfaceField.post_process`` builds - ``ipaddress.ip_interface(f'{ip}/{mask}')`` from wire bytes whose - trailing four octets are meant to be a dotted netmask. Unlike the - leading four octets (always exactly 4 octets, so always a valid - address), those trailing octets are not guaranteed to form a - *contiguous* netmask -- e.g. a capture with a malformed or corrupted - IPv4 interface option can carry ``0.255.0.255``, which - :func:`ipaddress.ip_interface` rejects with a bare - :exc:`~ipaddress.NetmaskValueError`. This is reachable through - :meth:`~pcapkit.corekit.fields.field.FieldBase.unpack` alone, with no - malformed-length input required, and must raise :exc:`FieldValueError` - instead. + def test_ipv4_interface_post_process_keeps_a_non_contiguous_netmask(self) -> None: + """``IPv4InterfaceField.post_process`` reads wire bytes whose trailing + four octets are meant to be a dotted netmask. Those octets are not + guaranteed to form a *contiguous* netmask -- e.g. a capture with a + malformed or corrupted IPv4 interface option can carry + ``0.255.0.255``, which :func:`ipaddress.ip_interface` rejects. The + field keeps such a mask as given, so the value packs back to the same + octets (c.f. :issue:`1294`). """ from pcapkit.corekit.fields.ipaddress import IPv4InterfaceField - from pcapkit.utilities.exceptions import BaseError, FieldValueError field = IPv4InterfaceField() raw = ipaddress.IPv4Address('1.2.3.4').packed + bytes([0, 255, 0, 255]) - with self.assertRaises(FieldValueError) as context: - field.unpack(raw, {}) - self.assertIsInstance(context.exception, BaseError) - self.assertIn('0.255.0.255', str(context.exception)) + value = field.unpack(raw, {}) + self.assertIsInstance(value, ipaddress.IPv4Interface) + self.assertEqual(value.netmask, ipaddress.IPv4Address('0.255.0.255')) + self.assertEqual(field.pack(value, {}), raw) if __name__ == '__main__': diff --git a/tests/corekit/test_ipv4interface_mask_unit.py b/tests/corekit/test_ipv4interface_mask_unit.py new file mode 100644 index 0000000000..75ff9800dd --- /dev/null +++ b/tests/corekit/test_ipv4interface_mask_unit.py @@ -0,0 +1,185 @@ +# -*- coding: utf-8 -*- +"""IPv4 interface fields keep the mask octets they read. + +GitHub issue #1294: :meth:`IPv4InterfaceField.post_process +` built the +value with :func:`ipaddress.ip_interface`, which reads a hostmask-shaped mask +such as ``0.0.0.255`` as ``/24``. The field then packed ``255.255.255.0``, so +a pcapng ``if_IPv4addr`` option did not rebuild byte for byte. + +A string keeps the stdlib's meaning wherever :func:`ipaddress.ip_interface` +accepts it; only a string it rejects is read with a literal mask, and the +:func:`str` of a kept mask is always such a string. + +Every case builds its own octets in memory and reads no capture. Classes are +imported inside each test, after +:func:`~tests._support.reimport_once_per_class`, for the reason given in +:mod:`tests.protocols.link.test_ethernet_mac_roundtrip_unit`. + +""" + +import copy +import ipaddress +import pickle +import random +import unittest + +from tests._support import reimport_once_per_class + +#: Address ``10.0.0.1`` followed by four mask octets. +MASKS = { + 'hostmask /24': '0a000001' '000000ff', + 'hostmask /1': '0a000001' '7fffffff', + 'non-contiguous': '0a000001' 'ff00ff00', + 'netmask /24': '0a000001' 'ffffff00', + 'netmask /0': '0a000001' '00000000', + 'netmask /32': '0a000001' 'ffffffff', +} + + +class TestIPv4InterfaceMask(unittest.TestCase): + """Pin the mask octets of :class:`IPv4InterfaceField` across a round trip.""" + + def setUp(self) -> None: + reimport_once_per_class(self) + + def test_unpack_then_pack_keeps_the_mask_octets(self) -> None: + from pcapkit.corekit.fields.ipaddress import IPv4InterfaceField + + field = IPv4InterfaceField() + for name, hexstr in MASKS.items(): + with self.subTest(mask=name): + raw = bytes.fromhex(hexstr) + value = field.unpack(raw, {}) + self.assertIsInstance(value, ipaddress.IPv4Interface) + self.assertEqual(value.ip, ipaddress.IPv4Address('10.0.0.1')) + self.assertEqual(value.netmask.packed, raw[4:]) + self.assertEqual(field.pack(value, {}), raw) + + def test_string_form_of_every_mask_packs_to_the_same_octets(self) -> None: + from pcapkit.corekit.fields.ipaddress import IPv4InterfaceField + + field = IPv4InterfaceField() + netmasks = [(0xFFFFFFFF << (32 - n)) & 0xFFFFFFFF for n in range(33)] + hostmasks = [(1 << n) - 1 for n in range(33)] + rng = random.Random(1294) + masks = sorted(set(netmasks + hostmasks + [rng.getrandbits(32) for _ in range(100_000)])) + self.assertGreater(len(masks), 100_000) + + failures = [] + for mask in masks: + raw = bytes.fromhex('0a000001') + mask.to_bytes(4, 'big') + text = str(field.unpack(raw, {})) + if field.pack(text, {}) != raw: + failures.append((raw.hex(), text)) + self.assertEqual(failures, []) + + def test_string_form_never_means_something_else_to_the_stdlib(self) -> None: + from pcapkit.corekit.fields.ipaddress import IPv4InterfaceField + + field = IPv4InterfaceField() + for name, hexstr in MASKS.items(): + with self.subTest(mask=name): + raw = bytes.fromhex(hexstr) + text = str(field.unpack(raw, {})) + try: + stdlib = ipaddress.ip_interface(text) + except ValueError: + continue + self.assertEqual(stdlib.ip.packed + stdlib.netmask.packed, raw) + self.assertEqual(str(field.unpack(bytes.fromhex(MASKS['hostmask /24']), {})), + '10.0.0.1/0x000000ff') + self.assertEqual(str(field.unpack(bytes.fromhex(MASKS['non-contiguous']), {})), + '10.0.0.1/255.0.255.0') + + def test_strings_the_stdlib_accepts_keep_the_stdlib_meaning(self) -> None: + from pcapkit.corekit.fields.ipaddress import IPv4InterfaceField + + field = IPv4InterfaceField() + texts = ['10.0.0.1'] + for n in range(33): + texts.append(f'10.0.0.1/{n}') + texts.append(f'10.0.0.1/{ipaddress.IPv4Address((0xFFFFFFFF << (32 - n)) & 0xFFFFFFFF)}') + texts.append(f'10.0.0.1/{ipaddress.IPv4Address((1 << n) - 1)}') + for text in texts: + with self.subTest(text=text): + stdlib = ipaddress.ip_interface(text) + self.assertEqual(field.pack(text, {}), stdlib.ip.packed + stdlib.netmask.packed) + # the stdlib reads the hostmask spelling of /24 as /24 + self.assertEqual(field.pack('10.0.0.1/0.0.0.255', {}), bytes.fromhex(MASKS['netmask /24'])) + + def test_strings_the_stdlib_rejects(self) -> None: + from pcapkit.corekit.fields.ipaddress import IPv4InterfaceField + from pcapkit.utilities.exceptions import FieldValueError + + field = IPv4InterfaceField() + literal = { + '10.0.0.1/255.0.255.0': MASKS['non-contiguous'], + '10.0.0.1/0x000000ff': MASKS['hostmask /24'], + '10.0.0.1/0XFFFFFF00': MASKS['netmask /24'], + } + for text, hexstr in literal.items(): + with self.subTest(text=text): + with self.assertRaises(ValueError): + ipaddress.ip_interface(text) + self.assertEqual(field.pack(text, {}), bytes.fromhex(hexstr)) + malformed = ['10.0.0.1/33', '10.0.0.1/1.2.3', '10.0.0.1/0xfff', '10.0.0.1/0x0000000g', + '10.0.0.1/0x 000ff', '10.0.0.1/', '10.0.0/24', 'not-an-interface', + '::1/0x000000ff', '10.0.0.1/0.0.0.255/1'] + for text in malformed: + with self.subTest(text=text): + with self.assertRaises(FieldValueError): + field.pack(text, {}) + + def test_ordering_agrees_with_equality(self) -> None: + from pcapkit.corekit.fields.ipaddress import IPv4InterfaceField + + field = IPv4InterfaceField() + values = [field.unpack(bytes.fromhex(hexstr), {}) for hexstr in MASKS.values()] + values += [field.unpack(bytes.fromhex('0a000002' '000000ff'), {}), + ipaddress.IPv4Interface('10.0.0.5/8'), ipaddress.IPv4Interface('10.0.0.3/32')] + for a in values: + for b in values: + with self.subTest(a=str(a), b=str(b)): + self.assertEqual([a < b, a == b, a > b].count(True), 1) + self.assertEqual(a < b, b > a) + self.assertEqual(a <= b, b >= a) + self.assertEqual(a <= b, a < b or a == b) + self.assertEqual(a >= b, a > b or a == b) + for c in values: + if a < b < c: + self.assertLess(a, c) + + def test_contiguous_netmask_stays_a_plain_interface(self) -> None: + from pcapkit.corekit.fields.ipaddress import IPv4InterfaceField + + value = IPv4InterfaceField().unpack(bytes.fromhex(MASKS['netmask /24']), {}) + self.assertIs(type(value), ipaddress.IPv4Interface) + self.assertEqual(value, ipaddress.IPv4Interface('10.0.0.1/24')) + + def test_kept_mask_survives_copy_pickle_and_compares_by_mask(self) -> None: + from pcapkit.corekit.fields.ipaddress import IPv4InterfaceField + + field = IPv4InterfaceField() + value = field.unpack(bytes.fromhex(MASKS['hostmask /24']), {}) + self.assertEqual(str(value), '10.0.0.1/0x000000ff') + self.assertEqual(value.hostmask, ipaddress.IPv4Address('255.255.255.0')) + for clone in (copy.copy(value), copy.deepcopy(value), pickle.loads(pickle.dumps(value))): + self.assertEqual(clone, value) + self.assertEqual(hash(clone), hash(value)) + self.assertEqual(clone.netmask, value.netmask) + # the stdlib reading of the same octets is a different value + self.assertNotEqual(value, ipaddress.IPv4Interface('10.0.0.1/24')) + self.assertNotEqual(value, field.unpack(bytes.fromhex(MASKS['hostmask /1']), {})) + + def test_pcapng_if_ipv4addr_option_rebuilds_byte_for_byte(self) -> None: + from pcapkit.protocols.schema.misc.pcapng import IF_IPv4AddrOption + + option = bytes.fromhex('0400' '0800') + bytes.fromhex(MASKS['hostmask /24']) + schema = IF_IPv4AddrOption.unpack(option) + self.assertEqual(schema.interface.netmask, ipaddress.IPv4Address('0.0.0.255')) + self.assertEqual(schema.pack(), option) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/corekit/test_ordered_multidict_copy_unit.py b/tests/corekit/test_ordered_multidict_copy_unit.py new file mode 100644 index 0000000000..b411fd1a15 --- /dev/null +++ b/tests/corekit/test_ordered_multidict_copy_unit.py @@ -0,0 +1,62 @@ +# -*- coding: utf-8 -*- +"""Copies of an :class:`OrderedMultiDict` keep its insertion order. + +GitHub issue #1295: :meth:`MultiDict.deepcopy +` (a verbatim werkzeug port) +rebuilds from ``to_dict(flat=False)``, which groups values by key. An +:class:`~pcapkit.corekit.multidict.OrderedMultiDict` with interleaved keys +therefore deep-copied into a different order and compared unequal to itself. + +:mod:`pcapkit` is imported inside each test, after +:func:`~tests._support.reimport_once_per_class`, for the reason given in +:mod:`tests.protocols.link.test_ethernet_mac_roundtrip_unit`. + +""" + +import copy +import pickle +import unittest + +from tests._support import reimport_once_per_class + +#: Interleaved keys, so grouping by key changes the order. +ITEMS = [('a', 1), ('b', 2), ('a', 3)] + + +class TestOrderedMultiDictCopy(unittest.TestCase): + """Pin the order of every copy path.""" + + def setUp(self) -> None: + reimport_once_per_class(self) + + def test_every_copy_path_keeps_insertion_order(self) -> None: + from pcapkit.corekit.multidict import OrderedMultiDict + + omd = OrderedMultiDict(ITEMS) + copies = { + 'copy.deepcopy': copy.deepcopy(omd), + '.deepcopy': omd.deepcopy(), + 'copy.copy': copy.copy(omd), + '.copy': omd.copy(), + 'pickle': pickle.loads(pickle.dumps(omd)), + } + for name, clone in copies.items(): + with self.subTest(path=name): + self.assertIs(type(clone), OrderedMultiDict) + self.assertEqual(list(clone.items(multi=True)), ITEMS) + self.assertEqual(clone, omd) + + def test_deepcopy_copies_values_and_shares_the_memo(self) -> None: + from pcapkit.corekit.multidict import OrderedMultiDict + + shared = [0] + omd = OrderedMultiDict([('a', shared), ('b', 2), ('a', shared)]) + clone = copy.deepcopy(omd) + first, second = clone.getlist('a') + self.assertIsNot(first, shared) + self.assertIs(first, second) + self.assertEqual(list(clone.keys()), ['a', 'b']) + + +if __name__ == '__main__': + unittest.main()