Skip to content

Commit 7068981

Browse files
authored
ASGI support (#4)
* WIP Asgi * fix readme * Add test for async * Explain how to use tasks * Fix failing checks * Run render in a thread
1 parent baacd9e commit 7068981

File tree

21 files changed

+482
-46
lines changed

21 files changed

+482
-46
lines changed

.github/workflows/tests.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ jobs:
5555

5656
- name: Install dependencies
5757
run: |
58-
pip install pytest pytest-django pytest-cov
58+
pip install pytest pytest-django pytest-cov pytest-asyncio
5959
pip install "Django~=${{ matrix.django-version }}"
6060
6161
- name: Run tests

README.md

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,82 @@ With [django-csp](https://django-csp.readthedocs.io/en/latest/nonce.html#middlew
122122
{% block script_attributes %}nonce="{{request.csp_nonce}}"{% endblock %}
123123
```
124124

125+
## ASGI support
126+
127+
Async views with an ASGI server is also supported.
128+
129+
```python
130+
import asyncio
131+
132+
from suspense.shortcuts import async_render
133+
134+
# app/views.py
135+
async def view(request):
136+
async def obj():
137+
138+
await asyncio.sleep(1)
139+
return range(10)
140+
141+
return async_render(request, 'template.html', {'obj': obj()})
142+
```
143+
144+
Suspense will wait for any awaitable object to finish before rendering the suspense tags.
145+
146+
### Specify which awaitable to wait for
147+
148+
If you have multiple suspense blocks with different awaitable, you can specify which awaitable to wait for or each suspense block will await everything.
149+
150+
Ex: `{% suspense obj obj2 %}`
151+
152+
```jinja
153+
{% load suspense %}
154+
155+
<ul>
156+
{% suspense obj %}
157+
{% fallback %}
158+
<li class="skeleton">Loading ... </li>
159+
{% endfallback %}
160+
161+
{% for data in obj %}
162+
<li>{{ data }}</li>
163+
{% endfor %}
164+
{% endsuspense %}
165+
166+
{% suspense obj2 %}
167+
{% fallback %}
168+
<li class="skeleton">Loading 2... </li>
169+
{% endfallback %}
170+
171+
{% for data in obj2 %}
172+
<li>{{ data }}</li>
173+
{% endfor %}
174+
{% endsuspense %}
175+
</ul>
176+
```
177+
178+
Important: If your async context variable is used by more than one suspense block, or you did not specify any variables on the tags, make sure to wrap your coroutines in tasks so they can be awaited multiple times.
179+
180+
Ex: `asyncio.create_task(obj())`
181+
182+
```python
183+
import asyncio
184+
185+
from suspense.shortcuts import async_render
186+
187+
188+
# app/views.py
189+
async def view(request):
190+
async def obj():
191+
await asyncio.sleep(1)
192+
return range(10)
193+
194+
task_obj = asyncio.create_task(obj())
195+
return async_render(request, 'template.html', {'obj': task_obj})
196+
```
197+
198+
199+
### ASGI notes
200+
- synchronous streaming response with AGSI will wait for the full render before sending the response to the client.
125201

126202
## Contributing
127203
If you would like to suggest a new feature, you can create an issue on the GitHub repository for this project.

config/asgi.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import os
2+
3+
from django.core.asgi import get_asgi_application
4+
5+
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
6+
7+
application = get_asgi_application()

example/README.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,3 +32,21 @@ Now that everything is ready, you can start the local server:
3232
```bash
3333
PYTHONPATH=$PWD/../ ./manage.py runserver
3434
```
35+
36+
37+
# ASGI
38+
39+
## Install unicorn
40+
```bash
41+
pip install -r requirements.txt uvicorn
42+
```
43+
44+
## Run ASGI server
45+
46+
```bash
47+
PYTHONPATH=$PWD/../ python -m uvicorn example.asgi:application
48+
```
49+
50+
## Test the async view
51+
52+
You must use https://127.0.0.1/async/ or https://127.0.0.1/async/class-view/ to test the async views.

example/example/asgi.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import os
2+
3+
from django.core.asgi import get_asgi_application
4+
5+
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'example.settings')
6+
7+
application = get_asgi_application()

example/posts/models.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,14 @@ class Post(models.Model):
1111

1212
@property
1313
def read_time(self):
14-
time.sleep(1)
14+
return randrange(10)
1515

16+
17+
class SlowPost(Post):
18+
@property
19+
def read_time(self):
20+
time.sleep(1)
1621
return randrange(10)
22+
23+
class Meta:
24+
proxy = True

example/posts/urls.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
from django.urls import path
22

3-
from .views import PostTemplateView, post, posts
3+
from .views import AsyncPostTemplateView, PostTemplateView, async_posts, post, posts
44

55
urlpatterns = [
66
path('', posts),
77
path('class-view/', PostTemplateView.as_view()),
8+
path('async/', async_posts),
9+
path('async/class-view/', AsyncPostTemplateView.as_view()),
810
path('<str:slug>/', post),
911
]

example/posts/views.py

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,21 @@
1+
import asyncio
2+
13
from django.shortcuts import get_object_or_404, render
24

3-
from suspense.shortcuts import render as suspense_render
4-
from suspense.views import SuspenseTemplateView
5+
from suspense.shortcuts import async_render, render as suspense_render
6+
from suspense.views import AsyncSuspenseTemplateView, SuspenseTemplateView
57

6-
from .models import Post
8+
from .models import Post, SlowPost
79

810

911
def posts(request):
10-
posts = Post.objects.all()
12+
posts = SlowPost.objects.all()
1113

1214
return suspense_render(request, 'posts/all.html', {'posts': posts})
1315

1416

1517
def post(request, slug):
16-
post = get_object_or_404(Post, slug=slug)
18+
post = get_object_or_404(SlowPost, slug=slug)
1719

1820
return render(request, 'posts/post.html', {'post': post})
1921

@@ -23,3 +25,22 @@ class PostTemplateView(SuspenseTemplateView):
2325

2426
def get_context_data(self, **kwargs):
2527
return {'posts': Post.objects.all()}
28+
29+
30+
async def async_posts(request):
31+
async def fetch_posts():
32+
await asyncio.sleep(5)
33+
return [a async for a in Post.objects.all()]
34+
35+
return async_render(request, 'posts/all.html', {'posts': fetch_posts()})
36+
37+
38+
class AsyncPostTemplateView(AsyncSuspenseTemplateView):
39+
template_name = 'posts/all.html'
40+
41+
def get_context_data(self, **kwargs):
42+
async def fetch_posts():
43+
await asyncio.sleep(5)
44+
return [a async for a in Post.objects.all()]
45+
46+
return {'posts': fetch_posts()}

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,3 +60,4 @@ known_first_party = ["config"]
6060

6161
[tool.pytest.ini_options]
6262
DJANGO_SETTINGS_MODULE = "config.tests"
63+
asyncio_default_fixture_loop_scope = "function"

suspense/futures.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import asyncio
2+
import inspect
13
import uuid
24

35

@@ -8,3 +10,34 @@ def task():
810
return key, nodelist.render(context)
911

1012
return key, task
13+
14+
15+
def create_async(nodelist, context, async_context_keys):
16+
key = str(uuid.uuid4())
17+
18+
async def task():
19+
coroutines = []
20+
if async_context_keys:
21+
for k in async_context_keys:
22+
v = context.get(k, None)
23+
if v and inspect.isawaitable(v):
24+
coroutines.append((k, v))
25+
else:
26+
flatten_context = context.flatten()
27+
for k, v in flatten_context.items():
28+
if inspect.isawaitable(v):
29+
coroutines.append((k, v))
30+
31+
async def wait_for(to_await):
32+
k, v = to_await
33+
return k, await v
34+
35+
results = await asyncio.gather(*[wait_for(co) for co in coroutines])
36+
new_context = {}
37+
for k, v in results:
38+
new_context[k] = v
39+
40+
context.push(new_context)
41+
return key, await asyncio.to_thread(nodelist.render, context)
42+
43+
return key, task()

0 commit comments

Comments
 (0)