Skip to content

Commit fe82711

Browse files
committed
Add a consistent rendering protocol.
This change provides a consistent API to render a htpy object as HTML or iterate over it. This commit introduces stream_chunks() which is identical with __iter__() but with a better name. With the introduction of Fragment, this commit makes render_node and iter_node redundant and they will be deprecated in another commit. This commit removes all internal usages of render_node and iter_node so they can be deprecated. More info: #86 (comment)
1 parent 509e5f6 commit fe82711

10 files changed

+161
-104
lines changed

docs/usage.md

+7-9
Original file line numberDiff line numberDiff line change
@@ -378,14 +378,14 @@ snippets as attributes:
378378

379379
```
380380

381-
## Iterating of the Output
381+
## Streaming chunks
382382

383-
Iterating over a htpy element will yield the resulting contents in chunks as
384-
they are rendered:
383+
htpy objects provide the `stream_chunks()` method to render an element with its
384+
children one piece at a time.
385385

386386
```pycon
387387
>>> from htpy import ul, li
388-
>>> for chunk in ul[li["a"], li["b"]]:
388+
>>> for chunk in ul[li["a"], li["b"]].stream_chunks():
389389
... print(f"got a chunk: {chunk!r}")
390390
...
391391
got a chunk: '<ul>'
@@ -399,13 +399,11 @@ got a chunk: '</ul>'
399399

400400
```
401401

402-
Just like [render_node()](#render-elements-without-a-parent-orphans), there is
403-
`iter_node()` that can be used when you need to iterate over a list of elements
404-
without a parent:
402+
If you need to get the chunks of an element without parents, wrap it in a `Fragment`:
405403

406404
```pycon
407-
>>> from htpy import li, Fragment
408-
>>> for chunk in fragment[li["a"], li["b"]]:
405+
>>> from htpy import li, fragment
406+
>>> for chunk in fragment[li["a"], li["b"]].stream_chunks():
409407
... print(f"got a chunk: {chunk!r}")
410408
...
411409
got a chunk: '<li>'

htpy/__init__.py

+61-20
Original file line numberDiff line numberDiff line change
@@ -131,10 +131,18 @@ class ContextProvider(t.Generic[T]):
131131
node: Node
132132

133133
def __iter__(self) -> Iterator[str]:
134-
return iter_node(self)
134+
return _stream_chunks(self, {})
135135

136-
def __str__(self) -> str:
137-
return render_node(self)
136+
def __str__(self) -> _Markup:
137+
return _as_markup(self)
138+
139+
__html__ = __str__
140+
141+
def stream_chunks(self) -> Iterator[str]:
142+
return _stream_chunks(self, {})
143+
144+
def encode(self, encoding: str = "utf-8", errors: str = "strict") -> bytes:
145+
return str(self).encode(encoding, errors)
138146

139147

140148
@dataclasses.dataclass(frozen=True)
@@ -143,6 +151,17 @@ class ContextConsumer(t.Generic[T]):
143151
debug_name: str
144152
func: Callable[[T], Node]
145153

154+
def __str__(self) -> _Markup:
155+
return _as_markup(self)
156+
157+
__html__ = __str__
158+
159+
def stream_chunks(self) -> Iterator[str]:
160+
return _stream_chunks(self, {})
161+
162+
def encode(self, encoding: str = "utf-8", errors: str = "strict") -> bytes:
163+
return str(self).encode(encoding, errors)
164+
146165

147166
class _NO_DEFAULT:
148167
pass
@@ -168,10 +187,10 @@ def wrapper(*args: P.args, **kwargs: P.kwargs) -> ContextConsumer[T]:
168187

169188

170189
def iter_node(x: Node) -> Iterator[str]:
171-
return _iter_node_context(x, {})
190+
return fragment[x].stream_chunks()
172191

173192

174-
def _iter_node_context(x: Node, context_dict: dict[Context[t.Any], t.Any]) -> Iterator[str]:
193+
def _stream_chunks(x: Node, context_dict: dict[Context[t.Any], t.Any]) -> Iterator[str]:
175194
while not isinstance(x, BaseElement) and callable(x):
176195
x = x()
177196

