From 18d400ab4d11078ca783497840d508660ba8d4c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rton=20Salomv=C3=A1ry?= Date: Tue, 19 Sep 2023 11:25:05 +0200 Subject: [PATCH 1/5] Add asynchronous examples (#1819) --- example/README.rst | 10 ++++++++++ example/asgi.py | 9 +++++++++ example/settings.py | 26 +++++++++++++++++++++++++- example/templates/async_db.html | 14 ++++++++++++++ example/urls.py | 11 ++++++++++- example/views.py | 27 +++++++++++++++++++++++++++ 6 files changed, 95 insertions(+), 2 deletions(-) create mode 100644 example/asgi.py create mode 100644 example/templates/async_db.html diff --git a/example/README.rst b/example/README.rst index 94c09f8e5..1c34e4893 100644 --- a/example/README.rst +++ b/example/README.rst @@ -46,3 +46,13 @@ environment variable:: $ DB_BACKEND=postgresql python example/manage.py migrate $ DB_BACKEND=postgresql python example/manage.py runserver + +Using an asynchronous (ASGI) server: + +Install [Daphne](https://pypi.org/project/daphne/) first: + + $ python -m pip install daphne + +Then run the Django development server: + + $ ASYNC_SERVER=true python example/manage.py runserver diff --git a/example/asgi.py b/example/asgi.py new file mode 100644 index 000000000..9d7c78703 --- /dev/null +++ b/example/asgi.py @@ -0,0 +1,9 @@ +"""ASGI config for example project.""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "example.settings") + +application = get_asgi_application() diff --git a/example/settings.py b/example/settings.py index 26b75fa5c..e083562aa 100644 --- a/example/settings.py +++ b/example/settings.py @@ -18,6 +18,7 @@ # Application definition INSTALLED_APPS = [ + *(["daphne"] if os.getenv("ASYNC_SERVER", False) else []), # noqa: FBT003 "django.contrib.admin", "django.contrib.auth", "django.contrib.contenttypes", @@ -66,6 +67,7 @@ USE_TZ = True WSGI_APPLICATION = "example.wsgi.application" +ASGI_APPLICATION = "example.asgi.application" # Cache and database @@ -103,7 +105,6 @@ STATICFILES_DIRS = [os.path.join(BASE_DIR, "example", "static")] - # Only enable the toolbar when we're in debug mode and we're # not running tests. Django will change DEBUG to be False for # tests, so we can't rely on DEBUG alone. @@ -117,3 +118,26 @@ ] # Customize the config to support turbo and htmx boosting. DEBUG_TOOLBAR_CONFIG = {"ROOT_TAG_EXTRA_ATTRS": "data-turbo-permanent hx-preserve"} + +LOGGING = { + "version": 1, + "disable_existing_loggers": False, + "handlers": { + "console": { + "class": "logging.StreamHandler", + }, + }, + "root": { + "handlers": ["console"], + "level": "WARNING", + }, + "loggers": { + # Log when an asynchronous handler is adapted for middleware. + # See warning here: https://docs.djangoproject.com/en/4.2/topics/async/#async-views + "django.request": { + "handlers": ["console"], + "level": os.getenv("DJANGO_REQUEST_LOG_LEVEL", "INFO"), + "propagate": False, + }, + }, +} diff --git a/example/templates/async_db.html b/example/templates/async_db.html new file mode 100644 index 000000000..771c039e3 --- /dev/null +++ b/example/templates/async_db.html @@ -0,0 +1,14 @@ + + + + + Async DB + + +

Async DB

+

+ Value + {{ user_count }} +

+ + diff --git a/example/urls.py b/example/urls.py index c5e60c309..86e6827fc 100644 --- a/example/urls.py +++ b/example/urls.py @@ -3,7 +3,13 @@ from django.views.generic import TemplateView from debug_toolbar.toolbar import debug_toolbar_urls -from example.views import increment, jinja2_view +from example.views import ( + async_db, + async_db_concurrent, + async_home, + increment, + jinja2_view, +) urlpatterns = [ path("", TemplateView.as_view(template_name="index.html"), name="home"), @@ -13,6 +19,9 @@ name="bad_form", ), path("jinja/", jinja2_view, name="jinja"), + path("async/", async_home, name="async_home"), + path("async/db/", async_db, name="async_db"), + path("async/db-concurrent/", async_db_concurrent, name="async_db_concurrent"), path("jquery/", TemplateView.as_view(template_name="jquery/index.html")), path("mootools/", TemplateView.as_view(template_name="mootools/index.html")), path("prototype/", TemplateView.as_view(template_name="prototype/index.html")), diff --git a/example/views.py b/example/views.py index e7e4c1253..3e1cb04a6 100644 --- a/example/views.py +++ b/example/views.py @@ -1,3 +1,7 @@ +import asyncio + +from asgiref.sync import sync_to_async +from django.contrib.auth.models import User from django.http import JsonResponse from django.shortcuts import render @@ -13,3 +17,26 @@ def increment(request): def jinja2_view(request): return render(request, "index.jinja", {"foo": "bar"}, using="jinja2") + + +async def async_home(request): + return await sync_to_async(render)(request, "index.html") + + +async def async_db(request): + user_count = await User.objects.acount() + + return await sync_to_async(render)( + request, "async_db.html", {"user_count": user_count} + ) + + +async def async_db_concurrent(request): + # Do database queries concurrently + (user_count, _) = await asyncio.gather( + User.objects.acount(), User.objects.filter(username="test").acount() + ) + + return await sync_to_async(render)( + request, "async_db.html", {"user_count": user_count} + ) From 2001eb863d0ef99ff2d0b1e35bc14b65d6dabc2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rton=20Salomv=C3=A1ry?= Date: Tue, 19 Sep 2023 12:14:59 +0200 Subject: [PATCH 2/5] Add tests for async usage (#1819) --- tests/panels/test_sql.py | 47 +++++++++++++++++++++++++++++++++++++++ tests/test_integration.py | 39 ++++++++++++++++++++++++++++++++ tests/urls.py | 2 ++ tests/views.py | 14 ++++++++++++ 4 files changed, 102 insertions(+) diff --git a/tests/panels/test_sql.py b/tests/panels/test_sql.py index 48c9e3845..29837ed37 100644 --- a/tests/panels/test_sql.py +++ b/tests/panels/test_sql.py @@ -32,6 +32,20 @@ def sql_call(*, use_iterator=False): return list(qs) +async def async_sql_call(*, use_iterator=False): + qs = User.objects.all() + if use_iterator: + qs = qs.iterator() + return await sync_to_async(list)(qs) + + +async def concurrent_async_sql_call(*, use_iterator=False): + qs = User.objects.all() + if use_iterator: + qs = qs.iterator() + return await asyncio.gather(sync_to_async(list)(qs), User.objects.acount()) + + class SQLPanelTestCase(BaseTestCase): panel_id = "SQLPanel" @@ -57,6 +71,39 @@ def test_recording(self): # ensure the stacktrace is populated self.assertTrue(len(query["stacktrace"]) > 0) + async def test_recording_async(self): + self.assertEqual(len(self.panel._queries), 0) + + await async_sql_call() + + # ensure query was logged + self.assertEqual(len(self.panel._queries), 1) + query = self.panel._queries[0] + self.assertEqual(query["alias"], "default") + self.assertTrue("sql" in query) + self.assertTrue("duration" in query) + self.assertTrue("stacktrace" in query) + + # ensure the stacktrace is populated + self.assertTrue(len(query["stacktrace"]) > 0) + + async def test_recording_concurrent_async(self): + self.assertEqual(len(self.panel._queries), 0) + + await concurrent_async_sql_call() + + # ensure query was logged + self.assertEqual(len(self.panel._queries), 2) + query = self.panel._queries[0] + self.assertEqual(query["alias"], "default") + self.assertTrue("sql" in query) + self.assertTrue("duration" in query) + self.assertTrue("stacktrace" in query) + + # ensure the stacktrace is populated + self.assertTrue(len(query["stacktrace"]) > 0) + + @unittest.skipUnless( connection.vendor == "postgresql", "Test valid only on PostgreSQL" ) diff --git a/tests/test_integration.py b/tests/test_integration.py index 95207c21b..ee13bf04d 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -250,6 +250,18 @@ def test_data_gone(self): ) self.assertIn("Please reload the page and retry.", response.json()["content"]) + def test_sql_page(self): + response = self.client.get("/execute_sql/") + self.assertEqual(len(response.toolbar.get_panel_by_id("SQLPanel").get_stats()["queries"]), 1) + + def test_async_sql_page(self): + response = self.client.get("/async_execute_sql/") + self.assertEqual(len(response.toolbar.get_panel_by_id("SQLPanel").get_stats()["queries"]), 1) + + def test_concurrent_async_sql_page(self): + response = self.client.get("/async_execute_sql_concurrently/") + self.assertEqual(len(response.toolbar.get_panel_by_id("SQLPanel").get_stats()["queries"]), 2) + @override_settings(DEBUG=True) class DebugToolbarIntegrationTestCase(IntegrationTestCase): @@ -843,3 +855,30 @@ def test_theme_toggle(self): self.get("/regular/basic/") toolbar = self.selenium.find_element(By.ID, "djDebug") self.assertEqual(toolbar.get_attribute("data-theme"), "light") + + def test_async_sql_action(self): + self.get("/async_execute_sql/") + sql_panel = self.selenium.find_element(By.ID, "SQLPanel") + debug_window = self.selenium.find_element(By.ID, "djDebugWindow") + + # Click to show the SQL panel + self.selenium.find_element(By.CLASS_NAME, "SQLPanel").click() + + # SQL panel loads + button = self.wait.until( + EC.visibility_of_element_located((By.CSS_SELECTOR, ".remoteCall")) + ) + + + def test_concurrent_async_sql_action(self): + self.get("/async_execute_sql_concurrently/") + sql_panel = self.selenium.find_element(By.ID, "SQLPanel") + debug_window = self.selenium.find_element(By.ID, "djDebugWindow") + + # Click to show the SQL panel + self.selenium.find_element(By.CLASS_NAME, "SQLPanel").click() + + # SQL panel loads + button = self.wait.until( + EC.visibility_of_element_located((By.CSS_SELECTOR, ".remoteCall")) + ) diff --git a/tests/urls.py b/tests/urls.py index f8929f1e8..68c6e0354 100644 --- a/tests/urls.py +++ b/tests/urls.py @@ -17,6 +17,8 @@ path("non_ascii_request/", views.regular_view, {"title": NonAsciiRepr()}), path("new_user/", views.new_user), path("execute_sql/", views.execute_sql), + path("async_execute_sql/", views.async_execute_sql), + path("async_execute_sql_concurrently/", views.async_execute_sql_concurrently), path("cached_view/", views.cached_view), path("cached_low_level_view/", views.cached_low_level_view), path("json_view/", views.json_view), diff --git a/tests/views.py b/tests/views.py index 8ae4631fe..1f64e40bf 100644 --- a/tests/views.py +++ b/tests/views.py @@ -1,3 +1,6 @@ +import asyncio + +from asgiref.sync import sync_to_async from django.contrib.auth.models import User from django.core.cache import cache from django.http import HttpResponseRedirect, JsonResponse @@ -11,6 +14,17 @@ def execute_sql(request): return render(request, "base.html") +async def async_execute_sql(request): + await sync_to_async(list)(User.objects.all()) + return render(request, "base.html") + + +async def async_execute_sql_concurrently(request): + await asyncio.gather(sync_to_async(list)(User.objects.all()), User.objects.acount()) + return render(request, "base.html") + + + def regular_view(request, title): return render(request, "basic.html", {"title": title}) From c409981b1b7243eee7bd920408d7c3e7f9052ed5 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 19 Sep 2023 10:45:37 +0000 Subject: [PATCH 3/5] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- tests/panels/test_sql.py | 1 - tests/test_integration.py | 25 +++++++++++++++---------- tests/views.py | 1 - 3 files changed, 15 insertions(+), 12 deletions(-) diff --git a/tests/panels/test_sql.py b/tests/panels/test_sql.py index 29837ed37..332e9b1e8 100644 --- a/tests/panels/test_sql.py +++ b/tests/panels/test_sql.py @@ -103,7 +103,6 @@ async def test_recording_concurrent_async(self): # ensure the stacktrace is populated self.assertTrue(len(query["stacktrace"]) > 0) - @unittest.skipUnless( connection.vendor == "postgresql", "Test valid only on PostgreSQL" ) diff --git a/tests/test_integration.py b/tests/test_integration.py index ee13bf04d..e6863e7a9 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -252,15 +252,21 @@ def test_data_gone(self): def test_sql_page(self): response = self.client.get("/execute_sql/") - self.assertEqual(len(response.toolbar.get_panel_by_id("SQLPanel").get_stats()["queries"]), 1) + self.assertEqual( + len(response.toolbar.get_panel_by_id("SQLPanel").get_stats()["queries"]), 1 + ) def test_async_sql_page(self): response = self.client.get("/async_execute_sql/") - self.assertEqual(len(response.toolbar.get_panel_by_id("SQLPanel").get_stats()["queries"]), 1) + self.assertEqual( + len(response.toolbar.get_panel_by_id("SQLPanel").get_stats()["queries"]), 1 + ) def test_concurrent_async_sql_page(self): response = self.client.get("/async_execute_sql_concurrently/") - self.assertEqual(len(response.toolbar.get_panel_by_id("SQLPanel").get_stats()["queries"]), 2) + self.assertEqual( + len(response.toolbar.get_panel_by_id("SQLPanel").get_stats()["queries"]), 2 + ) @override_settings(DEBUG=True) @@ -858,27 +864,26 @@ def test_theme_toggle(self): def test_async_sql_action(self): self.get("/async_execute_sql/") - sql_panel = self.selenium.find_element(By.ID, "SQLPanel") - debug_window = self.selenium.find_element(By.ID, "djDebugWindow") + self.selenium.find_element(By.ID, "SQLPanel") + self.selenium.find_element(By.ID, "djDebugWindow") # Click to show the SQL panel self.selenium.find_element(By.CLASS_NAME, "SQLPanel").click() # SQL panel loads - button = self.wait.until( + self.wait.until( EC.visibility_of_element_located((By.CSS_SELECTOR, ".remoteCall")) ) - def test_concurrent_async_sql_action(self): self.get("/async_execute_sql_concurrently/") - sql_panel = self.selenium.find_element(By.ID, "SQLPanel") - debug_window = self.selenium.find_element(By.ID, "djDebugWindow") + self.selenium.find_element(By.ID, "SQLPanel") + self.selenium.find_element(By.ID, "djDebugWindow") # Click to show the SQL panel self.selenium.find_element(By.CLASS_NAME, "SQLPanel").click() # SQL panel loads - button = self.wait.until( + self.wait.until( EC.visibility_of_element_located((By.CSS_SELECTOR, ".remoteCall")) ) diff --git a/tests/views.py b/tests/views.py index 1f64e40bf..8b8b75ef6 100644 --- a/tests/views.py +++ b/tests/views.py @@ -24,7 +24,6 @@ async def async_execute_sql_concurrently(request): return render(request, "base.html") - def regular_view(request, title): return render(request, "basic.html", {"title": title}) From ce9a94ba8712a1affcd37d8632b0032c326e9224 Mon Sep 17 00:00:00 2001 From: Tim Schilling Date: Sat, 24 Feb 2024 08:15:07 -0600 Subject: [PATCH 4/5] Include daphne is requirements_dev --- requirements_dev.txt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/requirements_dev.txt b/requirements_dev.txt index 8b24a8fbb..03e436622 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -12,6 +12,10 @@ selenium tox black +# Integration support + +daphne # async in Example app + # Documentation Sphinx From 1d80b58fbbceaeed2d5e58515aff45fc29f3660f Mon Sep 17 00:00:00 2001 From: Tim Schilling Date: Tue, 16 Jul 2024 07:23:29 -0500 Subject: [PATCH 5/5] Remove logging definition in example app about async concerns. --- example/settings.py | 23 ----------------------- 1 file changed, 23 deletions(-) diff --git a/example/settings.py b/example/settings.py index e083562aa..06b70f7fa 100644 --- a/example/settings.py +++ b/example/settings.py @@ -118,26 +118,3 @@ ] # Customize the config to support turbo and htmx boosting. DEBUG_TOOLBAR_CONFIG = {"ROOT_TAG_EXTRA_ATTRS": "data-turbo-permanent hx-preserve"} - -LOGGING = { - "version": 1, - "disable_existing_loggers": False, - "handlers": { - "console": { - "class": "logging.StreamHandler", - }, - }, - "root": { - "handlers": ["console"], - "level": "WARNING", - }, - "loggers": { - # Log when an asynchronous handler is adapted for middleware. - # See warning here: https://docs.djangoproject.com/en/4.2/topics/async/#async-views - "django.request": { - "handlers": ["console"], - "level": os.getenv("DJANGO_REQUEST_LOG_LEVEL", "INFO"), - "propagate": False, - }, - }, -}