From 1feb2715dccea1bf02bf7c9c8a8e81abcfd49af9 Mon Sep 17 00:00:00 2001 From: Thomas Kriechbaumer Date: Sat, 1 Feb 2025 12:05:46 +0100 Subject: [PATCH 01/30] prepare for next release cycle --- CHANGELOG.rst | 11 +++++++++++ src/h2/__init__.py | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 65cebf456..a4175f799 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,6 +1,17 @@ Release History =============== +dev +--- + +**API Changes (Backward Incompatible)** + +- + +**Bugfixes** + +- + 4.2.0 (2025-02-01) ------------------ diff --git a/src/h2/__init__.py b/src/h2/__init__.py index 0764daadb..35bfe397a 100644 --- a/src/h2/__init__.py +++ b/src/h2/__init__.py @@ -3,4 +3,4 @@ """ from __future__ import annotations -__version__ = "4.2.0" +__version__ = "4.3.0+dev" From 748eef93fffbf74049c90a10647fadf83f0c9e25 Mon Sep 17 00:00:00 2001 From: Maximilian Hils Date: Fri, 7 Feb 2025 19:23:25 +0100 Subject: [PATCH 02/30] enforce stricter types for `H2StreamStateMachine` --- src/h2/stream.py | 28 +++++++++++++++++----------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/src/h2/stream.py b/src/h2/stream.py index 7d4a12e35..a3c99e351 100644 --- a/src/h2/stream.py +++ b/src/h2/stream.py @@ -46,7 +46,7 @@ from .windows import WindowManager if TYPE_CHECKING: # pragma: no cover - from collections.abc import Generator, Iterable + from collections.abc import Callable, Generator, Iterable from hpack.hpack import Encoder from hpack.struct import Header, HeaderWeaklyTyped @@ -131,7 +131,7 @@ def __init__(self, stream_id: int) -> None: # How the stream was closed. One of StreamClosedBy. self.stream_closed_by: StreamClosedBy | None = None - def process_input(self, input_: StreamInputs) -> Any: + def process_input(self, input_: StreamInputs) -> list[Event]: """ Process a specific input in the state machine. """ @@ -315,21 +315,23 @@ def recv_push_promise(self, previous_state: StreamState) -> list[Event]: event.parent_stream_id = self.stream_id return [event] - def send_end_stream(self, previous_state: StreamState) -> None: + def send_end_stream(self, previous_state: StreamState) -> list[Event]: """ Called when an attempt is made to send END_STREAM in the HALF_CLOSED_REMOTE state. """ self.stream_closed_by = StreamClosedBy.SEND_END_STREAM + return [] - def send_reset_stream(self, previous_state: StreamState) -> None: + def send_reset_stream(self, previous_state: StreamState) -> list[Event]: """ Called when an attempt is made to send RST_STREAM in a non-closed stream state. """ self.stream_closed_by = StreamClosedBy.SEND_RST_STREAM + return [] - def reset_stream_on_error(self, previous_state: StreamState) -> None: + def reset_stream_on_error(self, previous_state: StreamState) -> list[Event]: """ Called when we need to forcefully emit another RST_STREAM frame on behalf of the state machine. @@ -350,7 +352,7 @@ def reset_stream_on_error(self, previous_state: StreamState) -> None: error._events = [event] raise error - def recv_on_closed_stream(self, previous_state: StreamState) -> None: + def recv_on_closed_stream(self, previous_state: StreamState) -> list[Event]: """ Called when an unexpected frame is received on an already-closed stream. @@ -362,7 +364,7 @@ def recv_on_closed_stream(self, previous_state: StreamState) -> None: """ raise StreamClosedError(self.stream_id) - def send_on_closed_stream(self, previous_state: StreamState) -> None: + def send_on_closed_stream(self, previous_state: StreamState) -> list[Event]: """ Called when an attempt is made to send data on an already-closed stream. @@ -374,7 +376,7 @@ def send_on_closed_stream(self, previous_state: StreamState) -> None: """ raise StreamClosedError(self.stream_id) - def recv_push_on_closed_stream(self, previous_state: StreamState) -> None: + def recv_push_on_closed_stream(self, previous_state: StreamState) -> list[Event]: """ Called when a PUSH_PROMISE frame is received on a full stop stream. @@ -393,7 +395,7 @@ def recv_push_on_closed_stream(self, previous_state: StreamState) -> None: msg = "Attempted to push on closed stream." raise ProtocolError(msg) - def send_push_on_closed_stream(self, previous_state: StreamState) -> None: + def send_push_on_closed_stream(self, previous_state: StreamState) -> list[Event]: """ Called when an attempt is made to push on an already-closed stream. @@ -473,7 +475,7 @@ def recv_alt_svc(self, previous_state: StreamState) -> list[Event]: # the event and let it get populated. return [AlternativeServiceAvailable()] - def send_alt_svc(self, previous_state: StreamState) -> None: + def send_alt_svc(self, previous_state: StreamState) -> list[Event]: """ Called when sending an ALTSVC frame on this stream. @@ -489,6 +491,7 @@ def send_alt_svc(self, previous_state: StreamState) -> None: if self.headers_sent: msg = "Cannot send ALTSVC after sending response headers." raise ProtocolError(msg) + return [] @@ -561,7 +564,10 @@ def send_alt_svc(self, previous_state: StreamState) -> None: # (state, input) to tuples of (side_effect_function, end_state). This # map contains all allowed transitions: anything not in this map is # invalid and immediately causes a transition to ``closed``. -_transitions = { +_transitions: dict[ + tuple[StreamState, StreamInputs], + tuple[Callable[[H2StreamStateMachine, StreamState], list[Event]] | None, StreamState], +] = { # State: idle (StreamState.IDLE, StreamInputs.SEND_HEADERS): (H2StreamStateMachine.request_sent, StreamState.OPEN), From 6901794827338c14ffdea4ad608a4af1d8078af8 Mon Sep 17 00:00:00 2001 From: Maximilian Hils Date: Fri, 7 Feb 2025 23:22:10 +0100 Subject: [PATCH 03/30] fix up type errors with stricter state machine --- src/h2/events.py | 14 +++++++------- src/h2/stream.py | 34 ++++++++++++++++++++++------------ 2 files changed, 29 insertions(+), 19 deletions(-) diff --git a/src/h2/events.py b/src/h2/events.py index b81fd1a63..7a22f152c 100644 --- a/src/h2/events.py +++ b/src/h2/events.py @@ -16,7 +16,7 @@ from .settings import ChangedSetting, SettingCodes, Settings, _setting_code_from_int if TYPE_CHECKING: # pragma: no cover - from hpack import HeaderTuple + from hpack.struct import Header from hyperframe.frame import Frame from .errors import ErrorCodes @@ -52,7 +52,7 @@ def __init__(self) -> None: self.stream_id: int | None = None #: The request headers. - self.headers: list[HeaderTuple] | None = None + self.headers: list[Header] | None = None #: If this request also ended the stream, the associated #: :class:`StreamEnded ` event will be available @@ -91,7 +91,7 @@ def __init__(self) -> None: self.stream_id: int | None = None #: The response headers. - self.headers: list[HeaderTuple] | None = None + self.headers: list[Header] | None = None #: If this response also ended the stream, the associated #: :class:`StreamEnded ` event will be available @@ -133,7 +133,7 @@ def __init__(self) -> None: self.stream_id: int | None = None #: The trailers themselves. - self.headers: list[HeaderTuple] | None = None + self.headers: list[Header] | None = None #: Trailers always end streams. This property has the associated #: :class:`StreamEnded ` in it. @@ -237,7 +237,7 @@ def __init__(self) -> None: self.stream_id: int | None = None #: The headers for this informational response. - self.headers: list[HeaderTuple] | None = None + self.headers: list[Header] | None = None #: If this response also had associated priority information, the #: associated :class:`PriorityUpdated ` @@ -436,7 +436,7 @@ def __init__(self) -> None: #: The error code given. Either one of :class:`ErrorCodes #: ` or ``int`` - self.error_code: ErrorCodes | None = None + self.error_code: ErrorCodes | int | None = None #: Whether the remote peer sent a RST_STREAM or we did. self.remote_reset = True @@ -460,7 +460,7 @@ def __init__(self) -> None: self.parent_stream_id: int | None = None #: The request headers, sent by the remote party in the push. - self.headers: list[HeaderTuple] | None = None + self.headers: list[Header] | None = None def __repr__(self) -> str: return ( diff --git a/src/h2/stream.py b/src/h2/stream.py index a3c99e351..3f6c97cd1 100644 --- a/src/h2/stream.py +++ b/src/h2/stream.py @@ -7,7 +7,7 @@ from __future__ import annotations from enum import Enum, IntEnum -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Union, cast from hpack import HeaderTuple from hyperframe.frame import AltSvcFrame, ContinuationFrame, DataFrame, Frame, HeadersFrame, PushPromiseFrame, RstStreamFrame, WindowUpdateFrame @@ -1046,10 +1046,11 @@ def receive_push_promise_in_band(self, events = self.state_machine.process_input( StreamInputs.RECV_PUSH_PROMISE, ) - events[0].pushed_stream_id = promised_stream_id + push_event = cast(PushedStreamReceived, events[0]) + push_event.pushed_stream_id = promised_stream_id hdr_validation_flags = self._build_hdr_validation_flags(events) - events[0].headers = self._process_received_headers( + push_event.headers = self._process_received_headers( headers, hdr_validation_flags, header_encoding, ) return [], events @@ -1083,22 +1084,30 @@ def receive_headers(self, input_ = StreamInputs.RECV_HEADERS events = self.state_machine.process_input(input_) + headers_event = cast( + Union[RequestReceived, ResponseReceived, TrailersReceived, InformationalResponseReceived], + events[0], + ) if end_stream: es_events = self.state_machine.process_input( StreamInputs.RECV_END_STREAM, ) - events[0].stream_ended = es_events[0] + # We ensured it's not an information response at the beginning of the method. + cast( + Union[RequestReceived, ResponseReceived, TrailersReceived], + headers_event, + ).stream_ended = cast(StreamEnded, es_events[0]) events += es_events self._initialize_content_length(headers) - if isinstance(events[0], TrailersReceived) and not end_stream: + if isinstance(headers_event, TrailersReceived) and not end_stream: msg = "Trailers must have END_STREAM set" raise ProtocolError(msg) hdr_validation_flags = self._build_hdr_validation_flags(events) - events[0].headers = self._process_received_headers( + headers_event.headers = self._process_received_headers( headers, hdr_validation_flags, header_encoding, ) return [], events @@ -1112,6 +1121,7 @@ def receive_data(self, data: bytes, end_stream: bool, flow_control_len: int) -> "set to %d", self, end_stream, flow_control_len, ) events = self.state_machine.process_input(StreamInputs.RECV_DATA) + data_event = cast(DataReceived, events[0]) self._inbound_window_manager.window_consumed(flow_control_len) self._track_content_length(len(data), end_stream) @@ -1119,11 +1129,11 @@ def receive_data(self, data: bytes, end_stream: bool, flow_control_len: int) -> es_events = self.state_machine.process_input( StreamInputs.RECV_END_STREAM, ) - events[0].stream_ended = es_events[0] + data_event.stream_ended = cast(StreamEnded, es_events[0]) events.extend(es_events) - events[0].data = data - events[0].flow_controlled_length = flow_control_len + data_event.data = data + data_event.flow_controlled_length = flow_control_len return [], events def receive_window_update(self, increment: int) -> tuple[list[Frame], list[Event]]: @@ -1143,7 +1153,7 @@ def receive_window_update(self, increment: int) -> tuple[list[Frame], list[Event # this should be treated as a *stream* error, not a *connection* error. # That means we need to catch the error and forcibly close the stream. if events: - events[0].delta = increment + cast(WindowUpdated, events[0]).delta = increment try: self.outbound_flow_control_window = guard_increment_window( self.outbound_flow_control_window, @@ -1226,7 +1236,7 @@ def stream_reset(self, frame: RstStreamFrame) -> tuple[list[Frame], list[Event]] if events: # We don't fire an event if this stream is already closed. - events[0].error_code = _error_code_from_int(frame.error_code) + cast(StreamReset, events[0]).error_code = _error_code_from_int(frame.error_code) return [], events @@ -1328,7 +1338,7 @@ def _build_headers_frames(self, def _process_received_headers(self, headers: Iterable[Header], header_validation_flags: HeaderValidationFlags, - header_encoding: bool | str | None) -> Iterable[Header]: + header_encoding: bool | str | None) -> list[Header]: """ When headers have been received from the remote peer, run a processing pipeline on them to transform them into the appropriate form for From 4ccc768800a2c77402bbbcfbd0c0decd0b29d1ee Mon Sep 17 00:00:00 2001 From: Maximilian Hils Date: Tue, 4 Feb 2025 00:46:22 +0100 Subject: [PATCH 04/30] convert `events.WindowUpdated` into a dataclass --- src/h2/connection.py | 4 +--- src/h2/events.py | 17 +++++++++++------ src/h2/stream.py | 4 +--- tests/test_events.py | 4 +--- 4 files changed, 14 insertions(+), 15 deletions(-) diff --git a/src/h2/connection.py b/src/h2/connection.py index 28be9fca2..d9ff0862f 100644 --- a/src/h2/connection.py +++ b/src/h2/connection.py @@ -1806,9 +1806,7 @@ def _receive_window_update_frame(self, frame: WindowUpdateFrame) -> tuple[list[F ) # FIXME: Should we split this into one event per active stream? - window_updated_event = WindowUpdated() - window_updated_event.stream_id = 0 - window_updated_event.delta = frame.window_increment + window_updated_event = WindowUpdated(stream_id=0, delta=frame.window_increment) stream_events = [window_updated_event] frames = [] diff --git a/src/h2/events.py b/src/h2/events.py index 7a22f152c..9a852e8ac 100644 --- a/src/h2/events.py +++ b/src/h2/events.py @@ -11,6 +11,7 @@ from __future__ import annotations import binascii +from dataclasses import dataclass from typing import TYPE_CHECKING from .settings import ChangedSetting, SettingCodes, Settings, _setting_code_from_int @@ -292,6 +293,7 @@ def __repr__(self) -> str: ) +@dataclass(kw_only=True) class WindowUpdated(Event): """ The WindowUpdated event is fired whenever a flow control window changes @@ -301,13 +303,16 @@ class WindowUpdated(Event): the connection), and the delta in the window size. """ - def __init__(self) -> None: - #: The Stream ID of the stream whose flow control window was changed. - #: May be ``0`` if the connection window was changed. - self.stream_id: int | None = None + stream_id: int + """ + The Stream ID of the stream whose flow control window was changed. + May be ``0`` if the connection window was changed. + """ - #: The window delta. - self.delta: int | None = None + delta: int | None = None + """ + The window delta. + """ def __repr__(self) -> str: return f"" diff --git a/src/h2/stream.py b/src/h2/stream.py index 3f6c97cd1..50c2f6024 100644 --- a/src/h2/stream.py +++ b/src/h2/stream.py @@ -232,9 +232,7 @@ def window_updated(self, previous_state: StreamState) -> list[Event]: """ Fires when a window update frame is received. """ - event = WindowUpdated() - event.stream_id = self.stream_id - return [event] + return [WindowUpdated(stream_id=self.stream_id)] def stream_half_closed(self, previous_state: StreamState) -> list[Event]: """ diff --git a/tests/test_events.py b/tests/test_events.py index aac913586..3c4f3cefd 100644 --- a/tests/test_events.py +++ b/tests/test_events.py @@ -186,9 +186,7 @@ def test_windowupdated_repr(self) -> None: """ WindowUpdated has a useful debug representation. """ - e = h2.events.WindowUpdated() - e.stream_id = 0 - e.delta = 2**16 + e = h2.events.WindowUpdated(stream_id=0, delta=2**16) assert repr(e) == "" From 11a01a3b83084bd702e073e167cad5f77cc81acb Mon Sep 17 00:00:00 2001 From: Maximilian Hils Date: Wed, 5 Feb 2025 11:57:02 +0100 Subject: [PATCH 05/30] ignore kw_only on Python 3.9 --- src/h2/events.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/h2/events.py b/src/h2/events.py index 9a852e8ac..ce1730436 100644 --- a/src/h2/events.py +++ b/src/h2/events.py @@ -11,6 +11,7 @@ from __future__ import annotations import binascii +import sys from dataclasses import dataclass from typing import TYPE_CHECKING @@ -23,6 +24,12 @@ from .errors import ErrorCodes +if sys.version_info < (3, 10): # pragma: no cover + kw_only: dict[str, bool] = {} +else: # pragma: no cover + kw_only = {"kw_only": True} + + class Event: """ Base class for h2 events. @@ -293,7 +300,7 @@ def __repr__(self) -> str: ) -@dataclass(kw_only=True) +@dataclass(**kw_only) class WindowUpdated(Event): """ The WindowUpdated event is fired whenever a flow control window changes From 8d64bfc4087564b36272b22fee5ff0a11f5895e7 Mon Sep 17 00:00:00 2001 From: Maximilian Hils Date: Fri, 7 Feb 2025 18:55:41 +0100 Subject: [PATCH 06/30] convert `events.StreamReset` into a dataclass --- src/h2/events.py | 21 +++++++++++++-------- src/h2/stream.py | 32 ++++++++++++++++---------------- tests/test_events.py | 9 +++++---- 3 files changed, 34 insertions(+), 28 deletions(-) diff --git a/src/h2/events.py b/src/h2/events.py index ce1730436..c4815cc71 100644 --- a/src/h2/events.py +++ b/src/h2/events.py @@ -431,6 +431,7 @@ def __repr__(self) -> str: return f"" +@dataclass(**kw_only) class StreamReset(Event): """ The StreamReset event is fired in two situations. The first is when the @@ -442,16 +443,20 @@ class StreamReset(Event): This event is now fired when h2 automatically resets a stream. """ - def __init__(self) -> None: - #: The Stream ID of the stream that was reset. - self.stream_id: int | None = None + stream_id: int + """ + The Stream ID of the stream that was reset. + """ - #: The error code given. Either one of :class:`ErrorCodes - #: ` or ``int`` - self.error_code: ErrorCodes | int | None = None + error_code: ErrorCodes | int = _LAZY_INIT + """ + The error code given. + """ - #: Whether the remote peer sent a RST_STREAM or we did. - self.remote_reset = True + remote_reset: bool = True + """ + Whether the remote peer sent a RST_STREAM or we did. + """ def __repr__(self) -> str: return f"" diff --git a/src/h2/stream.py b/src/h2/stream.py index 50c2f6024..0a231ad08 100644 --- a/src/h2/stream.py +++ b/src/h2/stream.py @@ -257,9 +257,7 @@ def stream_reset(self, previous_state: StreamState) -> list[Event]: Fired when a stream is forcefully reset. """ self.stream_closed_by = StreamClosedBy.RECV_RST_STREAM - event = StreamReset() - event.stream_id = self.stream_id - return [event] + return [StreamReset(stream_id=self.stream_id)] def send_new_pushed_stream(self, previous_state: StreamState) -> list[Event]: """ @@ -342,12 +340,13 @@ def reset_stream_on_error(self, previous_state: StreamState) -> list[Event]: self.stream_closed_by = StreamClosedBy.SEND_RST_STREAM error = StreamClosedError(self.stream_id) - - event = StreamReset() - event.stream_id = self.stream_id - event.error_code = ErrorCodes.STREAM_CLOSED - event.remote_reset = False - error._events = [event] + error._events = [ + StreamReset( + stream_id=self.stream_id, + error_code=ErrorCodes.STREAM_CLOSED, + remote_reset=False, + ), + ] raise error def recv_on_closed_stream(self, previous_state: StreamState) -> list[Event]: @@ -1160,13 +1159,14 @@ def receive_window_update(self, increment: int) -> tuple[list[Frame], list[Event except FlowControlError: # Ok, this is bad. We're going to need to perform a local # reset. - event = StreamReset() - event.stream_id = self.stream_id - event.error_code = ErrorCodes.FLOW_CONTROL_ERROR - event.remote_reset = False - - events = [event] - frames = self.reset_stream(event.error_code) + events = [ + StreamReset( + stream_id=self.stream_id, + error_code=ErrorCodes.FLOW_CONTROL_ERROR, + remote_reset=False, + ), + ] + frames = self.reset_stream(ErrorCodes.FLOW_CONTROL_ERROR) return frames, events diff --git a/tests/test_events.py b/tests/test_events.py index 3c4f3cefd..319d8a658 100644 --- a/tests/test_events.py +++ b/tests/test_events.py @@ -246,10 +246,11 @@ def test_streamreset_repr(self) -> None: """ StreamEnded has a useful debug representation. """ - e = h2.events.StreamReset() - e.stream_id = 919 - e.error_code = h2.errors.ErrorCodes.ENHANCE_YOUR_CALM - e.remote_reset = False + e = h2.events.StreamReset( + stream_id=919, + error_code=h2.errors.ErrorCodes.ENHANCE_YOUR_CALM, + remote_reset=False, + ) if sys.version_info >= (3, 11): assert repr(e) == ( From e40bfe74d6891fef2873ca85aed12885a450a3bc Mon Sep 17 00:00:00 2001 From: Maximilian Hils Date: Fri, 7 Feb 2025 19:04:57 +0100 Subject: [PATCH 07/30] convert `events.DataReceived` into a dataclass --- src/h2/events.py | 47 ++++++++++++++++++++++++++++---------------- src/h2/stream.py | 3 +-- tests/test_events.py | 9 +++++---- 3 files changed, 36 insertions(+), 23 deletions(-) diff --git a/src/h2/events.py b/src/h2/events.py index c4815cc71..add38af0a 100644 --- a/src/h2/events.py +++ b/src/h2/events.py @@ -13,7 +13,7 @@ import binascii import sys from dataclasses import dataclass -from typing import TYPE_CHECKING +from typing import Any, TYPE_CHECKING from .settings import ChangedSetting, SettingCodes, Settings, _setting_code_from_int @@ -30,6 +30,15 @@ kw_only = {"kw_only": True} +_LAZY_INIT: Any = object() +""" +Some h2 events are instantiated by the state machine, but its attributes are +subsequently populated by H2Stream. To make this work with strict type annotations +on the events, they are temporarily set to this placeholder value. +This value should never be exposed to users. +""" + + class Event: """ Base class for h2 events. @@ -258,6 +267,7 @@ def __repr__(self) -> str: return f"" +@dataclass(**kw_only) class DataReceived(Event): """ The DataReceived event is fired whenever data is received on a stream from @@ -268,25 +278,28 @@ class DataReceived(Event): Added ``stream_ended`` property. """ - def __init__(self) -> None: - #: The Stream ID for the stream this data was received on. - self.stream_id: int | None = None + stream_id: int + """The Stream ID for the stream this data was received on.""" + + data: bytes = _LAZY_INIT + """The data itself.""" - #: The data itself. - self.data: bytes | None = None + flow_controlled_length: int = _LAZY_INIT + """ + The amount of data received that counts against the flow control + window. Note that padding counts against the flow control window, so + when adjusting flow control you should always use this field rather + than ``len(data)``. + """ - #: The amount of data received that counts against the flow control - #: window. Note that padding counts against the flow control window, so - #: when adjusting flow control you should always use this field rather - #: than ``len(data)``. - self.flow_controlled_length: int | None = None + stream_ended: StreamEnded | None = None + """ + If this data chunk also completed the stream, the associated + :class:`StreamEnded ` event will be available + here. - #: If this data chunk also completed the stream, the associated - #: :class:`StreamEnded ` event will be available - #: here. - #: - #: .. versionadded:: 2.4.0 - self.stream_ended: StreamEnded | None = None + .. versionadded:: 2.4.0 + """ def __repr__(self) -> str: return ( diff --git a/src/h2/stream.py b/src/h2/stream.py index 0a231ad08..c1715cbaa 100644 --- a/src/h2/stream.py +++ b/src/h2/stream.py @@ -224,8 +224,7 @@ def data_received(self, previous_state: StreamState) -> list[Event]: if not self.headers_received: msg = "cannot receive data before headers" raise ProtocolError(msg) - event = DataReceived() - event.stream_id = self.stream_id + event = DataReceived(stream_id=self.stream_id) return [event] def window_updated(self, previous_state: StreamState) -> list[Event]: diff --git a/tests/test_events.py b/tests/test_events.py index 319d8a658..27efe5b48 100644 --- a/tests/test_events.py +++ b/tests/test_events.py @@ -172,10 +172,11 @@ def test_datareceived_repr(self) -> None: """ DataReceived has a useful debug representation. """ - e = h2.events.DataReceived() - e.stream_id = 888 - e.data = b"abcdefghijklmnopqrstuvwxyz" - e.flow_controlled_length = 88 + e = h2.events.DataReceived( + stream_id=888, + data=b"abcdefghijklmnopqrstuvwxyz", + flow_controlled_length=88, + ) assert repr(e) == ( " Date: Fri, 7 Feb 2025 23:42:03 +0100 Subject: [PATCH 08/30] convert `events.TrailerReceived` into a dataclass --- src/h2/events.py | 38 +++++++++++++++++++++----------------- src/h2/stream.py | 2 +- tests/test_events.py | 4 +--- 3 files changed, 23 insertions(+), 21 deletions(-) diff --git a/src/h2/events.py b/src/h2/events.py index add38af0a..c5d0708e3 100644 --- a/src/h2/events.py +++ b/src/h2/events.py @@ -13,7 +13,7 @@ import binascii import sys from dataclasses import dataclass -from typing import Any, TYPE_CHECKING +from typing import TYPE_CHECKING, Any from .settings import ChangedSetting, SettingCodes, Settings, _setting_code_from_int @@ -128,6 +128,7 @@ def __repr__(self) -> str: return f"" +@dataclass(**kw_only) class TrailersReceived(Event): """ The TrailersReceived event is fired whenever trailers are received on a @@ -145,25 +146,28 @@ class TrailersReceived(Event): Added ``stream_ended`` and ``priority_updated`` properties. """ - def __init__(self) -> None: - #: The Stream ID for the stream on which these trailers were received. - self.stream_id: int | None = None + stream_id: int + """The Stream ID for the stream on which these trailers were received.""" - #: The trailers themselves. - self.headers: list[Header] | None = None + headers: list[Header] = _LAZY_INIT + """The trailers themselves.""" - #: Trailers always end streams. This property has the associated - #: :class:`StreamEnded ` in it. - #: - #: .. versionadded:: 2.4.0 - self.stream_ended: StreamEnded | None = None + stream_ended: StreamEnded | None = None + """ + Trailers always end streams. This property has the associated + :class:`StreamEnded ` in it. - #: If the trailers also set associated priority information, the - #: associated :class:`PriorityUpdated ` - #: event will be available here. - #: - #: .. versionadded:: 2.4.0 - self.priority_updated: PriorityUpdated | None = None + .. versionadded:: 2.4.0 + """ + + priority_updated: PriorityUpdated | None = None + """ + If the trailers also set associated priority information, the + associated :class:`PriorityUpdated ` + event will be available here. + + .. versionadded:: 2.4.0 + """ def __repr__(self) -> str: return f"" diff --git a/src/h2/stream.py b/src/h2/stream.py index c1715cbaa..1c42f0c17 100644 --- a/src/h2/stream.py +++ b/src/h2/stream.py @@ -212,7 +212,7 @@ def response_received(self, previous_state: StreamState) -> list[Event]: else: assert not self.trailers_received self.trailers_received = True - event = TrailersReceived() + event = TrailersReceived(stream_id=self.stream_id) event.stream_id = self.stream_id return [event] diff --git a/tests/test_events.py b/tests/test_events.py index 27efe5b48..9dfe3abc6 100644 --- a/tests/test_events.py +++ b/tests/test_events.py @@ -144,9 +144,7 @@ def test_trailersreceived_repr(self) -> None: """ TrailersReceived has a useful debug representation. """ - e = h2.events.TrailersReceived() - e.stream_id = 62 - e.headers = self.example_response_headers + e = h2.events.TrailersReceived(stream_id=62, headers=self.example_response_headers) assert repr(e) == ( " Date: Fri, 7 Feb 2025 23:47:38 +0100 Subject: [PATCH 09/30] convert `events.StreamEnded` into a dataclass --- src/h2/events.py | 6 +++--- src/h2/stream.py | 6 ++---- tests/test_events.py | 3 +-- 3 files changed, 6 insertions(+), 9 deletions(-) diff --git a/src/h2/events.py b/src/h2/events.py index c5d0708e3..65d69d14d 100644 --- a/src/h2/events.py +++ b/src/h2/events.py @@ -433,6 +433,7 @@ def __repr__(self) -> str: return f"" +@dataclass(**kw_only) class StreamEnded(Event): """ The StreamEnded event is fired whenever a stream is ended by a remote @@ -440,9 +441,8 @@ class StreamEnded(Event): locally, but no further data or headers should be expected on that stream. """ - def __init__(self) -> None: - #: The Stream ID of the stream that was closed. - self.stream_id: int | None = None + stream_id: int + """The Stream ID of the stream that was closed.""" def __repr__(self) -> str: return f"" diff --git a/src/h2/stream.py b/src/h2/stream.py index 1c42f0c17..91263b7b7 100644 --- a/src/h2/stream.py +++ b/src/h2/stream.py @@ -238,8 +238,7 @@ def stream_half_closed(self, previous_state: StreamState) -> list[Event]: Fires when an END_STREAM flag is received in the OPEN state, transitioning this stream to a HALF_CLOSED_REMOTE state. """ - event = StreamEnded() - event.stream_id = self.stream_id + event = StreamEnded(stream_id=self.stream_id) return [event] def stream_ended(self, previous_state: StreamState) -> list[Event]: @@ -247,8 +246,7 @@ def stream_ended(self, previous_state: StreamState) -> list[Event]: Fires when a stream is cleanly ended. """ self.stream_closed_by = StreamClosedBy.RECV_END_STREAM - event = StreamEnded() - event.stream_id = self.stream_id + event = StreamEnded(stream_id=self.stream_id) return [event] def stream_reset(self, previous_state: StreamState) -> list[Event]: diff --git a/tests/test_events.py b/tests/test_events.py index 9dfe3abc6..17136aa12 100644 --- a/tests/test_events.py +++ b/tests/test_events.py @@ -236,8 +236,7 @@ def test_streamended_repr(self) -> None: """ StreamEnded has a useful debug representation. """ - e = h2.events.StreamEnded() - e.stream_id = 99 + e = h2.events.StreamEnded(stream_id=99) assert repr(e) == "" From f7c4c530b4f9e9cf44f79e611828a8b43f7c99e9 Mon Sep 17 00:00:00 2001 From: Maximilian Hils Date: Fri, 7 Feb 2025 23:53:49 +0100 Subject: [PATCH 10/30] convert `events.UnknownFrame` into a dataclass --- src/h2/connection.py | 3 +-- src/h2/events.py | 5 ++--- tests/test_events.py | 3 ++- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/src/h2/connection.py b/src/h2/connection.py index d9ff0862f..5dad57da7 100644 --- a/src/h2/connection.py +++ b/src/h2/connection.py @@ -1972,8 +1972,7 @@ def _receive_unknown_frame(self, frame: ExtensionFrame) -> tuple[list[Frame], li self.config.logger.debug( "Received unknown extension frame (ID %d)", frame.stream_id, ) - event = UnknownFrameReceived() - event.frame = frame + event = UnknownFrameReceived(frame=frame) return [], [event] def _local_settings_acked(self) -> dict[SettingCodes | int, ChangedSetting]: diff --git a/src/h2/events.py b/src/h2/events.py index 65d69d14d..e3364e949 100644 --- a/src/h2/events.py +++ b/src/h2/events.py @@ -635,6 +635,7 @@ def __repr__(self) -> str: ) +@dataclass(**kw_only) class UnknownFrameReceived(Event): """ The UnknownFrameReceived event is fired when the remote peer sends a frame @@ -650,9 +651,7 @@ class UnknownFrameReceived(Event): .. versionadded:: 2.7.0 """ - def __init__(self) -> None: - #: The hyperframe Frame object that encapsulates the received frame. - self.frame: Frame | None = None + frame: Frame def __repr__(self) -> str: return "" diff --git a/tests/test_events.py b/tests/test_events.py index 17136aa12..1eacb3acd 100644 --- a/tests/test_events.py +++ b/tests/test_events.py @@ -7,6 +7,7 @@ import inspect import sys +import hyperframe.frame import pytest from hypothesis import given from hypothesis.strategies import integers, lists, tuples @@ -360,7 +361,7 @@ def test_unknownframereceived_repr(self) -> None: """ UnknownFrameReceived has a useful debug representation. """ - e = h2.events.UnknownFrameReceived() + e = h2.events.UnknownFrameReceived(frame=hyperframe.frame.Frame(1)) assert repr(e) == "" From 68925801ae322ae6553126be15a8e1ff1b3902cb Mon Sep 17 00:00:00 2001 From: Maximilian Hils Date: Sat, 8 Feb 2025 00:01:10 +0100 Subject: [PATCH 11/30] convert `events.RequestReceived` into a dataclass --- src/h2/events.py | 39 +++++++++++++++++++++------------------ src/h2/stream.py | 3 +-- tests/test_events.py | 7 ++++--- 3 files changed, 26 insertions(+), 23 deletions(-) diff --git a/src/h2/events.py b/src/h2/events.py index e3364e949..f12c04288 100644 --- a/src/h2/events.py +++ b/src/h2/events.py @@ -45,7 +45,7 @@ class Event: """ - +@dataclass(**kw_only) class RequestReceived(Event): """ The RequestReceived event is fired whenever all of a request's headers @@ -64,26 +64,29 @@ class RequestReceived(Event): Added ``stream_ended`` and ``priority_updated`` properties. """ - def __init__(self) -> None: - #: The Stream ID for the stream this request was made on. - self.stream_id: int | None = None + stream_id: int + """The Stream ID for the stream this request was made on.""" - #: The request headers. - self.headers: list[Header] | None = None + headers: list[Header] = _LAZY_INIT + """The request headers.""" - #: If this request also ended the stream, the associated - #: :class:`StreamEnded ` event will be available - #: here. - #: - #: .. versionadded:: 2.4.0 - self.stream_ended: StreamEnded | None = None + stream_ended: StreamEnded | None = None + """ + If this request also ended the stream, the associated + :class:`StreamEnded ` event will be available + here. - #: If this request also had associated priority information, the - #: associated :class:`PriorityUpdated ` - #: event will be available here. - #: - #: .. versionadded:: 2.4.0 - self.priority_updated: PriorityUpdated | None = None + .. versionadded:: 2.4.0 + """ + + priority_updated: PriorityUpdated | None = None + """ + If this request also had associated priority information, the + associated :class:`PriorityUpdated ` + event will be available here. + + .. versionadded:: 2.4.0 + """ def __repr__(self) -> str: return f"" diff --git a/src/h2/stream.py b/src/h2/stream.py index 91263b7b7..b047a3d39 100644 --- a/src/h2/stream.py +++ b/src/h2/stream.py @@ -195,8 +195,7 @@ def request_received(self, previous_state: StreamState) -> list[Event]: self.client = False self.headers_received = True - event = RequestReceived() - event.stream_id = self.stream_id + event = RequestReceived(stream_id=self.stream_id) return [event] def response_received(self, previous_state: StreamState) -> list[Event]: diff --git a/tests/test_events.py b/tests/test_events.py index 1eacb3acd..7e9764ba7 100644 --- a/tests/test_events.py +++ b/tests/test_events.py @@ -115,9 +115,10 @@ def test_requestreceived_repr(self) -> None: """ RequestReceived has a useful debug representation. """ - e = h2.events.RequestReceived() - e.stream_id = 5 - e.headers = self.example_request_headers + e = h2.events.RequestReceived( + stream_id=5, + headers=self.example_request_headers + ) assert repr(e) == ( " Date: Sat, 8 Feb 2025 00:03:15 +0100 Subject: [PATCH 12/30] convert `events.ResponseReceived` into a dataclass --- src/h2/events.py | 38 +++++++++++++++++++++----------------- src/h2/stream.py | 2 +- tests/test_events.py | 7 ++++--- 3 files changed, 26 insertions(+), 21 deletions(-) diff --git a/src/h2/events.py b/src/h2/events.py index f12c04288..162b99c34 100644 --- a/src/h2/events.py +++ b/src/h2/events.py @@ -92,6 +92,7 @@ def __repr__(self) -> str: return f"" +@dataclass(**kw_only) class ResponseReceived(Event): """ The ResponseReceived event is fired whenever response headers are received. @@ -106,26 +107,29 @@ class ResponseReceived(Event): Added ``stream_ended`` and ``priority_updated`` properties. """ - def __init__(self) -> None: - #: The Stream ID for the stream this response was made on. - self.stream_id: int | None = None + stream_id: int + """The Stream ID for the stream this response was made on.""" - #: The response headers. - self.headers: list[Header] | None = None + headers: list[Header] = _LAZY_INIT + """The response headers.""" - #: If this response also ended the stream, the associated - #: :class:`StreamEnded ` event will be available - #: here. - #: - #: .. versionadded:: 2.4.0 - self.stream_ended: StreamEnded | None = None + stream_ended: StreamEnded | None = None + """ + If this response also ended the stream, the associated + :class:`StreamEnded ` event will be available + here. - #: If this response also had associated priority information, the - #: associated :class:`PriorityUpdated ` - #: event will be available here. - #: - #: .. versionadded:: 2.4.0 - self.priority_updated: PriorityUpdated | None = None + .. versionadded:: 2.4.0 + """ + + priority_updated: PriorityUpdated | None = None + """ + If this response also had associated priority information, the + associated :class:`PriorityUpdated ` + event will be available here. + + .. versionadded:: 2.4.0 + """ def __repr__(self) -> str: return f"" diff --git a/src/h2/stream.py b/src/h2/stream.py index b047a3d39..0fae6eb0f 100644 --- a/src/h2/stream.py +++ b/src/h2/stream.py @@ -207,7 +207,7 @@ def response_received(self, previous_state: StreamState) -> list[Event]: if not self.headers_received: assert self.client is True self.headers_received = True - event = ResponseReceived() + event = ResponseReceived(stream_id=self.stream_id) else: assert not self.trailers_received self.trailers_received = True diff --git a/tests/test_events.py b/tests/test_events.py index 7e9764ba7..24f801f97 100644 --- a/tests/test_events.py +++ b/tests/test_events.py @@ -132,9 +132,10 @@ def test_responsereceived_repr(self) -> None: """ ResponseReceived has a useful debug representation. """ - e = h2.events.ResponseReceived() - e.stream_id = 500 - e.headers = self.example_response_headers + e = h2.events.ResponseReceived( + stream_id=500, + headers=self.example_response_headers, + ) assert repr(e) == ( " Date: Sat, 8 Feb 2025 00:05:43 +0100 Subject: [PATCH 13/30] convert `events.InformationalResponseReceived` into a dataclass --- src/h2/events.py | 26 +++++++++++++------------- src/h2/stream.py | 4 +--- tests/test_events.py | 7 ++++--- 3 files changed, 18 insertions(+), 19 deletions(-) diff --git a/src/h2/events.py b/src/h2/events.py index 162b99c34..c7d7f32f5 100644 --- a/src/h2/events.py +++ b/src/h2/events.py @@ -235,7 +235,7 @@ class _PushedRequestSent(_HeadersSent): """ - +@dataclass(**kw_only) class InformationalResponseReceived(Event): """ The InformationalResponseReceived event is fired when an informational @@ -259,20 +259,20 @@ class InformationalResponseReceived(Event): Added ``priority_updated`` property. """ - def __init__(self) -> None: - #: The Stream ID for the stream this informational response was made - #: on. - self.stream_id: int | None = None + stream_id: int + """The Stream ID for the stream this informational response was made on.""" - #: The headers for this informational response. - self.headers: list[Header] | None = None + headers: list[Header] = _LAZY_INIT + """The headers for this informational response.""" - #: If this response also had associated priority information, the - #: associated :class:`PriorityUpdated ` - #: event will be available here. - #: - #: .. versionadded:: 2.4.0 - self.priority_updated: PriorityUpdated | None = None + priority_updated: PriorityUpdated | None = None + """ + If this response also had associated priority information, the + associated :class:`PriorityUpdated ` + event will be available here. + + .. versionadded:: 2.4.0 + """ def __repr__(self) -> str: return f"" diff --git a/src/h2/stream.py b/src/h2/stream.py index 0fae6eb0f..d102b056c 100644 --- a/src/h2/stream.py +++ b/src/h2/stream.py @@ -424,9 +424,7 @@ def recv_informational_response(self, previous_state: StreamState) -> list[Event msg = "Informational response after final response" raise ProtocolError(msg) - event = InformationalResponseReceived() - event.stream_id = self.stream_id - return [event] + return [InformationalResponseReceived(stream_id=self.stream_id)] def recv_alt_svc(self, previous_state: StreamState) -> list[Event]: """ diff --git a/tests/test_events.py b/tests/test_events.py index 24f801f97..904578adb 100644 --- a/tests/test_events.py +++ b/tests/test_events.py @@ -159,9 +159,10 @@ def test_informationalresponsereceived_repr(self) -> None: """ InformationalResponseReceived has a useful debug representation. """ - e = h2.events.InformationalResponseReceived() - e.stream_id = 62 - e.headers = self.example_informational_headers + e = h2.events.InformationalResponseReceived( + stream_id=62, + headers=self.example_informational_headers, + ) assert repr(e) == ( " Date: Sat, 8 Feb 2025 00:08:32 +0100 Subject: [PATCH 14/30] convert ping events into a dataclasses --- src/h2/connection.py | 5 ++--- src/h2/events.py | 12 ++++++------ tests/test_events.py | 6 ++---- 3 files changed, 10 insertions(+), 13 deletions(-) diff --git a/src/h2/connection.py b/src/h2/connection.py index 5dad57da7..aa773e891 100644 --- a/src/h2/connection.py +++ b/src/h2/connection.py @@ -1823,9 +1823,9 @@ def _receive_ping_frame(self, frame: PingFrame) -> tuple[list[Frame], list[Event evt: PingReceived | PingAckReceived if "ACK" in frame.flags: - evt = PingAckReceived() + evt = PingAckReceived(ping_data=frame.opaque_data) else: - evt = PingReceived() + evt = PingReceived(ping_data=frame.opaque_data) # automatically ACK the PING with the same 'opaque data' f = PingFrame(0) @@ -1833,7 +1833,6 @@ def _receive_ping_frame(self, frame: PingFrame) -> tuple[list[Frame], list[Event f.opaque_data = frame.opaque_data frames.append(f) - evt.ping_data = frame.opaque_data events.append(evt) return frames, events diff --git a/src/h2/events.py b/src/h2/events.py index c7d7f32f5..a68a88ce7 100644 --- a/src/h2/events.py +++ b/src/h2/events.py @@ -403,6 +403,7 @@ def __repr__(self) -> str: ) +@dataclass(**kw_only) class PingReceived(Event): """ The PingReceived event is fired whenever a PING is received. It contains @@ -412,14 +413,14 @@ class PingReceived(Event): .. versionadded:: 3.1.0 """ - def __init__(self) -> None: - #: The data included on the ping. - self.ping_data: bytes | None = None + ping_data: bytes + """The data included on the ping.""" def __repr__(self) -> str: return f"" +@dataclass(**kw_only) class PingAckReceived(Event): """ The PingAckReceived event is fired whenever a PING acknowledgment is @@ -432,9 +433,8 @@ class PingAckReceived(Event): Removed deprecated but equivalent ``PingAcknowledged``. """ - def __init__(self) -> None: - #: The data included on the ping. - self.ping_data: bytes | None = None + ping_data: bytes + """The data included on the ping.""" def __repr__(self) -> str: return f"" diff --git a/tests/test_events.py b/tests/test_events.py index 904578adb..a43543c86 100644 --- a/tests/test_events.py +++ b/tests/test_events.py @@ -222,8 +222,7 @@ def test_pingreceived_repr(self) -> None: """ PingReceived has a useful debug representation. """ - e = h2.events.PingReceived() - e.ping_data = b"abcdefgh" + e = h2.events.PingReceived(ping_data=b"abcdefgh") assert repr(e) == "" @@ -231,8 +230,7 @@ def test_pingackreceived_repr(self) -> None: """ PingAckReceived has a useful debug representation. """ - e = h2.events.PingAckReceived() - e.ping_data = b"abcdefgh" + e = h2.events.PingAckReceived(ping_data=b"abcdefgh") assert repr(e) == "" From f39dd8971ea36269e972e5eb851bc3f2efa97802 Mon Sep 17 00:00:00 2001 From: Maximilian Hils Date: Sun, 9 Feb 2025 19:07:54 +0100 Subject: [PATCH 15/30] fixup `events.WindowUpdated.delta` type annotation --- src/h2/events.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/h2/events.py b/src/h2/events.py index a68a88ce7..6aab0713d 100644 --- a/src/h2/events.py +++ b/src/h2/events.py @@ -340,7 +340,7 @@ class WindowUpdated(Event): May be ``0`` if the connection window was changed. """ - delta: int | None = None + delta: int = _LAZY_INIT """ The window delta. """ From 1ed6c610e02cde7eb3a8555a76da94ccfddd59d1 Mon Sep 17 00:00:00 2001 From: Maximilian Hils Date: Mon, 10 Feb 2025 15:50:35 +0100 Subject: [PATCH 16/30] add changelog entry --- CHANGELOG.rst | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index a4175f799..9aad5387f 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -8,6 +8,11 @@ dev - +**API Changes (Backward Compatible)** + +- h2 events now have tighter type bounds, e.g. `stream_id` is guaranteed to not be `None` for most events now. + This simplifies downstream type checking. + **Bugfixes** - From 500a3488d4920d051a5851bc4555dc35f1a64bed Mon Sep 17 00:00:00 2001 From: Thomas Kriechbaumer Date: Thu, 13 Feb 2025 22:09:46 +0100 Subject: [PATCH 17/30] fix error value when opening a new stream --- src/h2/connection.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/h2/connection.py b/src/h2/connection.py index aa773e891..313efc146 100644 --- a/src/h2/connection.py +++ b/src/h2/connection.py @@ -793,8 +793,9 @@ def send_headers(self, # Check we can open the stream. if stream_id not in self.streams: max_open_streams = self.remote_settings.max_concurrent_streams - if (self.open_outbound_streams + 1) > max_open_streams: - msg = f"Max outbound streams is {max_open_streams}, {self.open_outbound_streams} open" + value = self.open_outbound_streams # take a copy due to the property accessor having side affects + if (value + 1) > max_open_streams: + msg = f"Max outbound streams is {max_open_streams}, {value} open" raise TooManyStreamsError(msg) self.state_machine.process_input(ConnectionInputs.SEND_HEADERS) @@ -1593,8 +1594,9 @@ def _receive_headers_frame(self, frame: HeadersFrame) -> tuple[list[Frame], list # stream ID is valid. if frame.stream_id not in self.streams: max_open_streams = self.local_settings.max_concurrent_streams - if (self.open_inbound_streams + 1) > max_open_streams: - msg = f"Max outbound streams is {max_open_streams}, {self.open_outbound_streams} open" + value = self.open_inbound_streams # take a copy due to the property accessor having side affects + if (value + 1) > max_open_streams: + msg = f"Max inbound streams is {max_open_streams}, {value} open" raise TooManyStreamsError(msg) # Let's decode the headers. We handle headers as bytes internally up From 42ee8d75777bdd227aa95226dd039604e96e1703 Mon Sep 17 00:00:00 2001 From: deedy5 <65482418+deedy5@users.noreply.github.com> Date: Wed, 26 Feb 2025 20:46:57 +0300 Subject: [PATCH 18/30] perf(FrameBuffer.data): use bytearray() --- src/h2/frame_buffer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/h2/frame_buffer.py b/src/h2/frame_buffer.py index 30d96e816..e87e03092 100644 --- a/src/h2/frame_buffer.py +++ b/src/h2/frame_buffer.py @@ -30,7 +30,7 @@ class FrameBuffer: """ def __init__(self, server: bool = False) -> None: - self.data = b"" + self.data = bytearray() self.max_frame_size = 0 self._preamble = b"PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n" if server else b"" self._preamble_len = len(self._preamble) From 191ac06e0949fcfe3367b06eeb101a5a1a335964 Mon Sep 17 00:00:00 2001 From: deedy5 <65482418+deedy5@users.noreply.github.com> Date: Wed, 26 Feb 2025 22:45:48 +0300 Subject: [PATCH 19/30] refactor(FrameBuffer): rename self.data to self._data --- src/h2/frame_buffer.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/h2/frame_buffer.py b/src/h2/frame_buffer.py index e87e03092..e7b0a7126 100644 --- a/src/h2/frame_buffer.py +++ b/src/h2/frame_buffer.py @@ -30,7 +30,7 @@ class FrameBuffer: """ def __init__(self, server: bool = False) -> None: - self.data = bytearray() + self._data = bytearray() self.max_frame_size = 0 self._preamble = b"PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n" if server else b"" self._preamble_len = len(self._preamble) @@ -54,7 +54,7 @@ def add_data(self, data: bytes) -> None: self._preamble_len -= of_which_preamble self._preamble = self._preamble[of_which_preamble:] - self.data += data + self._data += data def _validate_frame_length(self, length: int) -> None: """ @@ -119,18 +119,18 @@ def __iter__(self) -> FrameBuffer: def __next__(self) -> Frame: # First, check that we have enough data to successfully parse the # next frame header. If not, bail. Otherwise, parse it. - if len(self.data) < 9: + if len(self._data) < 9: raise StopIteration try: - f, length = Frame.parse_frame_header(memoryview(self.data[:9])) + f, length = Frame.parse_frame_header(memoryview(self._data[:9])) except (InvalidDataError, InvalidFrameError) as err: # pragma: no cover msg = f"Received frame with invalid header: {err!s}" raise ProtocolError(msg) from err # Next, check that we have enough length to parse the frame body. If # not, bail, leaving the frame header data in the buffer for next time. - if len(self.data) < length + 9: + if len(self._data) < length + 9: raise StopIteration # Confirm the frame has an appropriate length. @@ -138,7 +138,7 @@ def __next__(self) -> Frame: # Try to parse the frame body try: - f.parse_body(memoryview(self.data[9:9+length])) + f.parse_body(memoryview(self._data[9:9+length])) except InvalidDataError as err: msg = "Received frame with non-compliant data" raise ProtocolError(msg) from err @@ -148,7 +148,7 @@ def __next__(self) -> Frame: # At this point, as we know we'll use or discard the entire frame, we # can update the data. - self.data = self.data[9+length:] + self._data = self._data[9+length:] # Pass the frame through the header buffer. new_frame = self._update_header_buffer(f) From 428b0b5d74a3456eb9d5551530284a00cd5dfa2b Mon Sep 17 00:00:00 2001 From: Jan Brasna <1784648+janbrasna@users.noreply.github.com> Date: Wed, 30 Apr 2025 17:04:30 +0200 Subject: [PATCH 20/30] Update README link to https --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 2b221d777..dd01fe3a2 100644 --- a/README.rst +++ b/README.rst @@ -62,7 +62,7 @@ to large feature requests and changes. Before you contribute (either by opening an issue or filing a pull request), please `read the contribution guidelines`_. -.. _read the contribution guidelines: http://python-hyper.org/en/latest/contributing.html +.. _read the contribution guidelines: https://python-hyper.org/en/latest/contributing.html License ======= From 243461d500822d1e78972946ce0dbc5905ab1ad9 Mon Sep 17 00:00:00 2001 From: Jan Brasna <1784648+janbrasna@users.noreply.github.com> Date: Wed, 30 Apr 2025 17:20:03 +0200 Subject: [PATCH 21/30] Create RTD config --- .readthedocs.yaml | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 .readthedocs.yaml diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 000000000..45b3f5527 --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,10 @@ +# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details +version: 2 + +build: + os: ubuntu-22.04 + tools: + python: "3.9" + +sphinx: + configuration: docs/source/conf.py From 492d3db0a206d22ccabb99faeb53924f4af7a982 Mon Sep 17 00:00:00 2001 From: Thomas Kriechbaumer Date: Wed, 30 Apr 2025 22:30:45 +0200 Subject: [PATCH 22/30] Update .readthedocs.yaml --- .readthedocs.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.readthedocs.yaml b/.readthedocs.yaml index 45b3f5527..3480c6fc3 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -4,7 +4,7 @@ version: 2 build: os: ubuntu-22.04 tools: - python: "3.9" + python: "3.13" sphinx: configuration: docs/source/conf.py From 9ce83ff7d77522ed30aea3b052990c43c1218104 Mon Sep 17 00:00:00 2001 From: Thomas Kriechbaumer Date: Wed, 30 Apr 2025 22:47:24 +0200 Subject: [PATCH 23/30] exclude RDT from sdist --- MANIFEST.in | 1 + 1 file changed, 1 insertion(+) diff --git a/MANIFEST.in b/MANIFEST.in index 25d6814dc..ff2f15801 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -11,3 +11,4 @@ recursive-include examples *.py *.crt *.key *.pem *.csr include README.rst LICENSE CHANGELOG.rst pyproject.toml global-exclude *.pyc *.pyo *.swo *.swp *.map *.yml *.DS_Store +exclude .readthedocs.yaml From ea3140f484a6646ec09abccd0d3c7e955bce4f4c Mon Sep 17 00:00:00 2001 From: Thomas Kriechbaumer Date: Wed, 30 Apr 2025 23:04:21 +0200 Subject: [PATCH 24/30] cleanup --- pyproject.toml | 3 --- 1 file changed, 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 9035d3955..694f71c3b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -183,9 +183,6 @@ python = """ """ [tool.tox.env_run_base] -pass_env = [ - "GITHUB_*", -] dependency_groups = ["testing"] commands = [ ["python", "-bb", "-m", "pytest", "--cov-report=xml", "--cov-report=term", "--cov=h2", { replace = "posargs", extend = true }] From bbd3d90c66cce51fbbe6b9af75cb0bef7004f9d5 Mon Sep 17 00:00:00 2001 From: Jan Brasna <1784648+janbrasna@users.noreply.github.com> Date: Fri, 25 Apr 2025 18:46:56 +0200 Subject: [PATCH 25/30] fix(packaging): bump twine to pass meta check wildcard bugs --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 694f71c3b..0830a91d4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -73,7 +73,7 @@ packaging = [ "check-manifest==0.50", "readme-renderer==44.0", "build>=1.2.2,<2", - "twine>=5.1.1,<6", + "twine>=6.1.0,<7", "wheel>=0.45.0,<1", ] From 0583911b29d05764bbe3f7691d59f4d5e83e249b Mon Sep 17 00:00:00 2001 From: Jan Brasna <1784648+janbrasna@users.noreply.github.com> Date: Fri, 25 Apr 2025 18:41:26 +0200 Subject: [PATCH 26/30] lint: fix TC006 --- src/h2/stream.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/h2/stream.py b/src/h2/stream.py index d102b056c..d6f5845c3 100644 --- a/src/h2/stream.py +++ b/src/h2/stream.py @@ -1037,7 +1037,7 @@ def receive_push_promise_in_band(self, events = self.state_machine.process_input( StreamInputs.RECV_PUSH_PROMISE, ) - push_event = cast(PushedStreamReceived, events[0]) + push_event = cast("PushedStreamReceived", events[0]) push_event.pushed_stream_id = promised_stream_id hdr_validation_flags = self._build_hdr_validation_flags(events) @@ -1076,7 +1076,7 @@ def receive_headers(self, events = self.state_machine.process_input(input_) headers_event = cast( - Union[RequestReceived, ResponseReceived, TrailersReceived, InformationalResponseReceived], + "Union[RequestReceived, ResponseReceived, TrailersReceived, InformationalResponseReceived]", events[0], ) @@ -1086,9 +1086,9 @@ def receive_headers(self, ) # We ensured it's not an information response at the beginning of the method. cast( - Union[RequestReceived, ResponseReceived, TrailersReceived], + "Union[RequestReceived, ResponseReceived, TrailersReceived]", headers_event, - ).stream_ended = cast(StreamEnded, es_events[0]) + ).stream_ended = cast("StreamEnded", es_events[0]) events += es_events self._initialize_content_length(headers) @@ -1112,7 +1112,7 @@ def receive_data(self, data: bytes, end_stream: bool, flow_control_len: int) -> "set to %d", self, end_stream, flow_control_len, ) events = self.state_machine.process_input(StreamInputs.RECV_DATA) - data_event = cast(DataReceived, events[0]) + data_event = cast("DataReceived", events[0]) self._inbound_window_manager.window_consumed(flow_control_len) self._track_content_length(len(data), end_stream) @@ -1120,7 +1120,7 @@ def receive_data(self, data: bytes, end_stream: bool, flow_control_len: int) -> es_events = self.state_machine.process_input( StreamInputs.RECV_END_STREAM, ) - data_event.stream_ended = cast(StreamEnded, es_events[0]) + data_event.stream_ended = cast("StreamEnded", es_events[0]) events.extend(es_events) data_event.data = data @@ -1144,7 +1144,7 @@ def receive_window_update(self, increment: int) -> tuple[list[Frame], list[Event # this should be treated as a *stream* error, not a *connection* error. # That means we need to catch the error and forcibly close the stream. if events: - cast(WindowUpdated, events[0]).delta = increment + cast("WindowUpdated", events[0]).delta = increment try: self.outbound_flow_control_window = guard_increment_window( self.outbound_flow_control_window, @@ -1228,7 +1228,7 @@ def stream_reset(self, frame: RstStreamFrame) -> tuple[list[Frame], list[Event]] if events: # We don't fire an event if this stream is already closed. - cast(StreamReset, events[0]).error_code = _error_code_from_int(frame.error_code) + cast("StreamReset", events[0]).error_code = _error_code_from_int(frame.error_code) return [], events From 883ed37be42592b2f0aa0caddab6ca5e3d668fa3 Mon Sep 17 00:00:00 2001 From: Thomas Kriechbaumer Date: Mon, 18 Aug 2025 22:46:12 +0200 Subject: [PATCH 27/30] reject header names and values containing unpermitted characters `\r`, `\n`, or `\0x00` --- CHANGELOG.rst | 2 +- src/h2/utilities.py | 25 +++++++++++++++++++++++++ tests/test_invalid_headers.py | 8 +++++++- 3 files changed, 33 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 9aad5387f..1a2deb8d5 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -6,7 +6,7 @@ dev **API Changes (Backward Incompatible)** -- +- Reject header names and values containing unpermitted characters `\r`, `\n`, or `\0x00`. **API Changes (Backward Compatible)** diff --git a/src/h2/utilities.py b/src/h2/utilities.py index 8cafdbd50..b874aa8fe 100644 --- a/src/h2/utilities.py +++ b/src/h2/utilities.py @@ -24,6 +24,12 @@ SIGIL = ord(b":") INFORMATIONAL_START = ord(b"1") +HEADER_UNPERMITTED_CHARACTERS = frozenset([ + b"\r", + b"\n", + b"\x00", +]) + # A set of headers that are hop-by-hop or connection-specific and thus # forbidden in HTTP/2. This list comes from RFC 7540 § 8.1.2.2. @@ -201,6 +207,9 @@ def validate_headers(headers: Iterable[Header], hdr_validation_flags: HeaderVali # For example, we avoid tuple unpacking in loops because it represents a # fixed cost that we don't want to spend, instead indexing into the header # tuples. + headers = _reject_unpermitted_characters( + headers, hdr_validation_flags, + ) headers = _reject_empty_header_names( headers, hdr_validation_flags, ) @@ -225,6 +234,22 @@ def validate_headers(headers: Iterable[Header], hdr_validation_flags: HeaderVali return _check_path_header(headers, hdr_validation_flags) +def _reject_unpermitted_characters(headers: Iterable[Header], + hdr_validation_flags: HeaderValidationFlags) -> Generator[Header, None, None]: + """ + Raises a ProtocolError if any header names or values contain unpermitted characters. + See RFC 7540, section 10.3 and 8.1.2.6. + """ + for header in headers: + for c in HEADER_UNPERMITTED_CHARACTERS: + if c in header[0]: + msg = f"Unpermitted character '{c}' in header name: {header[0]!r}" + raise ProtocolError(msg) + if c in header[1]: + msg = f"Unpermitted character '{c}' in header value: {header[1]!r}" + raise ProtocolError(msg) + yield header + def _reject_empty_header_names(headers: Iterable[Header], hdr_validation_flags: HeaderValidationFlags) -> Generator[Header, None, None]: diff --git a/tests/test_invalid_headers.py b/tests/test_invalid_headers.py index 192ba10d4..e8f1a13e4 100644 --- a/tests/test_invalid_headers.py +++ b/tests/test_invalid_headers.py @@ -48,6 +48,12 @@ class TestInvalidFrameSequences: [*base_request_headers, ("name ", "name with trailing space")], [*base_request_headers, ("name", " value with leading space")], [*base_request_headers, ("name", "value with trailing space ")], + [*base_request_headers, ("unpermitted-\r-characters", "value")], + [*base_request_headers, ("unpermitted-\n-characters", "value")], + [*base_request_headers, ("unpermitted-\x00-characters", "value")], + [*base_request_headers, ("unpermitted-characters", "some \r value")], + [*base_request_headers, ("unpermitted-characters", "some \n value")], + [*base_request_headers, ("unpermitted-characters", "some \x00 value")], [header for header in base_request_headers if header[0] != ":authority"], [(":protocol", "websocket"), *base_request_headers], @@ -665,7 +671,7 @@ def test_inbound_header_name_length(self, hdr_validation_flags) -> None: def test_inbound_header_name_length_full_frame_decode(self, frame_factory) -> None: f = frame_factory.build_headers_frame([]) - f.data = b"\x00\x00\x05\x00\x00\x00\x00\x04" + f.data = b"\x00\x00\x01\x04" data = f.serialize() c = h2.connection.H2Connection(config=h2.config.H2Configuration(client_side=False)) From 035e9899f95e3709af098f578bfc3cd302298e3a Mon Sep 17 00:00:00 2001 From: Maximilian Hils Date: Tue, 19 Aug 2025 08:35:52 +0200 Subject: [PATCH 28/30] be stricter about which characters to accept for headers This now adheres to the minimal requirements laid out in RFC 9113. We could consider putting additional restrictions on the header value, but in order to keep breakage at a minimum let's do that in a later release if at all. --- src/h2/utilities.py | 41 +++++++++++++++++++++-------------- tests/test_invalid_headers.py | 14 +++++++----- 2 files changed, 33 insertions(+), 22 deletions(-) diff --git a/src/h2/utilities.py b/src/h2/utilities.py index b874aa8fe..1bd48af65 100644 --- a/src/h2/utilities.py +++ b/src/h2/utilities.py @@ -24,12 +24,6 @@ SIGIL = ord(b":") INFORMATIONAL_START = ord(b"1") -HEADER_UNPERMITTED_CHARACTERS = frozenset([ - b"\r", - b"\n", - b"\x00", -]) - # A set of headers that are hop-by-hop or connection-specific and thus # forbidden in HTTP/2. This list comes from RFC 7540 § 8.1.2.2. @@ -207,7 +201,7 @@ def validate_headers(headers: Iterable[Header], hdr_validation_flags: HeaderVali # For example, we avoid tuple unpacking in loops because it represents a # fixed cost that we don't want to spend, instead indexing into the header # tuples. - headers = _reject_unpermitted_characters( + headers = _reject_illegal_characters( headers, hdr_validation_flags, ) headers = _reject_empty_header_names( @@ -234,20 +228,35 @@ def validate_headers(headers: Iterable[Header], hdr_validation_flags: HeaderVali return _check_path_header(headers, hdr_validation_flags) -def _reject_unpermitted_characters(headers: Iterable[Header], - hdr_validation_flags: HeaderValidationFlags) -> Generator[Header, None, None]: +def _reject_illegal_characters(headers: Iterable[Header], + hdr_validation_flags: HeaderValidationFlags) -> Generator[Header, None, None]: """ - Raises a ProtocolError if any header names or values contain unpermitted characters. - See RFC 7540, section 10.3 and 8.1.2.6. + Raises a ProtocolError if any header names or values contain illegal characters. + See RFC 9113, section 8.2.1. """ for header in headers: - for c in HEADER_UNPERMITTED_CHARACTERS: - if c in header[0]: - msg = f"Unpermitted character '{c}' in header name: {header[0]!r}" + # > A field name MUST NOT contain characters in the ranges 0x00-0x20, 0x41-0x5a, + # > or 0x7f-0xff (all ranges inclusive). + for c in header[0]: + if c <= 0x20 or 0x41 <= c <= 0x5a or 0x7f <= c: + msg = f"Illegal character '{chr(c)}' in header name: {header[0]!r}" raise ProtocolError(msg) - if c in header[1]: - msg = f"Unpermitted character '{c}' in header value: {header[1]!r}" + + # > With the exception of pseudo-header fields (Section 8.3), which have a name + # > that starts with a single colon, field names MUST NOT include a colon (ASCII + # > COLON, 0x3a). + if header[0].find(b":", 1) != -1: + msg = f"Illegal character ':' in header name: {header[0]!r}" + raise ProtocolError(msg) + + # > A field value MUST NOT contain the zero value (ASCII NUL, 0x00), line feed + # > (ASCII LF, 0x0a), or carriage return (ASCII CR, 0x0d) at any position. + for c in header[1]: + if c == 0 or c == 0x0a or c == 0x0d: + msg = f"Illegal character '{chr(c)}' in header value: {header[1]!r}" raise ProtocolError(msg) + + # Surrounding whitespace is enforced in `_reject_surrounding_whitespace`. yield header diff --git a/tests/test_invalid_headers.py b/tests/test_invalid_headers.py index e8f1a13e4..96876c6f5 100644 --- a/tests/test_invalid_headers.py +++ b/tests/test_invalid_headers.py @@ -48,12 +48,14 @@ class TestInvalidFrameSequences: [*base_request_headers, ("name ", "name with trailing space")], [*base_request_headers, ("name", " value with leading space")], [*base_request_headers, ("name", "value with trailing space ")], - [*base_request_headers, ("unpermitted-\r-characters", "value")], - [*base_request_headers, ("unpermitted-\n-characters", "value")], - [*base_request_headers, ("unpermitted-\x00-characters", "value")], - [*base_request_headers, ("unpermitted-characters", "some \r value")], - [*base_request_headers, ("unpermitted-characters", "some \n value")], - [*base_request_headers, ("unpermitted-characters", "some \x00 value")], + [*base_request_headers, ("illegal:characters", "value")], + [*base_request_headers, ("illegal-\r-characters", "value")], + [*base_request_headers, ("illegal-\n-characters", "value")], + [*base_request_headers, ("illegal-\x00-characters", "value")], + [*base_request_headers, ("illegal-\x01-characters", "value")], + [*base_request_headers, ("illegal-characters", "some \r value")], + [*base_request_headers, ("illegal-characters", "some \n value")], + [*base_request_headers, ("illegal-characters", "some \x00 value")], [header for header in base_request_headers if header[0] != ":authority"], [(":protocol", "websocket"), *base_request_headers], From 9e4bbed6138c825cd43d519674d00bd267650f30 Mon Sep 17 00:00:00 2001 From: Maximilian Hils Date: Sat, 23 Aug 2025 19:49:53 +0200 Subject: [PATCH 29/30] merge surrounding whitespace and uppercase validators into illegal character validation --- src/h2/utilities.py | 80 ++++++++++++++------------------------------- 1 file changed, 25 insertions(+), 55 deletions(-) diff --git a/src/h2/utilities.py b/src/h2/utilities.py index 1bd48af65..a7409b388 100644 --- a/src/h2/utilities.py +++ b/src/h2/utilities.py @@ -7,8 +7,6 @@ from __future__ import annotations import collections -import re -from string import whitespace from typing import TYPE_CHECKING, Any, NamedTuple from hpack.struct import HeaderTuple, NeverIndexedHeaderTuple @@ -20,7 +18,6 @@ from hpack.struct import Header, HeaderWeaklyTyped -UPPER_RE = re.compile(b"[A-Z]") SIGIL = ord(b":") INFORMATIONAL_START = ord(b"1") @@ -70,9 +67,6 @@ _CONNECT_REQUEST_ONLY_HEADERS = frozenset([b":protocol"]) -_WHITESPACE = frozenset(map(ord, whitespace)) - - def _secure_headers(headers: Iterable[Header], hdr_validation_flags: HeaderValidationFlags | None) -> Generator[Header, None, None]: """ @@ -207,12 +201,6 @@ def validate_headers(headers: Iterable[Header], hdr_validation_flags: HeaderVali headers = _reject_empty_header_names( headers, hdr_validation_flags, ) - headers = _reject_uppercase_header_fields( - headers, hdr_validation_flags, - ) - headers = _reject_surrounding_whitespace( - headers, hdr_validation_flags, - ) headers = _reject_te( headers, hdr_validation_flags, ) @@ -232,13 +220,16 @@ def _reject_illegal_characters(headers: Iterable[Header], hdr_validation_flags: HeaderValidationFlags) -> Generator[Header, None, None]: """ Raises a ProtocolError if any header names or values contain illegal characters. - See RFC 9113, section 8.2.1. + See . """ for header in headers: # > A field name MUST NOT contain characters in the ranges 0x00-0x20, 0x41-0x5a, # > or 0x7f-0xff (all ranges inclusive). for c in header[0]: - if c <= 0x20 or 0x41 <= c <= 0x5a or 0x7f <= c: + if 0x41 <= c <= 0x5a: + msg = f"Received uppercase header name {header[0]!r}." + raise ProtocolError(msg) + if c <= 0x20 or c >= 0x7f: msg = f"Illegal character '{chr(c)}' in header name: {header[0]!r}" raise ProtocolError(msg) @@ -249,14 +240,28 @@ def _reject_illegal_characters(headers: Iterable[Header], msg = f"Illegal character ':' in header name: {header[0]!r}" raise ProtocolError(msg) - # > A field value MUST NOT contain the zero value (ASCII NUL, 0x00), line feed - # > (ASCII LF, 0x0a), or carriage return (ASCII CR, 0x0d) at any position. - for c in header[1]: - if c == 0 or c == 0x0a or c == 0x0d: - msg = f"Illegal character '{chr(c)}' in header value: {header[1]!r}" + # For compatibility with RFC 7230 header fields, we need to allow the field + # value to be an empty string. This is ludicrous, but technically allowed. + if field_value := header[1]: + + # > A field value MUST NOT contain the zero value (ASCII NUL, 0x00), line feed + # > (ASCII LF, 0x0a), or carriage return (ASCII CR, 0x0d) at any position. + for c in field_value: + if c == 0 or c == 0x0a or c == 0x0d: # noqa: PLR1714 + msg = f"Illegal character '{chr(c)}' in header value: {field_value!r}" + raise ProtocolError(msg) + + # > A field value MUST NOT start or end with an ASCII whitespace character + # > (ASCII SP or HTAB, 0x20 or 0x09). + if ( + field_value[0] == 0x20 or + field_value[0] == 0x09 or + field_value[-1] == 0x20 or + field_value[-1] == 0x09 + ): + msg = f"Received header value surrounded by whitespace {field_value!r}" raise ProtocolError(msg) - # Surrounding whitespace is enforced in `_reject_surrounding_whitespace`. yield header @@ -275,41 +280,6 @@ def _reject_empty_header_names(headers: Iterable[Header], yield header -def _reject_uppercase_header_fields(headers: Iterable[Header], - hdr_validation_flags: HeaderValidationFlags) -> Generator[Header, None, None]: - """ - Raises a ProtocolError if any uppercase character is found in a header - block. - """ - for header in headers: - if UPPER_RE.search(header[0]): - msg = f"Received uppercase header name {header[0]!r}." - raise ProtocolError(msg) - yield header - - -def _reject_surrounding_whitespace(headers: Iterable[Header], - hdr_validation_flags: HeaderValidationFlags) -> Generator[Header, None, None]: - """ - Raises a ProtocolError if any header name or value is surrounded by - whitespace characters. - """ - # For compatibility with RFC 7230 header fields, we need to allow the field - # value to be an empty string. This is ludicrous, but technically allowed. - # The field name may not be empty, though, so we can safely assume that it - # must have at least one character in it and throw exceptions if it - # doesn't. - for header in headers: - if header[0][0] in _WHITESPACE or header[0][-1] in _WHITESPACE: - msg = f"Received header name surrounded by whitespace {header[0]!r}" - raise ProtocolError(msg) - if header[1] and ((header[1][0] in _WHITESPACE) or - (header[1][-1] in _WHITESPACE)): - msg = f"Received header value surrounded by whitespace {header[1]!r}" - raise ProtocolError(msg) - yield header - - def _reject_te(headers: Iterable[Header], hdr_validation_flags: HeaderValidationFlags) -> Generator[Header, None, None]: """ Raises a ProtocolError if the TE header is present in a header block and From 1aae569315eb170cdff00582644aca37ee38db62 Mon Sep 17 00:00:00 2001 From: Thomas Kriechbaumer Date: Sat, 23 Aug 2025 19:06:57 +0200 Subject: [PATCH 30/30] v4.3.0 --- CHANGELOG.rst | 12 ++++++++---- src/h2/__init__.py | 2 +- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 1a2deb8d5..06111c33d 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,21 +1,25 @@ Release History =============== -dev ---- +4.3.0 (2025-08-23) +------------------ **API Changes (Backward Incompatible)** -- Reject header names and values containing unpermitted characters `\r`, `\n`, or `\0x00`. +- Reject header names and values containing illegal characters, based on RFC 9113, section 8.2.1. + The main Python API is compatible, but some previously valid requests/response headers might now be blocked. + Use the `validate_inbound_headers` config option if needed. + Thanks to Sebastiano Sartor (sebsrt) for the report. **API Changes (Backward Compatible)** - h2 events now have tighter type bounds, e.g. `stream_id` is guaranteed to not be `None` for most events now. This simplifies downstream type checking. +- Various typing-related improvements. **Bugfixes** -- +- Fix error value when opening a new stream on too many open streams. 4.2.0 (2025-02-01) ------------------ diff --git a/src/h2/__init__.py b/src/h2/__init__.py index 35bfe397a..7f0ccd774 100644 --- a/src/h2/__init__.py +++ b/src/h2/__init__.py @@ -3,4 +3,4 @@ """ from __future__ import annotations -__version__ = "4.3.0+dev" +__version__ = "4.3.0"