Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ jobs:

- name: Install dependencies
run: |
pip install pytest pytest-django pytest-cov
pip install pytest pytest-django pytest-cov pytest-asyncio
pip install "Django~=${{ matrix.django-version }}"

- name: Run tests
Expand Down
76 changes: 76 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,82 @@ With [django-csp](https://django-csp.readthedocs.io/en/latest/nonce.html#middlew
{% block script_attributes %}nonce="{{request.csp_nonce}}"{% endblock %}
```

## ASGI support

Async views with an ASGI server is also supported.

```python
import asyncio

from suspense.shortcuts import async_render

# app/views.py
async def view(request):
async def obj():

await asyncio.sleep(1)
return range(10)

return async_render(request, 'template.html', {'obj': obj()})
```

Suspense will wait for any awaitable object to finish before rendering the suspense tags.

### Specify which awaitable to wait for

If you have multiple suspense blocks with different awaitable, you can specify which awaitable to wait for or each suspense block will await everything.

Ex: `{% suspense obj obj2 %}`

```jinja
{% load suspense %}

<ul>
{% suspense obj %}
{% fallback %}
<li class="skeleton">Loading ... </li>
{% endfallback %}

{% for data in obj %}
<li>{{ data }}</li>
{% endfor %}
{% endsuspense %}

{% suspense obj2 %}
{% fallback %}
<li class="skeleton">Loading 2... </li>
{% endfallback %}

{% for data in obj2 %}
<li>{{ data }}</li>
{% endfor %}
{% endsuspense %}
</ul>
```

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.

Ex: `asyncio.create_task(obj())`

```python
import asyncio

from suspense.shortcuts import async_render


# app/views.py
async def view(request):
async def obj():
await asyncio.sleep(1)
return range(10)

task_obj = asyncio.create_task(obj())
return async_render(request, 'template.html', {'obj': task_obj})
```


### ASGI notes
- synchronous streaming response with AGSI will wait for the full render before sending the response to the client.

## Contributing
If you would like to suggest a new feature, you can create an issue on the GitHub repository for this project.
Expand Down
7 changes: 7 additions & 0 deletions config/asgi.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import os

from django.core.asgi import get_asgi_application

os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')

application = get_asgi_application()
18 changes: 18 additions & 0 deletions example/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,21 @@ Now that everything is ready, you can start the local server:
```bash
PYTHONPATH=$PWD/../ ./manage.py runserver
```


# ASGI

## Install unicorn
```bash
pip install -r requirements.txt uvicorn
```

## Run ASGI server

```bash
PYTHONPATH=$PWD/../ python -m uvicorn example.asgi:application
```

## Test the async view

You must use https://127.0.0.1/async/ or https://127.0.0.1/async/class-view/ to test the async views.
7 changes: 7 additions & 0 deletions example/example/asgi.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import os

from django.core.asgi import get_asgi_application

os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'example.settings')

application = get_asgi_application()
10 changes: 9 additions & 1 deletion example/posts/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,14 @@ class Post(models.Model):

@property
def read_time(self):
time.sleep(1)
return randrange(10)


class SlowPost(Post):
@property
def read_time(self):
time.sleep(1)
return randrange(10)

class Meta:
proxy = True
4 changes: 3 additions & 1 deletion example/posts/urls.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
from django.urls import path

from .views import PostTemplateView, post, posts
from .views import AsyncPostTemplateView, PostTemplateView, async_posts, post, posts

urlpatterns = [
path('', posts),
path('class-view/', PostTemplateView.as_view()),
path('async/', async_posts),
path('async/class-view/', AsyncPostTemplateView.as_view()),
path('<str:slug>/', post),
]
31 changes: 26 additions & 5 deletions example/posts/views.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,21 @@
import asyncio

from django.shortcuts import get_object_or_404, render

from suspense.shortcuts import render as suspense_render
from suspense.views import SuspenseTemplateView
from suspense.shortcuts import async_render, render as suspense_render
from suspense.views import AsyncSuspenseTemplateView, SuspenseTemplateView

from .models import Post
from .models import Post, SlowPost


def posts(request):
posts = Post.objects.all()
posts = SlowPost.objects.all()

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


def post(request, slug):
post = get_object_or_404(Post, slug=slug)
post = get_object_or_404(SlowPost, slug=slug)

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

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

def get_context_data(self, **kwargs):
return {'posts': Post.objects.all()}


async def async_posts(request):
async def fetch_posts():
await asyncio.sleep(5)
return [a async for a in Post.objects.all()]

return async_render(request, 'posts/all.html', {'posts': fetch_posts()})


class AsyncPostTemplateView(AsyncSuspenseTemplateView):
template_name = 'posts/all.html'

def get_context_data(self, **kwargs):
async def fetch_posts():
await asyncio.sleep(5)
return [a async for a in Post.objects.all()]

return {'posts': fetch_posts()}
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -60,3 +60,4 @@ known_first_party = ["config"]

[tool.pytest.ini_options]
DJANGO_SETTINGS_MODULE = "config.tests"
asyncio_default_fixture_loop_scope = "function"
33 changes: 33 additions & 0 deletions suspense/futures.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import asyncio
import inspect
import uuid


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

return key, task


def create_async(nodelist, context, async_context_keys):
key = str(uuid.uuid4())

async def task():
coroutines = []
if async_context_keys:
for k in async_context_keys:
v = context.get(k, None)
if v and inspect.isawaitable(v):
coroutines.append((k, v))
else:
flatten_context = context.flatten()
for k, v in flatten_context.items():
if inspect.isawaitable(v):
coroutines.append((k, v))

async def wait_for(to_await):
k, v = to_await
return k, await v

results = await asyncio.gather(*[wait_for(co) for co in coroutines])
new_context = {}
for k, v in results:
new_context[k] = v

context.push(new_context)
return key, await asyncio.to_thread(nodelist.render, context)

return key, task()
19 changes: 18 additions & 1 deletion suspense/http.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from django.http import StreamingHttpResponse

from .streaming_render import streaming_render
from .streaming_render import async_streaming_render, streaming_render


class SuspenseTemplateResponse(StreamingHttpResponse):
Expand All @@ -18,3 +18,20 @@ def __init__(
content_type=content_type,
status=status,
)


class AsyncSuspenseTemplateResponse(StreamingHttpResponse):
def __init__(
self,
request,
template,
context=None,
using=None,
status=None,
content_type=None,
):
super().__init__(
async_streaming_render(request, template, context, using=using),
content_type=content_type,
status=status,
)
15 changes: 14 additions & 1 deletion suspense/shortcuts.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from .http import SuspenseTemplateResponse
from .http import AsyncSuspenseTemplateResponse, SuspenseTemplateResponse


def render(
Expand All @@ -12,3 +12,16 @@ def render(
content_type=content_type,
status=status,
)


def async_render(
request, template_name, context=None, content_type=None, status=None, using=None
):
return AsyncSuspenseTemplateResponse(
request,
template_name,
context,
using=using,
content_type=content_type,
status=status,
)
64 changes: 47 additions & 17 deletions suspense/streaming_render.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import asyncio
import logging
from concurrent import futures

Expand All @@ -7,31 +8,60 @@


def streaming_render(request, template_name, context=None, using=None):
content = loader.render_to_string(template_name, context, request, using=using)
if hasattr(request, "_suspense"):
tasks = request._suspense
delattr(request, "_suspense")
else:
tasks = []
context = context or {}
context["is_async"] = False
content, tasks = _render_base_template(context, request, template_name, using)
yield content
if tasks:
with futures.ThreadPoolExecutor() as executor:
tasks = [executor.submit(task) for task in tasks]
for task in futures.as_completed(tasks):
try:
uid, result = task.result()
escaped_string = result.replace('`', '\\`')
yield loader.render_to_string(
"suspense/replacer.html",
{
"uid": uid,
"escaped_string": escaped_string,
"request": request,
},
request,
using=using,
)
yield _render_replacer(result, request, uid, using)
except Exception:
logger.exception(
f'failed to render suspense template "{template_name}"'
)


async def async_streaming_render(request, template_name, context=None, using=None):
context = context or {}
context["is_async"] = True
content, tasks = _render_base_template(context, request, template_name, using)
yield content
if tasks:
for task in asyncio.as_completed([asyncio.create_task(task) for task in tasks]):
try:
uid, result = await task
yield _render_replacer(result, request, uid, using)
except asyncio.CancelledError:
raise
except Exception:
logger.exception(
f'failed to render suspense template "{template_name}"'
)


def _render_replacer(result, request, uid, using):
escaped_string = result.replace('`', '\\`')
return loader.render_to_string(
"suspense/replacer.html",
{
"uid": uid,
"escaped_string": escaped_string,
"request": request,
},
request,
using=using,
)


def _render_base_template(context, request, template_name, using):
content = loader.render_to_string(template_name, context, request, using=using)
if hasattr(request, "_suspense"):
tasks = request._suspense
delattr(request, "_suspense")
else:
tasks = []
return content, tasks
Loading
Loading