From a620be6eb460f2bfbbb4e5454340778cba224042 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Sun, 14 Sep 2025 11:38:12 +0300 Subject: [PATCH 1/5] Add support for Python 3.14 --- .github/workflows/ci.yml | 1 + README.rst | 2 +- setup.py | 2 ++ tox.ini | 3 ++- 4 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7617e1f..1005151 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,6 +19,7 @@ jobs: - "3.11" - "3.12" - "3.13" + - "3.14" - pypy-3.9 - pypy-3.10 diff --git a/README.rst b/README.rst index 5f28616..1344118 100644 --- a/README.rst +++ b/README.rst @@ -112,7 +112,7 @@ library. It has a test suite with 100.0% coverage for both statements and branches. -Currently it supports Python 3 (testing on 3.8-3.12) and PyPy 3. +Currently it supports Python 3 (testing on 3.8-3.14) and PyPy 3. The last Python 2-compatible version was h11 0.11.x. (Originally it had a Cython wrapper for `http-parser `_ and a beautiful nested state diff --git a/setup.py b/setup.py index 73713e2..c5d0ff5 100644 --- a/setup.py +++ b/setup.py @@ -29,6 +29,8 @@ "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Topic :: Internet :: WWW/HTTP", "Topic :: System :: Networking", ], diff --git a/tox.ini b/tox.ini index 6614ecf..ae7520a 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = format, py{38, 39, 310, 311, 312, py3}, mypy +envlist = format, py{38, 39, 310, 311, 312, 313, 314, py3}, mypy [gh-actions] python = @@ -9,6 +9,7 @@ python = 3.11: py311 3.12: py312 3.13: py313 + 3.14: py314 pypy-3.9: pypy3 pypy-3.10: pypy3 From 8730d8e6598521298573e072b157a648e1c22cbd Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Sun, 14 Sep 2025 11:39:15 +0300 Subject: [PATCH 2/5] Test latest PyPy3.11 --- .github/workflows/ci.yml | 3 +-- tox.ini | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1005151..13fe160 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,8 +20,7 @@ jobs: - "3.12" - "3.13" - "3.14" - - pypy-3.9 - - pypy-3.10 + - pypy-3.11 steps: - uses: actions/checkout@v4 diff --git a/tox.ini b/tox.ini index ae7520a..1b7118f 100644 --- a/tox.ini +++ b/tox.ini @@ -10,8 +10,7 @@ python = 3.12: py312 3.13: py313 3.14: py314 - pypy-3.9: pypy3 - pypy-3.10: pypy3 + pypy-3.11: pypy3 [testenv] deps = -r{toxinidir}/test-requirements.txt From 66452e114f024eae354178632d5573337b1be8d3 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Sun, 14 Sep 2025 11:41:51 +0300 Subject: [PATCH 3/5] Drop support for EOL Python 3.8 --- .github/workflows/ci.yml | 1 - .readthedocs.yaml | 2 +- README.rst | 2 +- setup.py | 3 +-- tox.ini | 9 ++++----- 5 files changed, 7 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 13fe160..187180a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,7 +13,6 @@ jobs: max-parallel: 7 matrix: python-version: - - 3.8 - 3.9 - "3.10" - "3.11" diff --git a/.readthedocs.yaml b/.readthedocs.yaml index 38d4fcc..500c596 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -5,7 +5,7 @@ build: apt_packages: - graphviz tools: - python: "3.8" + python: "3.9" sphinx: configuration: docs/source/conf.py diff --git a/README.rst b/README.rst index 1344118..b366998 100644 --- a/README.rst +++ b/README.rst @@ -112,7 +112,7 @@ library. It has a test suite with 100.0% coverage for both statements and branches. -Currently it supports Python 3 (testing on 3.8-3.14) and PyPy 3. +Currently it supports Python 3 (testing on 3.9-3.14) and PyPy 3. The last Python 2-compatible version was h11 0.11.x. (Originally it had a Cython wrapper for `http-parser `_ and a beautiful nested state diff --git a/setup.py b/setup.py index c5d0ff5..4e0ef3f 100644 --- a/setup.py +++ b/setup.py @@ -15,7 +15,7 @@ packages=find_packages(exclude=["h11.tests"]), package_data={'h11': ['py.typed']}, url="https://github.com/python-hyper/h11", - python_requires=">=3.8", + python_requires=">=3.9", classifiers=[ "Development Status :: 3 - Alpha", "Intended Audience :: Developers", @@ -24,7 +24,6 @@ "Programming Language :: Python :: Implementation :: PyPy", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", diff --git a/tox.ini b/tox.ini index 1b7118f..bd4f42f 100644 --- a/tox.ini +++ b/tox.ini @@ -1,10 +1,9 @@ [tox] -envlist = format, py{38, 39, 310, 311, 312, 313, 314, py3}, mypy +envlist = format, py{39, 310, 311, 312, 313, 314, py3}, mypy [gh-actions] python = - 3.8: py38, format, mypy - 3.9: py39 + 3.9: py39, format, mypy 3.10: py310 3.11: py311 3.12: py312 @@ -17,14 +16,14 @@ deps = -r{toxinidir}/test-requirements.txt commands = pytest --cov=h11 --cov-config=.coveragerc h11 [testenv:format] -basepython = python3.8 +basepython = python3.9 deps = -r{toxinidir}/format-requirements.txt commands = black --check --diff h11/ bench/ examples/ fuzz/ isort --check --diff --profile black --dt h11 bench examples fuzz [testenv:mypy] -basepython = python3.8 +basepython = python3.9 deps = mypy==1.8.0 pytest From 88f75296c3008cfed6aa1a40b505faa0bf1606e9 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Sun, 14 Sep 2025 11:48:51 +0300 Subject: [PATCH 4/5] Upgrade syntax with Ruff --- docs/source/make-state-diagrams.py | 6 ++-- h11/_connection.py | 45 ++++++++++----------------- h11/_events.py | 14 ++++----- h11/_headers.py | 29 ++++++++--------- h11/_readers.py | 17 +++++----- h11/_receivebuffer.py | 5 ++- h11/_state.py | 42 ++++++++++++------------- h11/_util.py | 11 ++++--- h11/_writers.py | 8 ++--- h11/tests/helpers.py | 29 +++++++---------- h11/tests/test_against_stdlib_http.py | 3 +- h11/tests/test_connection.py | 16 +++++----- h11/tests/test_headers.py | 1 - h11/tests/test_io.py | 7 +++-- h11/tests/test_receivebuffer.py | 5 +-- h11/tests/test_state.py | 1 - 16 files changed, 107 insertions(+), 132 deletions(-) diff --git a/docs/source/make-state-diagrams.py b/docs/source/make-state-diagrams.py index 617efa5..3ac9a36 100644 --- a/docs/source/make-state-diagrams.py +++ b/docs/source/make-state-diagrams.py @@ -25,12 +25,12 @@ """ def finish(machine_name): - return (""" + return (f""" labelloc="t" labeljust="l" - label=<h11 state machine: {}> + label=<h11 state machine: {machine_name}> }} -""".format(machine_name)) +""") class Edges: def __init__(self): diff --git a/h11/_connection.py b/h11/_connection.py index e37d82a..494143c 100644 --- a/h11/_connection.py +++ b/h11/_connection.py @@ -1,17 +1,6 @@ # This contains the main Connection class. Everything in h11 revolves around # this. -from typing import ( - Any, - Callable, - cast, - Dict, - List, - Optional, - overload, - Tuple, - Type, - Union, -) +from typing import Any, Callable, cast, Optional, overload, Union from ._events import ( ConnectionClosed, @@ -92,7 +81,7 @@ def _keep_alive(event: Union[Request, Response]) -> bool: def _body_framing( request_method: bytes, event: Union[Request, Response] -) -> Tuple[str, Union[Tuple[()], Tuple[int]]]: +) -> tuple[str, Union[tuple[()], tuple[int]]]: # Called when we enter SEND_BODY to figure out framing information for # this body. # @@ -166,7 +155,7 @@ class Connection: def __init__( self, - our_role: Type[Sentinel], + our_role: type[Sentinel], max_incomplete_event_size: int = DEFAULT_MAX_INCOMPLETE_EVENT_SIZE, ) -> None: self._max_incomplete_event_size = max_incomplete_event_size @@ -174,7 +163,7 @@ def __init__( if our_role not in (CLIENT, SERVER): raise ValueError(f"expected CLIENT or SERVER, not {our_role!r}") self.our_role = our_role - self.their_role: Type[Sentinel] + self.their_role: type[Sentinel] if our_role is CLIENT: self.their_role = SERVER else: @@ -204,7 +193,7 @@ def __init__( self.client_is_waiting_for_100_continue = False @property - def states(self) -> Dict[Type[Sentinel], Type[Sentinel]]: + def states(self) -> dict[type[Sentinel], type[Sentinel]]: """A dictionary like:: {CLIENT: , SERVER: } @@ -215,14 +204,14 @@ def states(self) -> Dict[Type[Sentinel], Type[Sentinel]]: return dict(self._cstate.states) @property - def our_state(self) -> Type[Sentinel]: + def our_state(self) -> type[Sentinel]: """The current state of whichever role we are playing. See :ref:`state-machine` for details. """ return self._cstate.states[self.our_role] @property - def their_state(self) -> Type[Sentinel]: + def their_state(self) -> type[Sentinel]: """The current state of whichever role we are NOT playing. See :ref:`state-machine` for details. """ @@ -252,12 +241,12 @@ def start_next_cycle(self) -> None: assert not self.client_is_waiting_for_100_continue self._respond_to_state_changes(old_states) - def _process_error(self, role: Type[Sentinel]) -> None: + def _process_error(self, role: type[Sentinel]) -> None: old_states = dict(self._cstate.states) self._cstate.process_error(role) self._respond_to_state_changes(old_states) - def _server_switch_event(self, event: Event) -> Optional[Type[Sentinel]]: + def _server_switch_event(self, event: Event) -> Optional[type[Sentinel]]: if type(event) is InformationalResponse and event.status_code == 101: return _SWITCH_UPGRADE if type(event) is Response: @@ -269,7 +258,7 @@ def _server_switch_event(self, event: Event) -> Optional[Type[Sentinel]]: return None # All events go through here - def _process_event(self, role: Type[Sentinel], event: Event) -> None: + def _process_event(self, role: type[Sentinel], event: Event) -> None: # First, pass the event through the state machine to make sure it # succeeds. old_states = dict(self._cstate.states) @@ -319,7 +308,7 @@ def _process_event(self, role: Type[Sentinel], event: Event) -> None: def _get_io_object( self, - role: Type[Sentinel], + role: type[Sentinel], event: Optional[Event], io_dict: Union[ReadersType, WritersType], ) -> Optional[Callable[..., Any]]: @@ -341,7 +330,7 @@ def _get_io_object( # self._cstate.states to change. def _respond_to_state_changes( self, - old_states: Dict[Type[Sentinel], Type[Sentinel]], + old_states: dict[type[Sentinel], type[Sentinel]], event: Optional[Event] = None, ) -> None: # Update reader/writer @@ -351,7 +340,7 @@ def _respond_to_state_changes( self._reader = self._get_io_object(self.their_role, event, READERS) @property - def trailing_data(self) -> Tuple[bytes, bool]: + def trailing_data(self) -> tuple[bytes, bool]: """Data that has been received, but not yet processed, represented as a tuple with two elements, where the first is a byte-string containing the unprocessed data itself, and the second is a bool that is True if @@ -409,7 +398,7 @@ def receive_data(self, data: bytes) -> None: def _extract_next_receive_event( self, - ) -> Union[Event, Type[NEED_DATA], Type[PAUSED]]: + ) -> Union[Event, type[NEED_DATA], type[PAUSED]]: state = self.their_state # We don't pause immediately when they enter DONE, because even in # DONE state we can still process a ConnectionClosed() event. But @@ -435,7 +424,7 @@ def _extract_next_receive_event( event = NEED_DATA return event # type: ignore[no-any-return] - def next_event(self) -> Union[Event, Type[NEED_DATA], Type[PAUSED]]: + def next_event(self) -> Union[Event, type[NEED_DATA], type[PAUSED]]: """Parse the next event out of our receive buffer, update our internal state, and return it. @@ -541,7 +530,7 @@ def send(self, event: Event) -> Optional[bytes]: else: return b"".join(data_list) - def send_with_data_passthrough(self, event: Event) -> Optional[List[bytes]]: + def send_with_data_passthrough(self, event: Event) -> Optional[list[bytes]]: """Identical to :meth:`send`, except that in situations where :meth:`send` returns a single :term:`bytes-like object`, this instead returns a list of them -- and when sending a :class:`Data` event, this @@ -567,7 +556,7 @@ def send_with_data_passthrough(self, event: Event) -> Optional[List[bytes]]: # In any situation where writer is None, process_event should # have raised ProtocolError assert writer is not None - data_list: List[bytes] = [] + data_list: list[bytes] = [] writer(event, data_list.append) return data_list except: diff --git a/h11/_events.py b/h11/_events.py index ca1c3ad..1f397b6 100644 --- a/h11/_events.py +++ b/h11/_events.py @@ -8,7 +8,7 @@ import re from abc import ABC from dataclasses import dataclass -from typing import List, Tuple, Union +from typing import Union from ._abnf import method, request_target from ._headers import Headers, normalize_and_validate @@ -83,7 +83,7 @@ def __init__( self, *, method: Union[bytes, str], - headers: Union[Headers, List[Tuple[bytes, bytes]], List[Tuple[str, str]]], + headers: Union[Headers, list[tuple[bytes, bytes]], list[tuple[str, str]]], target: Union[bytes, str], http_version: Union[bytes, str] = b"1.1", _parsed: bool = False, @@ -137,7 +137,7 @@ class _ResponseBase(Event): def __init__( self, *, - headers: Union[Headers, List[Tuple[bytes, bytes]], List[Tuple[str, str]]], + headers: Union[Headers, list[tuple[bytes, bytes]], list[tuple[str, str]]], status_code: int, http_version: Union[bytes, str] = b"1.1", reason: Union[bytes, str] = b"", @@ -207,7 +207,7 @@ def __post_init__(self) -> None: if not (100 <= self.status_code < 200): raise LocalProtocolError( "InformationalResponse status_code should be in range " - "[100, 200), not {}".format(self.status_code) + f"[100, 200), not {self.status_code}" ) # This is an unhashable type. @@ -247,9 +247,7 @@ class Response(_ResponseBase): def __post_init__(self) -> None: if not (200 <= self.status_code < 1000): raise LocalProtocolError( - "Response status_code should be in range [200, 1000), not {}".format( - self.status_code - ) + f"Response status_code should be in range [200, 1000), not {self.status_code}" ) # This is an unhashable type. @@ -338,7 +336,7 @@ def __init__( self, *, headers: Union[ - Headers, List[Tuple[bytes, bytes]], List[Tuple[str, str]], None + Headers, list[tuple[bytes, bytes]], list[tuple[str, str]], None ] = None, _parsed: bool = False, ) -> None: diff --git a/h11/_headers.py b/h11/_headers.py index 31da3e2..3d5acb9 100644 --- a/h11/_headers.py +++ b/h11/_headers.py @@ -1,5 +1,6 @@ import re -from typing import AnyStr, cast, List, overload, Sequence, Tuple, TYPE_CHECKING, Union +from collections.abc import Sequence +from typing import overload, TYPE_CHECKING, Union from ._abnf import field_name, field_value from ._util import bytesify, LocalProtocolError, validate @@ -74,7 +75,7 @@ _field_value_re = re.compile(field_value.encode("ascii")) -class Headers(Sequence[Tuple[bytes, bytes]]): +class Headers(Sequence[tuple[bytes, bytes]]): """ A list-like interface that allows iterating over headers as byte-pairs of (lowercased-name, value). @@ -101,7 +102,7 @@ class Headers(Sequence[Tuple[bytes, bytes]]): __slots__ = "_full_items" - def __init__(self, full_items: List[Tuple[bytes, bytes, bytes]]) -> None: + def __init__(self, full_items: list[tuple[bytes, bytes, bytes]]) -> None: self._full_items = full_items def __bool__(self) -> bool: @@ -114,21 +115,21 @@ def __len__(self) -> int: return len(self._full_items) def __repr__(self) -> str: - return "" % repr(list(self)) + return f"" - def __getitem__(self, idx: int) -> Tuple[bytes, bytes]: # type: ignore[override] + def __getitem__(self, idx: int) -> tuple[bytes, bytes]: # type: ignore[override] _, name, value = self._full_items[idx] return (name, value) - def raw_items(self) -> List[Tuple[bytes, bytes]]: + def raw_items(self) -> list[tuple[bytes, bytes]]: return [(raw_name, value) for raw_name, _, value in self._full_items] HeaderTypes = Union[ - List[Tuple[bytes, bytes]], - List[Tuple[bytes, str]], - List[Tuple[str, bytes]], - List[Tuple[str, str]], + list[tuple[bytes, bytes]], + list[tuple[bytes, str]], + list[tuple[str, bytes]], + list[tuple[str, str]], ] @@ -206,7 +207,7 @@ def normalize_and_validate( return Headers(new_headers) -def get_comma_header(headers: Headers, name: bytes) -> List[bytes]: +def get_comma_header(headers: Headers, name: bytes) -> list[bytes]: # Should only be used for headers whose value is a list of # comma-separated, case-insensitive values. # @@ -242,7 +243,7 @@ def get_comma_header(headers: Headers, name: bytes) -> List[bytes]: # Expect: the only legal value is the literal string # "100-continue". Splitting on commas is harmless. Case insensitive. # - out: List[bytes] = [] + out: list[bytes] = [] for _, found_name, found_raw_value in headers._full_items: if found_name == name: found_raw_value = found_raw_value.lower() @@ -253,7 +254,7 @@ def get_comma_header(headers: Headers, name: bytes) -> List[bytes]: return out -def set_comma_header(headers: Headers, name: bytes, new_values: List[bytes]) -> Headers: +def set_comma_header(headers: Headers, name: bytes, new_values: list[bytes]) -> Headers: # The header name `name` is expected to be lower-case bytes. # # Note that when we store the header we use title casing for the header @@ -263,7 +264,7 @@ def set_comma_header(headers: Headers, name: bytes, new_values: List[bytes]) -> # here given the cases where we're using `set_comma_header`... # # Connection, Content-Length, Transfer-Encoding. - new_headers: List[Tuple[bytes, bytes]] = [] + new_headers: list[tuple[bytes, bytes]] = [] for found_raw_name, found_name, found_raw_value in headers._full_items: if found_name != name: new_headers.append((found_raw_name, found_raw_value)) diff --git a/h11/_readers.py b/h11/_readers.py index 576804c..17a90c4 100644 --- a/h11/_readers.py +++ b/h11/_readers.py @@ -17,7 +17,8 @@ # - or, for body readers, a dict of per-framing reader factories import re -from typing import Any, Callable, Dict, Iterable, NoReturn, Optional, Tuple, Type, Union +from collections.abc import Iterable +from typing import Any, Callable, NoReturn, Optional, Union from ._abnf import chunk_header, header_field, request_line, status_line from ._events import Data, EndOfMessage, InformationalResponse, Request, Response @@ -63,7 +64,7 @@ def _obsolete_line_fold(lines: Iterable[bytes]) -> Iterable[bytes]: def _decode_header_lines( lines: Iterable[bytes], -) -> Iterable[Tuple[bytes, bytes]]: +) -> Iterable[tuple[bytes, bytes]]: for line in _obsolete_line_fold(lines): matches = validate(header_field_re, line, "illegal header line: {!r}", line) yield (matches["field_name"], matches["field_value"]) @@ -107,7 +108,7 @@ def maybe_read_from_SEND_RESPONSE_server( ) reason = b"" if matches["reason"] is None else matches["reason"] status_code = int(matches["status_code"]) - class_: Union[Type[InformationalResponse], Type[Response]] = ( + class_: Union[type[InformationalResponse], type[Response]] = ( InformationalResponse if status_code < 200 else Response ) return class_( @@ -136,9 +137,7 @@ def __call__(self, buf: ReceiveBuffer) -> Union[Data, EndOfMessage, None]: def read_eof(self) -> NoReturn: raise RemoteProtocolError( "peer closed connection without sending complete message body " - "(received {} bytes, expected {})".format( - self._length - self._remaining, self._length - ) + f"(received {self._length - self._remaining} bytes, expected {self._length})" ) @@ -227,9 +226,9 @@ def expect_nothing(buf: ReceiveBuffer) -> None: return None -ReadersType = Dict[ - Union[Type[Sentinel], Tuple[Type[Sentinel], Type[Sentinel]]], - Union[Callable[..., Any], Dict[str, Callable[..., Any]]], +ReadersType = dict[ + Union[type[Sentinel], tuple[type[Sentinel], type[Sentinel]]], + Union[Callable[..., Any], dict[str, Callable[..., Any]]], ] READERS: ReadersType = { diff --git a/h11/_receivebuffer.py b/h11/_receivebuffer.py index e5c4e08..bc24041 100644 --- a/h11/_receivebuffer.py +++ b/h11/_receivebuffer.py @@ -1,6 +1,5 @@ import re -import sys -from typing import List, Optional, Union +from typing import Optional, Union __all__ = ["ReceiveBuffer"] @@ -101,7 +100,7 @@ def maybe_extract_next_line(self) -> Optional[bytearray]: return self._extract(idx) - def maybe_extract_lines(self) -> Optional[List[bytearray]]: + def maybe_extract_lines(self) -> Optional[list[bytearray]]: """ Extract everything up to the first blank line, and return a list of lines. """ diff --git a/h11/_state.py b/h11/_state.py index 3ad444b..c4dc8ed 100644 --- a/h11/_state.py +++ b/h11/_state.py @@ -110,7 +110,7 @@ # tables. But it can't automatically read the transitions that are written # directly in Python code. So if you touch those, you need to also update the # script to keep it in sync! -from typing import cast, Dict, Optional, Set, Tuple, Type, Union +from typing import cast, Optional, Union from ._events import * from ._util import LocalProtocolError, Sentinel @@ -185,11 +185,11 @@ class _SWITCH_CONNECT(Sentinel, metaclass=Sentinel): pass -EventTransitionType = Dict[ - Type[Sentinel], - Dict[ - Type[Sentinel], - Dict[Union[Type[Event], Tuple[Type[Event], Type[Sentinel]]], Type[Sentinel]], +EventTransitionType = dict[ + type[Sentinel], + dict[ + type[Sentinel], + dict[Union[type[Event], tuple[type[Event], type[Sentinel]]], type[Sentinel]], ], ] @@ -226,8 +226,8 @@ class _SWITCH_CONNECT(Sentinel, metaclass=Sentinel): }, } -StateTransitionType = Dict[ - Tuple[Type[Sentinel], Type[Sentinel]], Dict[Type[Sentinel], Type[Sentinel]] +StateTransitionType = dict[ + tuple[type[Sentinel], type[Sentinel]], dict[type[Sentinel], type[Sentinel]] ] # NB: there are also some special-case state-triggered transitions hard-coded @@ -256,11 +256,11 @@ def __init__(self) -> None: # This is a subset of {UPGRADE, CONNECT}, containing the proposals # made by the client for switching protocols. - self.pending_switch_proposals: Set[Type[Sentinel]] = set() + self.pending_switch_proposals: set[type[Sentinel]] = set() - self.states: Dict[Type[Sentinel], Type[Sentinel]] = {CLIENT: IDLE, SERVER: IDLE} + self.states: dict[type[Sentinel], type[Sentinel]] = {CLIENT: IDLE, SERVER: IDLE} - def process_error(self, role: Type[Sentinel]) -> None: + def process_error(self, role: type[Sentinel]) -> None: self.states[role] = ERROR self._fire_state_triggered_transitions() @@ -268,17 +268,17 @@ def process_keep_alive_disabled(self) -> None: self.keep_alive = False self._fire_state_triggered_transitions() - def process_client_switch_proposal(self, switch_event: Type[Sentinel]) -> None: + def process_client_switch_proposal(self, switch_event: type[Sentinel]) -> None: self.pending_switch_proposals.add(switch_event) self._fire_state_triggered_transitions() def process_event( self, - role: Type[Sentinel], - event_type: Type[Event], - server_switch_event: Optional[Type[Sentinel]] = None, + role: type[Sentinel], + event_type: type[Event], + server_switch_event: Optional[type[Sentinel]] = None, ) -> None: - _event_type: Union[Type[Event], Tuple[Type[Event], Type[Sentinel]]] = event_type + _event_type: Union[type[Event], tuple[type[Event], type[Sentinel]]] = event_type if server_switch_event is not None: assert role is SERVER if server_switch_event not in self.pending_switch_proposals: @@ -298,18 +298,16 @@ def process_event( def _fire_event_triggered_transitions( self, - role: Type[Sentinel], - event_type: Union[Type[Event], Tuple[Type[Event], Type[Sentinel]]], + role: type[Sentinel], + event_type: Union[type[Event], tuple[type[Event], type[Sentinel]]], ) -> None: state = self.states[role] try: new_state = EVENT_TRIGGERED_TRANSITIONS[role][state][event_type] except KeyError: - event_type = cast(Type[Event], event_type) + event_type = cast(type[Event], event_type) raise LocalProtocolError( - "can't handle event type {} when role={} and state={}".format( - event_type.__name__, role, self.states[role] - ) + f"can't handle event type {event_type.__name__} when role={role} and state={self.states[role]}" ) from None self.states[role] = new_state diff --git a/h11/_util.py b/h11/_util.py index 6718445..d3a3e8a 100644 --- a/h11/_util.py +++ b/h11/_util.py @@ -1,4 +1,5 @@ -from typing import Any, Dict, NoReturn, Pattern, Tuple, Type, TypeVar, Union +from re import Pattern +from typing import Any, NoReturn, TypeVar, Union __all__ = [ "ProtocolError", @@ -83,7 +84,7 @@ class RemoteProtocolError(ProtocolError): def validate( regex: Pattern[bytes], data: bytes, msg: str = "malformed data", *format_args: Any -) -> Dict[str, bytes]: +) -> dict[str, bytes]: match = regex.fullmatch(data) if not match: if format_args: @@ -106,10 +107,10 @@ def validate( class Sentinel(type): def __new__( - cls: Type[_T_Sentinel], + cls: type[_T_Sentinel], name: str, - bases: Tuple[type, ...], - namespace: Dict[str, Any], + bases: tuple[type, ...], + namespace: dict[str, Any], **kwds: Any ) -> _T_Sentinel: assert bases == (Sentinel,) diff --git a/h11/_writers.py b/h11/_writers.py index 939cdb9..8ed325c 100644 --- a/h11/_writers.py +++ b/h11/_writers.py @@ -7,7 +7,7 @@ # - a writer # - or, for body writers, a dict of framin-dependent writer factories -from typing import Any, Callable, Dict, List, Tuple, Type, Union +from typing import Any, Callable, Union from ._events import Data, EndOfMessage, Event, InformationalResponse, Request, Response from ._headers import Headers @@ -124,10 +124,10 @@ def send_eom(self, headers: Headers, write: Writer) -> None: # Connection: close machinery -WritersType = Dict[ - Union[Tuple[Type[Sentinel], Type[Sentinel]], Type[Sentinel]], +WritersType = dict[ + Union[tuple[type[Sentinel], type[Sentinel]], type[Sentinel]], Union[ - Dict[str, Type[BodyWriter]], + dict[str, type[BodyWriter]], Callable[[Union[InformationalResponse, Response], Writer], None], Callable[[Request, Writer], None], ], diff --git a/h11/tests/helpers.py b/h11/tests/helpers.py index 571be44..5418391 100644 --- a/h11/tests/helpers.py +++ b/h11/tests/helpers.py @@ -1,16 +1,9 @@ -from typing import cast, List, Type, Union, ValuesView +from collections.abc import ValuesView +from typing import cast, Union from .._connection import Connection, NEED_DATA, PAUSED -from .._events import ( - ConnectionClosed, - Data, - EndOfMessage, - Event, - InformationalResponse, - Request, - Response, -) -from .._state import CLIENT, CLOSED, DONE, MUST_CLOSE, SERVER +from .._events import ConnectionClosed, Data, Event +from .._state import CLIENT, SERVER from .._util import Sentinel try: @@ -19,7 +12,7 @@ from typing_extensions import Literal # type: ignore -def get_all_events(conn: Connection) -> List[Event]: +def get_all_events(conn: Connection) -> list[Event]: got_events = [] while True: event = conn.next_event() @@ -32,15 +25,15 @@ def get_all_events(conn: Connection) -> List[Event]: return got_events -def receive_and_get(conn: Connection, data: bytes) -> List[Event]: +def receive_and_get(conn: Connection, data: bytes) -> list[Event]: conn.receive_data(data) return get_all_events(conn) # Merges adjacent Data events, converts payloads to bytestrings, and removes # chunk boundaries. -def normalize_data_events(in_events: List[Event]) -> List[Event]: - out_events: List[Event] = [] +def normalize_data_events(in_events: list[Event]) -> list[Event]: + out_events: list[Event] = [] for event in in_events: if type(event) is Data: event = Data(data=bytes(event.data), chunk_start=False, chunk_end=False) @@ -71,9 +64,9 @@ def conns(self) -> ValuesView[Connection]: # expect="match" if expect=send_events; expect=[...] to say what expected def send( self, - role: Type[Sentinel], - send_events: Union[List[Event], Event], - expect: Union[List[Event], Event, Literal["match"]] = "match", + role: type[Sentinel], + send_events: Union[list[Event], Event], + expect: Union[list[Event], Event, Literal["match"]] = "match", ) -> bytes: if not isinstance(send_events, list): send_events = [send_events] diff --git a/h11/tests/test_against_stdlib_http.py b/h11/tests/test_against_stdlib_http.py index 3f66a10..d98e7e1 100644 --- a/h11/tests/test_against_stdlib_http.py +++ b/h11/tests/test_against_stdlib_http.py @@ -3,9 +3,10 @@ import socket import socketserver import threading +from collections.abc import Generator from contextlib import closing, contextmanager from http.server import SimpleHTTPRequestHandler -from typing import Callable, Generator +from typing import Callable from urllib.request import urlopen import h11 diff --git a/h11/tests/test_connection.py b/h11/tests/test_connection.py index 01260dc..c71b9b4 100644 --- a/h11/tests/test_connection.py +++ b/h11/tests/test_connection.py @@ -1,4 +1,4 @@ -from typing import Any, cast, Dict, List, Optional, Tuple, Type +from typing import Any, cast, Optional import pytest @@ -58,7 +58,7 @@ def test__keep_alive() -> None: def test__body_framing() -> None: - def headers(cl: Optional[int], te: bool) -> List[Tuple[str, str]]: + def headers(cl: Optional[int], te: bool) -> list[tuple[str, str]]: headers = [] if cl is not None: headers.append(("Content-Length", str(cl))) @@ -78,7 +78,7 @@ def req(cl: Optional[int] = None, te: bool = False) -> Request: # Special cases where the headers are ignored: for kwargs in [{}, {"cl": 100}, {"te": True}, {"cl": 100, "te": True}]: - kwargs = cast(Dict[str, Any], kwargs) + kwargs = cast(dict[str, Any], kwargs) for meth, r in [ (b"HEAD", resp(**kwargs)), (b"GET", resp(status_code=204, **kwargs)), @@ -88,7 +88,7 @@ def req(cl: Optional[int] = None, te: bool = False) -> Request: # Transfer-encoding for kwargs in [{"te": True}, {"cl": 100, "te": True}]: - kwargs = cast(Dict[str, Any], kwargs) + kwargs = cast(dict[str, Any], kwargs) for meth, r in [(None, req(**kwargs)), (b"GET", resp(**kwargs))]: # type: ignore assert _body_framing(meth, r) == ("chunked", ()) @@ -303,7 +303,7 @@ def test_automatic_transfer_encoding_in_response() -> None: # because if both are set then Transfer-Encoding wins [("Transfer-Encoding", "chunked"), ("Content-Length", "100")], ]: - user_headers = cast(List[Tuple[str, str]], user_headers) + user_headers = cast(list[tuple[str, str]], user_headers) p = ConnectionPair() p.send( CLIENT, @@ -873,8 +873,8 @@ def __len__(self) -> int: placeholder = SendfilePlaceholder() def setup( - header: Tuple[str, str], http_version: str - ) -> Tuple[Connection, Optional[List[bytes]]]: + header: tuple[str, str], http_version: str + ) -> tuple[Connection, Optional[list[bytes]]]: c = Connection(SERVER) receive_and_get( c, f"GET / HTTP/{http_version}\r\nHost: a\r\n\r\n".encode("ascii") @@ -924,7 +924,7 @@ def test_errors() -> None: # After an error sending, you can no longer send # (This is especially important for things like content-length errors, # where there's complex internal state being modified) - def conn(role: Type[Sentinel]) -> Connection: + def conn(role: type[Sentinel]) -> Connection: c = Connection(our_role=role) if role is SERVER: # Put it into the state where it *could* send a response... diff --git a/h11/tests/test_headers.py b/h11/tests/test_headers.py index b57274c..f2884d2 100644 --- a/h11/tests/test_headers.py +++ b/h11/tests/test_headers.py @@ -4,7 +4,6 @@ from .._headers import ( get_comma_header, has_expect_100_continue, - Headers, normalize_and_validate, set_comma_header, ) diff --git a/h11/tests/test_io.py b/h11/tests/test_io.py index 407e044..dd8d996 100644 --- a/h11/tests/test_io.py +++ b/h11/tests/test_io.py @@ -1,4 +1,5 @@ -from typing import Any, Callable, Generator, List +from collections.abc import Generator +from typing import Any, Callable import pytest @@ -68,7 +69,7 @@ def dowrite(writer: Callable[..., None], obj: Any) -> bytes: - got_list: List[bytes] = [] + got_list: list[bytes] = [] writer(obj, got_list.append) return b"".join(got_list) @@ -343,7 +344,7 @@ def _run_reader_iter( yield reader.read_eof() -def _run_reader(*args: Any) -> List[Event]: +def _run_reader(*args: Any) -> list[Event]: events = list(_run_reader_iter(*args)) return normalize_data_events(events) diff --git a/h11/tests/test_receivebuffer.py b/h11/tests/test_receivebuffer.py index 21a3870..8bd948d 100644 --- a/h11/tests/test_receivebuffer.py +++ b/h11/tests/test_receivebuffer.py @@ -1,6 +1,3 @@ -import re -from typing import Tuple - import pytest from .._receivebuffer import ReceiveBuffer @@ -119,7 +116,7 @@ def test_receivebuffer() -> None: ), ], ) -def test_receivebuffer_for_invalid_delimiter(data: Tuple[bytes]) -> None: +def test_receivebuffer_for_invalid_delimiter(data: tuple[bytes]) -> None: b = ReceiveBuffer() for line in data: diff --git a/h11/tests/test_state.py b/h11/tests/test_state.py index bc974e6..4801e86 100644 --- a/h11/tests/test_state.py +++ b/h11/tests/test_state.py @@ -4,7 +4,6 @@ ConnectionClosed, Data, EndOfMessage, - Event, InformationalResponse, Request, Response, From 91ada998b31d34ec2981f47236bfb296e8c0cf48 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Sun, 14 Sep 2025 12:10:41 +0300 Subject: [PATCH 5/5] Update docs requirements for Python 3.9 --- docs/requirements.txt | 6 +++--- docs/source/conf.py | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/requirements.txt b/docs/requirements.txt index 1c6aca5..0271110 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,6 +1,6 @@ mistune jsonschema ipython -sphinx<4 -jinja2<3 -markupsafe<2 +sphinx +jinja2 +markupsafe diff --git a/docs/source/conf.py b/docs/source/conf.py index b3627f5..8eb6864 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -58,9 +58,9 @@ # Undocumented trick: if we def setup here in conf.py, it gets called just # like an extension's setup function. def setup(app): - app.add_javascript("show-code.js") - app.add_javascript("facebox.js") - app.add_stylesheet("facebox.css") + app.add_js_file("show-code.js") + app.add_js_file("facebox.js") + app.add_css_file("facebox.css") # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates']