diff --git a/docs/assets/starlette.webm b/docs/assets/starlette.webm new file mode 100644 index 0000000..0587c96 Binary files /dev/null and b/docs/assets/starlette.webm differ diff --git a/docs/changelog.md b/docs/changelog.md index d6dd65e..9f8df4e 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -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). diff --git a/docs/streaming.md b/docs/streaming.md index ad1cbc4..13f759e 100644 --- a/docs/streaming.md +++ b/docs/streaming.md @@ -14,43 +14,40 @@ client while the page is being generated. streaming will be the easiest way to get going. Streaming can give you 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)): - +## 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 `` 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. + -## 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 `

` tag and then calls `calculate_magic_number`. @@ -70,6 +67,7 @@ element = div[ calculate_magic_number, ] +# Iterate over the element to get the content incrementally for chunk in element: print(chunk) ``` @@ -105,9 +103,111 @@ print( div[ h1["Fibonacci!"], "fib(20)=", - lambda: str(fib(20)), + lambda: fib(20), ] ) # output:

Fibonacci!

fib(12)=6765
``` + +### 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 + +``` + + +## 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: + diff --git a/examples/async_example.py b/examples/async_example.py new file mode 100644 index 0000000..cd0d9a2 --- /dev/null +++ b/examples/async_example.py @@ -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()) diff --git a/examples/starlette_app.py b/examples/starlette_app.py new file mode 100644 index 0000000..f06b57c --- /dev/null +++ b/examples/starlette_app.py @@ -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), + ], +) diff --git a/examples/stream_generator.py b/examples/stream_generator.py new file mode 100644 index 0000000..3ffe348 --- /dev/null +++ b/examples/stream_generator.py @@ -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) diff --git a/htpy/__init__.py b/htpy/__init__.py index a10f337..4378da0 100644 --- a/htpy/__init__.py +++ b/htpy/__init__.py @@ -4,7 +4,15 @@ import functools import keyword import typing as t -from collections.abc import Callable, Iterable, Iterator, Mapping +from collections.abc import ( + AsyncIterable, + AsyncIterator, + Awaitable, + Callable, + Iterable, + Iterator, + Mapping, +) from markupsafe import Markup as _Markup from markupsafe import escape as _escape @@ -150,6 +158,11 @@ def __str__(self) -> _Markup: def iter_chunks(self, context: Mapping[Context[t.Any], t.Any] | None = None) -> Iterator[str]: return _iter_chunks_node(self.node, {**(context or {}), self.context: self.value}) # pyright: ignore [reportUnknownMemberType] + def aiter_chunks( + self, context: Mapping[Context[t.Any], t.Any] | None = None + ) -> AsyncIterator[str]: + return _aiter_chunks_node(self.node, {**(context or {}), self.context: self.value}) # pyright: ignore [reportUnknownMemberType] + def encode(self, encoding: str = "utf-8", errors: str = "strict") -> bytes: return str(self).encode(encoding, errors) @@ -165,7 +178,7 @@ def __str__(self) -> _Markup: __html__ = __str__ - def iter_chunks(self, context: Mapping[Context[t.Any], t.Any] | None = None) -> Iterator[str]: + def _get_value(self, context: Mapping[Context[t.Any], t.Any] | None = None) -> T: context_value = (context or {}).get(self.context, self.context.default) if context_value is _NO_DEFAULT: @@ -173,7 +186,15 @@ def iter_chunks(self, context: Mapping[Context[t.Any], t.Any] | None = None) -> f'Context value for "{self.context.name}" does not exist, ' # pyright: ignore f"requested by {self.debug_name}()." ) - return _iter_chunks_node(self.func(context_value), context) # pyright: ignore + return context_value # type: ignore[no-any-return] + + def iter_chunks(self, context: Mapping[Context[t.Any], t.Any] | None = None) -> Iterator[str]: + return _iter_chunks_node(self.func(self._get_value(context)), context) # pyright: ignore + + def aiter_chunks( + self, context: Mapping[Context[t.Any], t.Any] | None = None + ) -> AsyncIterator[str]: + return _aiter_chunks_node(self.func(self._get_value(context)), context) # pyright: ignore def encode(self, encoding: str = "utf-8", errors: str = "strict") -> bytes: return str(self).encode(encoding, errors) @@ -225,7 +246,7 @@ def _iter_chunks_node(x: Node, context: Mapping[Context[t.Any], t.Any] | None) - return if hasattr(x, "iter_chunks"): - yield from x.iter_chunks(context) # pyright: ignore + yield from x.iter_chunks(context) # pyright: ignore [reportUnknownMemberType, reportAttributeAccessIssue] elif isinstance(x, str | _HasHtml): yield str(_escape(x)) elif isinstance(x, int): @@ -233,6 +254,53 @@ def _iter_chunks_node(x: Node, context: Mapping[Context[t.Any], t.Any] | None) - elif isinstance(x, Iterable) and not isinstance(x, _KnownInvalidChildren): # pyright: ignore [reportUnnecessaryIsInstance] for child in x: yield from _iter_chunks_node(child, context) + elif isinstance(x, Awaitable | AsyncIterable): # pyright: ignore[reportUnnecessaryIsInstance] + raise TypeError( + 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") + + +async def _aiter_chunks_node( + x: Node, context: Mapping[Context[t.Any], t.Any] | None +) -> AsyncIterator[str]: + while True: + if isinstance(x, Awaitable): + x = await x # pyright: ignore [reportUnknownVariableType] + continue + + if not isinstance(x, BaseElement) and callable(x): # pyright: ignore [reportUnknownArgumentType] + x = x() # pyright: ignore [reportAssignmentType] + continue + + break + + if x is None: + return + + if x is True: + return + + if x is False: + return + + if hasattr(x, "aiter_chunks"): # pyright: ignore [reportUnknownVariableType, reportUnknownArgumentType] + async for chunk in x.aiter_chunks(context): # pyright: ignore + 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: # pyright: ignore + async for chunk in _aiter_chunks_node(child, context): # pyright: ignore + yield chunk + elif isinstance(x, AsyncIterable): # pyright: ignore[reportUnnecessaryIsInstance] + async for child in x: # pyright: ignore[reportUnknownVariableType] + async for chunk in _aiter_chunks_node(child, context): # pyright: ignore[reportUnknownArgumentType] + yield chunk else: raise TypeError(f"{x!r} is not a valid child element") @@ -318,6 +386,14 @@ def iter_chunks(self, context: Mapping[Context[t.Any], t.Any] | None = None) -> yield from _iter_chunks_node(self._children, context) yield f"" + async def aiter_chunks( + self, context: Mapping[Context[t.Any], t.Any] | None = None + ) -> AsyncIterator[str]: + yield f"<{self._name}{self._attrs}>" + async for chunk in _aiter_chunks_node(self._children, context): + yield chunk + yield f"" + def encode(self, encoding: str = "utf-8", errors: str = "strict") -> bytes: return str(self).encode(encoding, errors) @@ -366,11 +442,23 @@ def iter_chunks(self, context: Mapping[Context[t.Any], t.Any] | None = None) -> yield "" yield from super().iter_chunks(context) + async def aiter_chunks( + self, context: Mapping[Context[t.Any], t.Any] | None = None + ) -> AsyncIterator[str]: + yield "" + async for chunk in super().aiter_chunks(context): + yield chunk + class VoidElement(BaseElement): def iter_chunks(self, context: Mapping[Context[t.Any], t.Any] | None = None) -> Iterator[str]: yield f"<{self._name}{self._attrs}>" + async def aiter_chunks( + self, context: Mapping[Context[t.Any], t.Any] | None = None + ) -> AsyncIterator[str]: + yield f"<{self._name}{self._attrs}>" + def __repr__(self) -> str: return f"<{self.__class__.__name__} '<{self._name}{self._attrs}>'>" @@ -401,6 +489,11 @@ def __str__(self) -> _Markup: def iter_chunks(self, context: Mapping[Context[t.Any], t.Any] | None = None) -> Iterator[str]: return _iter_chunks_node(self._node, context) + def aiter_chunks( + self, context: Mapping[Context[t.Any], t.Any] | None = None + ) -> AsyncIterator[str]: + return _aiter_chunks_node(self._node, context) + def encode(self, encoding: str = "utf-8", errors: str = "strict") -> bytes: return str(self).encode(encoding, errors) @@ -443,6 +536,9 @@ def __html__(self) -> _Markup: ... def iter_chunks( self, context: Mapping[Context[t.Any], t.Any] | None = None ) -> Iterator[str]: ... + def aiter_chunks( + self, context: Mapping[Context[t.Any], t.Any] | None = None + ) -> AsyncIterator[str]: ... # Allow starlette Response.render to directly render this element without # explicitly casting to str: @@ -453,7 +549,16 @@ def encode(self, encoding: str = "utf-8", errors: str = "strict") -> bytes: ... _ClassNamesDict: t.TypeAlias = dict[str, bool] _ClassNames: t.TypeAlias = Iterable[str | None | bool | _ClassNamesDict] | _ClassNamesDict Node: t.TypeAlias = ( - Renderable | None | bool | str | int | _HasHtml | Iterable["Node"] | Callable[[], "Node"] + Renderable + | None + | bool + | str + | int + | _HasHtml + | Iterable["Node"] + | Callable[[], "Node"] + | AsyncIterable["Node"] + | Awaitable["Node"] ) Attribute: t.TypeAlias = None | bool | str | int | _HasHtml | _ClassNames @@ -586,6 +691,8 @@ def encode(self, encoding: str = "utf-8", errors: str = "strict") -> bytes: ... _KnownValidChildren: UnionType = ( None | BaseElement + | AsyncIterable # pyright: ignore [reportMissingTypeArgument] + | Awaitable # pyright: ignore [reportMissingTypeArgument] | ContextProvider # pyright: ignore [reportMissingTypeArgument] | ContextConsumer # pyright: ignore [reportMissingTypeArgument] | str diff --git a/t.py b/t.py new file mode 100644 index 0000000..37be74a --- /dev/null +++ b/t.py @@ -0,0 +1,3 @@ +import htpy + +htpy.div({"baz": "42"}, foo="asdf", **{"bar": 1234}) diff --git a/tests/conftest.py b/tests/conftest.py index 8a25f3c..29a0eae 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,6 @@ from __future__ import annotations +import asyncio import dataclasses import typing as t @@ -49,7 +50,24 @@ def func(description: str) -> None: @pytest.fixture -def render(render_result: RenderResult) -> Generator[RenderFixture, None, None]: +def render_async(render_result: RenderResult) -> RenderFixture: + def func(renderable: h.Renderable) -> RenderResult: + async def run() -> RenderResult: + async for chunk in renderable.aiter_chunks(): + render_result.append(chunk) + return render_result + + return asyncio.run(run(), debug=True) + + return func + + +@pytest.fixture(params=["sync", "async"]) +def render( + request: pytest.FixtureRequest, + render_async: RenderFixture, + render_result: RenderResult, +) -> Generator[RenderFixture, None, None]: called = False def func(renderable: h.Renderable) -> RenderResult: @@ -59,10 +77,14 @@ def func(renderable: h.Renderable) -> RenderResult: raise AssertionError("render() must only be called once per test") called = True - for chunk in renderable.iter_chunks(): - render_result.append(chunk) - return render_result + if request.param == "sync": + for chunk in renderable.iter_chunks(): + render_result.append(chunk) + + return render_result + else: + return render_async(renderable) yield func diff --git a/tests/test_async.py b/tests/test_async.py new file mode 100644 index 0000000..1255fe3 --- /dev/null +++ b/tests/test_async.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +import typing as t + +import pytest + +from htpy import Element, li, ul + +from .conftest import Trace + +if t.TYPE_CHECKING: + from collections.abc import AsyncIterator + + from .conftest import RenderFixture, TraceFixture + + +def test_async_iterator(render_async: RenderFixture, trace: TraceFixture) -> None: + async def lis() -> AsyncIterator[Element]: + trace("pre a") + yield li["a"] + trace("pre b") + yield li["b"] + trace("post b") + + result = render_async(ul[lis()]) + assert result == [ + "", + ] + + +def test_awaitable(render_async: RenderFixture, trace: TraceFixture) -> None: + async def hi() -> Element: + trace("in hi()") + return li["hi"] + + result = render_async(ul[hi()]) + assert result == [ + "", + ] + + +@pytest.mark.filterwarnings(r"ignore:coroutine '.*\.coroutine' was never awaited") +def test_iter_chunks_coroutine() -> None: + async def coroutine() -> None: + return None + + with pytest.raises( + TypeError, + match=( + r" is not a valid child element\. " + r"Use async iteration to retrieve element content: https://htpy.dev/streaming/" + ), + ): + list(ul[coroutine()].iter_chunks()) + + +def test_iter_chunks_async_generator() -> None: + async def generator() -> AsyncIterator[None]: + return + yield + + with pytest.raises( + TypeError, + match=( + r" is not a valid child element\. " + r"Use async iteration to retrieve element content: https://htpy.dev/streaming/" + ), + ): + list(ul[generator()].iter_chunks()) diff --git a/tests/test_render.py b/tests/test_render.py index 47f78fc..759ec19 100644 --- a/tests/test_render.py +++ b/tests/test_render.py @@ -5,6 +5,8 @@ import htpy as h +from .conftest import RenderFixture + example_ctx: h.Context[str] = h.Context("example_ctx", default="default!") @@ -25,18 +27,21 @@ def expected_bytes(self) -> bytes: return self.expected_str().encode("utf8") -cases = [ - RenderableTestCase(h.a, ["", ""]), - RenderableTestCase(h.img, [""]), - RenderableTestCase(example_ctx.provider("hi!", "stuff!"), ["stuff!"]), - RenderableTestCase(example_consumer(), ["default!"]), - RenderableTestCase(h.fragment["fragment!"], ["fragment!"]), - # comment() is a Fragment but test it anyways for completeness - RenderableTestCase(h.comment("comment!"), [""]), -] +@pytest.fixture( + params=[ + RenderableTestCase(h.a, ["", ""]), + RenderableTestCase(h.img, [""]), + RenderableTestCase(example_ctx.provider("hi!", "stuff!"), ["stuff!"]), + RenderableTestCase(example_consumer(), ["default!"]), + RenderableTestCase(h.fragment["fragment!"], ["fragment!"]), + # comment() is a Fragment but test it anyways for completeness + RenderableTestCase(h.comment("comment!"), [""]), + ] +) +def case(request: pytest.FixtureRequest) -> RenderableTestCase: + return request.param # type: ignore[no-any-return] -@pytest.mark.parametrize("case", cases) def test_str(case: RenderableTestCase) -> None: result = str(case.renderable) assert isinstance(result, str) @@ -44,7 +49,6 @@ def test_str(case: RenderableTestCase) -> None: assert result == case.expected_str() -@pytest.mark.parametrize("case", cases) def test_html(case: RenderableTestCase) -> None: result = case.renderable.__html__() assert isinstance(result, str) @@ -52,14 +56,12 @@ def test_html(case: RenderableTestCase) -> None: assert result == case.expected_str() -@pytest.mark.parametrize("case", cases) def test_encode(case: RenderableTestCase) -> None: result = case.renderable.encode() assert isinstance(result, bytes) assert result == case.expected_bytes() -@pytest.mark.parametrize("case", cases) def test_iter_chunks(case: RenderableTestCase) -> None: result = list(case.renderable.iter_chunks()) @@ -67,3 +69,8 @@ def test_iter_chunks(case: RenderableTestCase) -> None: assert type(result[0]) is str assert result == case.expected_chunks + + +def test_aiter_chunks(case: RenderableTestCase, render_async: RenderFixture) -> None: + result = render_async(case.renderable) + assert result == case.expected_chunks