Skip to content

Commit ec8d851

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 the iter_chunks() method which is identical with `__iter__()` but with a better name. With the introduction of Fragment, this commit makes render_node and iter_node redundant. This commit deprecates render_node, iter_node and direct iteration of elements. More info: #86 (comment)
1 parent 07a5bea commit ec8d851

File tree

14 files changed

+270
-143
lines changed

14 files changed

+270
-143
lines changed

docs/changelog.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
# Changelog
22

3+
## NEXT
4+
- 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`.
5+
- 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).
6+
37
## 25.4.0 - 2025-04-10
48
- Make Context's repr debug friendly [PR #96](https://github.com/pelme/htpy/pull/96). Thanks to Stein Magnus Jodal ([@jodal](https://github.com/jodal)).
59
- Strip whitespace around id and class values in CSS selector. Fixes

docs/static-typing.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,27 @@ def bootstrap_badge(
4747

4848
```
4949

50+
## Renderable
51+
52+
htpy elements, fragments and context objects provides are "renderable". The `Renderable` type provides a consistent API to render a `htpy` object as HTML.
53+
54+
The `Renderable` protocol defines these methods:
55+
56+
- `.__str__()` - render as a HTML string by calling `str()`
57+
- `.__html__()` - render as a HTML string that is safe to use as markup. This makes it possible to directly embed a `Renderable` object in [Django/Jinja templates](django.md#using-htpy-as-part-of-an-existing-django-template).
58+
- `.iter_chunks()` - stream the contents as string "chunks". See [Streaming](streaming.md) for more information.
59+
60+
All `Renderable`'s are also `Node`'s and can always be used as a child element. You can use this to write reusable components that can be used as a child node but also be rendered by themselves or embedded into a Django or Jinja template:
61+
62+
```pycon
63+
>>> from htpy import div, h1, Renderable
64+
>>> def my_component(name: str) -> Renderable:
65+
... return div[h1[f"Hello {name}!"]]
66+
>>> print(my_component("Dave"))
67+
<div><h1>Hello Dave!</h1></div>
68+
69+
```
70+
5071
## Node
5172

5273
`Node` is a type alias for all possible objects that can be used as a child

docs/usage.md

Lines changed: 21 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,7 @@ library. markupsafe is a dependency of htpy and is automatically installed:
163163

164164
```
165165

166-
If you are generating [Markdown](https://pypi.org/project/Markdown/) and want to insert it into an element,
166+
If you are generating [Markdown](https://pypi.org/project/Markdown/) and want to insert it into an element,
167167
use `Markup` to mark it as safe:
168168

169169
```pycon title="Injecting generated markdown"
@@ -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 `iter_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"]].iter_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"]].iter_chunks():
409407
... print(f"got a chunk: {chunk!r}")
410408
...
411409
got a chunk: '<li>'
@@ -427,10 +425,10 @@ React.
427425
Using contexts in htpy involves:
428426

429427
- Creating a context object with `my_context = Context(name[, *, default])` to
430-
define the type and optional default value of a context variable.
428+
define the type and optional default value of a context variable.
431429
- Using `my_context.provider(value, children)` to set the value of a context variable for a subtree.
432430
- Adding the `@my_context.consumer` decorator to a component that requires the
433-
context value. The decorator will add the context value as the first argument to the decorated function:
431+
context value. The decorator will add the context value as the first argument to the decorated function:
434432

435433
The `Context` class is a generic and fully supports static type checking.
436434

@@ -456,16 +454,16 @@ def my_component(a, b):
456454
This example shows how context can be used to pass data between components:
457455

458456
- `theme_context: Context[Theme] = Context("theme", default="light")` creates a
459-
context object that can later be used to define/retrieve the value. In this
460-
case, `"light"` acts as the default value if no other value is provided.
457+
context object that can later be used to define/retrieve the value. In this
458+
case, `"light"` acts as the default value if no other value is provided.
461459
- `theme_context.provider(value, subtree)` defines the value of the
462-
`theme_context` for the subtree. In this case the value is set to `"dark"` which
463-
overrides the default value.
460+
`theme_context` for the subtree. In this case the value is set to `"dark"` which
461+
overrides the default value.
464462
- The `sidebar` component uses the `@theme_context.consumer` decorator. This
465-
will make htpy pass the current context value as the first argument to the
466-
component function.
463+
will make htpy pass the current context value as the first argument to the
464+
component function.
467465
- In this example, a `Theme` type is used to ensure that the correct types are
468-
used when providing the value as well as when it is consumed.
466+
used when providing the value as well as when it is consumed.
469467

470468
```py
471469
from typing import Literal
@@ -498,5 +496,8 @@ print(my_page())
498496
Output:
499497

500498
```html
501-
<div><h1>Hello!</h1><div class="theme-dark">The Sidebar!</div></div>
499+
<div>
500+
<h1>Hello!</h1>
501+
<div class="theme-dark">The Sidebar!</div>
502+
</div>
502503
```

htpy/__init__.py

Lines changed: 112 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@
99
from markupsafe import Markup as _Markup
1010
from markupsafe import escape as _escape
1111

12+
try:
13+
from typing import deprecated # type: ignore[attr-defined]
14+
except ImportError:
15+
from typing_extensions import deprecated
16+
1217
if t.TYPE_CHECKING:
1318
from types import UnionType
1419

@@ -130,11 +135,26 @@ class ContextProvider(t.Generic[T]):
130135
value: T
131136
node: Node
132137

138+
@deprecated(
139+
"iterating over a context provider is deprecated and will be removed in a future release. "
140+
"Please use the context_provider.iter_chunks() method instead."
141+
) # pyright: ignore [reportUntypedFunctionDecorator]
133142
def __iter__(self) -> Iterator[str]:
134-
return iter_node(self)
143+
return self.iter_chunks()
144+
145+
def __str__(self) -> _Markup:
146+
return _as_markup(self)
135147

136-
def __str__(self) -> str:
137-
return render_node(self)
148+
__html__ = __str__
149+
150+
def iter_chunks(self) -> Iterator[str]:
151+
return self._iter_chunks_context({})
152+
153+
def encode(self, encoding: str = "utf-8", errors: str = "strict") -> bytes:
154+
return str(self).encode(encoding, errors)
155+
156+
def _iter_chunks_context(self, context: dict[Context[t.Any], t.Any]) -> Iterator[str]:
157+
yield from _iter_chunks_node(self.node, {**context, self.context: self.value}) # pyright: ignore [reportUnknownMemberType]
138158

139159

140160
@dataclasses.dataclass(frozen=True)
@@ -143,6 +163,27 @@ class ContextConsumer(t.Generic[T]):
143163
debug_name: str
144164
func: Callable[[T], Node]
145165

166+
def __str__(self) -> _Markup:
167+
return _as_markup(self)
168+
169+
__html__ = __str__
170+
171+
def iter_chunks(self) -> Iterator[str]:
172+
return self._iter_chunks_context({})
173+
174+
def encode(self, encoding: str = "utf-8", errors: str = "strict") -> bytes:
175+
return str(self).encode(encoding, errors)
176+
177+
def _iter_chunks_context(self, context: dict[Context[t.Any], t.Any]) -> Iterator[str]:
178+
context_value = context.get(self.context, self.context.default)
179+
180+
if context_value is _NO_DEFAULT:
181+
raise LookupError(
182+
f'Context value for "{self.context.name}" does not exist, ' # pyright: ignore
183+
f"requested by {self.debug_name}()."
184+
)
185+
yield from _iter_chunks_node(self.func(context_value), context) # pyright: ignore
186+
146187

147188
class _NO_DEFAULT:
148189
pass
@@ -168,11 +209,15 @@ def wrapper(*args: P.args, **kwargs: P.kwargs) -> ContextConsumer[T]:
168209
return wrapper
169210

170211

212+
@deprecated(
213+
"iter_node is deprecated and will be removed in a future release. "
214+
"Please use the .iter_chunks() method on elements/fragments instead."
215+
) # pyright: ignore [reportUntypedFunctionDecorator]
171216
def iter_node(x: Node) -> Iterator[str]:
172-
return _iter_node_context(x, {})
217+
return fragment[x].iter_chunks()
173218

174219

175-
def _iter_node_context(x: Node, context_dict: dict[Context[t.Any], t.Any]) -> Iterator[str]:
220+
def _iter_chunks_node(x: Node, context: dict[Context[t.Any], t.Any]) -> Iterator[str]:
176221
while not isinstance(x, BaseElement) and callable(x):
177222
x = x()
178223

@@ -185,27 +230,16 @@ def _iter_node_context(x: Node, context_dict: dict[Context[t.Any], t.Any]) -> It
185230
if x is False:
186231
return
187232

188-
if isinstance(x, BaseElement):
189-
yield from x._iter_context(context_dict) # pyright: ignore [reportPrivateUsage]
190-
elif isinstance(x, ContextProvider):
191-
yield from _iter_node_context(x.node, {**context_dict, x.context: x.value}) # pyright: ignore [reportUnknownMemberType]
192-
elif isinstance(x, ContextConsumer):
193-
context_value = context_dict.get(x.context, x.context.default)
194-
if context_value is _NO_DEFAULT:
195-
raise LookupError(
196-
f'Context value for "{x.context.name}" does not exist, '
197-
f"requested by {x.debug_name}()."
198-
)
199-
yield from _iter_node_context(x.func(context_value), context_dict)
200-
elif isinstance(x, Fragment):
201-
yield from _iter_node_context(x._node, context_dict) # pyright: ignore
233+
if hasattr(x, "_iter_chunks_context"):
234+
yield from x._iter_chunks_context(context) # pyright: ignore
235+
202236
elif isinstance(x, str | _HasHtml):
203237
yield str(_escape(x))
204238
elif isinstance(x, int):
205239
yield str(x)
206240
elif isinstance(x, Iterable) and not isinstance(x, _KnownInvalidChildren): # pyright: ignore [reportUnnecessaryIsInstance]
207241
for child in x:
208-
yield from _iter_node_context(child, context_dict)
242+
yield from _iter_chunks_node(child, context)
209243
else:
210244
raise TypeError(f"{x!r} is not a valid child element")
211245

@@ -232,7 +266,7 @@ def __init__(self, name: str, attrs_str: str = "", children: Node = None) -> Non
232266
self._children = children
233267

234268
def __str__(self) -> _Markup:
235-
return _Markup("".join(self))
269+
return _as_markup(self)
236270

237271
__html__ = __str__
238272

@@ -279,17 +313,21 @@ def __call__(self: BaseElementSelf, *args: t.Any, **kwargs: t.Any) -> BaseElemen
279313
self._children,
280314
)
281315

