diff --git a/django/views/generic/base.py b/django/views/generic/base.py index d45b1762e6..db1842e3e5 100644 --- a/django/views/generic/base.py +++ b/django/views/generic/base.py @@ -1,3 +1,4 @@ +import asyncio import logging from django.core.exceptions import ImproperlyConfigured @@ -11,6 +12,7 @@ from django.http import ( from django.template.response import TemplateResponse from django.urls import reverse from django.utils.decorators import classonlymethod +from django.utils.functional import classproperty logger = logging.getLogger("django.request") @@ -57,6 +59,23 @@ class View: for key, value in kwargs.items(): setattr(self, key, value) + @classproperty + def view_is_async(cls): + handlers = [ + getattr(cls, method) + for method in cls.http_method_names + if (method != "options" and hasattr(cls, method)) + ] + if not handlers: + return False + is_async = asyncio.iscoroutinefunction(handlers[0]) + if not all(asyncio.iscoroutinefunction(h) == is_async for h in handlers[1:]): + raise ImproperlyConfigured( + f"{cls.__qualname__} HTTP handlers must either be all sync or all " + "async." + ) + return is_async + @classonlymethod def as_view(cls, **initkwargs): """Main entry point for a request-response process.""" @@ -96,6 +115,10 @@ class View: # the dispatch method. view.__dict__.update(cls.dispatch.__dict__) + # Mark the callback if the view class is async. + if cls.view_is_async: + view._is_coroutine = asyncio.coroutines._is_coroutine + return view def setup(self, request, *args, **kwargs): @@ -132,7 +155,15 @@ class View: response = HttpResponse() response.headers["Allow"] = ", ".join(self._allowed_methods()) response.headers["Content-Length"] = "0" - return response + + if self.view_is_async: + + async def func(): + return response + + return func() + else: + return response def _allowed_methods(self): return [m.upper() for m in self.http_method_names if hasattr(self, m)] diff --git a/docs/ref/class-based-views/base.txt b/docs/ref/class-based-views/base.txt index 5c2eb712c1..f60950d1fa 100644 --- a/docs/ref/class-based-views/base.txt +++ b/docs/ref/class-based-views/base.txt @@ -77,6 +77,17 @@ MRO is an acronym for Method Resolution Order. ` to the ``args`` and ``kwargs`` attributes, respectively. Then :meth:`dispatch` is called. + If a ``View`` subclass defines asynchronous (``async def``) method + handlers, ``as_view()`` will mark the returned callable as a coroutine + function. An ``ImproperlyConfigured`` exception will be raised if both + asynchronous (``async def``) and synchronous (``def``) handlers are + defined on a single view-class. + + .. versionchanged:: 4.1 + + Compatibility with asynchronous (``async def``) method handlers was + added. + .. method:: setup(request, *args, **kwargs) Performs key view initialization prior to :meth:`dispatch`. @@ -111,6 +122,14 @@ MRO is an acronym for Method Resolution Order. response with the ``Allow`` header containing a list of the view's allowed HTTP method names. + If the other HTTP methods handlers on the class are asynchronous + (``async def``) then the response will be wrapped in a coroutine + function for use with ``await``. + + .. versionchanged:: 4.1 + + Compatibility with classes defining asynchronous (``async def``) + method handlers was added. ``TemplateView`` ================ diff --git a/docs/releases/4.1.txt b/docs/releases/4.1.txt index d83da638fc..2ec0d42cdd 100644 --- a/docs/releases/4.1.txt +++ b/docs/releases/4.1.txt @@ -26,6 +26,23 @@ officially support the latest release of each series. What's new in Django 4.1 ======================== +Asynchronous handlers for class-based views +------------------------------------------- + +View subclasses may now define async HTTP method handlers:: + + import asyncio + from django.http import HttpResponse + from django.views import View + + class AsyncView(View): + async def get(self, request, *args, **kwargs): + # Perform view logic using await. + await asyncio.sleep(1) + return HttpResponse("Hello async world!") + +See :ref:`async-class-based-views` for more details. + .. _csrf-cookie-masked-usage: ``CSRF_COOKIE_MASKED`` setting diff --git a/docs/topics/async.txt b/docs/topics/async.txt index 90a31b994b..ab2ccd3c98 100644 --- a/docs/topics/async.txt +++ b/docs/topics/async.txt @@ -22,8 +22,9 @@ Async views Any view can be declared async by making the callable part of it return a coroutine - commonly, this is done using ``async def``. For a function-based view, this means declaring the whole view using ``async def``. For a -class-based view, this means making its ``__call__()`` method an ``async def`` -(not its ``__init__()`` or ``as_view()``). +class-based view, this means declaring the HTTP method handlers, such as +``get()`` and ``post()`` as ``async def`` (not its ``__init__()``, or +``as_view()``). .. note:: diff --git a/docs/topics/class-based-views/index.txt b/docs/topics/class-based-views/index.txt index 01f9c35460..1a6368cc08 100644 --- a/docs/topics/class-based-views/index.txt +++ b/docs/topics/class-based-views/index.txt @@ -128,3 +128,33 @@ the response (using the ``book_list.html`` template). But if the client issues a ``HEAD`` request, the response has an empty body and the ``Last-Modified`` header indicates when the most recent book was published. Based on this information, the client may or may not download the full object list. + +.. _async-class-based-views: + +Asynchronous class-based views +============================== + +.. versionadded:: 4.1 + +As well as the synchronous (``def``) method handlers already shown, ``View`` +subclasses may define asynchronous (``async def``) method handlers to leverage +asynchronous code using ``await``:: + + import asyncio + from django.http import HttpResponse + from django.views import View + + class AsyncView(View): + async def get(self, request, *args, **kwargs): + # Perform io-blocking view logic using await, sleep for example. + await asyncio.sleep(1) + return HttpResponse("Hello async world!") + +Within a single view-class, all user-defined method handlers must be either +synchronous, using ``def``, or all asynchronous, using ``async def``. An +``ImproperlyConfigured`` exception will be raised in ``as_view()`` if ``def`` +and ``async def`` declarations are mixed. + +Django will automatically detect asynchronous views and run them in an +asynchronous context. You can read more about Django's asynchronous support, +and how to best use async views, in :doc:`/topics/async`. diff --git a/tests/async/tests.py b/tests/async/tests.py index be3e4e2576..1a0627a064 100644 --- a/tests/async/tests.py +++ b/tests/async/tests.py @@ -1,3 +1,4 @@ +import asyncio import os import sys from unittest import mock, skipIf @@ -5,9 +6,11 @@ from unittest import mock, skipIf from asgiref.sync import async_to_sync from django.core.cache import DEFAULT_CACHE_ALIAS, caches -from django.core.exceptions import SynchronousOnlyOperation +from django.core.exceptions import ImproperlyConfigured, SynchronousOnlyOperation +from django.http import HttpResponse from django.test import SimpleTestCase from django.utils.asyncio import async_unsafe +from django.views.generic.base import View from .models import SimpleModel @@ -72,3 +75,66 @@ class AsyncUnsafeTest(SimpleTestCase): self.dangerous_method() except SynchronousOnlyOperation: self.fail("SynchronousOnlyOperation should not be raised.") + + +class SyncView(View): + def get(self, request, *args, **kwargs): + return HttpResponse("Hello (sync) world!") + + +class AsyncView(View): + async def get(self, request, *args, **kwargs): + return HttpResponse("Hello (async) world!") + + +class ViewTests(SimpleTestCase): + def test_views_are_correctly_marked(self): + tests = [ + (SyncView, False), + (AsyncView, True), + ] + for view_cls, is_async in tests: + with self.subTest(view_cls=view_cls, is_async=is_async): + self.assertIs(view_cls.view_is_async, is_async) + callback = view_cls.as_view() + self.assertIs(asyncio.iscoroutinefunction(callback), is_async) + + def test_mixed_views_raise_error(self): + class MixedView(View): + def get(self, request, *args, **kwargs): + return HttpResponse("Hello (mixed) world!") + + async def post(self, request, *args, **kwargs): + return HttpResponse("Hello (mixed) world!") + + msg = ( + f"{MixedView.__qualname__} HTTP handlers must either be all sync or all " + "async." + ) + with self.assertRaisesMessage(ImproperlyConfigured, msg): + MixedView.as_view() + + def test_options_handler_responds_correctly(self): + tests = [ + (SyncView, False), + (AsyncView, True), + ] + for view_cls, is_coroutine in tests: + with self.subTest(view_cls=view_cls, is_coroutine=is_coroutine): + instance = view_cls() + response = instance.options(None) + self.assertIs( + asyncio.iscoroutine(response), + is_coroutine, + ) + if is_coroutine: + response = asyncio.run(response) + + self.assertIsInstance(response, HttpResponse) + + def test_base_view_class_is_sync(self): + """ + View and by extension any subclasses that don't define handlers are + sync. + """ + self.assertIs(View.view_is_async, False)