Procházet zdrojové kódy

Fixed #33611 -- Allowed View subclasses to define async method handlers.

Carlton Gibson před 3 roky
rodič
revize
9ffd4eae2c

+ 32 - 1
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)]

+ 19 - 0
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``
 ================

+ 17 - 0
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

+ 3 - 2
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::
 

+ 30 - 0
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`.

+ 67 - 1
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)