Skip to content

Commit 2e3ba33

Browse files
KludexCopilot
andauthored
Support Python 3.14 (#354)
Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
1 parent 97a25d6 commit 2e3ba33

File tree

16 files changed

+1310
-566
lines changed

16 files changed

+1310
-566
lines changed

.coveragerc

Lines changed: 0 additions & 5 deletions
This file was deleted.

.github/workflows/docs.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ jobs:
2222
- name: Install uv
2323
uses: astral-sh/setup-uv@v2
2424
with:
25-
version: "0.4.12"
25+
version: "0.9.18"
2626
enable-cache: true
2727

2828
- name: Set up Python

.github/workflows/main.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,14 @@ jobs:
1111
runs-on: ubuntu-latest
1212
strategy:
1313
matrix:
14-
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
14+
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"]
1515
steps:
1616
- uses: actions/checkout@v4
1717

1818
- name: Install uv
1919
uses: astral-sh/setup-uv@v2
2020
with:
21-
version: "0.4.12"
21+
version: "0.9.18"
2222
enable-cache: true
2323

2424
- name: Set up Python ${{ matrix.python-version }}

.github/workflows/publish.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ jobs:
1616
- name: Install uv
1717
uses: astral-sh/setup-uv@v2
1818
with:
19-
version: "0.4.12"
19+
version: "0.9.18"
2020
enable-cache: true
2121

2222
- name: Set up Python

mangum/_compat.py

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
from __future__ import annotations
2+
3+
import asyncio
4+
import sys
5+
from collections.abc import Callable, Coroutine
6+
from typing import Any, TypeVar
7+
8+
__all__ = ["asyncio_run", "iscoroutinefunction"]
9+
10+
if sys.version_info >= (3, 14):
11+
from inspect import iscoroutinefunction
12+
else:
13+
from asyncio import iscoroutinefunction
14+
15+
_T = TypeVar("_T")
16+
17+
if sys.version_info >= (3, 12):
18+
asyncio_run = asyncio.run
19+
elif sys.version_info >= (3, 11):
20+
21+
def asyncio_run(
22+
main: Coroutine[Any, Any, _T],
23+
*,
24+
debug: bool = False,
25+
loop_factory: Callable[[], asyncio.AbstractEventLoop] | None = None,
26+
) -> _T:
27+
# asyncio.run from Python 3.12
28+
# https://docs.python.org/3/license.html#psf-license
29+
with asyncio.Runner(debug=debug, loop_factory=loop_factory) as runner:
30+
return runner.run(main)
31+
32+
else:
33+
# modified version of asyncio.run from Python 3.10 to add loop_factory kwarg
34+
# https://docs.python.org/3/license.html#psf-license
35+
def asyncio_run(
36+
main: Coroutine[Any, Any, _T],
37+
*,
38+
debug: bool = False,
39+
loop_factory: Callable[[], asyncio.AbstractEventLoop] | None = None,
40+
) -> _T:
41+
try:
42+
asyncio.get_running_loop()
43+
except RuntimeError:
44+
pass
45+
else:
46+
raise RuntimeError("asyncio.run() cannot be called from a running event loop")
47+
48+
if not asyncio.iscoroutine(main):
49+
raise ValueError(f"a coroutine was expected, got {main!r}")
50+
51+
if loop_factory is None:
52+
loop = asyncio.new_event_loop()
53+
else:
54+
loop = loop_factory()
55+
try:
56+
if loop_factory is None:
57+
asyncio.set_event_loop(loop)
58+
if debug is not None:
59+
loop.set_debug(debug)
60+
return loop.run_until_complete(main)
61+
finally:
62+
try:
63+
_cancel_all_tasks(loop)
64+
loop.run_until_complete(loop.shutdown_asyncgens())
65+
loop.run_until_complete(loop.shutdown_default_executor())
66+
finally:
67+
if loop_factory is None:
68+
asyncio.set_event_loop(None)
69+
loop.close()
70+
71+
def _cancel_all_tasks(loop: asyncio.AbstractEventLoop) -> None:
72+
to_cancel = asyncio.all_tasks(loop)
73+
if not to_cancel:
74+
return
75+
76+
for task in to_cancel:
77+
task.cancel()
78+
79+
loop.run_until_complete(asyncio.gather(*to_cancel, return_exceptions=True))
80+
81+
for task in to_cancel:
82+
if task.cancelled():
83+
continue
84+
if task.exception() is not None:
85+
loop.call_exception_handler(
86+
{
87+
"message": "unhandled exception during asyncio.run() shutdown",
88+
"exception": task.exception(),
89+
"task": task,
90+
}
91+
)

mangum/adapter.py

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
from __future__ import annotations
22

33
import logging
4-
from contextlib import ExitStack
54
from itertools import chain
65
from typing import Any
76

7+
from mangum._compat import asyncio_run
88
from mangum.exceptions import ConfigurationError
99
from mangum.handlers import ALB, APIGateway, HTTPGateway, LambdaAtEdge
1010
from mangum.protocols import HTTPCycle, LifespanCycle
@@ -59,17 +59,20 @@ def infer(self, event: LambdaEvent, context: LambdaContext) -> LambdaHandler:
5959
)
6060

