From 4dfc6ff8a81ed36dfc7c5b942ecab7217866b935 Mon Sep 17 00:00:00 2001 From: Ben Lomax Date: Fri, 8 Jul 2022 09:39:33 +0100 Subject: [PATCH] Refs #31949 -- Made @never_cache and @cache_control() decorators to work with async functions. Thanks Carlton Gibson and Mariusz Felisiak for reviews. --- django/views/decorators/cache.py | 68 ++++++++++++-------- docs/releases/5.0.txt | 4 +- docs/topics/async.txt | 25 ++++++++ docs/topics/http/decorators.txt | 8 +++ tests/decorators/test_cache.py | 103 +++++++++++++++++++++++++++++++ 5 files changed, 182 insertions(+), 26 deletions(-) diff --git a/django/views/decorators/cache.py b/django/views/decorators/cache.py index 6004b2f138b..aa1679baff1 100644 --- a/django/views/decorators/cache.py +++ b/django/views/decorators/cache.py @@ -1,5 +1,7 @@ from functools import wraps +from asgiref.sync import iscoroutinefunction + from django.middleware.cache import CacheMiddleware from django.utils.cache import add_never_cache_headers, patch_cache_control from django.utils.decorators import decorator_from_middleware_with_args @@ -26,22 +28,34 @@ def cache_page(timeout, *, cache=None, key_prefix=None): ) +def _check_request(request, decorator_name): + # Ensure argument looks like a request. + if not hasattr(request, "META"): + raise TypeError( + f"{decorator_name} didn't receive an HttpRequest. If you are " + "decorating a classmethod, be sure to use @method_decorator." + ) + + def cache_control(**kwargs): def _cache_controller(viewfunc): - @wraps(viewfunc) - def _cache_controlled(request, *args, **kw): - # Ensure argument looks like a request. - if not hasattr(request, "META"): - raise TypeError( - "cache_control didn't receive an HttpRequest. If you are " - "decorating a classmethod, be sure to use " - "@method_decorator." - ) - response = viewfunc(request, *args, **kw) - patch_cache_control(response, **kwargs) - return response + if iscoroutinefunction(viewfunc): - return _cache_controlled + async def _view_wrapper(request, *args, **kw): + _check_request(request, "cache_control") + response = await viewfunc(request, *args, **kw) + patch_cache_control(response, **kwargs) + return response + + else: + + def _view_wrapper(request, *args, **kw): + _check_request(request, "cache_control") + response = viewfunc(request, *args, **kw) + patch_cache_control(response, **kwargs) + return response + + return wraps(viewfunc)(_view_wrapper) return _cache_controller @@ -51,16 +65,20 @@ def never_cache(view_func): Decorator that adds headers to a response so that it will never be cached. """ - @wraps(view_func) - def _wrapper_view_func(request, *args, **kwargs): - # Ensure argument looks like a request. - if not hasattr(request, "META"): - raise TypeError( - "never_cache didn't receive an HttpRequest. If you are " - "decorating a classmethod, be sure to use @method_decorator." - ) - response = view_func(request, *args, **kwargs) - add_never_cache_headers(response) - return response + if iscoroutinefunction(view_func): - return _wrapper_view_func + async def _view_wrapper(request, *args, **kwargs): + _check_request(request, "never_cache") + response = await view_func(request, *args, **kwargs) + add_never_cache_headers(response) + return response + + else: + + def _view_wrapper(request, *args, **kwargs): + _check_request(request, "never_cache") + response = view_func(request, *args, **kwargs) + add_never_cache_headers(response) + return response + + return wraps(view_func)(_view_wrapper) diff --git a/docs/releases/5.0.txt b/docs/releases/5.0.txt index 3a4355588b5..48df7133750 100644 --- a/docs/releases/5.0.txt +++ b/docs/releases/5.0.txt @@ -214,7 +214,9 @@ CSRF Decorators ~~~~~~~~~~ -* ... +* The :func:`~django.views.decorators.cache.cache_control` and + :func:`~django.views.decorators.cache.never_cache` decorators now support + wrapping asynchronous view functions. Email ~~~~~ diff --git a/docs/topics/async.txt b/docs/topics/async.txt index 4b667f22a14..769fac3c521 100644 --- a/docs/topics/async.txt +++ b/docs/topics/async.txt @@ -73,6 +73,31 @@ from an async view, you will trigger Django's :ref:`asynchronous safety protection ` to protect your data from corruption. +Decorators +---------- + +.. versionadded:: 5.0 + +The following decorators can be used with both synchronous and asynchronous +view functions: + +* :func:`~django.views.decorators.cache.cache_control` +* :func:`~django.views.decorators.cache.never_cache` + +For example:: + + from django.views.decorators.cache import never_cache + + + @never_cache + def my_sync_view(request): + ... + + + @never_cache + async def my_async_view(request): + ... + Queries & the ORM ----------------- diff --git a/docs/topics/http/decorators.txt b/docs/topics/http/decorators.txt index 3481eefb3ca..4bcae64e27d 100644 --- a/docs/topics/http/decorators.txt +++ b/docs/topics/http/decorators.txt @@ -117,6 +117,10 @@ client-side caching. :func:`~django.utils.cache.patch_cache_control` for the details of the transformation. + .. versionchanged:: 5.0 + + Support for wrapping asynchronous view functions was added. + .. function:: never_cache(view_func) This decorator adds an ``Expires`` header to the current date/time. @@ -127,6 +131,10 @@ client-side caching. Each header is only added if it isn't already set. + .. versionchanged:: 5.0 + + Support for wrapping asynchronous view functions was added. + .. module:: django.views.decorators.common Common diff --git a/tests/decorators/test_cache.py b/tests/decorators/test_cache.py index 9aa224c0241..513ff7e7114 100644 --- a/tests/decorators/test_cache.py +++ b/tests/decorators/test_cache.py @@ -1,5 +1,7 @@ from unittest import mock +from asgiref.sync import iscoroutinefunction + from django.http import HttpRequest, HttpResponse from django.test import SimpleTestCase from django.utils.decorators import method_decorator @@ -16,6 +18,20 @@ class HttpRequestProxy: class CacheControlDecoratorTest(SimpleTestCase): + def test_wrapped_sync_function_is_not_coroutine_function(self): + def sync_view(request): + return HttpResponse() + + wrapped_view = cache_control()(sync_view) + self.assertIs(iscoroutinefunction(wrapped_view), False) + + def test_wrapped_async_function_is_coroutine_function(self): + async def async_view(request): + return HttpResponse() + + wrapped_view = cache_control()(async_view) + self.assertIs(iscoroutinefunction(wrapped_view), True) + def test_cache_control_decorator_http_request(self): class MyClass: @cache_control(a="b") @@ -32,6 +48,22 @@ class CacheControlDecoratorTest(SimpleTestCase): with self.assertRaisesMessage(TypeError, msg): MyClass().a_view(HttpRequestProxy(request)) + async def test_cache_control_decorator_http_request_async_view(self): + class MyClass: + @cache_control(a="b") + async def async_view(self, request): + return HttpResponse() + + msg = ( + "cache_control didn't receive an HttpRequest. If you are decorating a " + "classmethod, be sure to use @method_decorator." + ) + request = HttpRequest() + with self.assertRaisesMessage(TypeError, msg): + await MyClass().async_view(request) + with self.assertRaisesMessage(TypeError, msg): + await MyClass().async_view(HttpRequestProxy(request)) + def test_cache_control_decorator_http_request_proxy(self): class MyClass: @method_decorator(cache_control(a="b")) @@ -50,6 +82,14 @@ class CacheControlDecoratorTest(SimpleTestCase): response = a_view(HttpRequest()) self.assertEqual(response.get("Cache-Control"), "") + async def test_cache_control_empty_decorator_async_view(self): + @cache_control() + async def async_view(request): + return HttpResponse() + + response = await async_view(HttpRequest()) + self.assertEqual(response.get("Cache-Control"), "") + def test_cache_control_full_decorator(self): @cache_control(max_age=123, private=True, public=True, custom=456) def a_view(request): @@ -61,6 +101,17 @@ class CacheControlDecoratorTest(SimpleTestCase): set(cache_control_items), {"max-age=123", "private", "public", "custom=456"} ) + async def test_cache_control_full_decorator_async_view(self): + @cache_control(max_age=123, private=True, public=True, custom=456) + async def async_view(request): + return HttpResponse() + + response = await async_view(HttpRequest()) + cache_control_items = response.get("Cache-Control").split(", ") + self.assertEqual( + set(cache_control_items), {"max-age=123", "private", "public", "custom=456"} + ) + class CachePageDecoratorTest(SimpleTestCase): def test_cache_page(self): @@ -74,6 +125,20 @@ class CachePageDecoratorTest(SimpleTestCase): class NeverCacheDecoratorTest(SimpleTestCase): + def test_wrapped_sync_function_is_not_coroutine_function(self): + def sync_view(request): + return HttpResponse() + + wrapped_view = never_cache(sync_view) + self.assertIs(iscoroutinefunction(wrapped_view), False) + + def test_wrapped_async_function_is_coroutine_function(self): + async def async_view(request): + return HttpResponse() + + wrapped_view = never_cache(async_view) + self.assertIs(iscoroutinefunction(wrapped_view), True) + @mock.patch("time.time") def test_never_cache_decorator_headers(self, mocked_time): @never_cache @@ -91,6 +156,20 @@ class NeverCacheDecoratorTest(SimpleTestCase): "max-age=0, no-cache, no-store, must-revalidate, private", ) + @mock.patch("time.time") + async def test_never_cache_decorator_headers_async_view(self, mocked_time): + @never_cache + async def async_view(request): + return HttpResponse() + + mocked_time.return_value = 1167616461.0 + response = await async_view(HttpRequest()) + self.assertEqual(response.headers["Expires"], "Mon, 01 Jan 2007 01:54:21 GMT") + self.assertEqual( + response.headers["Cache-Control"], + "max-age=0, no-cache, no-store, must-revalidate, private", + ) + def test_never_cache_decorator_expires_not_overridden(self): @never_cache def a_view(request): @@ -99,6 +178,14 @@ class NeverCacheDecoratorTest(SimpleTestCase): response = a_view(HttpRequest()) self.assertEqual(response.headers["Expires"], "tomorrow") + async def test_never_cache_decorator_expires_not_overridden_async_view(self): + @never_cache + async def async_view(request): + return HttpResponse(headers={"Expires": "tomorrow"}) + + response = await async_view(HttpRequest()) + self.assertEqual(response.headers["Expires"], "tomorrow") + def test_never_cache_decorator_http_request(self): class MyClass: @never_cache @@ -115,6 +202,22 @@ class NeverCacheDecoratorTest(SimpleTestCase): with self.assertRaisesMessage(TypeError, msg): MyClass().a_view(HttpRequestProxy(request)) + async def test_never_cache_decorator_http_request_async_view(self): + class MyClass: + @never_cache + async def async_view(self, request): + return HttpResponse() + + request = HttpRequest() + msg = ( + "never_cache didn't receive an HttpRequest. If you are decorating " + "a classmethod, be sure to use @method_decorator." + ) + with self.assertRaisesMessage(TypeError, msg): + await MyClass().async_view(request) + with self.assertRaisesMessage(TypeError, msg): + await MyClass().async_view(HttpRequestProxy(request)) + def test_never_cache_decorator_http_request_proxy(self): class MyClass: @method_decorator(never_cache)