Skip to content

Commit a7d4dd0

Browse files
authored
Raise exception when rendering a generator twice (#102) (#142)
Generators can only be consumed once. Previously, attempting to render an element containing a generator multiple times would silently produce empty output on subsequent renders. Now raises RuntimeError with clear message when a generator is consumed twice, preventing this footgun while maintaining performance.
1 parent 20ea5c6 commit a7d4dd0

File tree

3 files changed

+51
-2
lines changed

3 files changed

+51
-2
lines changed

docs/usage.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,20 @@ You can pass a list, tuple or generator to generate multiple children:
125125
directly when the element is constructed. See [Streaming](streaming.md) for
126126
more information.
127127

128+
!!! warning "Generator Consumption"
129+
130+
Generators can only be consumed once. If you try to render an element containing a generator multiple times, you will get a `RuntimeError` on the second attempt:
131+
132+
```python
133+
>>> element = div[(x for x in "abc")]
134+
>>> str(element) # First render - works
135+
'<div>abc</div>'
136+
>>> str(element) # Second render - fails
137+
RuntimeError: Generator has already been consumed
138+
```
139+
140+
If you need to render the same content multiple times, use a `list` instead of a generator.
141+
128142
A `list` can be used similar to a [JSX fragment](https://react.dev/reference/react/Fragment):
129143

130144
```pycon title="Render a list of child elements"

src/htpy/_rendering.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
from __future__ import annotations
22

33
import typing as t
4-
from collections.abc import Iterable
4+
import weakref
5+
from collections.abc import Generator, Iterable
56

67
import markupsafe
78

@@ -13,6 +14,9 @@
1314
from htpy._contexts import Context
1415
from htpy._types import Node, Renderable
1516

17+
# Track consumed generators to prevent double consumption
18+
_consumed_generators: weakref.WeakSet[Generator[t.Any, t.Any, t.Any]] = weakref.WeakSet()
19+
1620

1721
def chunks_as_markup(renderable: Renderable) -> markupsafe.Markup:
1822
return markupsafe.Markup("".join(renderable.iter_chunks()))
@@ -39,6 +43,12 @@ def iter_chunks_node(x: Node, context: Mapping[Context[t.Any], t.Any] | None) ->
3943
yield str(markupsafe.escape(x))
4044
elif isinstance(x, int):
4145
yield str(x)
46+
elif isinstance(x, Generator):
47+
if x in _consumed_generators:
48+
raise RuntimeError("Generator has already been consumed")
49+
_consumed_generators.add(x)
50+
for child in x:
51+
yield from iter_chunks_node(child, context)
4252
elif isinstance(x, Iterable) and not isinstance(x, KnownInvalidChildren): # pyright: ignore [reportUnnecessaryIsInstance]
4353
for child in x:
4454
yield from iter_chunks_node(child, context)

tests/test_children.py

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,21 @@
1212
from markupsafe import Markup
1313
from typing_extensions import assert_type
1414

15-
from htpy import Element, VoidElement, dd, div, dl, dt, html, img, input, li, my_custom_element, ul
15+
from htpy import (
16+
Element,
17+
VoidElement,
18+
dd,
19+
div,
20+
dl,
21+
dt,
22+
fragment,
23+
html,
24+
img,
25+
input,
26+
li,
27+
my_custom_element,
28+
ul,
29+
)
1630

1731
from .conftest import Trace
1832

@@ -133,6 +147,17 @@ def test_generator_children(render: RenderFixture) -> None:
133147
assert render(result) == ["<ul>", "<li>", "a", "</li>", "<li>", "b", "</li>", "</ul>"]
134148

135149

150+
def test_raise_error_consume_generator_twice() -> None:
151+
def gen() -> Iterator[str]:
152+
yield "hi"
153+
154+
fragment_ = fragment[gen()]
155+
assert list(fragment_.iter_chunks()) == ["hi"]
156+
157+
with pytest.raises(RuntimeError, match="Generator has already been consumed"):
158+
list(fragment_.iter_chunks())
159+
160+
136161
def test_non_generator_iterator(render: RenderFixture, trace: TraceFixture) -> None:
137162
result = div[SingleShotIterator("hello", trace=trace)]
138163

0 commit comments

Comments
 (0)