6161
def __call__(self, event: LambdaEvent, context: LambdaContext) -> dict[str, Any]:
62-
handler = self.infer(event, context)
63-
scope = handler.scope
64-
with ExitStack() as stack:
62+
async def handle_request() -> dict[str, Any]:
63+
handler = self.infer(event, context)
64+
scope = handler.scope
65+
6566
if self.lifespan in ("auto", "on"):
6667
lifespan_cycle = LifespanCycle(self.app, self.lifespan)
67-
stack.enter_context(lifespan_cycle)
68-
scope.update({"state": lifespan_cycle.lifespan_state.copy()})
69-
70-
http_cycle = HTTPCycle(scope, handler.body)
71-
http_response = http_cycle(self.app)
72-
73-
return handler(http_response)
68+
async with lifespan_cycle:
69+
scope.update({"state": lifespan_cycle.lifespan_state.copy()})
70+
http_cycle = HTTPCycle(scope, handler.body)
71+
http_response = await http_cycle(self.app)
72+
return handler(http_response)
73+
else:
74+
http_cycle = HTTPCycle(scope, handler.body)
75+
http_response = await http_cycle(self.app)
76+
return handler(http_response)
7477

75-
assert False, "unreachable" # pragma: no cover
78+
return asyncio_run(handle_request())

mangum/protocols/http.py

Lines changed: 4 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -33,25 +33,11 @@ def __init__(self, scope: Scope, body: bytes) -> None:
3333
self.state = HTTPCycleState.REQUEST
3434
self.logger = logging.getLogger("mangum.http")
3535
self.app_queue: asyncio.Queue[Message] = asyncio.Queue()
36-
self.app_queue.put_nowait(
37-
{
38-
"type": "http.request",
39-
"body": body,
40-
"more_body": False,
41-
}
42-
)
36+
self.app_queue.put_nowait({"type": "http.request", "body": body, "more_body": False})
4337

44-
def __call__(self, app: ASGI) -> Response:
45-
asgi_instance = self.run(app)
46-
loop = asyncio.get_event_loop()
47-
asgi_task = loop.create_task(asgi_instance)
48-
loop.run_until_complete(asgi_task)
49-
50-
return {
51-
"status": self.status,
52-
"headers": self.headers,
53-
"body": self.body,
54-
}
38+
async def __call__(self, app: ASGI) -> Response:
39+
await self.run(app)
40+
return {"status": self.status, "headers": self.headers, "body": self.body}
5541

5642
async def run(self, app: ASGI) -> None:
5743
try:

mangum/protocols/lifespan.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -59,26 +59,26 @@ def __init__(self, app: ASGI, lifespan: LifespanMode) -> None:
5959
self.lifespan = lifespan
6060
self.state: LifespanCycleState = LifespanCycleState.CONNECTING
6161
self.exception: BaseException | None = None
62-
self.loop = asyncio.get_event_loop()
6362
self.app_queue: asyncio.Queue[Message] = asyncio.Queue()
6463
self.startup_event: asyncio.Event = asyncio.Event()
6564
self.shutdown_event: asyncio.Event = asyncio.Event()
6665
self.logger = logging.getLogger("mangum.lifespan")
6766
self.lifespan_state: dict[str, Any] = {}
6867

69-
def __enter__(self) -> None:
68+
async def __aenter__(self) -> LifespanCycle:
7069
"""Runs the event loop for application startup."""
71-
self.loop.create_task(self.run())
72-
self.loop.run_until_complete(self.startup())
70+
asyncio.create_task(self.run())
71+
await self.startup()
72+
return self
7373

74-
def __exit__(
74+
async def __aexit__(
7575
self,
7676
exc_type: type[BaseException] | None,
7777
exc_value: BaseException | None,
7878
traceback: TracebackType | None,
7979
) -> None:
8080
"""Runs the event loop for application shutdown."""
81-
self.loop.run_until_complete(self.shutdown())
81+
await self.shutdown()
8282

8383
async def run(self) -> None:
8484
"""Calls the application with the `lifespan` connection scope."""

pyproject.toml

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,17 +21,21 @@ classifiers = [
2121
"Programming Language :: Python :: 3.11",
2222
"Programming Language :: Python :: 3.12",
2323
"Programming Language :: Python :: 3.13",
24+
"Programming Language :: Python :: 3.14",
2425
"Topic :: Internet :: WWW/HTTP",
2526
]
2627
dependencies = ["typing_extensions"]
2728

29+
[tool.uv]
30+
default-groups = ["dev"]
31+
2832
[dependency-groups]
2933
dev = [
30-
"pytest",
31-
"pytest-cov",
34+
"pytest>=8.0.0",
35+
"coverage",
3236
"ruff",
3337
"starlette",
34-
"quart",
38+
"quart>=0.20.0",
3539
"hypercorn>=0.15.0",
3640
"mypy",
3741
"brotli",
@@ -66,10 +70,6 @@ ignore = ["UP031"] # https://docs.astral.sh/ruff/rules/printf-string-formatting/
6670
strict = true
6771

6872
[tool.pytest.ini_options]
69-
log_cli = true
70-
log_cli_level = "INFO"
71-
log_cli_format = "%(asctime)s [%(levelname)8s] %(message)s (%(filename)s:%(lineno)s)"
72-
log_cli_date_format = "%Y-%m-%d %H:%M:%S"
7373
addopts = "-rXs --strict-config --strict-markers"
7474
xfail_strict = true
7575
filterwarnings = [
@@ -86,8 +86,12 @@ filterwarnings = [
8686

8787
[tool.coverage.run]
8888
source_pkgs = ["mangum", "tests"]
89+
omit = ["mangum/_compat.py"]
8990

9091
[tool.coverage.report]
92+
fail_under = 100
93+
skip_covered = true
94+
show_missing = true
9195
exclude_lines = [
9296
"pragma: no cover",
9397
"pragma: nocover",

scripts/test

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,5 @@
22

33
set -x # print executed commands to the terminal
44

5-
uv run pytest --ignore venv --cov=mangum --cov=tests --cov-fail-under=100 --cov-report=term-missing "${@}"
5+
uv run coverage run -m pytest "${@}"
6+
uv run coverage report

0 commit comments

Comments
 (0)