Skip to content

Commit 98b951c

Browse files
committed
Async iteration
1 parent 456453c commit 98b951c

File tree

10 files changed

+438
-43
lines changed

10 files changed

+438
-43
lines changed

docs/assets/starlette.webm

151 KB
Binary file not shown.

docs/streaming.md

Lines changed: 134 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,16 @@
11
# Streaming of Contents
22

33
Internally, htpy is built with generators. Most of the time, you would render
4-
the full page with `str()`, but htpy can also incrementally generate pages which
5-
can then be streamed to the browser. If your page uses a database or other
6-
services to retrieve data, you can sending the first part of the page to the
7-
client while the page is being generated.
4+
the full page with `str()`, but htpy can also incrementally generate pages
5+
synchronously or asynchronous which can then be streamed to the browser. If your
6+
page uses a database or other services to retrieve data, you can send the
7+
beginning of the page while the rest is being generated. This can improve the
8+
user experience of your site: Typically the `<head>` tag with CSS and JavaScript
9+
files are sent first. The browser will start evaluating scripts and parse CSS
10+
while the page loads. Once the actual data, typically part of the `<body>` or
11+
`<main>` arrives, it can directly be rendered. Typical template based systems
12+
render the entire page at once and then send it to the client, when the server
13+
rendered the full page.
814

915
!!! note
1016

@@ -14,43 +20,40 @@ client while the page is being generated.
1420
streaming will be the easiest way to get going. Streaming can give you
1521
improved user experience from faster pages/rendering.
1622

