summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCarlton Gibson <carlton.gibson@noumenal.es>2022-04-07 07:05:59 +0200
committerGitHub <noreply@github.com>2022-04-07 07:05:59 +0200
commit9ffd4eae2ce7a7100c98f681e2b6ab818df384a4 (patch)
tree2cc678b6feff9f187517439bf2856a8702c1f356
parent2ee4caf56b8e000cabbb73ad81ff05738d6d0a35 (diff)
Fixed #33611 -- Allowed View subclasses to define async method handlers.
-rw-r--r--django/views/generic/base.py33
-rw-r--r--docs/ref/class-based-views/base.txt19
-rw-r--r--docs/releases/4.1.txt17
-rw-r--r--docs/topics/async.txt5
-rw-r--r--docs/topics/class-based-views/index.txt30
-rw-r--r--tests/async/tests.py68
6 files changed, 168 insertions, 4 deletions
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.
<how-django-processes-a-request>` 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)