Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add async tests #1835

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
10 changes: 10 additions & 0 deletions example/README.rst
Expand Up @@ -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
tim-schilling marked this conversation as resolved.
Show resolved Hide resolved

Then run the Django development server:

$ ASYNC_SERVER=true python example/manage.py runserver
9 changes: 9 additions & 0 deletions 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()
25 changes: 25 additions & 0 deletions example/settings.py
Expand Up @@ -16,6 +16,7 @@
# Application definition

INSTALLED_APPS = [
*(["daphne"] if os.getenv("ASYNC_SERVER", False) else []),
tim-schilling marked this conversation as resolved.
Show resolved Hide resolved
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
Expand Down Expand Up @@ -60,6 +61,7 @@
USE_TZ = True

WSGI_APPLICATION = "example.wsgi.application"
ASGI_APPLICATION = "example.asgi.application"

DEBUG_TOOLBAR_CONFIG = {"ROOT_TAG_EXTRA_ATTRS": "data-turbo-permanent hx-preserve"}

Expand Down Expand Up @@ -97,3 +99,26 @@
}

STATICFILES_DIRS = [os.path.join(BASE_DIR, "example", "static")]

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
Comment on lines +116 to +117
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, I don't fully understand the purpose here. Can you help me understand this?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning the user when having to switch from async to sync in the middleware/view stack makes a lot of sense.

I'm not 100% sure if that has to be a part of django-debug-toolbar's example settings though. Is it necessary to define LOGGING at all? Isn't it the default behavior already to emit those warnings?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This allows surfacing the fact that django-debug-toolbar is not an async middleware, therefore Django needs to some extra stuff when using async views.

Probably only relevant until django-debug-toolbar becomes an async middleware.

Shall we remove from the example then?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think my vote is remove it or keep it but default it to DEBUG so that we get that logging you mention.

"django.request": {
"handlers": ["console"],
"level": os.getenv("DJANGO_REQUEST_LOG_LEVEL", "INFO"),
"propagate": False,
},
},
}
14 changes: 14 additions & 0 deletions example/templates/async_db.html
@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="content-type" content="text/html; charset=utf-8">
<title>Async DB</title>
</head>
<body>
<h1>Async DB</h1>
<p>
<span>Value </span>
<span>{{ user_count }}</span>
</p>
</body>
</html>
5 changes: 4 additions & 1 deletion example/urls.py
Expand Up @@ -2,10 +2,13 @@
from django.urls import include, path
from django.views.generic import TemplateView

from example.views import increment
from example.views import async_db, async_db_concurrent, async_home, increment

urlpatterns = [
path("", TemplateView.as_view(template_name="index.html"), name="home"),
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")),
Expand Down
28 changes: 28 additions & 0 deletions example/views.py
@@ -1,4 +1,9 @@
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


def increment(request):
Expand All @@ -8,3 +13,26 @@ def increment(request):
value = 1
request.session["value"] = value
return JsonResponse({"value": value})


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}
)
46 changes: 46 additions & 0 deletions tests/panels/test_sql.py
Expand Up @@ -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"

Expand All @@ -57,6 +71,38 @@ 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"
)
Expand Down
44 changes: 44 additions & 0 deletions tests/test_integration.py
Expand Up @@ -218,6 +218,24 @@ 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):
Expand Down Expand Up @@ -749,3 +767,29 @@ def test_toolbar_language_will_render_to_locale_when_set_both(self):
)
self.assertIn("Query", table.text)
self.assertIn("Action", table.text)

def test_async_sql_action(self):
self.get("/async_execute_sql/")
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
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/")
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
self.wait.until(
EC.visibility_of_element_located((By.CSS_SELECTOR, ".remoteCall"))
)
2 changes: 2 additions & 0 deletions tests/urls.py
Expand Up @@ -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),
Expand Down
13 changes: 13 additions & 0 deletions 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
Expand All @@ -11,6 +14,16 @@ 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})

Expand Down