Skip to content

Commit df70493

Browse files
Refactor protocols, better documentation, more correct ASGI behaviour (#108)
* Refactor lifespan, HTTP, WebSockets to be more consistent in behaviour, move things around, better docstrings/comments, pytest.ini
1 parent 8de9e84 commit df70493

File tree

19 files changed

+848
-475
lines changed

19 files changed

+848
-475
lines changed

README.md

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ The adapter accepts various arguments for configuring lifespan, logging, HTTP, W
6868
```python
6969
handler = Mangum(
7070
app,
71-
enable_lifespan=True,
71+
lifespan="auto",
7272
log_level="info",
7373
api_gateway_base_path=None,
7474
text_mime_types=None,
@@ -80,17 +80,32 @@ handler = Mangum(
8080

8181
### Parameters
8282

83-
- `app` : ***ASGI application***
83+
- `app` : **ASGI application**
8484

8585
An asynchronous callable that conforms to ASGI specification version 3.0. This will usually be a framework application instance that exposes a valid ASGI callable.
8686

87-
- `enable_lifespan` : **bool**
87+
- `lifespan` : **str** (`auto`|`on`|`off`)
8888

89-
Specify whether or not to enable lifespan support. The adapter will automatically determine if lifespan is supported by the framework unless explicitly disabled.
89+
Specify lifespan support option. Default: `auto`.
90+
91+
* `auto`
92+
Application support for lifespan will be inferred.
93+
94+
Any error that occurs during startup will be logged and the request will continue being handled unless a `lifespan.startup.failed` event is sent by the application.
95+
96+
* `on`:
97+
Application support for lifespan is explicitly set.
98+
99+
Any error that occurs during startup will be raised and a 500 response will be returned.
100+
101+
* `off`:
102+
Application support for lifespan should be ignored.
103+
104+
The application will not enter the lifespan cycle context.
90105

91-
- `log_level` : **str**
106+
- `log_level` : **str** (`info`|`critical`|`error`|`warning`|`debug`)
92107

93-
Level parameter for the logger.
108+
Level parameter for the logger. Default: `info`.
94109

95110
- `api_gateway_base_path` : **str**
96111

docs/asgi.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,7 @@ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "app.settings")
173173

174174
application = get_asgi_application()
175175

176-
handler = Mangum(application, enable_lifespan=False)
176+
handler = Mangum(application, lifespan="off")
177177
```
178178

179179
This example looks a bit different than the others because it is based on Django's standard project configuration, but the ASGI behaviour is the same.
@@ -199,7 +199,7 @@ django.setup()
199199
application = get_default_application()
200200

201201
wrapped_application = guarantee_single_callable(application)
202-
handler = Mangum(wrapped_application, enable_lifespan=False)
202+
handler = Mangum(wrapped_application, lifespan="off")
203203
```
204204

205205
## Middleware

docs/index.md

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ The adapter accepts various arguments for configuring lifespan, logging, HTTP, W
6868
```python
6969
handler = Mangum(
7070
app,
71-
enable_lifespan=True,
71+
lifespan="auto",
7272
log_level="info",
7373
api_gateway_base_path=None,
7474
text_mime_types=None,
@@ -80,17 +80,32 @@ handler = Mangum(
8080

8181
### Parameters
8282

83-
- `app` : ***ASGI application***
83+
- `app` : **ASGI application**
8484

8585
An asynchronous callable that conforms to ASGI specification version 3.0. This will usually be a framework application instance that exposes a valid ASGI callable.
8686

87-
- `enable_lifespan` : **bool**
87+
- `lifespan` : **str** (`auto`|`on`|`off`)
8888

89-
Specify whether or not to enable lifespan support. The adapter will automatically determine if lifespan is supported by the framework unless explicitly disabled.
89+
Specify lifespan support option. Default: `auto`.
90+
91+
* `auto`
92+
Application support for lifespan will be inferred.
93+
94+
Any error that occurs during startup will be logged and the request will continue being handled unless a `lifespan.startup.failed` event is sent by the application.
95+
96+
* `on`:
97+
Application support for lifespan is explicitly set.
98+
99+
Any error that occurs during startup will be raised and a 500 response will be returned.
100+
101+
* `off`:
102+
Application support for lifespan should be ignored.
103+
104+
The application will not enter the lifespan cycle context.
90105

91-
- `log_level` : **str**
106+
- `log_level` : **str** (`info`|`critical`|`error`|`warning`|`debug`)
92107

93-
Level parameter for the logger.
108+
Level parameter for the logger. Default: `info`.
94109

95110
- `api_gateway_base_path` : **str**
96111

docs/websockets.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ A connected client has sent a message. The adapter will retrieve the initial req
1818

1919
The client or the server disconnects from the API. The adapter will remove the connection from the backend.
2020

21-
### Backends
21+
## Backends
2222

2323
A data source, such as a cloud database, is required in order to persist the connection identifiers in a 'serverless' environment. Any data source can be used as long as it is accessible remotely to the AWS Lambda function.
2424

mangum/adapter.py

Lines changed: 53 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,16 @@
11
import base64
2-
import asyncio
32
import urllib.parse
43
import typing
54
import logging
65
import os
6+
import warnings
7+
from contextlib import ExitStack
78
from dataclasses import dataclass
89

9-
from mangum.lifespan import Lifespan
1010
from mangum.types import ASGIApp
11+
from mangum.protocols.lifespan import LifespanCycle
1112
from mangum.protocols.http import HTTPCycle
12-
from mangum.protocols.ws import WebSocketCycle
13+
from mangum.protocols.websockets import WebSocketCycle
1314
from mangum.websocket import WebSocket
1415
from mangum.exceptions import ConfigurationError
1516

@@ -52,23 +53,51 @@ def get_logger(log_level: str) -> logging.Logger:
5253

5354
@dataclass
5455
class Mangum:
56+
"""
57+
Creates an adapter instance.
58+
59+
**Parameters:**
60+
61+
* **app** - An asynchronous callable that conforms to version 3.0 of the ASGI
62+
specification. This will usually be an ASGI framework application instance.
63+
* **lifespan** - A string to configure lifespan support. Choices are `auto`, `on`,
64+
and `off`. Default is `auto`.
65+
* **log_level** - A string to configure the log level. Choices are: `info`,
66+
`critical`, `error`, `warning`, and `debug`. Default is `info`.
67+
* **api_gateway_base_path** - Base path to strip from URL when using a custom
68+
domain name.
69+
* **text_mime_types** - A list of MIME types to include with the defaults that
70+
should never return a binary response in API Gateway.
71+
* **dsn** - A connection string required to configure a supported WebSocket backend.
72+
* **api_gateway_endpoint_url** - A string endpoint url to use for API Gateway when
73+
sending data to WebSocket connections. Default is `None`.
74+
* **api_gateway_region_name** - A string region name to use for API Gateway when
75+
sending data to WebSocket connections. Default is `AWS_REGION` environment variable.
76+
"""
5577

5678
app: ASGIApp
57-
enable_lifespan: bool = True
79+
lifespan: str = "auto"
5880
log_level: str = "info"
5981
api_gateway_base_path: typing.Optional[str] = None
6082
text_mime_types: typing.Optional[typing.List[str]] = None
6183
dsn: typing.Optional[str] = None
6284
api_gateway_endpoint_url: typing.Optional[str] = None
6385
api_gateway_region_name: typing.Optional[str] = None
86+
enable_lifespan: bool = True # Deprecated.
6487

6588
def __post_init__(self) -> None:
6689
self.logger = get_logger(self.log_level)
67-
if self.enable_lifespan:
68-
loop = asyncio.get_event_loop()
69-
self.lifespan = Lifespan(self.app)
70-
loop.create_task(self.lifespan.run())
71-
loop.run_until_complete(self.lifespan.startup())
90+
if not self.enable_lifespan: # pragma: no cover
91+
warnings.warn(
92+
"The `enable_lifespan` parameter will be removed in a future release. "
93+
"It is replaced by `lifespan` setting.",
94+
DeprecationWarning,
95+
stacklevel=2,
96+
)
97+
if self.lifespan not in ("auto", "on", "off"): # pragma: no cover
98+
raise ConfigurationError(
99+
"Invalid argument supplied for `lifespan`. Choices are: auto|on|off."
100+
)
72101

73102
def __call__(self, event: dict, context: dict) -> dict:
74103
response = self.handler(event, context)
@@ -84,16 +113,18 @@ def strip_base_path(self, path: str) -> str:
84113
return urllib.parse.unquote(path or "/")
85114

86115
def handler(self, event: dict, context: dict) -> dict:
87-
if "eventType" in event["requestContext"]:
88-
response = self.handle_ws(event, context)
89-
else:
90-
is_http_api = "http" in event["requestContext"]
91-
response = self.handle_http(event, context, is_http_api=is_http_api)
92-
93-
if self.enable_lifespan:
94-
if self.lifespan.is_supported:
95-
loop = asyncio.get_event_loop()
96-
loop.run_until_complete(self.lifespan.shutdown())
116+
with ExitStack() as stack:
117+
if self.lifespan in ("auto", "on"):
118+
asgi_cycle: typing.ContextManager = LifespanCycle(
119+
self.app, self.lifespan
120+
)
121+
stack.enter_context(asgi_cycle)
122+
123+
if "eventType" in event["requestContext"]:
124+
response = self.handle_ws(event, context)
125+
else:
126+
is_http_api = "http" in event["requestContext"]
127+
response = self.handle_http(event, context, is_http_api=is_http_api)
97128

98129
return response
99130

@@ -155,10 +186,7 @@ def handle_http(self, event: dict, context: dict, *, is_http_api: bool) -> dict:
155186
text_mime_types = DEFAULT_TEXT_MIME_TYPES
156187

157188
asgi_cycle = HTTPCycle(
158-
scope, text_mime_types=text_mime_types, log_level=self.log_level
159-
)
160-
asgi_cycle.put_message(
161-
{"type": "http.request", "body": body, "more_body": False}
189+
scope, body=body, text_mime_types=text_mime_types, log_level=self.log_level
162190
)
163191
response = asgi_cycle(self.app)
164192

@@ -220,12 +248,9 @@ def handle_ws(self, event: dict, context: dict) -> dict:
220248

221249
elif event_type == "MESSAGE":
222250
websocket.fetch()
223-
asgi_cycle = WebSocketCycle(websocket, log_level=self.log_level)
224-
asgi_cycle.put_message({"type": "websocket.connect"})
225-
asgi_cycle.put_message(
226-
{"type": "websocket.receive", "bytes": None, "text": event["body"]}
251+
asgi_cycle = WebSocketCycle(
252+
event["body"], websocket=websocket, log_level=self.log_level
227253
)
228-
229254
response = asgi_cycle(self.app)
230255

231256
elif event_type == "DISCONNECT":

mangum/exceptions.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,17 @@
11
class LifespanFailure(Exception):
2-
"""Raise when an error occurs in a lifespan event"""
2+
"""Raise when a lifespan failure event is sent by an application."""
3+
4+
5+
class LifespanUnsupported(Exception):
6+
"""Raise when lifespan events are not supported by an application."""
7+
8+
9+
class UnexpectedMessage(Exception):
10+
"""Raise when an unexpected message type is received during an ASGI cycle."""
11+
12+
13+
class WebSocketClosed(Exception):
14+
"""Raise when an application closes the connection during the handshake."""
315

416

517
class WebSocketError(Exception):

mangum/lifespan.py

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

0 commit comments

Comments
 (0)