Skip to content

Commit 7dd0abb

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 8b23487 commit 7dd0abb

14 files changed

+238
-135
lines changed

docs/changelog.md

+4
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
- Strip whitespace around id and class values in CSS selector. Fixes
59
[issue #97](https://github.com/pelme/htpy/issues/97). See [PR #100](https://github.com/pelme/htpy/pull/100).

docs/static-typing.md

+21
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

+21-20
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

+80-37
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
from markupsafe import Markup as _Markup
1010
from markupsafe import escape as _escape
11+
from typing_extensions import deprecated
1112

1213
if t.TYPE_CHECKING:
1314
from types import UnionType
@@ -130,11 +131,23 @@ class ContextProvider(t.Generic[T]):
130131
value: T
131132
node: Node
132133

134+
@deprecated(
135+
"iterating over a context provider is deprecated and will be removed in a future release. "
136+
"Please use the context_provider.iter_chunks() method instead."
137+
)
133138
def __iter__(self) -> Iterator[str]:
134-
return iter_node(self)
139+
return self.iter_chunks()
135140

136-
def __str__(self) -> str:
137-
return render_node(self)
141+
def __str__(self) -> _Markup:
142+
return _as_markup(self)
143+
144+
__html__ = __str__
145+
146+
def iter_chunks(self) -> Iterator[str]:
147+
return _iter_chunks(self, {})
148+
149+
def encode(self, encoding: str = "utf-8", errors: str = "strict") -> bytes:
150+
return str(self).encode(encoding, errors)
138151

139152

140153
@dataclasses.dataclass(frozen=True)
@@ -143,6 +156,17 @@ class ContextConsumer(t.Generic[T]):
143156
debug_name: str
144157
func: Callable[[T], Node]
145158

159+
def __str__(self) -> _Markup:
160+
return _as_markup(self)
161+
162+
__html__ = __str__
163+
164+
def iter_chunks(self) -> Iterator[str]:
165+
return _iter_chunks(self, {})
166+
167+
def encode(self, encoding: str = "utf-8", errors: str = "strict") -> bytes:
168+
return str(self).encode(encoding, errors)
169+
146170

147171
class _NO_DEFAULT:
148172
pass
@@ -168,11 +192,15 @@ def wrapper(*args: P.args, **kwargs: P.kwargs) -> ContextConsumer[T]:
168192
return wrapper
169193

170194

195+
@deprecated(
196+
"iter_node is deprecated and will be removed in a future release. "
197+
"Please use the .iter_chunks() method on elements/fragments instead."
198+
)
171199
def iter_node(x: Node) -> Iterator[str]:
172-
return _iter_node_context(x, {})
200+
return fragment[x].iter_chunks()
173201

174202

175-
def _iter_node_context(x: Node, context_dict: dict[Context[t.Any], t.Any]) -> Iterator[str]:
203+
def _iter_chunks(x: Node, context_dict: dict[Context[t.Any], t.Any]) -> Iterator[str]:
176204
while not isinstance(x, BaseElement) and callable(x):
177205
x = x()
178206

@@ -186,26 +214,27 @@ def _iter_node_context(x: Node, context_dict: dict[Context[t.Any], t.Any]) -> It
186214
return
187215

188216
if isinstance(x, BaseElement):
189-
yield from x._iter_context(context_dict) # pyright: ignore [reportPrivateUsage]
217+
yield from x._iter_chunks_context(context_dict) # pyright: ignore [reportPrivateUsage]
190218
elif isinstance(x, ContextProvider):
191-
yield from _iter_node_context(x.node, {**context_dict, x.context: x.value}) # pyright: ignore [reportUnknownMemberType]
219+
yield from _iter_chunks(x.node, {**context_dict, x.context: x.value}) # pyright: ignore [reportUnknownMemberType]
192220
elif isinstance(x, ContextConsumer):
193-
context_value = context_dict.get(x.context, x.context.default)
221+
context_value = context_dict.get(x.context, x.context.default) # pyright: ignore
222+
194223
if context_value is _NO_DEFAULT:
195224
raise LookupError(
196-
f'Context value for "{x.context.name}" does not exist, '
225+
f'Context value for "{x.context.name}" does not exist, ' # pyright: ignore
197226
f"requested by {x.debug_name}()."
198227
)
199-
yield from _iter_node_context(x.func(context_value), context_dict)
228+
yield from _iter_chunks(x.func(context_value), context_dict) # pyright: ignore
200229
elif isinstance(x, Fragment):
201-
yield from _iter_node_context(x._node, context_dict) # pyright: ignore
230+
yield from _iter_chunks(x._node, context_dict) # pyright: ignore
202231
elif isinstance(x, str | _HasHtml):
203232
yield str(_escape(x))
204233
elif isinstance(x, int):
205234
yield str(x)
206235
elif isinstance(x, Iterable) and not isinstance(x, _KnownInvalidChildren): # pyright: ignore [reportUnnecessaryIsInstance]
207236
for child in x:
208-
yield from _iter_node_context(child, context_dict)
237+
yield from _iter_chunks(child, context_dict)
209238
else:
210239
raise TypeError(f"{x!r} is not a valid child element")
211240

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

234263
def __str__(self) -> _Markup:
235-
return _Markup("".join(self))
264+
return _as_markup(self)
236265

237266
__html__ = __str__
238267

@@ -279,17 +308,21 @@ def __call__(self: BaseElementSelf, *args: t.Any, **kwargs: t.Any) -> BaseElemen
279308
self._children,
280309
)
281310

