From c327b1a085ac773cccbe70b265a0408851bb83da Mon Sep 17 00:00:00 2001
From: jakkdl
Date: Thu, 22 May 2025 13:29:23 +0200
Subject: [PATCH 1/3] drop 3.8 testing+support remove some 3.7 remnants add
3.13 and 3.14 testing+support silence pytest-asyncio deprecationwarning fix
typing errors fix pep8 errors fix poetry warning
---
.github/workflows/ci.yml | 13 +++++++------
.github/workflows/publish.yml | 2 +-
pyproject.toml | 10 ++++++----
setup.cfg | 2 +-
src/hypercorn/asyncio/run.py | 2 --
src/hypercorn/logging.py | 2 +-
src/hypercorn/protocol/__init__.py | 6 +++++-
src/hypercorn/protocol/h2.py | 23 +++++++++++++++++++----
src/hypercorn/run.py | 2 +-
src/hypercorn/trio/__init__.py | 2 +-
src/hypercorn/trio/run.py | 2 +-
src/hypercorn/trio/worker_context.py | 2 +-
src/hypercorn/utils.py | 3 ++-
tests/asyncio/test_sanity.py | 2 ++
tests/middleware/test_dispatcher.py | 3 ---
tests/middleware/test_http_to_https.py | 4 ----
tests/protocol/test_h11.py | 9 +--------
tests/protocol/test_h2.py | 8 +-------
tests/protocol/test_http_stream.py | 8 +-------
tests/protocol/test_ws_stream.py | 8 +-------
tests/test_app_wrappers.py | 2 --
tests/trio/test_keep_alive.py | 15 ++++++---------
tests/trio/test_sanity.py | 20 ++++++++++----------
tox.ini | 13 ++++++-------
24 files changed, 74 insertions(+), 89 deletions(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 3eb26124..0162a2c7 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -14,15 +14,16 @@ jobs:
fail-fast: false
matrix:
include:
+ - {name: '3.14', python: '3.14', tox: py314}
+ - {name: '3.13', python: '3.13', tox: py313}
- {name: '3.12', python: '3.12', tox: py312}
- {name: '3.11', python: '3.11', tox: py311}
- {name: '3.10', python: '3.10', tox: py310}
- {name: '3.9', python: '3.9', tox: py39}
- - {name: '3.8', python: '3.8', tox: py38}
- - {name: 'format', python: '3.12', tox: format}
- - {name: 'mypy', python: '3.12', tox: mypy}
- - {name: 'pep8', python: '3.12', tox: pep8}
- - {name: 'package', python: '3.12', tox: package}
+ - {name: 'format', python: '3.13', tox: format}
+ - {name: 'mypy', python: '3.13', tox: mypy}
+ - {name: 'pep8', python: '3.13', tox: pep8}
+ - {name: 'package', python: '3.13', tox: package}
steps:
- uses: actions/checkout@v4
@@ -56,7 +57,7 @@ jobs:
- uses: actions/setup-python@v5
with:
- python-version: "3.12"
+ python-version: "3.13"
- name: update pip
run: |
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index 5e011a7c..0f4486df 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -11,7 +11,7 @@ jobs:
- uses: actions/setup-python@v3
with:
- python-version: 3.12
+ python-version: 3.13
- run: |
pip install poetry
diff --git a/pyproject.toml b/pyproject.toml
index 7a6b6a84..ab0d1035 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -11,11 +11,12 @@ classifiers = [
"Operating System :: OS Independent",
"Programming Language :: Python",
"Programming Language :: Python :: 3",
- "Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"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 :: Dynamic Content",
"Topic :: Software Development :: Libraries :: Python Modules",
]
@@ -26,7 +27,7 @@ repository = "https://github.com/pgjones/hypercorn/"
documentation = "https://hypercorn.readthedocs.io"
[tool.poetry.dependencies]
-python = ">=3.8"
+python = ">=3.9"
aioquic = { version = ">= 0.9.0, < 1.0", optional = true }
exceptiongroup = { version = ">= 1.1.0", python = "<3.11" }
h11 = "*"
@@ -41,7 +42,7 @@ typing_extensions = { version = "*", python = "<3.11" }
uvloop = { version = ">=0.18", markers = "platform_system != 'Windows'", optional = true }
wsproto = ">=0.14.0"
-[tool.poetry.dev-dependencies]
+[tool.poetry.group.dev.dependencies]
httpx = "*"
hypothesis = "*"
mock = "*"
@@ -61,7 +62,7 @@ uvloop = ["uvloop"]
[tool.black]
line-length = 100
-target-version = ["py38"]
+target-version = ["py39"]
[tool.isort]
combine_as_imports = true
@@ -104,6 +105,7 @@ warn_return_any = true
[tool.pytest.ini_options]
addopts = "--no-cov-on-fail --showlocals --strict-markers"
+asyncio_default_fixture_loop_scope = "function" # silence deprecationwarning
asyncio_mode = "strict"
testpaths = ["tests"]
diff --git a/setup.cfg b/setup.cfg
index 3423d8f8..bb54d51f 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -1,5 +1,5 @@
[flake8]
ignore = E203, E252, FI58, W503, W504
max_line_length = 100
-min_version = 3.8
+min_version = 3.9
require_code = True
diff --git a/src/hypercorn/asyncio/run.py b/src/hypercorn/asyncio/run.py
index 93bd7fc5..fd718e8f 100644
--- a/src/hypercorn/asyncio/run.py
+++ b/src/hypercorn/asyncio/run.py
@@ -102,8 +102,6 @@ def _signal_handler(*_: Any) -> None: # noqa: N803
server_tasks: Set[asyncio.Task] = set()
async def _server_callback(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
- nonlocal server_tasks
-
task = asyncio.current_task(loop)
server_tasks.add(task)
task.add_done_callback(server_tasks.discard)
diff --git a/src/hypercorn/logging.py b/src/hypercorn/logging.py
index d9b8901a..8e02a150 100644
--- a/src/hypercorn/logging.py
+++ b/src/hypercorn/logging.py
@@ -34,7 +34,7 @@ def _create_logger(
if target:
logger = logging.getLogger(name)
logger.handlers = [
- logging.StreamHandler(sys_default) if target == "-" else logging.FileHandler(target) # type: ignore # noqa: E501
+ logging.StreamHandler(sys_default) if target == "-" else logging.FileHandler(target)
]
logger.propagate = propagate
formatter = logging.Formatter(
diff --git a/src/hypercorn/protocol/__init__.py b/src/hypercorn/protocol/__init__.py
index 4e8feae8..fa95969c 100644
--- a/src/hypercorn/protocol/__init__.py
+++ b/src/hypercorn/protocol/__init__.py
@@ -91,6 +91,10 @@ async def handle(self, event: Event) -> None:
self.server,
self.send,
)
- await self.protocol.initiate(error.headers, error.settings)
+ # H2Connection only accepts bytes, not str, but it passes the value to
+ # base64.urlsafe_b64encode that also handles ASCII strings.
+ # But H2CProtocolRequiredError intentionally decodes bytes in __init__,
+ # which should maybe be remedied.
+ await self.protocol.initiate(error.headers, error.settings) # type: ignore[arg-type]
if error.data != b"":
return await self.protocol.handle(RawData(data=error.data))
diff --git a/src/hypercorn/protocol/h2.py b/src/hypercorn/protocol/h2.py
index b19a2bcc..10704508 100644
--- a/src/hypercorn/protocol/h2.py
+++ b/src/hypercorn/protocol/h2.py
@@ -1,6 +1,17 @@
from __future__ import annotations
-from typing import Awaitable, Callable, Dict, List, Optional, Tuple, Type, Union
+from typing import (
+ Awaitable,
+ Callable,
+ cast,
+ Dict,
+ List,
+ Optional,
+ Tuple,
+ Type,
+ TYPE_CHECKING,
+ Union,
+)
import h2
import h2.connection
@@ -27,6 +38,10 @@
from ..typing import AppWrapper, ConnectionState, Event as IOEvent, TaskGroup, WorkerContext
from ..utils import filter_pseudo_headers
+if TYPE_CHECKING:
+ # fancy alias for tuple[bytes, bytes]
+ from hpack import HeaderTuple
+
BUFFER_HIGH_WATER = 2 * 2**14 # Twice the default max frame size (two frames worth)
BUFFER_LOW_WATER = BUFFER_HIGH_WATER / 2
@@ -127,7 +142,7 @@ def idle(self) -> bool:
return len(self.streams) == 0 or all(stream.idle for stream in self.streams.values())
async def initiate(
- self, headers: Optional[List[Tuple[bytes, bytes]]] = None, settings: Optional[str] = None
+ self, headers: Optional[List[Tuple[bytes, bytes]]] = None, settings: Optional[bytes] = None
) -> None:
if settings is not None:
self.connection.initiate_upgrade_connection(settings)
@@ -137,7 +152,7 @@ async def initiate(
if headers is not None:
event = h2.events.RequestReceived()
event.stream_id = 1
- event.headers = headers
+ event.headers = cast("list[HeaderTuple]", headers)
await self._create_stream(event)
await self.streams[event.stream_id].handle(EndBody(stream_id=event.stream_id))
self.task_group.spawn(self.send_task)
@@ -389,7 +404,7 @@ async def _create_server_push(
else:
event = h2.events.RequestReceived()
event.stream_id = push_stream_id
- event.headers = request_headers
+ event.headers = cast("list[HeaderTuple]", request_headers)
await self._create_stream(event)
await self.streams[event.stream_id].handle(EndBody(stream_id=event.stream_id))
self.keep_alive_requests += 1
diff --git a/src/hypercorn/run.py b/src/hypercorn/run.py
index cfe801aa..181b0487 100644
--- a/src/hypercorn/run.py
+++ b/src/hypercorn/run.py
@@ -56,7 +56,7 @@ def run(config: Config) -> int:
shutdown_event = ctx.Event()
def shutdown(*args: Any) -> None:
- nonlocal active, shutdown_event
+ nonlocal active
shutdown_event.set()
active = False
diff --git a/src/hypercorn/trio/__init__.py b/src/hypercorn/trio/__init__.py
index 07957062..23d83db3 100644
--- a/src/hypercorn/trio/__init__.py
+++ b/src/hypercorn/trio/__init__.py
@@ -16,7 +16,7 @@ async def serve(
config: Config,
*,
shutdown_trigger: Optional[Callable[..., Awaitable[None]]] = None,
- task_status: trio.TaskStatus = trio.TASK_STATUS_IGNORED,
+ task_status: trio.TaskStatus[list[str]] = trio.TASK_STATUS_IGNORED,
mode: Optional[Literal["asgi", "wsgi"]] = None,
) -> None:
"""Serve an ASGI framework app given the config.
diff --git a/src/hypercorn/trio/run.py b/src/hypercorn/trio/run.py
index 7c55df10..b974035a 100644
--- a/src/hypercorn/trio/run.py
+++ b/src/hypercorn/trio/run.py
@@ -33,7 +33,7 @@ async def worker_serve(
*,
sockets: Optional[Sockets] = None,
shutdown_trigger: Optional[Callable[..., Awaitable[None]]] = None,
- task_status: trio.TaskStatus = trio.TASK_STATUS_IGNORED,
+ task_status: trio.TaskStatus[list[str]] = trio.TASK_STATUS_IGNORED,
) -> None:
config.set_statsd_logger_class(StatsdLogger)
diff --git a/src/hypercorn/trio/worker_context.py b/src/hypercorn/trio/worker_context.py
index 1cac17e3..9aa7b362 100644
--- a/src/hypercorn/trio/worker_context.py
+++ b/src/hypercorn/trio/worker_context.py
@@ -11,7 +11,7 @@
def _cancel_wrapper(func: Callable[[], Awaitable[None]]) -> Callable[[], Awaitable[None]]:
@wraps(func)
async def wrapper(
- task_status: trio.TaskStatus = trio.TASK_STATUS_IGNORED,
+ task_status: trio.TaskStatus[trio.CancelScope] = trio.TASK_STATUS_IGNORED,
) -> None:
cancel_scope = trio.CancelScope()
task_status.started(cancel_scope)
diff --git a/src/hypercorn/utils.py b/src/hypercorn/utils.py
index 39249c53..bb7c0e91 100644
--- a/src/hypercorn/utils.py
+++ b/src/hypercorn/utils.py
@@ -18,6 +18,7 @@
List,
Literal,
Optional,
+ Sequence,
Tuple,
TYPE_CHECKING,
)
@@ -74,7 +75,7 @@ def build_and_validate_headers(headers: Iterable[Tuple[bytes, bytes]]) -> List[T
return validated_headers
-def filter_pseudo_headers(headers: List[Tuple[bytes, bytes]]) -> List[Tuple[bytes, bytes]]:
+def filter_pseudo_headers(headers: Sequence[Tuple[bytes, bytes]]) -> List[Tuple[bytes, bytes]]:
filtered_headers: List[Tuple[bytes, bytes]] = [(b"host", b"")] # Placeholder
authority = None
host = b""
diff --git a/tests/asyncio/test_sanity.py b/tests/asyncio/test_sanity.py
index c4c87f9a..0d1d0f90 100644
--- a/tests/asyncio/test_sanity.py
+++ b/tests/asyncio/test_sanity.py
@@ -224,11 +224,13 @@ async def test_http2_websocket() -> None:
h2_client.send_data(stream_id, client.send(wsproto.events.BytesMessage(data=SANITY_BODY)))
await server.reader.send(h2_client.data_to_send()) # type: ignore
events = h2_client.receive_data(await server.writer.receive()) # type: ignore
+ assert isinstance(events[0], h2.events.DataReceived)
client.receive_data(events[0].data)
assert list(client.events()) == [wsproto.events.TextMessage(data="Hello & Goodbye")]
h2_client.send_data(stream_id, client.send(wsproto.events.CloseConnection(code=1000)))
await server.reader.send(h2_client.data_to_send()) # type: ignore
events = h2_client.receive_data(await server.writer.receive()) # type: ignore
+ assert isinstance(events[0], h2.events.DataReceived)
client.receive_data(events[0].data)
assert list(client.events()) == [wsproto.events.CloseConnection(code=1000, reason="")]
h2_client.close_connection()
diff --git a/tests/middleware/test_dispatcher.py b/tests/middleware/test_dispatcher.py
index 2c6d8a1e..927d3212 100644
--- a/tests/middleware/test_dispatcher.py
+++ b/tests/middleware/test_dispatcher.py
@@ -33,7 +33,6 @@ async def __call__(self, scope: Scope, receive: Callable, send: Callable) -> Non
sent_events = []
async def send(message: dict) -> None:
- nonlocal sent_events
sent_events.append(message)
await app({**http_scope, **{"path": "/api/x/b"}}, None, send) # type: ignore
@@ -66,7 +65,6 @@ async def test_asyncio_dispatcher_lifespan() -> None:
sent_events = []
async def send(message: dict) -> None:
- nonlocal sent_events
sent_events.append(message)
async def receive() -> dict:
@@ -83,7 +81,6 @@ async def test_trio_dispatcher_lifespan() -> None:
sent_events = []
async def send(message: dict) -> None:
- nonlocal sent_events
sent_events.append(message)
async def receive() -> dict:
diff --git a/tests/middleware/test_http_to_https.py b/tests/middleware/test_http_to_https.py
index 01583e26..4d7518a0 100644
--- a/tests/middleware/test_http_to_https.py
+++ b/tests/middleware/test_http_to_https.py
@@ -14,7 +14,6 @@ async def test_http_to_https_redirect_middleware_http(raw_path: bytes) -> None:
sent_events = []
async def send(message: dict) -> None:
- nonlocal sent_events
sent_events.append(message)
scope: HTTPScope = {
@@ -53,7 +52,6 @@ async def test_http_to_https_redirect_middleware_websocket(raw_path: bytes) -> N
sent_events = []
async def send(message: dict) -> None:
- nonlocal sent_events
sent_events.append(message)
scope: WebsocketScope = {
@@ -90,7 +88,6 @@ async def test_http_to_https_redirect_middleware_websocket_http2() -> None:
sent_events = []
async def send(message: dict) -> None:
- nonlocal sent_events
sent_events.append(message)
scope: WebsocketScope = {
@@ -127,7 +124,6 @@ async def test_http_to_https_redirect_middleware_websocket_no_rejection() -> Non
sent_events = []
async def send(message: dict) -> None:
- nonlocal sent_events
sent_events.append(message)
scope: WebsocketScope = {
diff --git a/tests/protocol/test_h11.py b/tests/protocol/test_h11.py
index aa3b0bd5..819a28dc 100755
--- a/tests/protocol/test_h11.py
+++ b/tests/protocol/test_h11.py
@@ -2,7 +2,7 @@
import asyncio
from typing import Any
-from unittest.mock import call, Mock
+from unittest.mock import AsyncMock, call, Mock
import h11
import pytest
@@ -18,13 +18,6 @@
from hypercorn.protocol.http_stream import HTTPStream
from hypercorn.typing import ConnectionState, Event as IOEvent
-try:
- from unittest.mock import AsyncMock
-except ImportError:
- # Python < 3.8
- from mock import AsyncMock # type: ignore
-
-
BASIC_HEADERS = [("Host", "hypercorn"), ("Connection", "close")]
diff --git a/tests/protocol/test_h2.py b/tests/protocol/test_h2.py
index a13c494a..b549496d 100644
--- a/tests/protocol/test_h2.py
+++ b/tests/protocol/test_h2.py
@@ -1,7 +1,7 @@
from __future__ import annotations
import asyncio
-from unittest.mock import call, Mock
+from unittest.mock import AsyncMock, call, Mock
import pytest
from h2.connection import H2Connection
@@ -13,12 +13,6 @@
from hypercorn.protocol.h2 import BUFFER_HIGH_WATER, BufferCompleteError, H2Protocol, StreamBuffer
from hypercorn.typing import ConnectionState
-try:
- from unittest.mock import AsyncMock
-except ImportError:
- # Python < 3.8
- from mock import AsyncMock # type: ignore
-
@pytest.mark.asyncio
async def test_stream_buffer_push_and_pop() -> None:
diff --git a/tests/protocol/test_http_stream.py b/tests/protocol/test_http_stream.py
index b25cb2f3..3f82a02b 100644
--- a/tests/protocol/test_http_stream.py
+++ b/tests/protocol/test_http_stream.py
@@ -1,7 +1,7 @@
from __future__ import annotations
from typing import Any, cast
-from unittest.mock import call
+from unittest.mock import AsyncMock, call
import pytest
import pytest_asyncio
@@ -28,12 +28,6 @@
)
from hypercorn.utils import UnexpectedMessageError
-try:
- from unittest.mock import AsyncMock
-except ImportError:
- # Python < 3.8
- from mock import AsyncMock # type: ignore
-
@pytest_asyncio.fixture(name="stream") # type: ignore[misc]
async def _stream() -> HTTPStream:
diff --git a/tests/protocol/test_ws_stream.py b/tests/protocol/test_ws_stream.py
index 7b5ee98b..a8956fb8 100644
--- a/tests/protocol/test_ws_stream.py
+++ b/tests/protocol/test_ws_stream.py
@@ -2,7 +2,7 @@
import asyncio
from typing import Any, cast, List, Tuple
-from unittest.mock import call, Mock
+from unittest.mock import AsyncMock, call, Mock
import pytest
import pytest_asyncio
@@ -30,12 +30,6 @@
)
from hypercorn.utils import UnexpectedMessageError
-try:
- from unittest.mock import AsyncMock
-except ImportError:
- # Python < 3.8
- from mock import AsyncMock # type: ignore
-
def test_buffer() -> None:
buffer_ = WebsocketBuffer(10)
diff --git a/tests/test_app_wrappers.py b/tests/test_app_wrappers.py
index 0640350e..5112f124 100644
--- a/tests/test_app_wrappers.py
+++ b/tests/test_app_wrappers.py
@@ -47,7 +47,6 @@ async def test_wsgi_trio() -> None:
messages = []
async def _send(message: ASGISendEvent) -> None:
- nonlocal messages
messages.append(message)
await app(scope, receive_channel.receive, _send, trio.to_thread.run_sync, trio.from_thread.run)
@@ -69,7 +68,6 @@ async def _run_app(app: WSGIWrapper, scope: HTTPScope, body: bytes = b"") -> Lis
messages = []
async def _send(message: ASGISendEvent) -> None:
- nonlocal messages
messages.append(message)
event_loop = asyncio.get_running_loop()
diff --git a/tests/trio/test_keep_alive.py b/tests/trio/test_keep_alive.py
index c570a2a0..43d5793e 100644
--- a/tests/trio/test_keep_alive.py
+++ b/tests/trio/test_keep_alive.py
@@ -81,13 +81,10 @@ async def test_http1_keep_alive_during(
client_stream: ClientStream,
) -> None:
client = h11.Connection(h11.CLIENT)
- # client.send(h11.Request) and client.send(h11.EndOfMessage) only returns bytes.
- # Fixed on master/ in the h11 repo, once released the ignore's can be removed.
- # See https://github.com/python-hyper/h11/issues/175
- await client_stream.send_all(client.send(REQUEST)) # type: ignore[arg-type]
+ await client_stream.send_all(client.send(REQUEST))
await trio.sleep(2 * KEEP_ALIVE_TIMEOUT)
# Key is that this doesn't error
- await client_stream.send_all(client.send(h11.EndOfMessage())) # type: ignore[arg-type]
+ await client_stream.send_all(client.send(h11.EndOfMessage()))
@pytest.mark.trio
@@ -95,9 +92,9 @@ async def test_http1_keep_alive(
client_stream: ClientStream,
) -> None:
client = h11.Connection(h11.CLIENT)
- await client_stream.send_all(client.send(REQUEST)) # type: ignore[arg-type]
+ await client_stream.send_all(client.send(REQUEST))
await trio.sleep(2 * KEEP_ALIVE_TIMEOUT)
- await client_stream.send_all(client.send(h11.EndOfMessage())) # type: ignore[arg-type]
+ await client_stream.send_all(client.send(h11.EndOfMessage()))
while True:
event = client.next_event()
if event == h11.NEED_DATA:
@@ -106,10 +103,10 @@ async def test_http1_keep_alive(
elif isinstance(event, h11.EndOfMessage):
break
client.start_next_cycle()
- await client_stream.send_all(client.send(REQUEST)) # type: ignore[arg-type]
+ await client_stream.send_all(client.send(REQUEST))
await trio.sleep(2 * KEEP_ALIVE_TIMEOUT)
# Key is that this doesn't error
- await client_stream.send_all(client.send(h11.EndOfMessage())) # type: ignore[arg-type]
+ await client_stream.send_all(client.send(h11.EndOfMessage()))
@pytest.mark.trio
diff --git a/tests/trio/test_sanity.py b/tests/trio/test_sanity.py
index bea93f13..f055c8be 100644
--- a/tests/trio/test_sanity.py
+++ b/tests/trio/test_sanity.py
@@ -1,7 +1,7 @@
from __future__ import annotations
from typing import cast
-from unittest.mock import Mock, PropertyMock
+from unittest.mock import AsyncMock, Mock, PropertyMock
import h2
import h11
@@ -15,12 +15,6 @@
from hypercorn.trio.worker_context import WorkerContext
from ..helpers import MockSocket, SANITY_BODY, sanity_framework
-try:
- from unittest.mock import AsyncMock
-except ImportError:
- # Python < 3.8
- from mock import AsyncMock # type: ignore
-
@pytest.mark.trio
async def test_http1_request(nursery: trio._core._run.Nursery) -> None:
@@ -33,7 +27,7 @@ async def test_http1_request(nursery: trio._core._run.Nursery) -> None:
nursery.start_soon(server.run)
client = h11.Connection(h11.CLIENT)
await client_stream.send_all(
- client.send( # type: ignore[arg-type]
+ client.send(
h11.Request(
method="POST",
target="/",
@@ -45,8 +39,8 @@ async def test_http1_request(nursery: trio._core._run.Nursery) -> None:
)
)
)
- await client_stream.send_all(client.send(h11.Data(data=SANITY_BODY))) # type: ignore[arg-type]
- await client_stream.send_all(client.send(h11.EndOfMessage())) # type: ignore[arg-type]
+ await client_stream.send_all(client.send(h11.Data(data=SANITY_BODY)))
+ await client_stream.send_all(client.send(h11.EndOfMessage()))
events = []
while True:
event = client.next_event()
@@ -143,6 +137,8 @@ async def test_http2_request(nursery: trio._core._run.Nursery) -> None:
h2_events = client.receive_data(data)
for event in h2_events:
if isinstance(event, h2.events.DataReceived):
+ assert event.flow_controlled_length is not None
+ assert event.stream_id is not None
client.acknowledge_received_data(event.flow_controlled_length, event.stream_id)
elif isinstance(
event,
@@ -193,6 +189,7 @@ async def test_http2_websocket(nursery: trio._core._run.Nursery) -> None:
events = h2_client.receive_data(await client_stream.receive_some(1024))
if not isinstance(events[-1], h2.events.ResponseReceived):
events = h2_client.receive_data(await client_stream.receive_some(1024))
+ assert isinstance(events[-1], h2.events.ResponseReceived)
assert events[-1].headers == [
(b":status", b"200"),
(b"date", b"Thu, 01 Jan 1970 01:23:20 GMT"),
@@ -202,11 +199,14 @@ async def test_http2_websocket(nursery: trio._core._run.Nursery) -> None:
h2_client.send_data(stream_id, client.send(wsproto.events.BytesMessage(data=SANITY_BODY)))
await client_stream.send_all(h2_client.data_to_send())
events = h2_client.receive_data(await client_stream.receive_some(1024))
+ assert isinstance(events[0], h2.events.DataReceived)
client.receive_data(events[0].data)
assert list(client.events()) == [wsproto.events.TextMessage(data="Hello & Goodbye")]
+
h2_client.send_data(stream_id, client.send(wsproto.events.CloseConnection(code=1000)))
await client_stream.send_all(h2_client.data_to_send())
events = h2_client.receive_data(await client_stream.receive_some(1024))
+ assert isinstance(events[0], h2.events.DataReceived)
client.receive_data(events[0].data)
assert list(client.events()) == [wsproto.events.CloseConnection(code=1000, reason="")]
await client_stream.send_all(b"")
diff --git a/tox.ini b/tox.ini
index d931bfab..053e3092 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,11 +1,10 @@
[tox]
-envlist = docs,format,mypy,py38,py39,py310,py311,py312,package,pep8
+envlist = docs,format,mypy,py39,py310,py311,py312,py313,py314,package,pep8
minversion = 3.3
isolated_build = true
[testenv]
deps =
- py37: mock
httpx
hypothesis
pytest
@@ -16,7 +15,7 @@ deps =
commands = pytest --cov=hypercorn {posargs}
[testenv:docs]
-basepython = python3.12
+basepython = python3.13
deps =
pydata-sphinx-theme
sphinx
@@ -27,7 +26,7 @@ commands =
sphinx-build -W --keep-going -b html -d {envtmpdir}/doctrees docs/ docs/_build/html/
[testenv:format]
-basepython = python3.12
+basepython = python3.13
deps =
black
isort
@@ -36,7 +35,7 @@ commands =
isort --check --diff src/hypercorn tests
[testenv:pep8]
-basepython = python3.12
+basepython = python3.13
deps =
flake8
pep8-naming
@@ -45,7 +44,7 @@ deps =
commands = flake8 src/hypercorn/ tests/
[testenv:mypy]
-basepython = python3.12
+basepython = python3.13
deps =
mypy
pytest
@@ -54,7 +53,7 @@ commands =
mypy src/hypercorn/ tests/
[testenv:package]
-basepython = python3.12
+basepython = python3.13
deps =
poetry
twine
From b0f4273b89952ad04b2ed47472e1d3059182461b Mon Sep 17 00:00:00 2001
From: jakkdl
Date: Fri, 8 Aug 2025 12:52:54 +0200
Subject: [PATCH 2/3] add allow_prereleases, fix mypy errors with
--strict-bytes
---
.github/workflows/ci.yml | 1 +
src/hypercorn/app_wrappers.py | 3 ++-
src/hypercorn/events.py | 2 +-
src/hypercorn/protocol/h11.py | 8 +++++---
src/hypercorn/protocol/h2.py | 4 +++-
5 files changed, 12 insertions(+), 6 deletions(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 0162a2c7..58ffd8de 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -31,6 +31,7 @@ jobs:
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python }}
+ allow-prereleases: true
- name: update pip
run: |
diff --git a/src/hypercorn/app_wrappers.py b/src/hypercorn/app_wrappers.py
index 56c1bfa7..057cd720 100644
--- a/src/hypercorn/app_wrappers.py
+++ b/src/hypercorn/app_wrappers.py
@@ -1,6 +1,7 @@
from __future__ import annotations
import sys
+from collections.abc import Buffer
from functools import partial
from io import BytesIO
from typing import Callable, List, Optional, Tuple
@@ -117,7 +118,7 @@ def start_response(
response_body.close()
-def _build_environ(scope: HTTPScope, body: bytes) -> dict:
+def _build_environ(scope: HTTPScope, body: Buffer) -> dict:
server = scope.get("server") or ("localhost", 80)
path = scope["path"]
script_name = scope.get("root_path", "")
diff --git a/src/hypercorn/events.py b/src/hypercorn/events.py
index e829616a..dfef3314 100644
--- a/src/hypercorn/events.py
+++ b/src/hypercorn/events.py
@@ -11,7 +11,7 @@ class Event(ABC):
@dataclass(frozen=True)
class RawData(Event):
- data: bytes
+ data: bytes | bytearray | memoryview[int] # this can likely be collections.abc.Buffer
address: Optional[Tuple[str, int]] = None
diff --git a/src/hypercorn/protocol/h11.py b/src/hypercorn/protocol/h11.py
index c3c6e0f3..311e8f98 100644
--- a/src/hypercorn/protocol/h11.py
+++ b/src/hypercorn/protocol/h11.py
@@ -1,7 +1,7 @@
from __future__ import annotations
from itertools import chain
-from typing import Awaitable, Callable, cast, Optional, Tuple, Type, Union
+from typing import Awaitable, Callable, cast, Iterable, Optional, SupportsIndex, Tuple, Type, Union
import h11
@@ -59,7 +59,7 @@ def __init__(self, h11_connection: h11.Connection) -> None:
self.buffer = bytearray(h11_connection.trailing_data[0])
self.h11_connection = h11_connection
- def receive_data(self, data: bytes) -> None:
+ def receive_data(self, data: Iterable[SupportsIndex]) -> None:
self.buffer.extend(data)
def next_event(self) -> Union[Data, Type[h11.NEED_DATA]]:
@@ -111,7 +111,9 @@ async def initiate(self) -> None:
async def handle(self, event: Event) -> None:
if isinstance(event, RawData):
- self.connection.receive_data(event.data)
+ # `h11.Connection.receive_data` should accept `Buffer`, but is overly narrow.
+ # See https://github.com/python-hyper/h11/issues/186
+ self.connection.receive_data(event.data) # type: ignore[arg-type]
await self._handle_events()
elif isinstance(event, Closed):
if self.stream is not None:
diff --git a/src/hypercorn/protocol/h2.py b/src/hypercorn/protocol/h2.py
index 10704508..9c255fb2 100644
--- a/src/hypercorn/protocol/h2.py
+++ b/src/hypercorn/protocol/h2.py
@@ -199,7 +199,9 @@ async def _send_data(self, stream_id: int) -> None:
async def handle(self, event: Event) -> None:
if isinstance(event, RawData):
try:
- events = self.connection.receive_data(event.data)
+ # H2 relies on legacy typing behavior of `bytes` accepting `bytearray`
+ # See https://github.com/python-hyper/h2/issues/1305
+ events = self.connection.receive_data(event.data) # type: ignore[arg-type]
except h2.exceptions.ProtocolError:
await self._flush()
await self.send(Closed())
From 73685aa6c6e6d1c2cbdc5c0ba02f7ba7c508c6b3 Mon Sep 17 00:00:00 2001
From: jakkdl
Date: Fri, 8 Aug 2025 12:57:22 +0200
Subject: [PATCH 3/3] Buffer is 3.12+
---
src/hypercorn/app_wrappers.py | 6 ++++--
1 file changed, 4 insertions(+), 2 deletions(-)
diff --git a/src/hypercorn/app_wrappers.py b/src/hypercorn/app_wrappers.py
index 057cd720..fbd828ba 100644
--- a/src/hypercorn/app_wrappers.py
+++ b/src/hypercorn/app_wrappers.py
@@ -1,10 +1,9 @@
from __future__ import annotations
import sys
-from collections.abc import Buffer
from functools import partial
from io import BytesIO
-from typing import Callable, List, Optional, Tuple
+from typing import Callable, List, Optional, Tuple, TYPE_CHECKING
from .typing import (
ASGIFramework,
@@ -15,6 +14,9 @@
WSGIFramework,
)
+if TYPE_CHECKING:
+ from typing_extensions import Buffer # for py<3.12
+
class InvalidPathError(Exception):
pass