Skip to content

Commit

Permalink
async iteration support
Browse files Browse the repository at this point in the history
  • Loading branch information
pelme committed Aug 24, 2024
1 parent 23a0aee commit bddc8df
Show file tree
Hide file tree
Showing 9 changed files with 223 additions and 4 deletions.
Binary file added docs/assets/starlette.webm
Binary file not shown.
9 changes: 9 additions & 0 deletions docs/async.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Async

In addition to being able to [stream/lazily render elements](streaming.md), it is also possible
to use htpy fully asynchronous. This intended to be used with ASGI/async web
frameworks/servers such as Starlette and Django.

## U


59 changes: 57 additions & 2 deletions docs/streaming.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Streaming of Contents

Internally, htpy is built with generators. Most of the time, you would render
the full page with `str()`, but htpy can also incrementally generate pages which
the full page with `str()`, but htpy can also incrementally generate pages synchronously or asynchronous which
can then be streamed to the browser. If your page uses a database or other
services to retrieve data, you can sending the first part of the page to the
client while the page is being generated.
Expand All @@ -16,7 +16,6 @@ client while the page is being generated.

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>

Expand Down Expand Up @@ -111,3 +110,59 @@ print(
# output: <div><h1>Fibonacci!</h1>fib(12)=6765</div>

```


## Asynchronous streaming

It is also possible to use htpy to stream fully asynchronous. This intended to be used
with ASGI/async web frameworks/servers such as Starlette and Django. You can
build htpy components using Python's `asyncio` module and the `async`/`await`
syntax.

### 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>
26 changes: 26 additions & 0 deletions examples/async_coroutine.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import asyncio
import random

from htpy import Element, b, div, h1


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


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


async def main() -> None:
import time

async for chunk in my_component():
print(f"got: chunk")


asyncio.run(main())
29 changes: 29 additions & 0 deletions examples/starlette_app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
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)
59 changes: 57 additions & 2 deletions htpy/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,14 @@
import dataclasses
import functools
import typing as t
from collections.abc import Callable, Iterable, Iterator
from collections.abc import (
AsyncIterable,
AsyncIterator,
Awaitable,
Callable,
Iterable,
Iterator,
)

from markupsafe import Markup as _Markup
from markupsafe import escape as _escape
Expand Down Expand Up @@ -191,10 +198,47 @@ def _iter_node_context(x: Node, context_dict: dict[Context[t.Any], t.Any]) -> It
elif isinstance(x, Iterable): # pyright: ignore [reportUnnecessaryIsInstance]
for child in x:
yield from _iter_node_context(child, context_dict)
elif isinstance(x, Awaitable | AsyncIterable):
raise ValueError(
f"{x!r} is not a valid child element. "
"Use async iteration to retrieve element content: https://htpy.dev/async/"
)
else:
raise ValueError(f"{x!r} is not a valid child element")


async def aiter_node(x: Node) -> AsyncIterator[str]:
while True:
if isinstance(x, Awaitable):
x = await x
continue

if not isinstance(x, BaseElement) and callable(x):
x = x()
continue

break

if x is None:
return

if isinstance(x, BaseElement):
async for child in x:
yield child
elif isinstance(x, str) or hasattr(x, "__html__"):
yield str(_escape(x))
elif isinstance(x, AsyncIterable):
async for child in x: # type: ignore
async for chunk in aiter_node(child): # pyright: ignore[reportUnknownArgumentType]
yield chunk
elif isinstance(x, Iterable):
for child in x: # type: ignore
async for chunk in aiter_node(child):
yield chunk
else:
raise ValueError(f"{x!r} is not a valid async child element")


@functools.lru_cache(maxsize=300)
def _get_element(name: str) -> Element:
if not name.islower():
Expand Down Expand Up @@ -223,7 +267,10 @@ def __str__(self) -> _Markup:

@t.overload
def __call__(
self: BaseElementSelf, id_class: str, attrs: dict[str, Attribute], **kwargs: Attribute
self: BaseElementSelf,
id_class: str,
attrs: dict[str, Attribute],
**kwargs: Attribute,
) -> BaseElementSelf: ...
@t.overload
def __call__(
Expand Down Expand Up @@ -262,6 +309,12 @@ def __call__(self: BaseElementSelf, *args: t.Any, **kwargs: t.Any) -> BaseElemen
self._children,
)

async def __aiter__(self) -> AsyncIterator[str]:
yield f"<{self._name}{_attrs_string(self._attrs)}>"
async for x in aiter_node(self._children):
yield x
yield f"</{self._name}>"

def __iter__(self) -> Iterator[str]:
return self._iter_context({})

Expand Down Expand Up @@ -328,6 +381,8 @@ def __html__(self) -> str: ...
| Callable[[], "Node"]
| ContextProvider[t.Any]
| ContextConsumer[t.Any]
| AsyncIterable["Node"]
| Awaitable["Node"]
)

Attribute: t.TypeAlias = None | bool | str | _HasHtml | _ClassNames
Expand Down
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ nav:
- static-typing.md
- django.md
- streaming.md
- async.md
- html2htpy.md
- faq.md
- references.md
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ optional-dependencies.dev = [
"mypy",
"pyright",
"pytest",
"pytest-asyncio",
"black",
"ruff",
"django",
Expand Down
43 changes: 43 additions & 0 deletions tests/test_async.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
from collections.abc import AsyncIterator

import pytest

from htpy import Element, li, ul


async def async_lis() -> AsyncIterator[Element]:
yield li["a"]
yield li["b"]


async def hi() -> Element:
return li["hi"]


@pytest.mark.asyncio
async def test_async_iterator() -> None:
result = [chunk async for chunk in ul[async_lis()]]
assert result == ["<ul>", "<li>", "a", "</li>", "<li>", "b", "</li>", "</ul>"]


@pytest.mark.asyncio
async def test_cororoutinefunction_children() -> None:
result = [chunk async for chunk in ul[hi]]
assert result == ["<ul>", "<li>", "hi", "</li>", "</ul>"]


@pytest.mark.asyncio
async def test_cororoutine_children() -> None:
result = [chunk async for chunk in ul[hi()]]
assert result == ["<ul>", "<li>", "hi", "</li>", "</ul>"]


def test_sync_iteration_with_async_children() -> None:
with pytest.raises(
ValueError,
match=(
r"<async_generator object async_lis at .+> is not a valid child element\. "
r"Use async iteration to retrieve element content: https://htpy.dev/async/"
),
):
str(ul[async_lis()])

0 comments on commit bddc8df

Please sign in to comment.