316+
@deprecated(
317+
"iterating over an element is deprecated and will be removed in a future release. "
318+
"Please use the element.iter_chunks() method instead."
319+
) # pyright: ignore [reportUntypedFunctionDecorator]
282320
def __iter__(self) -> Iterator[str]:
283-
return self._iter_context({})
321+
return self.iter_chunks()
284322

285-
def _iter_context(self, ctx: dict[Context[t.Any], t.Any]) -> Iterator[str]:
323+
def iter_chunks(self) -> Iterator[str]:
324+
return self._iter_chunks_context({})
325+
326+
def _iter_chunks_context(self, context: dict[Context[t.Any], t.Any]) -> Iterator[str]:
286327
yield f"<{self._name}{self._attrs}>"
287-
yield from _iter_node_context(self._children, ctx)
328+
yield from _iter_chunks_node(self._children, context)
288329
yield f"</{self._name}>"
289330

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

@@ -334,13 +372,13 @@ def __repr__(self) -> str:
334372

335373

336374
class HTMLElement(Element):
337-
def _iter_context(self, ctx: dict[Context[t.Any], t.Any]) -> Iterator[str]:
375+
def _iter_chunks_context(self, context: dict[Context[t.Any], t.Any]) -> Iterator[str]:
338376
yield "<!doctype html>"
339-
yield from super()._iter_context(ctx)
377+
yield from super()._iter_chunks_context(context)
340378

