Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 8 additions & 6 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,22 +14,24 @@ 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

- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python }}
allow-prereleases: true

- name: update pip
run: |
Expand All @@ -56,7 +58,7 @@ jobs:

- uses: actions/setup-python@v5
with:
python-version: "3.12"
python-version: "3.13"

- name: update pip
run: |
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ jobs:

- uses: actions/setup-python@v3
with:
python-version: 3.12
python-version: 3.13

- run: |
pip install poetry
Expand Down
10 changes: 6 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]
Expand All @@ -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 = "*"
Expand All @@ -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 = "*"
Expand All @@ -61,7 +62,7 @@ uvloop = ["uvloop"]

[tool.black]
line-length = 100
target-version = ["py38"]
target-version = ["py39"]

[tool.isort]
combine_as_imports = true
Expand Down Expand Up @@ -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"]

Expand Down
2 changes: 1 addition & 1 deletion setup.cfg
Original file line number Diff line number Diff line change
@@ -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
7 changes: 5 additions & 2 deletions src/hypercorn/app_wrappers.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import sys
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,
Expand All @@ -14,6 +14,9 @@
WSGIFramework,
)

if TYPE_CHECKING:
from typing_extensions import Buffer # for py<3.12


class InvalidPathError(Exception):
pass
Expand Down Expand Up @@ -117,7 +120,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", "")
Expand Down
2 changes: 0 additions & 2 deletions src/hypercorn/asyncio/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion src/hypercorn/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
2 changes: 1 addition & 1 deletion src/hypercorn/logging.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
6 changes: 5 additions & 1 deletion src/hypercorn/protocol/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
8 changes: 5 additions & 3 deletions src/hypercorn/protocol/h11.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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]]:
Expand Down Expand Up @@ -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:
Expand Down
27 changes: 22 additions & 5 deletions src/hypercorn/protocol/h2.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -184,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())
Expand Down Expand Up @@ -389,7 +406,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
Expand Down
2 changes: 1 addition & 1 deletion src/hypercorn/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion src/hypercorn/trio/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion src/hypercorn/trio/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
2 changes: 1 addition & 1 deletion src/hypercorn/trio/worker_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
3 changes: 2 additions & 1 deletion src/hypercorn/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
List,
Literal,
Optional,
Sequence,
Tuple,
TYPE_CHECKING,
)
Expand Down Expand Up @@ -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""
Expand Down
2 changes: 2 additions & 0 deletions tests/asyncio/test_sanity.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
3 changes: 0 additions & 3 deletions tests/middleware/test_dispatcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand Down
4 changes: 0 additions & 4 deletions tests/middleware/test_http_to_https.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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 = {
Expand Down
Loading