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 26, 2024
1 parent 4c1d1cd commit 9f6fa92
Show file tree
Hide file tree
Showing 14 changed files with 435 additions and 133 deletions.
25 changes: 25 additions & 0 deletions conftest.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import asyncio
import typing as t

import pytest

from htpy import Node, aiter_node, iter_node


@pytest.fixture(scope="session")
def django_env() -> None:
Expand All @@ -13,3 +18,23 @@ def django_env() -> None:
]
)
django.setup()


@pytest.fixture(params=["sync", "async"])
def to_list(request: pytest.FixtureRequest) -> t.Any:
def func(node: Node) -> t.Any:
if request.param == "sync":
return list(iter_node(node))
else:

async def get_list() -> t.Any:
return [chunk async for chunk in aiter_node(node)]

return asyncio.run(get_list(), debug=True)

return func


@pytest.fixture
def to_str(to_list: t.Any) -> t.Any:
return lambda node: "".join(to_list(node))
Binary file added docs/assets/starlette.webm
Binary file not shown.
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>
24 changes: 24 additions & 0 deletions examples/async_coroutine.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(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:
async for _chunk in my_component():
print("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)
91 changes: 90 additions & 1 deletion 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,73 @@ 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): # pyright: ignore[reportUnnecessaryIsInstance]
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")


def aiter_node(x: Node) -> AsyncIterator[str]:
return _aiter_node_context(x, {})


async def _aiter_node_context(
x: Node, context_dict: dict[Context[t.Any], t.Any]
) -> 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 x is True:
return

if x is False:
return

if isinstance(x, BaseElement):
async for child in x._aiter_context(context_dict): # pyright: ignore [reportPrivateUsage]
yield child
elif isinstance(x, ContextProvider):
async for chunk in _aiter_node_context(x.func(), {**context_dict, x.context: x.value}): # pyright: ignore [reportUnknownMemberType]
yield chunk

elif isinstance(x, ContextConsumer):
context_value = context_dict.get(x.context, x.context.default)
if context_value is _NO_DEFAULT:
raise LookupError(
f'Context value for "{x.context.name}" does not exist, '
f"requested by {x.debug_name}()."
)
async for chunk in _aiter_node_context(x.func(context_value), context_dict):
yield chunk

elif isinstance(x, str | _HasHtml):
yield str(_escape(x))
elif isinstance(x, Iterable):
for child in x: # type: ignore[assignment]
async for chunk in _aiter_node_context(child, context_dict):
yield chunk
elif isinstance(x, AsyncIterable): # pyright: ignore[reportUnnecessaryIsInstance]
async for child in x: # type: ignore[assignment]
async for chunk in _aiter_node_context(child, context_dict): # pyright: ignore[reportUnknownArgumentType]
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 @@ -262,6 +332,15 @@ def __call__(self: BaseElementSelf, *args: t.Any, **kwargs: t.Any) -> BaseElemen
self._children,
)

async def _aiter_context(self, context: dict[Context[t.Any], t.Any]) -> AsyncIterator[str]:
yield f"<{self._name}{_attrs_string(self._attrs)}>"
async for x in _aiter_node_context(self._children, context):
yield x
yield f"</{self._name}>"

def __aiter__(self) -> AsyncIterator[str]:
return self._aiter_context({})

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

Expand Down Expand Up @@ -296,8 +375,16 @@ def _iter_context(self, ctx: dict[Context[t.Any], t.Any]) -> Iterator[str]:
yield "<!doctype html>"
yield from super()._iter_context(ctx)

async def _aiter_context(self, context: dict[Context[t.Any], t.Any]) -> AsyncIterator[str]:
yield "<!doctype html>"
async for x in super()._aiter_context(context):
yield x


class VoidElement(BaseElement):
async def _aiter_context(self, context: dict[Context[t.Any], t.Any]) -> AsyncIterator[str]:
yield f"<{self._name}{_attrs_string(self._attrs)}>"

def _iter_context(self, ctx: dict[Context[t.Any], t.Any]) -> Iterator[str]:
yield f"<{self._name}{_attrs_string(self._attrs)}>"

Expand Down Expand Up @@ -328,6 +415,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 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
48 changes: 48 additions & 0 deletions tests/test_async.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
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()])


@pytest.mark.xfail
def test_repr_with_async_children() -> None:
assert repr(ul[async_lis()]) == "<Element '<ul></ul>'>"
Loading

0 comments on commit 9f6fa92

Please sign in to comment.