Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

AsgiWhiteNoise and async WhiteNoiseMiddleware #359

Open
wants to merge 131 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
131 commits
Select commit Hold shift + click to select a range
9a56981
Add ASGI adapter
kmichel Sep 14, 2020
8590e82
add venv to gitignore
Archmonger Feb 7, 2022
24c5319
Merge remote-tracking branch 'upstream/master' into asgi-compat
Archmonger Feb 7, 2022
391bd8e
add aiofile
Archmonger Feb 7, 2022
3e735ec
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Feb 7, 2022
42fbe15
Merge branch 'asgi-compat' of https://github.com/Archmonger/whitenois…
Archmonger Feb 7, 2022
aec9ac1
Revert "add venv to gitignore"
Archmonger Feb 8, 2022
cf4ee09
add ASGI extra
Archmonger Feb 10, 2022
9616e78
absolute imports
Archmonger Feb 10, 2022
35b9003
use SimpleNamespace
Archmonger Feb 10, 2022
8eef8d3
add zero-copy send todo
Archmonger Feb 10, 2022
2f0f685
replace empty equality with boolean operation
Archmonger Feb 10, 2022
f4931e3
minor syntax or performance improvements
Archmonger Feb 10, 2022
3fb5a38
BaseWhiteNoise class
Archmonger Feb 10, 2022
3bb4088
Merge remote-tracking branch 'upstream/master' into asgi-compat
Archmonger Feb 10, 2022
8a0930e
use f-strings
Archmonger Feb 10, 2022
e6fd03b
fix imports
Archmonger Feb 10, 2022
8fc1682
Merge remote-tracking branch 'upstream/master' into asgi-compat
Archmonger Feb 10, 2022
64fc5c7
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Feb 10, 2022
83fea65
Revert "minor syntax or performance improvements"
Archmonger Feb 10, 2022
58f3743
Revert "use f-strings"
Archmonger Feb 10, 2022
f79aec7
re-add missing import
Archmonger Feb 10, 2022
e18df38
Remove upper bound
Archmonger Feb 10, 2022
45301b2
fix recceive call
Archmonger Feb 10, 2022
88ccf21
fix some of the tests
Archmonger Feb 11, 2022
998cf5e
temporarily remove broken tests
Archmonger Feb 11, 2022
d611e90
fix py3.10 deprecation warning
Archmonger Feb 11, 2022
54c7148
convert wsgi to asgi
Archmonger Feb 12, 2022
1057a77
remove contrived tests
Archmonger Feb 12, 2022
ea2e84c
AsgiWhiteNoise -> AsyncWhiteNoise
Archmonger Feb 12, 2022
6dcecc0
Revert "convert wsgi to asgi"
Archmonger Feb 12, 2022
c506e96
ASGI v3 static file server
Archmonger Jun 21, 2023
9892bea
Merge remote-tracking branch 'upstream/main' into asgi-compat
Archmonger Jun 21, 2023
e636411
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jun 21, 2023
41468a1
guarantee_single_callable
Archmonger Jun 22, 2023
08c70fb
customizable block size
Archmonger Jun 22, 2023
240ce22
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jun 22, 2023
be3796c
clean up comment
Archmonger Jun 22, 2023
b81ab6b
Merge branch 'asgi-compat' of https://github.com/Archmonger/whitenois…
Archmonger Jun 22, 2023
7aeb29a
Add functional middleware
Archmonger Jun 22, 2023
cd0ab1b
add aiofiles to all tests
Archmonger Jun 22, 2023
1265f03
temporarily add aiofiles to tox.ini
Archmonger Jun 22, 2023
f5e9d37
temporarily disable asgi tests
Archmonger Jun 22, 2023
0e42cb8
async -> asgi
Archmonger Jun 22, 2023
279b342
aiofile != aiofiles
Archmonger Jul 20, 2023
890fdd8
fix comment
Archmonger Jul 20, 2023
ba3bed4
properly close Django file responses
Archmonger Jul 21, 2023
7af5b32
async find_file within AsgiWhiteNoise
Archmonger Jul 21, 2023
ee068a7
AsyncSlicedFile
Archmonger Jul 21, 2023
f09b384
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jul 21, 2023
ca26354
simplify __call__ method
Archmonger Jul 21, 2023
c2c320c
Add WSGI compat docstring
Archmonger Jul 21, 2023
d0e9bfa
Merge branch 'asgi-compat' of https://github.com/Archmonger/whitenois…
Archmonger Jul 21, 2023
33ad918
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jul 21, 2023
067ab33
aget_response update comment
Archmonger Jul 21, 2023
a15422f
add prototypes and warnings for call/serve
Archmonger Jul 21, 2023
8d01092
update middleware comment
Archmonger Jul 21, 2023
d6bb4d8
fix compatibilty with SecurityMiddleware
Archmonger Jul 21, 2023
2a386cc
fix name
Archmonger Jul 21, 2023
06a874f
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jul 21, 2023
1f9f8b3
refactor WhiteNoiseFileResponse
Archmonger Jul 21, 2023
64c1c27
reduce code duplication for AsyncSlicedFile
Archmonger Jul 21, 2023
f3b00ab
Merge branch 'asgi-compat' of https://github.com/Archmonger/whitenois…
Archmonger Jul 21, 2023
68f19c2
update WhiteNoiseFileResponse docstring
Archmonger Jul 21, 2023
00f064d
customizable block_size
Archmonger Jul 21, 2023
e5f7526
docstring update
Archmonger Jul 21, 2023
9ee0d7d
rename aiofile param
Archmonger Jul 21, 2023
d97a05b
fix file seeking
Archmonger Jul 23, 2023
2dbc8b2
add async pytest for django
Archmonger Jul 23, 2023
de3456e
merge imports
Archmonger Jul 23, 2023
f0f0a66
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jul 23, 2023
511da12
comment out everything from test_asgi
Archmonger Jul 23, 2023
7fd30ef
handle file closure within iterator
Archmonger Jul 24, 2023
62de430
remove pytest asyncio
Archmonger Jul 24, 2023
bb62496
async file closure
Archmonger Jul 24, 2023
d839cf8
Py 3.8 compatibility
Archmonger Jul 24, 2023
b40ff65
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jul 24, 2023
522a50b
remove unused import
Archmonger Jul 24, 2023
518a46b
start from scratch for ASGI tests
Archmonger Jul 24, 2023
ed07496
Add async iterator support to old django versions
Archmonger Jul 24, 2023
3bff168
Merge branch 'asgi-compat' of https://github.com/Archmonger/whitenois…
Archmonger Jul 24, 2023
4c98b5e
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jul 24, 2023
4656a53
new base for AsyncSlicedFile
Archmonger Jul 24, 2023
4a77e8b
Py 3.12+ compatibility
Archmonger Jul 24, 2023
dd2d700
Merge branch 'asgi-compat' of https://github.com/Archmonger/whitenois…
Archmonger Jul 24, 2023
ea74977
refactor file closure and docstrings
Archmonger Jul 25, 2023
2bc2a7b
way more performant way of retaining WSGI compatibility
Archmonger Jul 26, 2023
d648ffc
move file closure to AsgiFileServer loop
Archmonger Jul 26, 2023
f6bf0c2
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jul 26, 2023
367a17d
Support for ASGI on Django<4.2
Archmonger Jul 26, 2023
e3bea39
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jul 26, 2023
b895b8f
resolve flake8 lint warning
Archmonger Jul 26, 2023
7132626
Remove `USE_ASYNC`. Always use the best available method.
Archmonger Jul 27, 2023
56fe9d1
remove type hints that break visual similarity between sync/async code
Archmonger Jul 27, 2023
17b9dc7
reduce tab depth
Archmonger Jul 27, 2023
2dc620e
add first batch of ASGI tests
Archmonger Jul 27, 2023
6d4baf3
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jul 27, 2023
f8fb573
complete tests for asgi.py
Archmonger Jul 28, 2023
b91dd7e
get_response is always mandatory
Archmonger Jul 28, 2023
5eaac51
Merge branch 'asgi-compat' of https://github.com/Archmonger/whitenois…
Archmonger Jul 28, 2023
292de02
More tests
Archmonger Jul 28, 2023
3117769
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jul 28, 2023
5de2d63
minor refactoring
Archmonger Jul 28, 2023
6197911
Merge branch 'asgi-compat' of https://github.com/Archmonger/whitenois…
Archmonger Jul 28, 2023
37dfac6
Make aiofiles mandatory because of Django
Archmonger Jul 28, 2023
f20e90d
format setup.cfg
Archmonger Jul 28, 2023
865a68d
WHITENOISE_BLOCK_SIZE docs
Archmonger Jul 28, 2023
d8f564e
rename base to wsgi
Archmonger Jul 28, 2023
4c2ca1d
QuickStart for other ASGI apps
Archmonger Jul 28, 2023
b238278
AsgiWhiteNoise docs
Archmonger Jul 28, 2023
fb118ad
Merge remote-tracking branch 'upstream/main' into asgi-compat
Archmonger Jul 28, 2023
8e8c63b
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jul 28, 2023
bd580a2
minor comment cleanup
Archmonger Jul 28, 2023
371ca90
minor docs wordsmithing
Archmonger Jul 28, 2023
6695140
Remove block size configuration attributes
Archmonger Jul 28, 2023
018f864
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jul 28, 2023
3a0180a
use dict comphrension for header conversion
Archmonger Jul 28, 2023
29c678c
headers -> wsgi_headers
Archmonger Jul 28, 2023
6da4dfd
serve doesn't need stubs
Archmonger Jul 28, 2023
b1c8ae3
Add ASGI to readme and pkg info
Archmonger Jul 29, 2023
88e8a86
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jul 29, 2023
1f0fa9f
More readible way of converting ASGI app
Archmonger Aug 2, 2023
5b74486
asgi_app -> user_app
Archmonger Aug 2, 2023
9f77bf7
no need for make_bytes
Archmonger Aug 4, 2023
484ddb4
make middleware sync capable
Archmonger Aug 6, 2023
07b8b3d
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Aug 6, 2023
4db8458
reduce LOC changes for serve
Archmonger Aug 6, 2023
503e957
rename to `AsyncSlicedFile`
Archmonger Aug 7, 2023
67f8dee
Update tests/test_asgi.py
Archmonger Mar 23, 2024
1f8c4b2
Update docs/asgi.rst
Archmonger Mar 23, 2024
aa36dce
Update docs/asgi.rst
Archmonger Mar 23, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Expand Up @@ -8,3 +8,5 @@ __pycache__
/docs/_build
/dist
/*.egg-info
/.venv
/venv
Archmonger marked this conversation as resolved.
Show resolved Hide resolved
2 changes: 2 additions & 0 deletions setup.cfg
Expand Up @@ -37,6 +37,8 @@ packages = find:
include_package_data = True
python_requires = >=3.7
zip_safe = False
install_requires =
aiofile >=3.0,<4.0
Archmonger marked this conversation as resolved.
Show resolved Hide resolved

[options.extras_require]
brotli =
Expand Down
262 changes: 262 additions & 0 deletions tests/test_asgi.py
@@ -0,0 +1,262 @@
from __future__ import annotations

import asyncio
import io
import os
import stat
import tempfile

import pytest

from whitenoise.asgi import (
AsgiWhiteNoise,
convert_asgi_headers,
convert_wsgi_headers,
read_file,
receive_request,
serve_static_file,
)
from whitenoise.responders import StaticFile

from .test_whitenoise import application as whitenoise_application
from .test_whitenoise import files
Archmonger marked this conversation as resolved.
Show resolved Hide resolved


@pytest.fixture()
def loop():
return asyncio.get_event_loop()


class MockStat:
Archmonger marked this conversation as resolved.
Show resolved Hide resolved
def __init__(self, st_mode, st_size, st_mtime):
self.st_mode = st_mode
self.st_size = st_size
self.st_mtime = st_mtime


@pytest.fixture()
def static_file_sample():
content = b"01234567890123456789"
modification_time = "Sun, 09 Sep 2001 01:46:40 GMT"
modification_epoch = 1000000000
temporary_file = tempfile.NamedTemporaryFile(suffix=".js", delete=False)
Archmonger marked this conversation as resolved.
Show resolved Hide resolved
try:
temporary_file.write(content)
temporary_file.close()
stat_cache = {
temporary_file.name: MockStat(
stat.S_IFREG, len(content), modification_epoch
)
}
static_file = StaticFile(temporary_file.name, [], stat_cache=stat_cache)
yield {
"static_file": static_file,
"content": content,
"content_length": len(content),
"modification_time": modification_time,
}
finally:
os.unlink(temporary_file.name)


@pytest.fixture(params=["GET", "HEAD"])
def method(request):
return request.param


@pytest.fixture(params=[10, 20])
def block_size(request):
return request.param


@pytest.fixture()
def file_not_found():
async def application(scope, receive, send):
if scope["type"] != "http":
raise RuntimeError()
await receive()
await send({"type": "http.response.start", "status": 404})
await send({"type": "http.response.body", "body": b"Not found"})

return application


@pytest.fixture()
def websocket():
async def application(scope, receive, send):
if scope["type"] != "websocket":
raise RuntimeError()
await receive()
await send({"type": "websocket.accept"})
await send({"type": "websocket.close"})

return application


class Receiver:
def __init__(self):
self.events = [{"type": "http.request"}]

async def __call__(self):
return self.events.pop(0)


class Sender:
def __init__(self):
self.events = []

async def __call__(self, event):
self.events.append(event)


@pytest.fixture()
def receive():
return Receiver()


@pytest.fixture()
def send():
return Sender()


def test_asgiwhitenoise(loop, receive, send, method, whitenoise_application, files):
asgi_whitenoise = AsgiWhiteNoise(whitenoise_application, None)
scope = {
"type": "http",
"path": "/" + files.js_path,
"headers": [],
"method": method,
}
loop.run_until_complete(asgi_whitenoise(scope, receive, send))
assert receive.events == []
assert send.events[0]["status"] == 200
if method == "GET":
assert send.events[1]["body"] == files.js_content


def test_asgiwhitenoise_not_found(
loop, receive, send, whitenoise_application, file_not_found
):
asgi_whitenoise = AsgiWhiteNoise(whitenoise_application, file_not_found)
scope = {
"type": "http",
"path": "/static/foo.js",
"headers": [],
"method": "GET",
}
loop.run_until_complete(asgi_whitenoise(scope, receive, send))
assert receive.events == []
assert send.events == [
{"type": "http.response.start", "status": 404},
{"type": "http.response.body", "body": b"Not found"},
]


def test_asgiwhitenoise_not_http(
loop, receive, send, whitenoise_application, websocket
):
asgi_whitenoise = AsgiWhiteNoise(whitenoise_application, websocket)
receive.events = [{"type": "websocket.connect"}]
scope = {
"type": "websocket",
"path": "/endpoint",
"headers": [],
"method": "GET",
}
loop.run_until_complete(asgi_whitenoise(scope, receive, send))
assert receive.events == []
assert send.events == [
{"type": "websocket.accept"},
{"type": "websocket.close"},
]


def test_serve_static_file(loop, send, method, block_size, static_file_sample):
loop.run_until_complete(
serve_static_file(
send, static_file_sample["static_file"], method, {}, block_size
)
)
expected_events = [
{
"type": "http.response.start",
"status": 200,
"headers": [
(b"last-modified", static_file_sample["modification_time"].encode()),
(b"etag", static_file_sample["static_file"].etag.encode()),
(b"content-length", str(static_file_sample["content_length"]).encode()),
],
}
]
if method == "GET":
for start in range(0, static_file_sample["content_length"], block_size):
expected_events.append(
{
"type": "http.response.body",
"body": static_file_sample["content"][start : start + block_size],
"more_body": True,
}
)
Archmonger marked this conversation as resolved.
Show resolved Hide resolved
expected_events.append({"type": "http.response.body"})
assert send.events == expected_events


def test_receive_request(loop, receive):
loop.run_until_complete(receive_request(receive))
assert receive.events == []


def test_receive_request_with_more_body(loop, receive):
receive.events = [
{"type": "http.request", "more_body": True, "body": b"content"},
{"type": "http.request", "more_body": True, "body": b"more content"},
{"type": "http.request"},
]
loop.run_until_complete(receive_request(receive))
assert receive.events == []


def test_receive_request_with_invalid_event(loop, receive):
receive.events = [{"type": "http.weirdstuff"}]
with pytest.raises(RuntimeError):
Archmonger marked this conversation as resolved.
Show resolved Hide resolved
loop.run_until_complete(receive_request(receive))


def test_read_file():
content = io.BytesIO(b"0123456789")
content.seek(4)
blocks = list(read_file(content, content_length=5, block_size=2))
assert blocks == [b"45", b"67", b"8"]


def test_read_too_short_file():
content = io.BytesIO(b"0123456789")
content.seek(4)
with pytest.raises(RuntimeError):
list(read_file(content, content_length=11, block_size=2))


def test_convert_asgi_headers():
wsgi_headers = convert_asgi_headers(
[
(b"accept-encoding", b"gzip,br"),
(b"range", b"bytes=10-100"),
]
)
assert wsgi_headers == {
"HTTP_ACCEPT_ENCODING": "gzip,br",
"HTTP_RANGE": "bytes=10-100",
}


def test_convert_wsgi_headers():
wsgi_headers = convert_wsgi_headers(
[
("Content-Length", "1234"),
("ETag", "ada"),
]
)
assert wsgi_headers == [
(b"content-length", b"1234"),
(b"etag", b"ada"),
]
86 changes: 86 additions & 0 deletions whitenoise/asgi.py
@@ -0,0 +1,86 @@
from __future__ import annotations


class AsgiWhiteNoise:
# This is the same block size as wsgiref.FileWrapper
BLOCK_SIZE = 8192

def __init__(self, whitenoise, application):
self.whitenoise = whitenoise
self.application = application
Archmonger marked this conversation as resolved.
Show resolved Hide resolved

async def __call__(self, scope, receive, send):
static_file = None
if scope["type"] == "http":
if self.whitenoise.autorefresh:
static_file = self.whitenoise.find_file(scope["path"])
else:
static_file = self.whitenoise.files.get(scope["path"])
if static_file is None:
await self.application(scope, receive, send)
else:
await receive_request(receive)
request_headers = convert_asgi_headers(scope["headers"])
await serve_static_file(
send, static_file, scope["method"], request_headers, self.BLOCK_SIZE
)


async def serve_static_file(send, static_file, method, request_headers, block_size):
response = static_file.get_response(method, request_headers)
try:
await send(
{
"type": "http.response.start",
"status": response.status.value,
"headers": convert_wsgi_headers(response.headers),
}
)
if response.file:
# We need to only read content-length bytes instead of the whole file,
# the difference is important when serving range requests.
content_length = int(dict(response.headers)["Content-Length"])
for block in read_file(response.file, content_length, block_size):
await send(
{"type": "http.response.body", "body": block, "more_body": True}
)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So ASGI has a "zero copy send" extension: https://asgi.readthedocs.io/en/latest/extensions.html#zero-copy-send

This is much more efficient than chunking and recombining the file in Python

That said, it looks like support isn't there yet:

So it might be worth creating a follow-up issue for this

Copy link

@Kludex Kludex May 4, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

there's also a PR for hypercorn, jfyk: https://gitlab.com/pgjones/hypercorn/-/merge_requests/62

in any case, none of the ASGI servers supports it yet 👍

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Has anything changed in the "zero copy send" landscape?

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Has anything changed in the "zero copy send" landscape?

I've created a fork of hypercorn to add all extension support here, you can have a try, currently it's under development and should work with all asgi extension listed in asgi spec.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Even when zero copy send gets merged into most ASGI webservers, there's still the question of Windows compatibility.

Ref: https://discuss.python.org/t/support-for-os-sendfile-for-windows/25020

await send({"type": "http.response.body"})
finally:
if response.file:
response.file.close()


async def receive_request(receive):
more_body = True
while more_body:
event = await receive()
if event["type"] != "http.request":
raise RuntimeError(
"Unexpected ASGI event {!r}, expected {!r}".format(
event["type"], "http.request"
)
)
more_body = event.get("more_body", False)


def read_file(file_handle, content_length, block_size):
bytes_left = content_length
while bytes_left > 0:
data = file_handle.read(min(block_size, bytes_left))
Archmonger marked this conversation as resolved.
Show resolved Hide resolved
if data == b"":
raise RuntimeError(
Archmonger marked this conversation as resolved.
Show resolved Hide resolved
f"Premature end of file, expected {bytes_left} more bytes"
)
bytes_left -= len(data)
yield data


def convert_asgi_headers(headers):
return {
"HTTP_" + name.decode().upper().replace("-", "_"): value.decode()
for name, value in headers
}


def convert_wsgi_headers(headers):
return [(key.lower().encode(), value.encode()) for key, value in headers]