Skip to content
Closed
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
Binary file added docs/assets/starlette.webm
Binary file not shown.
3 changes: 3 additions & 0 deletions docs/changelog.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
# Changelog

## NEXT
- Add async streaming support. See [the Streaming docs for more information](https://htpy.dev/streaming/). [PR #38](https://github.com/pelme/htpy/pull/38).

## 25.4.1 - 2025-04-12
- Add the `Renderable` protocol, a consistent API to render an `htpy` object as HTML or to iterate over it. `Element`, `Fragment`, `ContextProvider`, and `ContextConsumer` are all `Renderable`. [PR #92](https://github.com/pelme/htpy/pull/92). Thanks to [Stein Magnus Jodal (@jodal)](https://github.com/jodal) and [Dave Peck (@davepeck)](https://github.com/davepeck).
- Deprecate `render_node()` and `iter_node()` and direct iteration over elements. Call `Renderable.__str__()` or `Renderable.iter_chunks()` instead. [Read the Usage docs for more details](usage.md#renderable).
Expand Down
148 changes: 124 additions & 24 deletions docs/streaming.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,43 +14,40 @@ client while the page is being generated.
streaming will be the easiest way to get going. Streaming can give you
Copy link
Copy Markdown
Owner Author

@pelme pelme Apr 13, 2025

Choose a reason for hiding this comment

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

Improve these docs:

  • Use the same example/output for for both sync and async examples
  • Show the example code first in an inline code block instead of link to it. Show the video directly below.
  • Remove/shorten the section on callables/lambda and generators.

improved user experience from faster pages/rendering.

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)):
<video width="500" controls loop >

<source src="/assets/stream.webm" type="video/webm">
</video>
## Example
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)):

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

## Using Generators and Callables as Children

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

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

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

from myapp.models import Article
- Callables/lambdas without any arguments
- Generators

def article_list(request):
return StreamingHttpResponse(ul[
(li[article.title] for article in Article.objects.all())
])
```
These will be evaluated lazily and

## Using Callables to Delay Evalutation
### Callables/lambda

Pass a callable that does not accept any arguements as child to delay the
evaluation.
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.

This example shows how the page starts rendering and outputs the `<h1>` tag and
then calls `calculate_magic_number`.
Expand All @@ -70,6 +67,7 @@ element = div[
calculate_magic_number,
]

# Iterate over the element to get the content incrementally
for chunk in element:
print(chunk)
```
Expand Down Expand Up @@ -105,9 +103,111 @@ print(
div[
h1["Fibonacci!"],
"fib(20)=",
lambda: str(fib(20)),
lambda: fib(20),
]
)
# output: <div><h1>Fibonacci!</h1>fib(12)=6765</div>

```

### Generators

Generators can also be used to gradually retrieve output. You may create a
generator function (a function that uses the `yield` keyword) or an generator
comprehension/expression.

```py
import time
from collections.abc import Iterator

from htpy import Element, li, ul


def numbers() -> Iterator[Element]:
yield li[1]
time.sleep(1)
yield li[2]


def component() -> Element:
return ul[numbers]


for chunk in component():
print(chunk)
```

Output:

```html
<ul>
<li>
1
</li>
<li> <|- Appears after 1 second
2 <|
</li> <|
</ul>
```


## Asynchronous streaming

htpy can be used in fully async mode.

This intended to be used with ASGI/async web frameworks/servers such as
Starlette, Sanic, FastAPI and Django.

Combined with an ORM, database adapter or reading backing data from an async
source, all parts of the stack will be fully async and the client will get the data incrementally.

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`.


### Starlette, ASGI and uvicorn example

```python
title="starlette_demo.py"
import asyncio
from collections.abc import AsyncIterator

from starlette.applications import Starlette
from starlette.requests import Request
from starlette.responses import StreamingResponse

from htpy import Element, div, h1, li, p, ul

app = Starlette(debug=True)


@app.route("/")
async def index(request: Request) -> StreamingResponse:
return StreamingResponse(await index_page(), media_type="text/html")


async def index_page() -> Element:
return div[
h1["Starlette Async example"],
p["This page is generated asynchronously using Starlette and ASGI."],
ul[(li[str(num)] async for num in slow_numbers(1, 10))],
]


async def slow_numbers(minimum: int, maximum: int) -> AsyncIterator[int]:
for number in range(minimum, maximum + 1):
yield number
await asyncio.sleep(0.5)

```

Run with [uvicorn](https://www.uvicorn.org/):


```
$ uvicorn starlette_demo:app
```

In the browser, it looks like this:
<video width="500" controls loop >
<source src="/assets/starlette.webm" type="video/webm">
</video>
24 changes: 24 additions & 0 deletions examples/async_example.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import asyncio
import random

from htpy import Element, b, div, h1


async def magic_number() -> Element:
await asyncio.sleep(2)
return b[f"The Magic Number is: {random.randint(1, 100)}"]


async def my_component() -> Element:
return div[
h1["The Magic Number"],
magic_number(),
]


async def main() -> None:
async for chunk in await my_component():
print(chunk)


asyncio.run(main())
35 changes: 35 additions & 0 deletions examples/starlette_app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import asyncio
from collections.abc import AsyncIterator

from starlette.applications import Starlette
from starlette.requests import Request
from starlette.responses import StreamingResponse
from starlette.routing import Route

from htpy import Element, div, h1, li, p, ul


async def index(request: Request) -> StreamingResponse:
return StreamingResponse(await index_page(), media_type="text/html")


async def index_page() -> Element:
return div[
h1["Starlette Async example"],
p["This page is generated asynchronously using Starlette and ASGI."],
ul[(li[str(num)] async for num in slow_numbers(1, 10))],
]


async def slow_numbers(minimum: int, maximum: int) -> AsyncIterator[int]:
for number in range(minimum, maximum + 1):
yield number
await asyncio.sleep(0.5)


app = Starlette(
debug=True,
routes=[
Route("/", index),
],
)
18 changes: 18 additions & 0 deletions examples/stream_generator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import time
from collections.abc import Iterator

from htpy import Element, li, ul


def numbers() -> Iterator[Element]:
yield li[1]
time.sleep(1)
yield li[2]


def component() -> Element:
return ul[numbers]


for chunk in component():
print(chunk)
Loading