Skip to content

Commit dd6a47c

Browse files
authored
refactor(application)!: the HTTP base follows the versioned-family rules (#2628) (#2651)
- `HTTP.__new__` and `HTTP.make`: building from fields takes `version` and raises `ProtocolError` without it. `HTTP._dispatch_fields` and its `_make_fields` helper go, so no field picks a member and HTTPv1 is no default. Octet dispatch through `HTTP._dispatch_version` is kept. - `HTTP.from_data` dispatches by data-model class and refuses a dict, rather than picking by its `receipt` or `sid` key. - `HTTP.__init__`: from fields, `version` must be the member's own, and HTTPv1 refuses `preface`, which `HTTP.pack` would write ahead of an HTTP/1 message. `HTTP._member_of_version` refuses a bool or a float. - Docs: Dispatching Bases lists HTTP; the HTTP page records the change. - Tests: `test_http_base_dispatch_2628_unit.py` pins each refusal; the #2544, #2298, HTTP/2 round-trip and #1447 tests pass `version` or rebuild a dict through its member. Breaking against v1.5.1: there `HTTP(...)` from fields built HTTP/1.* and `HTTP.from_data` took a dict, and each raises here.
1 parent 2766707 commit dd6a47c

9 files changed

Lines changed: 307 additions & 155 deletions

File tree

‎docs/source/contributing/conventions/protocol-family-grouping.rst‎

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,11 @@ version under that name to the layer it carries.
122122
- ``version``
123123
- Octets without ``version``
124124
- ``from_data``
125+
* - :class:`~pcapkit.protocols.application.http.HTTP`
126+
- ``1``, ``2``
127+
- :meth:`~pcapkit.protocols.application.http.HTTP._dispatch_version`: the
128+
connection preface, a start line or a frame header
129+
- the data model; a :obj:`dict` is refused
125130
* - :class:`~pcapkit.protocols.internet.igmp.IGMP`
126131
- ``1``, ``2``, ``3``
127132
- :meth:`~pcapkit.protocols.internet.igmp.IGMP._dispatch`: type, max response
@@ -146,13 +151,11 @@ version under that name to the layer it carries.
146151
- refused, below
147152
- the data model; a :obj:`dict` is refused
148153

149-
:class:`~pcapkit.protocols.application.http.HTTP` dispatches otherwise. Its octets
150-
go to :meth:`~pcapkit.protocols.application.http.HTTP._dispatch_version`, which reads
151-
the connection preface, a start line or a frame header, for a payload a transport
152-
port binds to HTTP as well. Without ``version``, its
153-
:meth:`~pcapkit.protocols.application.http.HTTP._dispatch_fields` picks by each
154-
member's own fields and builds ``HTTPv1`` from none, and its ``from_data`` reads the
155-
data model, or a :obj:`dict` by its ``receipt`` or ``sid``.
154+
HTTP octets carry their version, but the HTTP data models have no top-level
155+
``version`` field, only HTTP/1's ``receipt.version`` string, so
156+
:meth:`HTTP.from_data <pcapkit.protocols.application.http.HTTP.from_data>` reads the
157+
data model's class. It refuses a :obj:`dict` even though the ``receipt`` and ``sid``
158+
keys would tell the members apart, since no key picks a member (:issue:`2628`).
156159

157160
**Octets that carry no version are refused through the base without** ``version``
158161
(:issue:`2574`). An ICMP message and a Linux cooked-capture header have no version

‎docs/source/pcapkit/protocols/application/http.rst‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,10 @@ which tells whether a payload opens with an HTTP/1.* start line.
2020
:class:`~pcapkit.protocols.application.httpv1.HTTPv1` or
2121
:class:`~pcapkit.protocols.application.httpv2.HTTPv2` rather than an instance
2222
of it, so the data sits under ``httpv1`` or ``httpv2`` rather than ``http``
23-
(:issue:`2524`).
23+
(:issue:`2524`). Building it from fields takes ``version`` and is refused
24+
without it, where it built HTTP/1.*, and its
25+
:meth:`~pcapkit.protocols.application.http.HTTP.from_data` refuses a
26+
:obj:`dict`, where it read the ``receipt`` or ``sid`` key (:issue:`2628`).
2427

2528
.. autoclass:: pcapkit.protocols.application.http.HTTP
2629
:no-members:
@@ -38,11 +41,11 @@ which tells whether a payload opens with an HTTP/1.* start line.
3841

3942
.. automethod:: _make_data
4043

44+
.. automethod:: __init__
4145
.. automethod:: __new__
4246

4347
.. automethod:: _guess_version
4448
.. automethod:: _dispatch_version
45-
.. automethod:: _dispatch_fields
4649
.. automethod:: _member_of_version
4750

4851
.. autoattribute:: _preface_length

‎pcapkit/protocols/application/http.py‎

Lines changed: 85 additions & 112 deletions
Original file line numberDiff line numberDiff line change
@@ -23,18 +23,19 @@
2323
:class:`~pcapkit.protocols.application.httpv1.HTTPv1` or
2424
:class:`~pcapkit.protocols.application.httpv2.HTTPv2` rather than an instance
2525
of it, so the data sits under ``httpv1`` or ``httpv2`` rather than ``http``
26-
(:issue:`2524`).
26+
(:issue:`2524`). Building it from fields takes ``version`` and is refused
27+
without it, where it built HTTP/1.*, and its
28+
:meth:`~pcapkit.protocols.application.http.HTTP.from_data` refuses a
29+
:obj:`dict`, where it read the ``receipt`` or ``sid`` key (:issue:`2628`).
2730
2831
"""
2932
import contextlib
30-
import inspect
3133
import re
3234
import struct
3335
from typing import TYPE_CHECKING, Generic, cast
3436

3537
from pcapkit.corekit.protochain import ProtoChain
3638
from pcapkit.protocols.application.application import Application
37-
from pcapkit.protocols.data.data import Data
3839
from pcapkit.protocols.misc.no_payload import NoPayload
3940
from pcapkit.protocols.protocol import _PT, _ST
4041
from pcapkit.utilities.exceptions import ProtocolError
@@ -146,21 +147,6 @@ def _test_http2_frame(data: 'bytes') -> 'bool':
146147
_HTTP2_PREFACE_HEADER = _HTTP2_PREFACE.split(b'\r\n\r\n', maxsplit=1)[0]
147148

148149

149-
def _make_fields(klass: 'Type[HTTP]') -> 'frozenset[str]':
150-
"""Names of the fields ``klass.make`` takes, as keywords.
151-
152-
Args:
153-
klass: An HTTP member class.
154-
155-
Returns:
156-
Every named parameter of its ``make`` but ``self``.
157-
158-
"""
159-
kinds = (inspect.Parameter.POSITIONAL_OR_KEYWORD, inspect.Parameter.KEYWORD_ONLY)
160-
return frozenset(name for name, param in inspect.signature(klass.make).parameters.items()
161-
if name != 'self' and param.kind in kinds)
162-
163-
164150
def test_start_line(data: 'bytes') -> 'bool':
165151
"""Whether ``data`` opens with an HTTP/1.* start line.
166152
@@ -237,7 +223,11 @@ class HTTP(Application[_PT, _ST], Generic[_PT, _ST]):
237223
and :meth:`from_data` give
238224
:class:`~pcapkit.protocols.application.httpv1.HTTPv1` or
239225
:class:`~pcapkit.protocols.application.httpv2.HTTPv2`, never an instance of
240-
this class (:issue:`2524`); see :meth:`__new__`.
226+
this class (:issue:`2524`). ``version`` names the member; without it, octets
227+
give the one :meth:`_dispatch_version` identifies, and fields are refused,
228+
since no field picks a member (:issue:`2574`). :meth:`from_data` gives the
229+
member whose data model it is handed, and refuses a :obj:`dict`. See
230+
:meth:`__new__`.
241231
242232
"""
243233

@@ -383,13 +373,14 @@ def read(self, length: 'Optional[int]' = None, *,
383373
return info
384374

385375
def make(self,
386-
version: 'Literal[1, 2]' = 1, *,
376+
version: 'Optional[Literal[1, 2]]' = None, *,
387377
preface: 'bool' = False,
388378
**kwargs: 'Any') -> '_ST':
389379
"""Make (construct) packet data.
390380
391381
Args:
392-
version: Version of HTTP.
382+
version: Version of HTTP, ``1`` or ``2``. There is no default: no
383+
field picks a member (:issue:`2574`).
393384
preface: Whether the HTTP/2 connection preface goes ahead of the
394385
frame; :meth:`pack` writes it.
395386
**kwargs: Arbitrary keyword arguments.
@@ -398,20 +389,16 @@ def make(self,
398389
The header schema the versioned ``make`` constructs.
399390
400391
Raises:
401-
ProtocolError: If ``version`` is unknown, or if ``preface`` is
402-
requested for a version other than HTTP/2.
392+
ProtocolError: If ``version`` is missing or names neither member, or
393+
if ``preface`` is requested for a version other than HTTP/2.
403394
404395
"""
396+
if version is None:
397+
raise ProtocolError('HTTP: no version to build with; pass version=1 or version=2')
398+
protocol = self._member_of_version(version)
405399
if preface and version != 2:
406400
raise ProtocolError(f'HTTP/{version}: connection preface is HTTP/2 only')
407401

408-
if version == 1:
409-
from pcapkit.protocols.application.httpv1 import HTTPv1 as protocol # isort: skip # pylint: disable=line-too-long,import-outside-toplevel
410-
elif version == 2:
411-
from pcapkit.protocols.application.httpv2 import HTTPv2 as protocol # type: ignore[assignment] # isort: skip # pylint: disable=line-too-long,import-outside-toplevel
412-
else:
413-
raise ProtocolError(f'HTTP: invalid version: {version!r}')
414-
415402
# NOTE: ``protocol.make`` is an ordinary instance method (the abstract
416403
# declaration at ``ProtocolBase.make`` takes ``self``, and the
417404
# versioned overrides use instance-bound helpers such as
@@ -447,56 +434,87 @@ def from_data(cls, data: '_PT | dict[str, Any]', **kwargs: 'Any') -> 'Self':
447434
"""Create protocol instance from data.
448435
449436
Args:
450-
data: Protocol data. A :obj:`dict`, as :meth:`Info.to_dict
451-
<pcapkit.corekit.infoclass.Info.to_dict>` returns it, is rebuilt
452-
into the versioned data model its keys belong to -- ``receipt``
453-
is HTTP/1.*'s and ``sid`` HTTP/2's -- since this class's own data
454-
model carries no version.
437+
data: Protocol data.
455438
**kwargs: As :meth:`Protocol.from_data
456439
<pcapkit.protocols.protocol.Protocol.from_data>`.
457440
458441
Returns:
459-
Protocol instance; on this class, an instance of the member the data
460-
model belongs to (:issue:`2524`).
442+
Protocol instance; on this class, an instance of the member whose data
443+
model ``data`` is (:issue:`2524`).
461444
462445
Raises:
463-
ProtocolError: If ``data`` is neither HTTP/1.* nor HTTP/2 data, on
464-
this class.
446+
ProtocolError: If ``data`` is neither member's data model, on this
447+
class. A :obj:`dict` is refused: it carries no version, and no
448+
key picks a member (:issue:`2574`).
465449
466450
"""
451+
if cls is not HTTP:
452+
return super().from_data(data, **kwargs)
453+
467454
from pcapkit.protocols.application.httpv1 import HTTPv1 # isort: skip # pylint: disable=line-too-long,import-outside-toplevel
468455
from pcapkit.protocols.application.httpv2 import HTTPv2 # isort: skip # pylint: disable=line-too-long,import-outside-toplevel
469456
from pcapkit.protocols.data.application.httpv1 import HTTPv1 as Data_HTTPv1 # isort: skip # pylint: disable=line-too-long,import-outside-toplevel
470457
from pcapkit.protocols.data.application.httpv2 import HTTPv2 as Data_HTTPv2 # isort: skip # pylint: disable=line-too-long,import-outside-toplevel
471458

472-
if not isinstance(data, Data) and not issubclass(cls.__data__, (Data_HTTPv1, Data_HTTPv2)):
473-
if 'receipt' in data:
474-
data = cast('_PT', Data_HTTPv1.from_dict(data))
475-
elif 'sid' in data:
476-
data = cast('_PT', Data_HTTPv2.from_dict(data))
477-
if cls is not HTTP:
478-
return super().from_data(data, **kwargs)
479459
if isinstance(data, Data_HTTPv1):
480460
return cast('Self', HTTPv1.from_data(data, **kwargs))
481461
if isinstance(data, Data_HTTPv2):
482462
return cast('Self', HTTPv2.from_data(data, **kwargs))
483-
raise ProtocolError(f'HTTP: invalid data: must be HTTP/1.* or HTTP/2 data, '
484-
f'not {type(data).__name__}')
463+
raise ProtocolError('HTTP: invalid data: must be an HTTPv1 or HTTPv2 data model')
485464

486465
##########################################################################
487466
# Data models.
488467
##########################################################################
489468

469+
def __init__(self, file: 'Optional[IO[bytes] | bytes]' = None,
470+
length: 'Optional[int]' = None, *,
471+
version: 'Optional[Literal[1, 2]]' = None, **kwargs: 'Any') -> 'None':
472+
"""Initialisation.
473+
474+
Args:
475+
file: Source packet stream.
476+
length: Length of packet data.
477+
version: Version of HTTP, ``1`` or ``2``: the member :meth:`__new__`
478+
picks. Building from fields, it must be this class's own. Parsing,
479+
it is handed on unread.
480+
**kwargs: As :meth:`Protocol.__init__
481+
<pcapkit.protocols.protocol.Protocol.__init__>`.
482+
483+
Raises:
484+
ProtocolError: If, building from fields, ``version`` names neither
485+
member, or is not this class's own version: that of the member
486+
it is or derives from, which a subclass outside the members
487+
does not have; or if ``preface`` is asked of HTTP/1.
488+
489+
"""
490+
if file is not None:
491+
if version is not None:
492+
kwargs['version'] = version
493+
else:
494+
from pcapkit.protocols.application.httpv1 import HTTPv1 # isort: skip # pylint: disable=import-outside-toplevel
495+
from pcapkit.protocols.application.httpv2 import HTTPv2 # isort: skip # pylint: disable=import-outside-toplevel
496+
497+
if version is not None and not isinstance(self, self._member_of_version(version)):
498+
if not isinstance(self, (HTTPv1, HTTPv2)):
499+
raise ProtocolError(f'{type(self).__name__}: no version of its own to match '
500+
f'version={version!r}')
501+
raise ProtocolError(f'{type(self).__name__}: version={version!r} '
502+
'names another member')
503+
# NOTE: ``pack`` writes the preface for any member, so HTTP/1 refuses it here.
504+
if kwargs.get('preface') and isinstance(self, HTTPv1):
505+
raise ProtocolError('HTTP/1: connection preface is HTTP/2 only')
506+
super().__init__(file, length, **kwargs) # type: ignore[arg-type]
507+
490508
def __new__(cls, file: 'Optional[IO[bytes] | bytes]' = None, # type: ignore[misc]
491509
length: 'Optional[int]' = None, **kwargs: 'Any') -> 'HTTP':
492510
"""Create the member a construction of this class describes.
493511
494-
Parsing octets gives the member an explicit ``version`` names, or else
495-
the one :meth:`_dispatch_version` identifies; building from fields gives
496-
the one :meth:`_dispatch_fields` picks. That member's ``__init__`` then
497-
runs with the same arguments, which it accepts, since a member inherits
498-
this class's ``version`` and ``preface`` keywords. A subclass of this
499-
class is created as itself.
512+
The ``version`` keyword names the member, on either path. Without it,
513+
parsing octets gives the member :meth:`_dispatch_version` identifies,
514+
since HTTP octets carry their own version, and building from fields is
515+
refused, since no field picks a member (:issue:`2574`). That member's
516+
``__init__`` then runs with the same arguments. A subclass of this class
517+
is created as itself.
500518
501519
Args:
502520
file: Source packet stream, read here without being consumed.
@@ -507,17 +525,17 @@ class is created as itself.
507525
An uninitialised instance of the member.
508526
509527
Raises:
510-
ProtocolError: If the octets are neither version's, including an
511-
HTTP/2 connection preface with no frame after it, or if
512-
:meth:`_dispatch_fields` refuses the fields.
528+
ProtocolError: If ``version`` names neither member; or, without it,
529+
if building from fields, or if the octets are neither version's,
530+
including an HTTP/2 connection preface with no frame after it.
513531
514532
"""
515533
if cls is not HTTP:
516534
return super().__new__(cls, file, length, **kwargs) # type: ignore[call-arg]
517-
if file is None:
518-
member = cls._dispatch_fields(kwargs)
519-
elif kwargs.get('version') is not None:
535+
if kwargs.get('version') is not None:
520536
member = cls._member_of_version(kwargs['version'])
537+
elif file is None:
538+
raise ProtocolError('HTTP: no version to build with; pass version=1 or version=2')
521539
else:
522540
if isinstance(file, bytes):
523541
data = file
@@ -815,53 +833,6 @@ def _dispatch_version(cls, data: 'bytes', length: 'int') -> 'Type[ProtocolBase]'
815833
return HTTPv2
816834
return Raw
817835

818-
@classmethod
819-
def _dispatch_fields(cls, fields: 'dict[str, Any]') -> 'Type[HTTP]':
820-
"""Pick the member that builds a packet from the keyword ``fields``.
821-
822-
An explicit ``version`` names the member. Otherwise a field that only
823-
:meth:`HTTPv2.make <pcapkit.protocols.application.httpv2.HTTPv2.make>`
824-
takes picks :class:`~pcapkit.protocols.application.httpv2.HTTPv2`, and a
825-
field that only :meth:`HTTPv1.make
826-
<pcapkit.protocols.application.httpv1.HTTPv1.make>` takes, or no field
827-
of either, picks :class:`~pcapkit.protocols.application.httpv1.HTTPv1`,
828-
the version :meth:`make` defaults to. ``preface`` counts only when true,
829-
as in the ``version`` check, since its default is :data:`False`.
830-
831-
Args:
832-
fields: Construction keywords.
833-
834-
Returns:
835-
:class:`~pcapkit.protocols.application.httpv1.HTTPv1` or
836-
:class:`~pcapkit.protocols.application.httpv2.HTTPv2`.
837-
838-
Raises:
839-
ProtocolError: If ``version`` is neither ``1`` nor ``2``, if
840-
``preface`` is asked of HTTP/1, or if ``fields`` holds fields
841-
only HTTP/1 takes and fields only HTTP/2 takes.
842-
843-
"""
844-
from pcapkit.protocols.application.httpv1 import HTTPv1 # isort: skip # pylint: disable=import-outside-toplevel
845-
from pcapkit.protocols.application.httpv2 import HTTPv2 # isort: skip # pylint: disable=import-outside-toplevel
846-
847-
version = fields.get('version')
848-
if version is not None:
849-
member = cls._member_of_version(version)
850-
if fields.get('preface') and member is not HTTPv2:
851-
raise ProtocolError(f'HTTP/{version}: connection preface is HTTP/2 only')
852-
return member
853-
854-
# NOTE: ``preface=False`` is the default, not a request for HTTP/2.
855-
names = {name for name, value in fields.items() if name != 'preface' or value}
856-
own1, own2 = _make_fields(HTTPv1), _make_fields(HTTPv2)
857-
only1, only2 = names & (own1 - own2), names & (own2 - own1)
858-
if only1 and only2:
859-
if only2 == {'preface'}:
860-
raise ProtocolError('HTTP/1: connection preface is HTTP/2 only')
861-
raise ProtocolError(f'HTTP: fields of both HTTP/1 ({", ".join(sorted(only1))}) '
862-
f'and HTTP/2 ({", ".join(sorted(only2))})')
863-
return HTTPv2 if only2 else HTTPv1
864-
865836
@staticmethod
866837
def _member_of_version(version: 'Any') -> 'Type[HTTP]':
867838
"""The member a ``version`` of ``1`` or ``2`` names.
@@ -874,14 +845,16 @@ def _member_of_version(version: 'Any') -> 'Type[HTTP]':
874845
:class:`~pcapkit.protocols.application.httpv2.HTTPv2`.
875846
876847
Raises:
877-
ProtocolError: If ``version`` is neither.
848+
ProtocolError: If ``version`` is neither, or is no :obj:`int`: a
849+
:obj:`bool` or a :obj:`float` is refused even where it equals one.
878850
879851
"""
880852
from pcapkit.protocols.application.httpv1 import HTTPv1 # isort: skip # pylint: disable=import-outside-toplevel
881853
from pcapkit.protocols.application.httpv2 import HTTPv2 # isort: skip # pylint: disable=import-outside-toplevel
882854

883-
if version == 1:
884-
return HTTPv1
885-
if version == 2:
886-
return HTTPv2
855+
if isinstance(version, int) and not isinstance(version, bool):
856+
if version == 1:
857+
return HTTPv1
858+
if version == 2:
859+
return HTTPv2
887860
raise ProtocolError(f'HTTP: invalid version: {version!r}')

‎tests/protocols/application/test_http2_roundtrip_unit.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -254,7 +254,7 @@ def test_parse_then_rebuild_is_byte_exact(self) -> None:
254254
with self.subTest(segment=name):
255255
parsed = HTTP(wire, len(wire))
256256
for rebuilt in (HTTP.from_data(parsed.info), HTTPv2.from_data(parsed.info),
257-
HTTP.from_data(parsed.info.to_dict())):
257+
HTTPv2.from_data(parsed.info.to_dict())):
258258
self.assertEqual(bytes(rebuilt).hex(), wire.hex())
259259
self.assertEqual(len(_layers(rebuilt)[0]), len(_split_frames(wire)))
260260

0 commit comments

Comments
 (0)