diff --git a/pcapkit/protocols/application/httpv2.py b/pcapkit/protocols/application/httpv2.py index 57ae267bba..ed64f7f158 100644 --- a/pcapkit/protocols/application/httpv2.py +++ b/pcapkit/protocols/application/httpv2.py @@ -41,7 +41,7 @@ from pcapkit.const.http.error_code import ErrorCode as Enum_ErrorCode from pcapkit.const.http.frame import Frame as Enum_Frame from pcapkit.const.http.setting import Setting as Enum_Setting -from pcapkit.corekit.multidict import OrderedMultiDict +from pcapkit.corekit.multidict import MultiDict, OrderedMultiDict from pcapkit.protocols.application.http import HTTP as HTTPBase from pcapkit.protocols.data.application.httpv2 import HTTP as Data_HTTP from pcapkit.protocols.data.application.httpv2 import ContinuationFrame as Data_ContinuationFrame @@ -1282,7 +1282,7 @@ def _make_http_rst_stream(self, frame: 'Optional[Data_RSTStreamFrame]' = None, * def _make_http_settings(self, frame: 'Optional[Data_SettingsFrame]' = None, *, ack: 'bool' = False, - settings: 'Optional[OrderedMultiDict[Enum_Setting, int] | bytes | list[Schema_SettingPair | tuple[Enum_Setting, int]]]' = None, # pylint: disable=line-too-long + settings: 'Optional[OrderedMultiDict[Enum_Setting, int] | dict[Enum_Setting | int, int] | bytes | list[Schema_SettingPair | tuple[Enum_Setting, int]]]' = None, # pylint: disable=line-too-long **kwargs: 'Any') -> 'tuple[Schema_SettingsFrame, Flags]': """Make HTTP/2 ``SETTINGS`` frame. @@ -1290,7 +1290,8 @@ def _make_http_settings(self, frame: 'Optional[Data_SettingsFrame]' = None, *, frame: Frame data model. ack: Acknowledge flag. settings: Settings, as raw octets, an - :class:`~pcapkit.corekit.multidict.OrderedMultiDict` or a list of + :class:`~pcapkit.corekit.multidict.OrderedMultiDict`, a plain + :obj:`dict` of identifier to value or a list of ``(identifier, value)`` pairs. **kwargs: Arbitrary keyword arguments. @@ -1314,7 +1315,9 @@ def _make_http_settings(self, frame: 'Optional[Data_SettingsFrame]' = None, *, settings_val = settings # type: bytes | list[Schema_SettingPair] elif isinstance(settings, dict): settings_val = [] - for key, val in settings.items(multi=True): + # A plain ``dict`` has no ``multi`` keyword (#2223). + items = settings.items(multi=True) if isinstance(settings, MultiDict) else settings.items() + for key, val in items: settings_val.append(Schema_SettingPair( id=key, value=val, diff --git a/tests/protocols/application/test_httpv2_settings_dict_2223_unit.py b/tests/protocols/application/test_httpv2_settings_dict_2223_unit.py new file mode 100644 index 0000000000..a3fac16706 --- /dev/null +++ b/tests/protocols/application/test_httpv2_settings_dict_2223_unit.py @@ -0,0 +1,73 @@ +# -*- coding: utf-8 -*- +"""HTTP/2 ``SETTINGS`` frames build from a plain :obj:`dict`. + +GitHub issue #2223: ``HTTP._make_http_settings`` accepted a :obj:`dict` but +called ``settings.items(multi=True)``, which only +:class:`~pcapkit.corekit.multidict.MultiDict` supports, so a plain :obj:`dict` +raised a stdlib :exc:`TypeError` instead of building the frame. A plain +:obj:`dict` builds the same octets as the equivalent list of pairs, and an +:class:`~pcapkit.corekit.multidict.OrderedMultiDict` keeps its repeated +identifiers. + +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`. + +""" + +import unittest + +from tests._support import reimport_once_per_class + +#: ``HEADER_TABLE_SIZE = 2`` as a ``SETTINGS`` frame on stream 0. +ONE_PAIR = bytes.fromhex('000006040000000000000100000002') + + +class TestHTTPv2SettingsPlainDict(unittest.TestCase): + """Pin ``SETTINGS`` construction from each accepted mapping type.""" + + def setUp(self) -> None: + reimport_once_per_class(self) + + def build(self, settings: 'object') -> 'bytes': + """Build a ``SETTINGS`` frame from ``settings``.""" + from pcapkit.const.http.frame import Frame + from pcapkit.protocols.application.httpv2 import HTTP + + return bytes(HTTP(type=Frame.SETTINGS, frame={'settings': settings})) + + def test_plain_dict_builds_the_list_octets(self) -> None: + from pcapkit.const.http.setting import Setting + + self.assertEqual(self.build([(Setting.HEADER_TABLE_SIZE, 2)]), ONE_PAIR) + for name, settings in (('int key', {1: 2}), + ('enum key', {Setting.HEADER_TABLE_SIZE: 2})): + with self.subTest(settings=name): + self.assertEqual(self.build(settings), ONE_PAIR) + + def test_plain_dict_keeps_insertion_order(self) -> None: + settings = {3: 100, 1: 4096, 4: 65535} + self.assertEqual(self.build(settings), self.build(list(settings.items()))) + + def test_plain_dict_frame_parses_back(self) -> None: + import io + + from pcapkit.protocols.application.httpv2 import HTTP + + raw = self.build({1: 4096, 3: 100}) + info = HTTP(io.BytesIO(raw), len(raw)).info + self.assertEqual([(int(k), v) for k, v in info.settings.items(multi=True)], + [(1, 4096), (3, 100)]) + + def test_ordered_multidict_keeps_repeated_identifiers(self) -> None: + from pcapkit.corekit.multidict import OrderedMultiDict + + pairs = [(1, 4096), (1, 2)] + self.assertEqual(self.build(OrderedMultiDict(pairs)), self.build(pairs)) + + def test_empty_plain_dict_builds_an_empty_frame(self) -> None: + self.assertEqual(self.build({}), bytes.fromhex('000000040000000000')) + + +if __name__ == '__main__': + unittest.main()