341379

342380
class VoidElement(BaseElement):
343-
def _iter_context(self, ctx: dict[Context[t.Any], t.Any]) -> Iterator[str]:
381+
def _iter_chunks_context(self, context: dict[Context[t.Any], t.Any]) -> Iterator[str]:
344382
yield f"<{self._name}{self._attrs}>"
345383

346384
def __repr__(self) -> str:
@@ -358,14 +396,27 @@ def __init__(self) -> None:
358396
# node directly via the constructor.
359397
self._node: Node = None
360398

399+
@deprecated(
400+
"iterating over a fragment is deprecated and will be removed in a future release. "
401+
"Please use the fragment.iter_chunks() method instead."
402+
) # pyright: ignore [reportUntypedFunctionDecorator]
361403
def __iter__(self) -> Iterator[str]:
362-
return iter_node(self)
404+
return self.iter_chunks()
363405

364-
def __str__(self) -> str:
365-
return render_node(self)
406+
def __str__(self) -> _Markup:
407+
return _as_markup(self)
366408

367409
__html__ = __str__
368410

411+
def iter_chunks(self) -> Iterator[str]:
412+
return self._iter_chunks_context({})
413+
414+
def encode(self, encoding: str = "utf-8", errors: str = "strict") -> bytes:
415+
return str(self).encode(encoding, errors)
416+
417+
def _iter_chunks_context(self, context: dict[Context[t.Any], t.Any]) -> Iterator[str]:
418+
yield from _iter_chunks_node(self._node, context)
419+
369420

