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

FORKED: AsgiWhiteNoise and async WhiteNoiseMiddleware #359

Open
wants to merge 131 commits into
base: main
Choose a base branch
from

Conversation

Archmonger
Copy link
Contributor

@Archmonger Archmonger commented Feb 7, 2022

This PR has resulted in a fork of whitenoise called ServeStatic for continued maintenance and ASGI feature updates.

Tasks

  • Create async whitenoise class
  • Use aiofiles for asynchronous file handling
  • Async Django Middleware
  • Compatibility with existing tests
  • Add new ASGI-specific tests
  • Update the docs to show ASGI configuration

Summary

I've created async variants for any methods/functions/classes that can benefit from aiofiles. For IO operations that currently do not have an async equivalent (such as the OS calls within WhiteNoise.find_file(...)), I've converted to async via asyncio.to_thread(...) (if available).

Django middleware is now sync and async compatible. Thus, the middleware will provide Django an async FileResponse when possible.

WhiteNoise

  • whitenoise.base.BaseWhiteNoise
    • Similar to the old WhiteNoise class, but __call__ is now a stub.
  • whitenoise.wsgi.WhiteNoise
    • Functionally identical to the old WhiteNoise class. Uses BaseWhiteNoise as a subclass to obtain most methods.
  • whitenoise.asgi.AsgiWhiteNoise
    • Implements the same behavior as WhiteNoise, but using the ASGI interface (async/threading)

Responders

  • whitenoise.responders.StaticFile
    • Added aget_response (async variant).
    • Added aget_range_response (async variant).
    • Added aget_range_not_satisfiable_response (async variant).
  • whitenoise.responders.AsyncSlicedFile
    • Implements the same behavior as SlicedFile, but using the aiofiles context manager interface.
  • whitenoise.responders.SlicedFile
    • Minor refactoring to keep visual similarity to AsyncSlicedFile.
  • whitenoise.responders.Redirect
    • Added aget_response (async variant).

Middleware

  • whitenoise.middleware.WhiteNoiseMiddleware
  • whitenoise.middleware.AsyncWhiteNoiseFileResponse
    • Variant that uses async responses when possible (Django 4.2+ ASGI).
    • Used when Django's middleware system suggests using async
    • Responses must be converted to sync on WSGI.
  • whitenoise.middleware.AsyncFileIterator
    • Simple __aiter__ helper that allow Django file responses to use aiofiles.
    • Required to not break WSGI compatibility by providing an isolated async environment for responses.
  • whitenoise.middleware.AsyncToSyncIterator
    • Simple __aiter__ to __iter__ converter, since Django cannot use __aiter__ within WSGI.
    • Far more production-ready than the built-in converter on Django 4.2+.

Design Considerations

Code Architecture

  • The vast majority of this PR is the addition of new async functions, rather than changing/removal of existing code. This is due to the infectious nature of asyncio.
  • We do have the option of allowing ASGI applications directly within WhiteNoise, rather than creating a whole separate AsgiWhiteNoise class. However, this would either rely on heuristics or an initialization arg. This seemed more error-prone than an explicit class.
  • Only a minimal subset of methods were converted to async. There is a performance benefit to converting every method in BaseWhiteNoise to async, since event loops perform best with as much yielding via await as possible. However, that's a really high maintenance burden that we shouldn't commit to in this PR.
  • I tried to retain as much visual similarity between sync/async variants as possible to improve maintainability. Most async methods match up line-to-line with their sync counterparts. Minor changes were sometimes made to sync code to increase visual similarity.
  • Tests were only written to accommodate for the new async-specific bits. Since all non-file methods are still sync, there isn't a particular need to write tests that hit the same code.

Django Integration

  • Our middleware will automatically use sync or async responses depending on Django's context-aware sync/async auto-selection. Django tries to reduce the amount of sync/async context switches. If we are preceded by a bunch of sync middleware then Django will tell us to use sync.
  • It is necessary for async middleware to be able to convert responses to sync. This is because WSGI supports async middleware but not async file responses. Our conversion is done within a thread, which is far more production-ready than Django core's built-in method of consuming all iterators.
  • aiofiles is now a mandatory requirement due to Django middleware being async compatible. The alternative would have been a completely separate middleware class for ASGI, but that would negate a lot of the benefits of Django's middleware's context-aware sync/async auto-selection. Additionally, would have been a significant amount of extra code that would not have been worth the maintenance burden.
  • By default, we send ASGI responses containing 8192 byte chunks of file data (same as wsgiref.FileWrapper).

