Skip to content

Commit

Permalink
Support for async iteration
Browse files Browse the repository at this point in the history
  • Loading branch information
pelme committed Sep 15, 2024
1 parent f27fc9c commit c5af9c7
Show file tree
Hide file tree
Showing 11 changed files with 413 additions and 22 deletions.
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(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),
],
)
118 changes: 106 additions & 12 deletions htpy/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,15 @@
import dataclasses
import functools
import typing as t
from collections.abc import Callable, Generator, Iterable, Iterator
from collections.abc import (
AsyncIterable,
AsyncIterator,
Awaitable,
Callable,
Generator,
Iterable,
Iterator,
)

from markupsafe import Markup as _Markup
from markupsafe import escape as _escape
Expand Down Expand Up @@ -126,6 +134,9 @@ class ContextProvider(t.Generic[T]):
def __iter__(self) -> Iterator[str]:
return iter_node(self)

def __aiter__(self) -> AsyncIterator[str]:
return aiter_node(self)

def __str__(self) -> str:
return render_node(self)

Expand All @@ -137,6 +148,10 @@ class ContextConsumer(t.Generic[T]):
func: Callable[[T], Node]


def _is_noop_node(x: Node) -> bool:
return x is None or x is True or x is False


class _NO_DEFAULT:
pass

Expand Down Expand Up @@ -168,15 +183,8 @@ def _iter_node_context(x: Node, context_dict: dict[Context[t.Any], t.Any]) -> It
while not isinstance(x, BaseElement) and callable(x):
x = x()

if x is None:
return

if x is True:
return

if x is False:
if _is_noop_node(x):
return

if isinstance(x, BaseElement):
yield from x._iter_context(context_dict) # pyright: ignore [reportPrivateUsage]
elif isinstance(x, ContextProvider):
Expand All @@ -196,6 +204,68 @@ def _iter_node_context(x: Node, context_dict: dict[Context[t.Any], t.Any]) -> It
elif isinstance(x, Iterable) and not isinstance(x, _KnownInvalidChildren): # 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/streaming/"
)
else:
raise TypeError(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 _is_noop_node(x):
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, int):
yield str(x)
elif isinstance(x, Iterable) and not isinstance(x, _KnownInvalidChildren): # pyright: ignore [reportUnnecessaryIsInstance]
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 TypeError(f"{x!r} is not a valid child element")

Expand Down Expand Up @@ -267,6 +337,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}{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 All @@ -275,9 +354,6 @@ def _iter_context(self, ctx: dict[Context[t.Any], t.Any]) -> Iterator[str]:
yield from _iter_node_context(self._children, ctx)
yield f"</{self._name}>"

def __repr__(self) -> str:
return f"<{self.__class__.__name__} '{self}'>"

# Allow starlette Response.render to directly render this element without
# explicitly casting to str:
# https://github.com/encode/starlette/blob/5ed55c441126687106109a3f5e051176f88cd3e6/starlette/responses.py#L44-L49
Expand Down Expand Up @@ -308,17 +384,31 @@ def __getitem__(self: ElementSelf, children: Node) -> ElementSelf:
_validate_children(children)
return self.__class__(self._name, self._attrs, children) # pyright: ignore [reportUnknownArgumentType]

def __repr__(self) -> str:
return f"<{self.__class__.__name__} '<{self._name}{self._attrs}>...</{self._name}>'>"


class HTMLElement(Element):
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}{self._attrs}>"

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

def __repr__(self) -> str:
return f"<{self.__class__.__name__} '<{self._name}{self._attrs}>'>"


def render_node(node: Node) -> _Markup:
return _Markup("".join(iter_node(node)))
Expand Down Expand Up @@ -347,6 +437,8 @@ def __html__(self) -> str: ...
| Callable[[], "Node"]
| ContextProvider[t.Any]
| ContextConsumer[t.Any]
| AsyncIterable["Node"]
| Awaitable["Node"]
)

Attribute: t.TypeAlias = None | bool | str | int | _HasHtml | _ClassNames
Expand Down Expand Up @@ -480,6 +572,8 @@ def __html__(self) -> str: ...
_KnownValidChildren: UnionType = ( # pyright: ignore [reportUnknownVariableType]
None
| BaseElement
| AsyncIterable # pyright: ignore [reportMissingTypeArgument]
| Awaitable # pyright: ignore [reportMissingTypeArgument]
| ContextProvider # pyright: ignore [reportMissingTypeArgument]
| ContextConsumer # pyright: ignore [reportMissingTypeArgument]
| Callable # pyright: ignore [reportMissingTypeArgument]
Expand Down
30 changes: 30 additions & 0 deletions htpy/starlette.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
from __future__ import annotations

import typing as t

from starlette.responses import StreamingResponse

from . import aiter_node

if t.TYPE_CHECKING:
from starlette.background import BackgroundTask

from . import Node


class HtpyResponse(StreamingResponse):
def __init__(
self,
content: Node,
status_code: int = 200,
headers: t.Mapping[str, str] | None = None,
media_type: str | None = "text/html",
background: BackgroundTask | None = None,
):
super().__init__(
aiter_node(content),
status_code=status_code,
headers=headers,
media_type=media_type,
background=background,
)
Loading

0 comments on commit c5af9c7

Please sign in to comment.