From 0798570f990c21d8805fc6b74f75d14dba1530cf Mon Sep 17 00:00:00 2001 From: Jarry Shaw Date: Wed, 7 Oct 2026 14:23:24 -0400 Subject: [PATCH] fix(dumpkit)!: output writers drop or corrupt what the packets carried - pcap: forward the input's global-header version to PCAPIO (#1255). - plist: write None values and list items instead of skipping them (#1257). - Networks, NULL, NO_VALUE and timezones get an explicit rendering, and _append_fallback reads the slots of every base class (#1258). - json: write characters above U+FFFF as surrogate pairs (#1261). - plist: write a string XML 1.0 cannot spell as (#1262). - OrderedMultiDict dumps as a list of single-key objects in insertion order (#1263). BREAKING: changes the dump schema of every option list. Closes #1255 Closes #1257 Closes #1258 Closes #1261 Closes #1262 Closes #1263 --- pcapkit/dumpkit/common.py | 163 +++++++++++++++++- pcapkit/dumpkit/pcap.py | 13 +- pcapkit/foundation/engines/pcap.py | 2 +- tests/dumpkit/test_common_unit.py | 20 ++- tests/dumpkit/test_dumper_roundtrip_unit.py | 153 ++++++++++++++++ .../test_json_key_escaping_regression.py | 2 +- tests/dumpkit/test_json_key_escaping_unit.py | 2 +- .../test_nameless_enum_rendering_unit.py | 2 +- 8 files changed, 331 insertions(+), 26 deletions(-) create mode 100644 tests/dumpkit/test_dumper_roundtrip_unit.py diff --git a/pcapkit/dumpkit/common.py b/pcapkit/dumpkit/common.py index 9da80f4d25..47a6d34762 100644 --- a/pcapkit/dumpkit/common.py +++ b/pcapkit/dumpkit/common.py @@ -16,6 +16,8 @@ import enum import ipaddress import json +import re +import string import xml.sax.saxutils from typing import TYPE_CHECKING @@ -26,6 +28,7 @@ from pcapkit.corekit.infoclass import Info from pcapkit.corekit.multidict import MultiDict, OrderedMultiDict +from pcapkit.corekit.sentinels import NoValueType, NullType from pcapkit.protocols.schema.schema import Schema from pcapkit.utilities.exceptions import UnsupportedCall from pcapkit.utilities.logging import get_logger @@ -34,7 +37,7 @@ if TYPE_CHECKING: - from typing import Any, DefaultDict, Optional, TextIO, Type + from typing import Any, DefaultDict, Iterator, Optional, TextIO, Type from dictdumper.dumper import Dumper as ABCDumper from typing_extensions import Literal @@ -44,6 +47,16 @@ #: :data:`pcapkit.utilities.logging.logger`. logger = get_logger(__name__) +#: re.Pattern: A character XML 1.0 does not allow anywhere in a document, not +#: even as a character reference: every C0 control other than tab, LF and CR, +#: the surrogates, and ``U+FFFE``/``U+FFFF`` (XML 1.0, production ``[2] Char``). +_XML_ILLEGAL = re.compile(r'[\x00-\x08\x0b\x0c\x0e-\x1f\ud800-\udfff\ufffe\uffff]') + +#: frozenset[str]: Characters :meth:`dictdumper.json.JSON._append_string` +#: writes as themselves or through its escape table rather than as a +#: ``\uXXXX`` escape. +_JSON_PRINTABLE = frozenset(string.printable) + class DumperBase(dictdumper.dumper.Dumper): """Base :class:`~dictdumper.dumper.Dumper` object. @@ -221,6 +234,40 @@ def render_enum(o: 'enum.Enum | aenum.Enum') -> 'str': return f'{type(o).__name__}::{name} [{o.value}]' +def _iter_slots(value: 'Any') -> 'Iterator[str]': + """Yield the names of every slot ``value`` has an assigned value for. + + Args: + value: Object to inspect. + + Yields: + Each slot name declared anywhere in ``type(value).__mro__``, base classes + first and each name once, skipping ``__dict__``, ``__weakref__`` and + any slot that is unset on ``value``. + + Note: + ``__slots__`` on a class names only the slots *that* class adds, and + reading it from an instance finds the nearest class that declares one, + so a subclass declaring ``__slots__ = ()`` hides all of its bases' + slots. :class:`ipaddress.IPv6Network` is such a class. + + """ + seen = set() # type: set[str] + for klass in reversed(type(value).__mro__): + names = klass.__dict__.get('__slots__', ()) + if isinstance(names, str): + names = (names,) + for name in names: + if name in seen or name in ('__dict__', '__weakref__'): + continue + seen.add(name) + try: + getattr(value, name) + except AttributeError: + continue + yield name + + def make_dumper(output: 'Type[ABCDumper]') -> 'Type[ABCDumper]': """Create a customised :class:`~dictdumper.dumper.Dumper` object. @@ -253,6 +300,12 @@ def make_dumper(output: 'Type[ABCDumper]') -> 'Type[ABCDumper]': # worked around here. escape_json_keys = issubclass(output, dictdumper.json.JSON) escape_keys = escape_strings or escape_json_keys + # NOTE: :meth:`~dictdumper.plist.PLIST._append_dict` and + # :meth:`~dictdumper.plist.PLIST._append_array` skip every :data:`None` + # value and item outright, so a ``plist`` report lacks keys the ``json`` + # report has (GitHub issue #1257). The defect is :mod:`dictdumper`'s, so it + # is worked around here. + keep_none = issubclass(output, dictdumper.plist.PLIST) def escape_key(key: 'Any') -> 'Any': """Escape a mapping key on its way to the writer. @@ -325,7 +378,10 @@ def object_hook(self, o: 'Any') -> 'Any': them, so both branches that hand the writer a mapping -- a :class:`~pcapkit.corekit.multidict.MultiDict` and a plain :class:`dict` -- escape their own keys through - :func:`escape_key` instead. + :func:`escape_key` instead. An + :class:`~pcapkit.corekit.multidict.OrderedMultiDict` becomes a + :class:`list` of single-key :class:`dict` objects, each of + which comes back through the :class:`dict` branch. """ if isinstance(o, decimal.Decimal): @@ -334,9 +390,29 @@ def object_hook(self, o: 'Any') -> 'Any': result = o.total_seconds() elif isinstance(o, (Info, Schema)): result = o.to_dict() - elif isinstance(o, (ipaddress.IPv4Address, ipaddress.IPv6Address)): + elif isinstance(o, (ipaddress.IPv4Address, ipaddress.IPv6Address, + ipaddress.IPv4Network, ipaddress.IPv6Network)): result = str(o) - elif isinstance(o, (MultiDict, OrderedMultiDict)): + elif isinstance(o, datetime.timezone): + result = o.utcoffset(None).total_seconds() + elif isinstance(o, NullType): + result = '' + elif isinstance(o, NoValueType): + result = '' + elif isinstance(o, OrderedMultiDict): + # NOTE: one single-key mapping per entry, in insertion order -- + # i.e. wire order for the option lists -- since a mapping keyed + # by name could only gather the repeats of a key under its first + # occurrence (GitHub issue #1263). The keys are escaped when the + # writer hands each mapping back to this hook, through the + # :class:`dict` branch below. + result = [ + {render_enum(key) if isinstance(key, (enum.Enum, aenum.Enum)) else key: val} + for key, val in o.items(multi=True) + ] + elif isinstance(o, MultiDict): + # NOTE: a :class:`MultiDict` keeps no order across keys, only + # among the values of each, so grouping by key loses nothing. temp = collections.defaultdict(list) # type: DefaultDict[str, list[Any]] for key, val in o.items(multi=True): if isinstance(key, (enum.Enum, aenum.Enum)): @@ -364,9 +440,76 @@ def object_hook(self, o: 'Any') -> 'Any': result = super(type(self), self).object_hook(o) if escape_strings and isinstance(result, str): + # NOTE: XML 1.0 has no spelling at all for some characters, not + # even a character reference, so a string holding one is written + # as ````, the way :class:`bytes` already is (GitHub issue + # #1262). + if _XML_ILLEGAL.search(result) is not None: + return result.encode('utf-8', 'surrogatepass') return xml.sax.saxutils.escape(result) return result + if keep_none: + def _append_dict(self, value: 'dict[Any, Any]', file: 'TextIO') -> 'None': + """Call this function to write dict contents. + + Args: + self: Dumper instance. + value: Content to be dumped. + file: Output file. + + Notes: + Each :data:`None` value is first converted through + :meth:`~dictdumper.plist.PLIST._encode_value`, whose own + rendering of it is ``{'type': 'NoneType', 'value': 'None'}``, + so that the writer no longer skips it. + + """ + value = {key: self._encode_value(val) if val is None else val for key, val in value.items()} + super()._append_dict(value, file) + + def _append_array(self, value: 'list[Any]', file: 'TextIO') -> 'None': + """Call this function to write array contents. + + Args: + self: Dumper instance. + value: Content to be dumped. + file: Output file. + + Notes: + As :meth:`_append_dict`, for each :data:`None` item. + + """ + value = [self._encode_value(item) if item is None else item for item in value] + super()._append_array(value, file) + + if escape_json_keys: + def _append_string(self, value: 'str', file: 'TextIO') -> 'None': + """Call this function to write string contents. + + Args: + self: Dumper instance. + value: Content to be dumped. + file: Output file. + + Notes: + :meth:`dictdumper.json.JSON._append_string` writes every + other character as ``'\\u{0:04x}'.format(ord(char))``, which + for a code point above ``U+FFFF`` yields five or six hex + digits that a reader takes as one escape and literal text + (GitHub issue #1261). This writes such a character as a + UTF-16 surrogate pair instead, as :func:`json.dumps` does, and + every other character exactly as the upstream writer does. + + """ + text = [] # type: list[str] + for char in value: + if char in _JSON_PRINTABLE: + text.append(dictdumper.json.ESCAPE_DCT.get(char, char)) + else: + text.append(json.dumps(char)[1:-1] if ord(char) > 0xFFFF else f'\\u{ord(char):04x}') + file.write('"' + ''.join(text) + '"') + def default(self, o: 'Any') -> 'Literal["fallback"]': # pylint: disable=unused-argument """Check content type for function call. @@ -401,14 +544,16 @@ def _append_fallback(self, value: 'Any', file: 'TextIO') -> 'None': ``default`` for use. """ - if hasattr(value, '__slots__'): - new_value = {key: getattr(value, key) for key in value.__slots__} - elif hasattr(value, '__dict__'): - new_value = vars(value) - else: + new_value = {key: getattr(value, key) for key in _iter_slots(value)} + if hasattr(value, '__dict__'): + new_value.update(vars(value)) + if not new_value: logger.warning('unsupported object type: %s', type(value)) new_value = str(value) # type: ignore[assignment] + # NOTE: through :meth:`object_hook` as every other value is, so that + # the keys and strings built here are escaped like theirs. + new_value = self.object_hook(new_value) func = self._encode_func(new_value) func(new_value, file) diff --git a/pcapkit/dumpkit/pcap.py b/pcapkit/dumpkit/pcap.py index 979ee876a8..a212b11f7f 100644 --- a/pcapkit/dumpkit/pcap.py +++ b/pcapkit/dumpkit/pcap.py @@ -25,6 +25,7 @@ from typing_extensions import Literal from pcapkit.const.reg.linktype import LinkType as Enum_LinkType + from pcapkit.corekit.version import VersionInfo from pcapkit.protocols.data.misc.pcap.frame import Frame as Data_Frame __all__ = [ @@ -57,8 +58,8 @@ class PCAPIO(DumperBase): protocol: data link type byteorder: header byte order nanosecond: nanosecond-resolution file flag - **kwargs: further global header fields, i.e. ``thiszone``, ``sigfigs`` - and ``snaplen``, and arbitrary keyword arguments + **kwargs: further global header fields, i.e. ``version``, ``thiszone``, + ``sigfigs`` and ``snaplen``, and arbitrary keyword arguments """ if TYPE_CHECKING: @@ -90,8 +91,9 @@ def __init__(self, fname: 'str', *, protocol: 'Enum_LinkType | StdlibIntEnum | A protocol: data link type byteorder: header byte order nanosecond: nanosecond-resolution file flag - **kwargs: further global header fields, i.e. ``thiszone``, - ``sigfigs`` and ``snaplen``, and arbitrary keyword arguments + **kwargs: further global header fields, i.e. ``version``, + ``thiszone``, ``sigfigs`` and ``snaplen``, and arbitrary keyword + arguments """ #: int: Frame counter. @@ -129,6 +131,7 @@ def __call__(self, value: 'Data_Frame', name: 'Optional[str]' = None) -> 'PCAPIO def _dump_header(self, *, protocol: 'Enum_LinkType | StdlibIntEnum | AenumIntEnum | str | int', # pylint: disable=arguments-differ,too-many-arguments byteorder: 'Literal["big", "little"]' = sys.byteorder, nanosecond: 'bool' = False, + version: 'tuple[int, int] | VersionInfo' = (2, 4), thiszone: 'int' = 0, sigfigs: 'int' = 0, snaplen: 'int' = 0x40_000, **kwargs: 'Any') -> 'None': # pylint: disable=unused-argument """Initially dump file heads and tails. @@ -137,6 +140,7 @@ def _dump_header(self, *, protocol: 'Enum_LinkType | StdlibIntEnum | AenumIntEnu protocol: data link type byteorder: header byte order nanosecond: nanosecond-resolution file flag + version: format version, as ``(major, minor)`` thiszone: GMT to local correction sigfigs: accuracy of timestamps snaplen: max length of captured packets, in octets @@ -147,6 +151,7 @@ def _dump_header(self, *, protocol: 'Enum_LinkType | StdlibIntEnum | AenumIntEnu network=protocol, byteorder=byteorder, nanosecond=nanosecond, + version=version, thiszone=thiszone, sigfigs=sigfigs, snaplen=snaplen, diff --git a/pcapkit/foundation/engines/pcap.py b/pcapkit/foundation/engines/pcap.py index 8261e6d630..9ee5974d6c 100644 --- a/pcapkit/foundation/engines/pcap.py +++ b/pcapkit/foundation/engines/pcap.py @@ -125,7 +125,7 @@ def run(self) -> 'None': return ext._open_output(protocol=self._dlink, byteorder=self._gbhdr.byteorder, - nanosecond=self._nnsec, thiszone=self._gbhdr.info.thiszone, + nanosecond=self._nnsec, version=self._vinfo, thiszone=self._gbhdr.info.thiszone, sigfigs=self._gbhdr.info.sigfigs, snaplen=self._gbhdr.info.snaplen) if ext._flag_f: ofile = ext._ofile(f'{ext._ofnm}/Global Header.{ext._fext}') diff --git a/tests/dumpkit/test_common_unit.py b/tests/dumpkit/test_common_unit.py index f280dd1546..68a7eefdf2 100644 --- a/tests/dumpkit/test_common_unit.py +++ b/tests/dumpkit/test_common_unit.py @@ -199,7 +199,7 @@ def test_make_dumper_object_hook_conversions_and_fallbacks(self) -> None: self.assertEqual(converted['plain'], ['value']) ordered = OrderedMultiDict([('first', 1), ('first', 2)]) - self.assertEqual(dumper.object_hook(ordered)['first'], [1, 2]) + self.assertEqual(dumper.object_hook(ordered), [{'first': 1}, {'first': 2}]) self.assertEqual(dumper.object_hook(PlainEnum.VALUE), 'PlainEnum::VALUE [1]') rich = dumper.object_hook(RichEnum.VALUE) @@ -216,9 +216,11 @@ def test_make_dumper_object_hook_conversions_and_fallbacks(self) -> None: dumper._append_fallback(types.SimpleNamespace(name='dict'), file) self.assertEqual(file.getvalue(), "{'name': 'dict'}") + # The rendering goes back through ``object_hook``, which for this stub + # wraps whatever it does not convert itself. file = io.StringIO() dumper._append_fallback(123, file) - self.assertEqual(file.getvalue(), "'123'") + self.assertEqual(file.getvalue(), "{'base': '123'}") def test_make_dumper_escapes_strings_only_for_plist_like_output(self) -> None: """GitHub issue #772: no entity escaping anywhere in :mod:`dictdumper`. @@ -291,15 +293,15 @@ def test_make_dumper_escapes_mapping_keys_for_plist_like_output(self) -> None: self.assertEqual(converted['a & b <c>'], 'value') self.assertEqual(converted[ESCAPED_BYTES_KEY], 'secret') - # A MultiDict: a plain-str key was interpolated raw until now, and the - # bytes key of the fixture arrives through here rather than through the - # dict branch -- its TLS key log entries are an OrderedMultiDict. + # An OrderedMultiDict: the bytes key of the fixture arrives through here + # -- its TLS key log entries are an OrderedMultiDict. Each entry becomes + # a single-key mapping, whose key is escaped when the writer hands that + # mapping back to the hook. multidict = OrderedMultiDict() multidict.add('a & b ', 'value') multidict.add(BYTES_KEY, 'secret') - converted = plist_dumper.object_hook(multidict) - self.assertEqual(list(converted), ['a & b <c>', ESCAPED_BYTES_KEY]) - self.assertEqual(converted[ESCAPED_BYTES_KEY], ['secret']) + converted = [plist_dumper.object_hook(entry) for entry in plist_dumper.object_hook(multidict)] + self.assertEqual(converted, [{'a & b <c>': 'value'}, {ESCAPED_BYTES_KEY: 'secret'}]) # An enum-derived key is still escaped exactly once: the escaping moved # out of this branch and into one shared helper, so escaping it twice is @@ -336,7 +338,7 @@ def test_make_dumper_hands_other_output_the_mapping_it_was_given(self) -> None: multidict.add('a & b ', 'value') multidict.add(BYTES_KEY, 'secret') converted = plain_dumper.object_hook(multidict) - self.assertEqual(list(converted), ['a & b ', BYTES_KEY]) + self.assertEqual(converted, [{'a & b ': 'value'}, {BYTES_KEY: 'secret'}]) def test_an_unassigned_port_dumps_its_addon_keys_in_the_declared_order(self) -> None: """GitHub issue #575's fallback must not reorder what it renders. diff --git a/tests/dumpkit/test_dumper_roundtrip_unit.py b/tests/dumpkit/test_dumper_roundtrip_unit.py new file mode 100644 index 0000000000..0226624405 --- /dev/null +++ b/tests/dumpkit/test_dumper_roundtrip_unit.py @@ -0,0 +1,153 @@ +# -*- coding: utf-8 -*- +"""Every output writer keeps what the extracted packets carried. + +- GitHub issue #1255: the ``pcap`` writer wrote version 2.4 whatever the input's + global header carried. +- GitHub issue #1257: the ``plist`` writer skipped every :data:`None` value and + list item. +- GitHub issue #1258: networks, :data:`~pcapkit.corekit.sentinels.NULL` and + :data:`~pcapkit.corekit.sentinels.NO_VALUE` dumped as ``{}``, and a timezone + as a bare string after an ``unsupported object type`` warning. +- GitHub issue #1261: the ``json`` writer escaped a character above ``U+FFFF`` + as five or six hex digits. +- GitHub issue #1262: the ``plist`` writer wrote control characters raw. +- GitHub issue #1263: an :class:`~pcapkit.corekit.multidict.OrderedMultiDict` + dumped grouped by key, losing its order. + +Every case builds its input in memory or in a temporary directory, and reads no +capture. :mod:`pcapkit` is imported inside each test, after +:func:`~tests._support.reimport_once_per_class`. + +""" + +import datetime +import importlib.util +import ipaddress +import json +import os +import plistlib +import struct +import tempfile +import unittest +import warnings + +from tests._support import reimport_once_per_class + +RUNTIME_DEPS = ('tbtrim', 'aenum', 'chardet', 'dictdumper') +HAS_RUNTIME = all(importlib.util.find_spec(name) is not None for name in RUNTIME_DEPS) + + +def pcap_file(major: 'int', minor: 'int') -> 'bytes': + """A little-endian PCAP file of the given version holding one Ethernet frame.""" + frame = bytes.fromhex('0123456789ab' 'fedcba987654' '88b5') + b'payload' + header = struct.pack(' None: + reimport_once_per_class(self) + self.tmpdir = tempfile.TemporaryDirectory() # pylint: disable=consider-using-with + self.addCleanup(self.tmpdir.cleanup) + + def dump(self, kind: 'str', value: 'dict[str, object]') -> 'bytes': + """Dump ``value`` through the customised ``dictdumper`` writer ``kind``.""" + import dictdumper + + from pcapkit.dumpkit.common import make_dumper + + path = os.path.join(self.tmpdir.name, f'out.{kind.lower()}') + make_dumper(getattr(dictdumper, kind))(path)(value, name='x') + with open(path, 'rb') as file: + return file.read() + + def test_pcap_output_keeps_the_input_version(self) -> None: + """#1255: the output global header carries the input's version.""" + from pcapkit import extract + + for version in ((2, 2), (2, 3), (2, 4)): + with self.subTest(version=version): + src = os.path.join(self.tmpdir.name, 'in.pcap') + dst = os.path.join(self.tmpdir.name, 'out.pcap') + with open(src, 'wb') as file: + file.write(pcap_file(*version)) + with warnings.catch_warnings(): + warnings.simplefilter('ignore') + extract(fin=src, fout=dst, format='pcap', store=False, extension=False) + with open(dst, 'rb') as file: + self.assertEqual(file.read(), pcap_file(*version)) + + def test_plist_keeps_none_values_and_items(self) -> None: + """#1257: a :data:`None` value or item is written, not skipped.""" + none = {'type': 'NoneType', 'value': 'None'} + got = plistlib.loads(self.dump('PLIST', {'error': None, 'items': [1, None, 2]}))['x'] + self.assertEqual(got, {'error': none, 'items': [1, none, 2]}) + + def test_unhandled_value_types_get_an_explicit_rendering(self) -> None: + """#1258: networks, sentinels and timezones no longer dump as ``{}``.""" + from pcapkit.corekit.sentinels import NO_VALUE, NULL + + value = { + 'net4': ipaddress.IPv4Network('10.0.0.0/8'), + 'net6': ipaddress.IPv6Network('::/0'), + 'nv': NO_VALUE, + 'nl': NULL, + 'tz': datetime.timezone(datetime.timedelta(hours=8)), + } + with self.assertNoLogs('pcapkit', level='WARNING'): + got = json.loads(self.dump('JSON', value))['x'] + self.assertEqual(got, {'net4': '10.0.0.0/8', 'net6': '::/0', 'nv': '', + 'nl': '', 'tz': 28800.0}) + + def test_fallback_reads_the_slots_of_every_base_class(self) -> None: + """#1258: a leaf ``__slots__ = ()`` no longer hides its bases' slots.""" + + class Base: + __slots__ = ('first', 'unset') + + class Middle(Base): + __slots__ = 'second' + + class Leaf(Middle): + __slots__ = () + + obj = Leaf() + obj.first, obj.second = 1, 'a None: + """#1261: a character above ``U+FFFF`` round-trips through ``json``.""" + value = {'c': 'smile \U0001F600', 'bmp': 'a\x7f\x0bé"\\\n'} + data = self.dump('JSON', value) + self.assertIn(b'"smile \\ud83d\\ude00"', data) + # Every other character is spelt exactly as the upstream writer does. + self.assertIn(b'"a\\u007f\\u000b\\u00e9\\"\\\\\\n"', data) + self.assertEqual(json.loads(data)['x'], value) + + def test_plist_writes_strings_xml_cannot_spell_as_data(self) -> None: + """#1262: a control character no longer makes the document unparseable.""" + value = {'c': 'a\x01b\x1bc', 'ok': 'tab\there & '} + got = plistlib.loads(self.dump('PLIST', value))['x'] + self.assertEqual(got, {'c': 'a\x01b\x1bc'.encode(), 'ok': 'tab\there & '}) + + def test_ordered_multidict_dumps_in_insertion_order(self) -> None: + """#1263: one single-key mapping per entry, in insertion order.""" + from pcapkit.corekit.multidict import MultiDict, OrderedMultiDict + + ordered = OrderedMultiDict([('NOP', 1), ('M&S', 2), ('NOP', 3)]) + expected = [{'NOP': 1}, {'M&S': 2}, {'NOP': 3}] + self.assertEqual(json.loads(self.dump('JSON', {'o': ordered}))['x']['o'], expected) + self.assertEqual(plistlib.loads(self.dump('PLIST', {'o': ordered}))['x']['o'], expected) + + # A MultiDict keeps no order across keys, so it is still grouped. + multi = MultiDict([('NOP', 1), ('MSS', 2), ('NOP', 3)]) + self.assertEqual(json.loads(self.dump('JSON', {'m': multi}))['x']['m'], {'NOP': [1, 3], 'MSS': [2]}) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/dumpkit/test_json_key_escaping_regression.py b/tests/dumpkit/test_json_key_escaping_regression.py index a4c835c5bf..7757643997 100644 --- a/tests/dumpkit/test_json_key_escaping_regression.py +++ b/tests/dumpkit/test_json_key_escaping_regression.py @@ -54,7 +54,7 @@ def test_the_json_report_parses_and_keeps_the_bytes_key(self) -> None: entries = [frame['secrets_data']['entries'] for frame in report.values() if isinstance(frame, dict) and 'secrets_data' in frame] self.assertEqual(len(entries), 1) - self.assertIn(RAW_BYTES_KEY, entries[0]['CLIENT_RANDOM']) + self.assertIn(RAW_BYTES_KEY, entries[0]['CLIENT_RANDOM'][0]) if __name__ == '__main__': diff --git a/tests/dumpkit/test_json_key_escaping_unit.py b/tests/dumpkit/test_json_key_escaping_unit.py index d356bcb031..46e7b20570 100644 --- a/tests/dumpkit/test_json_key_escaping_unit.py +++ b/tests/dumpkit/test_json_key_escaping_unit.py @@ -59,7 +59,7 @@ def test_a_multidict_with_awkward_keys_parses(self) -> None: report = json.loads(self.dump('json', entries).read_text(encoding='utf-8')) self.assertEqual(report['Frame 1']['entries'], - {format(key, ''): [index] for index, key in enumerate(AWKWARD_KEYS)}) + [{format(key, ''): index} for index, key in enumerate(AWKWARD_KEYS)]) def test_a_plain_dict_with_awkward_keys_parses(self) -> None: """A plain :class:`dict` goes through a separate branch of the hook.""" diff --git a/tests/dumpkit/test_nameless_enum_rendering_unit.py b/tests/dumpkit/test_nameless_enum_rendering_unit.py index f7f4763631..2c8b1a336c 100644 --- a/tests/dumpkit/test_nameless_enum_rendering_unit.py +++ b/tests/dumpkit/test_nameless_enum_rendering_unit.py @@ -212,7 +212,7 @@ def test_multidict_key_path_renders_a_nameless_key(self) -> None: ordered = OrderedMultiDict() ordered.add(Flags(0), 'first') ordered.add(Flags(0), 'second') - self.assertEqual(dumper.object_hook(ordered)['Flags::0 [0]'], ['first', 'second']) + self.assertEqual(dumper.object_hook(ordered), [{'Flags::0 [0]': 'first'}, {'Flags::0 [0]': 'second'}]) def test_addon_branch_renders_a_nameless_member(self) -> None: """The third interpolation -- the ``'enum'`` key of the ``addon`` mapping.