@@ -187,24 +206,25 @@ def _iter_node_context(x: Node, context_dict: dict[Context[t.Any], t.Any]) -> It
187206
if isinstance(x, BaseElement):
188207
yield from x._iter_context(context_dict) # pyright: ignore [reportPrivateUsage]
189208
elif isinstance(x, ContextProvider):
190-
yield from _iter_node_context(x.node, {**context_dict, x.context: x.value}) # pyright: ignore [reportUnknownMemberType]
209+
yield from _stream_chunks(x.node, {**context_dict, x.context: x.value}) # pyright: ignore [reportUnknownMemberType]
191210
elif isinstance(x, ContextConsumer):
192-
context_value = context_dict.get(x.context, x.context.default)
211+
context_value = context_dict.get(x.context, x.context.default) # pyright: ignore
212+
193213
if context_value is _NO_DEFAULT:
194214
raise LookupError(
195-
f'Context value for "{x.context.name}" does not exist, '
215+
f'Context value for "{x.context.name}" does not exist, ' # pyright: ignore
196216
f"requested by {x.debug_name}()."
197217
)
198-
yield from _iter_node_context(x.func(context_value), context_dict)
218+
yield from _stream_chunks(x.func(context_value), context_dict) # pyright: ignore
199219
elif isinstance(x, Fragment):
200-
yield from _iter_node_context(x._node, context_dict) # pyright: ignore
220+
yield from _stream_chunks(x._node, context_dict) # pyright: ignore
201221
elif isinstance(x, str | _HasHtml):
202222
yield str(_escape(x))
203223
elif isinstance(x, int):
204224
yield str(x)
205225
elif isinstance(x, Iterable) and not isinstance(x, _KnownInvalidChildren): # pyright: ignore [reportUnnecessaryIsInstance]
206226
for child in x:
207-
yield from _iter_node_context(child, context_dict)
227+
yield from _stream_chunks(child, context_dict)
208228
else:
209229
raise TypeError(f"{x!r} is not a valid child element")
210230

@@ -231,7 +251,7 @@ def __init__(self, name: str, attrs_str: str = "", children: Node = None) -> Non
231251
self._children = children
232252

233253
def __str__(self) -> _Markup:
234-
return _Markup("".join(self))
254+
return _as_markup(self)
235255

236256
__html__ = __str__
237257

@@ -281,14 +301,14 @@ def __call__(self: BaseElementSelf, *args: t.Any, **kwargs: t.Any) -> BaseElemen
281301
def __iter__(self) -> Iterator[str]:
282302
return self._iter_context({})
283303

304+
def stream_chunks(self) -> Iterator[str]:
305+
return self._iter_context({})
306+
284307
def _iter_context(self, ctx: dict[Context[t.Any], t.Any]) -> Iterator[str]:
285308
yield f"<{self._name}{self._attrs}>"
286-
yield from _iter_node_context(self._children, ctx)
309+
yield from _stream_chunks(self._children, ctx)
287310
yield f"</{self._name}>"
288311

289-
# Allow starlette Response.render to directly render this element without
290-
# explicitly casting to str:
291-
# https://github.com/encode/starlette/blob/5ed55c441126687106109a3f5e051176f88cd3e6/starlette/responses.py#L44-L49
292312
def encode(self, encoding: str = "utf-8", errors: str = "strict") -> bytes:
293313
return str(self).encode(encoding, errors)
294314

@@ -358,13 +378,19 @@ def __init__(self) -> None:
358378
self._node: Node = None
359379

360380
def __iter__(self) -> Iterator[str]:
361-
return iter_node(self)
381+
return _stream_chunks(self, {})
362382

363-
def __str__(self) -> str:
364-
return render_node(self)
383+
def __str__(self) -> _Markup:
384+
return _as_markup(self)
365385

366386
__html__ = __str__
367387

388+
def stream_chunks(self) -> Iterator[str]:
389+
return _stream_chunks(self, {})
390+
391+
def encode(self, encoding: str = "utf-8", errors: str = "strict") -> bytes:
392+
return str(self).encode(encoding, errors)
393+
368394

369395
class _FragmentGetter:
370396
def __getitem__(self, node: Node) -> Fragment:
@@ -376,8 +402,12 @@ def __getitem__(self, node: Node) -> Fragment:
376402
fragment = _FragmentGetter()
377403

378404

405+
def _as_markup(renderable: Renderable) -> _Markup:
406+
return _Markup("".join(renderable.stream_chunks()))
407+
408+
379409
def render_node(node: Node) -> _Markup:
380-
return _Markup("".join(iter_node(node)))
410+
return _Markup(fragment[node])
381411

382412

383413
def comment(text: str) -> Fragment:
@@ -545,3 +575,14 @@ def __html__(self) -> str: ...
545575
| Callable
546576
| Iterable
547577
)
578+
579+
580+
class Renderable(t.Protocol):
581+
def __str__(self) -> _Markup: ...
582+
def __html__(self) -> _Markup: ...
583+
def stream_chunks(self) -> Iterator[str]: ...
584+
585+
# Allow starlette Response.render to directly render this element without
586+
# explicitly casting to str:
587+
# https://github.com/encode/starlette/blob/5ed55c441126687106109a3f5e051176f88cd3e6/starlette/responses.py#L44-L49
588+
def encode(self, encoding: str = "utf-8", errors: str = "strict") -> bytes: ...

htpy/django.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from django.template import Context, TemplateDoesNotExist
66
from django.utils.module_loading import import_string
77

8-
from . import Element, render_node
8+
from . import Element, fragment
99