Feature Compatibility

  • I did not implement Zero Copy Send support due to no ASGI webservers having compatibility for this yet. No point in adding a feature that can't be used.
  • We use asgiref.compatibility.guarantee_single_callable to retain compatibility for both ASGI v2 and ASGI v3.

Test Environment: AsgiWhiteNoise

pip install git+https://github.com/Archmonger/whitenoise@asgi-compat

# example.py
from whitenoise import AsgiWhiteNoise

async def asgi_app(scope, receive, send):
    await send({
        'type': 'http.response.start',
        'status': 200,
        'headers': [(b'content-type', b'text/plain')],
    })
    await send({
        'type': 'http.response.body',
        'body': b'Hello, world!',
    })

# Note: This also works with legacy ASGI v2 applications
app = AsgiWhiteNoise(asgi_app, root='static', prefix='static')
# bash
uvicorn example:app

Test Environment: WhiteNoiseMiddleware

pip install git+https://github.com/Archmonger/whitenoise@asgi-compat

The existing middleware has been replaced with an async compatible variant. Therefore, follow the normal documentation for installing WhiteNoiseMiddleware.

Issues

fix #251
fix #179
close #261
related #181

.gitignore Outdated Show resolved Hide resolved
setup.cfg Outdated Show resolved Hide resolved
tests/test_asgi.py Outdated Show resolved Hide resolved
tests/test_asgi.py Outdated Show resolved Hide resolved
whitenoise/asgi.py Outdated Show resolved Hide resolved
Comment on lines 42 to 46
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
Contributor 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

@synodriver synodriver Jul 18, 2023

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
Contributor Author

@Archmonger Archmonger Jul 26, 2023

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

tests/test_asgi.py Outdated Show resolved Hide resolved
tests/test_asgi.py Outdated Show resolved Hide resolved
tests/test_asgi.py Show resolved Hide resolved
whitenoise/asgi.py Outdated Show resolved Hide resolved
@adamchainz
Copy link
Collaborator

Set up pre-commit locally - install as per https://pre-commit.com/ , then pre-commit install in the repo.

setup.cfg Outdated Show resolved Hide resolved
@brianglass
Copy link

@Archmonger, I'm running Django 5.0b1. I just tried it with 4.2.7 with the same result. I though it might be django-cors-headers since I had the middleware before whitenoise, but I took it out and still got the warning.

@Archmonger
Copy link
Contributor Author

Can you send me your middleware and installed apps? Also let's pull this discussion to the side so that we don't spam everyone with this conversation.

Discord: totoboto
Email: [email protected]

@robd003 robd003 mentioned this pull request Nov 7, 2023
@helenap
Copy link

helenap commented Nov 8, 2023

Hey @evansd could you please review and merge this in? This branch has been working just fine for me for months!

@daniel-brenot
Copy link

Bumping this since it's been a couple more months. Can we get this merged and released? I'd love to get rid of the errors and sync penalty in my code.

@robd003
Copy link

robd003 commented Feb 20, 2024

Hey @evansd any update on you looking at this?

@jlost
Copy link

jlost commented Mar 6, 2024

Would like to see this!

@duanhongyi
Copy link

Can this PR be merged?

@jordanorc
Copy link

Any update on this PR?

@Archmonger
Copy link
Contributor Author

@jordanorc This PR is complete, but has been stuck in an "awaiting review" stage due to the WhiteNoise maintainers largely not having time.

In order to avoid continuously re-merging with main, I've been waiting for a maintainer to review and/or approve.

I have offered to help as a maintainer but haven't heard anything back yet.

@smathieson
Copy link

Also would like to see this merged ASAP.

docs/asgi.rst Outdated Show resolved Hide resolved
docs/asgi.rst Outdated Show resolved Hide resolved
tests/test_asgi.py Outdated Show resolved Hide resolved
Archmonger and others added 3 commits March 22, 2024 23:25
Co-authored-by: James Ostrander <[email protected]>
Co-authored-by: James Ostrander <[email protected]>
Co-authored-by: James Ostrander <[email protected]>
@charlesobrien
Copy link

@Archmonger @adamchainz How can we get this one across the finish line? If it’s just resolving conflicts, would it be merged?

@matthiask
Copy link