370421
class _FragmentGetter:
371422
def __getitem__(self, node: Node) -> Fragment:
@@ -377,8 +428,16 @@ def __getitem__(self, node: Node) -> Fragment:
377428
fragment = _FragmentGetter()
378429

379430

431+
def _as_markup(renderable: Renderable) -> _Markup:
432+
return _Markup("".join(renderable.iter_chunks()))
433+
434+
435+
@deprecated(
436+
"render_node is deprecated and will be removed in a future release. "
437+
"Please use Renderable.__str__() instead."
438+
) # pyright: ignore [reportUntypedFunctionDecorator]
380439
def render_node(node: Node) -> _Markup:
381-
return _Markup("".join(iter_node(node)))
440+
return _Markup(fragment[node])
382441

383442

384443
def comment(text: str) -> Fragment:
@@ -391,20 +450,28 @@ class _HasHtml(t.Protocol):
391450
def __html__(self) -> str: ...
392451

393452

453+
class Renderable(t.Protocol):
454+
def __str__(self) -> _Markup: ...
455+
def __html__(self) -> _Markup: ...
456+
def iter_chunks(self) -> Iterator[str]: ...
457+
458+
# Allow starlette Response.render to directly render this element without
459+
# explicitly casting to str:
460+
# https://github.com/encode/starlette/blob/5ed55c441126687106109a3f5e051176f88cd3e6/starlette/responses.py#L44-L49
461+
def encode(self, encoding: str = "utf-8", errors: str = "strict") -> bytes: ...
462+
463+
# The _iter_chunks_context method is called internally to render the element.
464+
# This method is not a public API with any backward compatibilty and is
465+
# subject to change without any notice. Feel free to use it at your own risk
466+
# and report how you use it in a Github issue at https://github.com/pelme/htpy
467+
# to encourage turning it into a public API. :)
468+
def _iter_chunks_context(self, context: dict[Context[t.Any], t.Any]) -> Iterator[str]: ...
469+
470+
394471
_ClassNamesDict: t.TypeAlias = dict[str, bool]
395472
_ClassNames: t.TypeAlias = Iterable[str | None | bool | _ClassNamesDict] | _ClassNamesDict
396473
Node: t.TypeAlias = (
397-
None
398-
| bool
399-
| str
400-
| int
401-
| BaseElement
402-
| _HasHtml
403-
| Fragment
404-
| Iterable["Node"]
405-
| Callable[[], "Node"]
406-
| ContextProvider[t.Any]
407-
| ContextConsumer[t.Any]
474+
Renderable | None | bool | str | int | _HasHtml | Iterable["Node"] | Callable[[], "Node"]
408475
)
409476

410477
Attribute: t.TypeAlias = None | bool | str | int | _HasHtml | _ClassNames

0 commit comments

Comments
 (0)