1010
if t.TYPE_CHECKING:
1111
from collections.abc import Callable
@@ -19,7 +19,7 @@ def __init__(self, func: Callable[[Context | None, HttpRequest | None], Element]
1919
self.func = func
2020

2121
def render(self, context: Context | None, request: HttpRequest | None) -> str:
22-
return render_node(self.func(context, request))
22+
return str(fragment[self.func(context, request)])
2323

2424

2525
class HtpyTemplateBackend:

tests/conftest.py

+4-4
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
import pytest
77

8-
from htpy import Node, iter_node
8+
import htpy as h
99

1010
if t.TYPE_CHECKING:
1111
from collections.abc import Callable, Generator
@@ -17,7 +17,7 @@ class Trace:
1717

1818

1919
RenderResult: t.TypeAlias = list[str | Trace]
20-
RenderFixture: t.TypeAlias = t.Callable[[Node], RenderResult]
20+
RenderFixture: t.TypeAlias = t.Callable[[h.Renderable], RenderResult]
2121
TraceFixture: t.TypeAlias = t.Callable[[str], None]
2222

2323

@@ -52,14 +52,14 @@ def func(description: str) -> None:
5252
def render(render_result: RenderResult) -> Generator[RenderFixture, None, None]:
5353
called = False
5454

55-
def func(node: Node) -> RenderResult:
55+
def func(renderable: h.Renderable) -> RenderResult:
5656
nonlocal called
5757

5858
if called:
5959
raise AssertionError("render() must only be called once per test")
6060

6161
called = True
62-
for chunk in iter_node(node):
62+
for chunk in renderable.stream_chunks():
6363
render_result.append(chunk)
6464

6565
return render_result

tests/test_comment.py

-4
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,3 @@ def test_escape_three_dashes(render: RenderFixture) -> None:
2222

2323
def test_escape_four_dashes(render: RenderFixture) -> None:
2424
assert render(div[comment("foo----bar")]) == ["<div>", "<!-- foobar -->", "</div>"]
25-
26-
27-
def test_str() -> None:
28-
assert str(comment("foo")) == "<!-- foo -->"

tests/test_context.py

-14
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
import typing as t
44

5-
import markupsafe
65
import pytest
76

87
from htpy import Context, Node, div, fragment
@@ -34,19 +33,6 @@ def test_context_provider(render: RenderFixture) -> None:
3433
assert render(result) == ["<div>", "Hello: c!", "</div>"]
3534

3635

37-
class Test_provider_outer_api:
38-
"""Ensure provider implements __iter__/__str__"""
39-
40-
def test_iter(self) -> None:
41-
result = letter_ctx.provider("c", div[display_letter("Hello")])
42-
assert list(result) == ["<div>", "Hello: c!", "</div>"]
43-
44-
def test_str(self) -> None:
45-
result = str(letter_ctx.provider("c", div[display_letter("Hello")]))
46-
assert result == "<div>Hello: c!</div>"
47-
assert isinstance(result, markupsafe.Markup)
48-
49-
5036
def test_no_default(render: RenderFixture) -> None:
5137
with pytest.raises(
5238
LookupError,

tests/test_element.py

-14
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,6 @@ def test_void_element_repr() -> None:
2626
assert repr(htpy.hr("#a")) == """<VoidElement '<hr id="a">'>"""
2727

2828

29-
def test_markup_str() -> None:
30-
result = str(div(id="a"))
31-
assert isinstance(result, str)
32-
assert isinstance(result, markupsafe.Markup)
33-
assert result == '<div id="a"></div>'
34-
35-
3629
def test_element_type() -> None:
3730
assert_type(div, Element)
3831
assert isinstance(div, Element)
@@ -44,13 +37,6 @@ def test_element_type() -> None:
4437
assert isinstance(div()["a"], Element)
4538

4639

47-
def test_html_protocol() -> None:
48-
element = div["test"]
49-
result = element.__html__()
50-
assert result == "<div>test</div>"
51-
assert isinstance(result, markupsafe.Markup)
52-
53-
5440
def test_markupsafe_escape() -> None:
5541
result = markupsafe.escape(div["test"])
5642
assert result == "<div>test</div>"

tests/test_legacy_render.py

+18
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
from htpy import render_node, p, iter_node
2+
3+
from markupsafe import Markup
4+
5+
6+
def test_render_node() -> None:
7+
result = render_node(p["hi"])
8+
assert isinstance(result, Markup)
9+
10+
11+
def test_iter_node() -> None:
12+
result = list(iter_node(p["hi"]))
13+
assert result == ["<p>", "hi", "</p>"]
14+
15+
16+
def test_element_iter() -> None:
17+
result = list(p["hi"])
18+
assert result == ["<p>", "hi", "</p>"]

tests/test_nodes.py

-37
This file was deleted.

0 commit comments

Comments
 (0)