17-
This video shows what it looks like in the browser to generate a HTML table with [Django StreamingHttpResponse](https://docs.djangoproject.com/en/5.0/ref/request-response/#django.http.StreamingHttpResponse) ([source code](https://github.com/pelme/htpy/blob/main/examples/djangoproject/stream/views.py)):
18-
<video width="500" controls loop >
1923

20-
<source src="/assets/stream.webm" type="video/webm">
21-
</video>
24+
## Example
25+
This video shows what it looks like in the browser to generate a HTML table with
26+
[Django
27+
StreamingHttpResponse](https://docs.djangoproject.com/en/5.0/ref/request-response/#django.http.StreamingHttpResponse)
28+
([source
29+
code](https://github.com/pelme/htpy/blob/main/examples/djangoproject/stream/views.py)):
2230

23-
This example simulates a (very) slow fetch of data and shows the power of
31+
This example simulates a (very) slow data source and shows the power of
2432
streaming: The browser loads CSS and gradually shows the contents. By loading
2533
CSS files in the `<head>` tag before dynamic content, the browser can start
2634
working on loading the CSS and styling the page while the server keeps
2735
generating the rest of the page.
36+
<video width="500" controls loop >
37+
<source src="/assets/stream.webm" type="video/webm">
38+
</video>
2839

29-
## Using Generators and Callables as Children
3040

31-
Django's querysets are [lazily
32-
evaluated](https://docs.djangoproject.com/en/5.0/topics/db/queries/#querysets-are-lazy).
33-
They will not execute a database query before their value is actually needed.
41+
## Synchronous streaming
3442

35-
This example shows how this property of Django querysets can be used to create a
36-
page that streams objects:
43+
Instead of calling `str()` of an element, you may iterate/loop over it. You will then
44+
get "chunks" of the element as htpy renders the result, as soon as they are ready.
3745

38-
```python
39-
from django.http import StreamingHttpResponse
40-
from htpy import ul, li
46+
To delay the calculation and allow htpy to incrementally render elements, there
47+
are two types of lazy constructs that can be used:
4148

42-
from myapp.models import Article
49+
- Callables/lambdas without any arguments
50+
- Generators
4351

44-
def article_list(request):
45-
return StreamingHttpResponse(ul[
46-
(li[article.title] for article in Article.objects.all())
47-
])
48-
```
52+
These will be evaluated lazily and
4953

50-
## Using Callables to Delay Evalutation
54+
### Callables/lambda
5155

52-
Pass a callable that does not accept any arguements as child to delay the
53-
evaluation.
56+
Pass a callable that does not accept any arguments as child. When htpy renders the children, it will call the function to retrieve the result.
5457

5558
This example shows how the page starts rendering and outputs the `<h1>` tag and
5659
then calls `calculate_magic_number`.
@@ -70,6 +73,7 @@ element = div[
7073
calculate_magic_number,
7174
]
7275

76+
# Iterate over the element to get the content incrementally
7377
for chunk in element:
7478
print(chunk)
7579
```
@@ -105,9 +109,111 @@ print(
105109
div[
106110
h1["Fibonacci!"],
107111
"fib(20)=",
108-
lambda: str(fib(20)),
112+
lambda: fib(20),
109113
]
110114
)
111115
# output: <div><h1>Fibonacci!</h1>fib(12)=6765</div>
112116

113117
```
118+
119+
### Generators
120+
121+
Generators can also be used to gradually retrieve output. You may create a
122+
generator function (a function that uses the `yield` keyword) or an generator
123+
comprehension/expression.
124+
125+
```py
126+
import time
127+
from collections.abc import Iterator
128+
129+
from htpy import Element, li, ul
130+
131+
132+
def numbers() -> Iterator[Element]:
133+
yield li[1]
134+
time.sleep(1)
135+
yield li[2]
136+
137+
138+
def component() -> Element:
139+
return ul[numbers]
140+
141+
142+
for chunk in component():
143+
print(chunk)
144+
```
145+
146+
Output:
147+
148+
```html
149+
<ul>
150+
<li>
151+
1
152+
</li>
153+
<li> <|- Appears after 1 second
154+
2 <|
155+
</li> <|
156+
</ul>
157+
```
158+
159+
160+
## Asynchronous streaming
161+
162+
htpy can be used in fully async mode.
163+
164+
This intended to be used with ASGI/async web frameworks/servers such as
165+
Starlette, Sanic, FastAPI and Django.
166+
167+
Combined with an ORM, database adapter or reading backing data from an async
168+
source, all parts of the stack will be fully async and the client will get the data incrementally.
169+
170+
htpy will `await` any awaitables and iterate over async iterators. Use async iteration on a htpy element or use `aiter_node()` to render any `Node`.
171+
172+
173+
### Starlette, ASGI and uvicorn example
174+
175+
```python
176+
title="starlette_demo.py"
177+
import asyncio
178+
from collections.abc import AsyncIterator
179+
180+
from starlette.applications import Starlette
181+
from starlette.requests import Request
182+
from starlette.responses import StreamingResponse
183+
184+
from htpy import Element, div, h1, li, p, ul
185+
186+
app = Starlette(debug=True)
187+
188+
189+
@app.route("/")
190+
async def index(request: Request) -> StreamingResponse:
191+
return StreamingResponse(await index_page(), media_type="text/html")
192+
193+
194+
async def index_page() -> Element:
195+
return div[
196+
h1["Starlette Async example"],
197+
p["This page is generated asynchronously using Starlette and ASGI."],
198+
ul[(li[str(num)] async for num in slow_numbers(1, 10))],
199+
]
200+
201+
202+
async def slow_numbers(minimum: int, maximum: int) -> AsyncIterator[int]:
203+
for number in range(minimum, maximum + 1):
204+
yield number
205+
await asyncio.sleep(0.5)
206+
207+
```
208+
209+
Run with [uvicorn](https://www.uvicorn.org/):
210+
211+
212+
```
213+
$ uvicorn starlette_demo:app
214+
```
215+
216+
In the browser, it looks like this:
217+
<video width="500" controls loop >
218+
<source src="/assets/starlette.webm" type="video/webm">
219+
</video>

examples/async_example.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import asyncio
2+
import random
3+
4+
from htpy import Element, b, div, h1
5+
6+
7+
async def magic_number() -> Element:
8+
await asyncio.sleep(2)
9+
return b[f"The Magic Number is: {random.randint(1, 100)}"]
10+
11+
12+
async def my_component() -> Element:
13+
return div[
14+
h1["The Magic Number"],
15+
magic_number(),
16+
]
17+
18+
19+
async def main() -> None:
20+
async for chunk in await my_component():
21+
print(chunk)
22+
23+
24+
asyncio.run(main())

examples/starlette_app.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import asyncio
2+
from collections.abc import AsyncIterator
3+
4+
from starlette.applications import Starlette
5+
from starlette.requests import Request
6+
from starlette.responses import StreamingResponse
7+
from starlette.routing import Route
8+
9+
from htpy import Element, div, h1, li, p, ul
10+
11+
12+
async def index(request: Request) -> StreamingResponse:
13+
return StreamingResponse(await index_page(), media_type="text/html")
14+
15+
16+
async def index_page() -> Element:
17+
return div[
18+
h1["Starlette Async example"],
19+
p["This page is generated asynchronously using Starlette and ASGI."],
20+
ul[(li[str(num)] async for num in slow_numbers(1, 10))],
21+
]
22+
23+
24+
async def slow_numbers(minimum: int, maximum: int) -> AsyncIterator[int]:
25+
for number in range(minimum, maximum + 1):
26+
yield number
27+
await asyncio.sleep(0.5)
28+
29+
30+
app = Starlette(
31+
debug=True,
32+
routes=[
33+
Route("/", index),
34+
],
35+
)

examples/stream_generator.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import time
2+
from collections.abc import Iterator
3+
4+
from htpy import Element, li, ul
5+
6+
7+
def numbers() -> Iterator[Element]:
8+
yield li[1]
9+
time.sleep(1)
10+
yield li[2]
11+
12+
13+
def component() -> Element:
14+
return ul[numbers]
15+
16+
17+
for chunk in component():
18+
print(chunk)

0 commit comments

Comments
 (0)