311+
@deprecated(
312+
"iterating over an element is deprecated and will be removed in a future release. "
313+
"Please use the element.iter_chunks() method instead."
314+
)
282315
def __iter__(self) -> Iterator[str]:
283-
return self._iter_context({})
316+
return self.iter_chunks()
284317

285-
def _iter_context(self, ctx: dict[Context[t.Any], t.Any]) -> Iterator[str]:
318+
def iter_chunks(self) -> Iterator[str]:
319+
return _iter_chunks(self, {})
320+
321+
def _iter_chunks_context(self, ctx: dict[Context[t.Any], t.Any]) -> Iterator[str]:
286322
yield f"<{self._name}{self._attrs}>"
287-
yield from _iter_node_context(self._children, ctx)
323+
yield from _iter_chunks(self._children, ctx)
288324
yield f"</{self._name}>"
289325

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
293326
def encode(self, encoding: str = "utf-8", errors: str = "strict") -> bytes:
294327
return str(self).encode(encoding, errors)
295328

@@ -334,13 +367,13 @@ def __repr__(self) -> str:
334367

335368

336369
class HTMLElement(Element):
337-
def _iter_context(self, ctx: dict[Context[t.Any], t.Any]) -> Iterator[str]:
370+
def _iter_chunks_context(self, ctx: dict[Context[t.Any], t.Any]) -> Iterator[str]:
338371
yield "<!doctype html>"
339-
yield from super()._iter_context(ctx)
372+
yield from super()._iter_chunks_context(ctx)
340373

341374

342375
class VoidElement(BaseElement):
343-
def _iter_context(self, ctx: dict[Context[t.Any], t.Any]) -> Iterator[str]:
376+
def _iter_chunks_context(self, ctx: dict[Context[t.Any], t.Any]) -> Iterator[str]:
344377
yield f"<{self._name}{self._attrs}>"
345378

346379
def __repr__(self) -> str:
@@ -358,14 +391,24 @@ def __init__(self) -> None:
358391
# node directly via the constructor.
359392
self._node: Node = None
360393

394+
@deprecated(
395+
"iterating over a fragment is deprecated and will be removed in a future release. "
396+
"Please use the fragment.iter_chunks() method instead."
397+
)
361398
def __iter__(self) -> Iterator[str]:
362-
return iter_node(self)
399+
return self.iter_chunks()
363400

364-
def __str__(self) -> str:
365-
return render_node(self)
401+
def __str__(self) -> _Markup:
402+
return _as_markup(self)
366403

367404
__html__ = __str__
368405

406+
def iter_chunks(self) -> Iterator[str]:
407+
return _iter_chunks(self, {})
408+
409+
def encode(self, encoding: str = "utf-8", errors: str = "strict") -> bytes:
410+
return str(self).encode(encoding, errors)
411+
369412

370413
class _FragmentGetter:
371414
def __getitem__(self, node: Node) -> Fragment:
@@ -377,8 +420,16 @@ def __getitem__(self, node: Node) -> Fragment:
377420
fragment = _FragmentGetter()
378421

379422

423+
def _as_markup(renderable: Renderable) -> _Markup:
424+
return _Markup("".join(renderable.iter_chunks()))
425+
426+
427+
@deprecated(
428+
"render_node is deprecated and will be removed in a future release. "
429+
"Please use Renderable.__str__() instead."
430+
)
380431
def render_node(node: Node) -> _Markup:
381-
return _Markup("".join(iter_node(node)))
432+
return _Markup(fragment[node])
382433

383434

384435
def comment(text: str) -> Fragment:
@@ -391,20 +442,12 @@ class _HasHtml(t.Protocol):
391442
def __html__(self) -> str: ...
392443

393444

445+
Renderable = BaseElement | ContextConsumer[t.Any] | ContextProvider[t.Any] | Fragment
446+
394447
_ClassNamesDict: t.TypeAlias = dict[str, bool]
395448
_ClassNames: t.TypeAlias = Iterable[str | None | bool | _ClassNamesDict] | _ClassNamesDict
396449
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]
450+
Renderable | None | bool | str | int | _HasHtml | Iterable["Node"] | Callable[[], "Node"]
408451
)
409452

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

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:

pyproject.toml

+3-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@ description = "htpy - HTML in Python"
44
requires-python = ">=3.10"
55
dynamic = ["version"]
66
dependencies = [
7-
"markupsafe>=2.0.0"
7+
"markupsafe>=2.0.0",
8+
# typing_extensions is used for @typing.deprecated introduced in Python 3.13
9+
"typing_extensions>=4.13.2",
810
]
911
readme = "docs/README.md"
1012
authors = [

0 commit comments

Comments
 (0)