I totally understand that maintaining open source software is hard work and I don't want to criticize anyone. WhiteNoise is great software and I have been using it happily for years in many projects, so thanks for that. That being said, I really wanted to move ahead and therefore have thrown together an ASGI middleware for static file serving here https://github.com/matthiask/blacknoise . It doesn't integrate with Django (middleware) making the implementation much smaller and simpler.

It is running in production for a few weeks now and seems to work well. It uses the proven Starlette under the hood, uses anyio for reading files and supports compression using gzip and brotli. Full API compatibility with WhiteNoise isn't the goal, but PRs are welcome.

I'm sorry for plugging my own project here, but maybe it helps someone else. If you're interested let's discuss it elsewhere, not here in this PR. Thanks.

@Archmonger
Copy link
Contributor Author

Archmonger commented May 2, 2024

On a related note...

Given that there's been no major whitenoise feature updates within the last 5 years and I didn't see any interest from @evansd in adding me as a maintainer, I've been debating forking whitenoise based on my branch to allow for continued maintenance.

I will wait a few days in case Dave Evans responds back, but otherwise I'll close this PR and fork the repository.

@evansd
Copy link
Owner

evansd commented May 3, 2024

Hi @Archmonger,

Sorry to have been unresponsive. I'm not running ASGI anywhere in my day job at the moment, and so I've been reluctant to take on responsibility for a significant quantity of code which I don't use and therefore don't fully understand. I've also been reluctant to hand out maintainer status to people I don't know personally. And I feel like the whole xz debacle has confirmed my instincts here. (Not that I'm at all suspicious of you personally or anything you've done! – just as a general point of principle.)

So I actually think the best option at this point probably is to fork; not in an acrimonious spirit, but just as an acknowledgement that different people have different needs and priorities when it comes to this library. You're welcome to call it whitenoise-asgi or something if you want to keep the association.

It's certainly possible that I will end up with a personal need to support ASGI in the future, in which case maybe I'll end up using your fork or we can look at re-merging the projects or something.

Again, sorry for not making a call on this sooner: I've been prevaricating and somehow hoping that more spare time would drop out of the sky at some point – open-source life, eh?

@Archmonger
Copy link
Contributor Author

Archmonger commented Jul 11, 2024

Whitenoise has now been forked into ServeStatic!

Version 1.0.0 of servestatic is practically identical to this PR, except with a different name.

Also, in the near future, I intend to review the existing Whitenoise PRs and merge them into a new ServeStatic release.

@Archmonger Archmonger changed the title AsgiWhiteNoise and async WhiteNoiseMiddleware FORKED: AsgiWhiteNoise and async WhiteNoiseMiddleware Jul 11, 2024
@adamchainz
Copy link
Collaborator

Hi all.

Sorry for being unresponsive here as well. I was in the same position as Dave: I don’t do much work with asynchronous Python, and every time I’ve tried to review this PR, it has just seemed like too much for my limited open source session.

You’re very welcome to fork @Archmonger. It’s not clear to me what the advantage of ServeStatic is over the other fork, Blacknoise, but I’ll let you figure that out.

One thing I would note is that it would be best for performance to support the zero copy send and or path send ASGI extensions. I would check which are best supported across ASGI servers.

Outstanding PRs will be addressed on Whitenoise in due course, depending on availability. I rotate focus between the open source projects I maintain, so sometimes issues PRs can build up. One step at a time.

Thanks!

@adamchainz adamchainz mentioned this pull request Jul 13, 2024
@Archmonger
Copy link
Contributor Author

Archmonger commented Jul 16, 2024

@adamchainz To my knowledge, the three popular ASGI webservers do not support those extensions at the moment. They seem pretty resistant to implementing support for them since it over complicates their webserver code.

@matthiask
Copy link

Hi,

granian supports pathsend emmett-framework/granian#82 but not zerocopy send since forwarding file descriptors from Python to Rust doesn't seem to work nicely. Indeed uvicorn seems to not support any of those.

You’re very welcome to fork @Archmonger. It’s not clear to me what the advantage of ServeStatic is over the other fork, Blacknoise, but I’ll let you figure that out.

Blacknoise isn't a fork, just a minimal ASGI static file serving solution modeled after whitenoise. It doesn't do many of the things WhiteNoise (and by extension ServeStatic) do, but in my opinion it doesn't have to.

Thanks!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Running whitenoise behind a WSGI-to-ASGI adapter Considering ASGI.