Skip to content

Commit 4f47267

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 4f47267

14 files changed

+252
-143
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
- 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

+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

+94-45
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,23 @@ 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 _chunks_as_markup(self)
147+
148+
__html__ = __str__
149+
150+
def iter_chunks(self, context: Mapping[Context[t.Any], t.Any] | None = None) -> Iterator[str]:
151+
yield from _iter_chunks_node(self.node, {**(context or {}), self.context: self.value}) # pyright: ignore [reportUnknownMemberType]
135152

136-
def __str__(self) -> str:
137-
return render_node(self)
153+
def encode(self, encoding: str = "utf-8", errors: str = "strict") -> bytes:
154+
return str(self).encode(encoding, errors)
138155

139156

140157
@dataclasses.dataclass(frozen=True)
@@ -143,6 +160,24 @@ class ContextConsumer(t.Generic[T]):
143160
debug_name: str
144161
func: Callable[[T], Node]
145162

163+
def __str__(self) -> _Markup:
164+
return _chunks_as_markup(self)
165+
166+
__html__ = __str__
167+
168+
def iter_chunks(self, context: Mapping[Context[t.Any], t.Any] | None = None) -> Iterator[str]:
169+
context_value = (context or {}).get(self.context, self.context.default)
170+
171+
if context_value is _NO_DEFAULT:
172+
raise LookupError(
173+
f'Context value for "{self.context.name}" does not exist, ' # pyright: ignore
174+
f"requested by {self.debug_name}()."
175+
)
176+
yield from _iter_chunks_node(self.func(context_value), context) # pyright: ignore
177+
178+
def encode(self, encoding: str = "utf-8", errors: str = "strict") -> bytes:
179+
return str(self).encode(encoding, errors)
180+
146181

147182
class _NO_DEFAULT:
148183
pass
@@ -168,11 +203,15 @@ def wrapper(*args: P.args, **kwargs: P.kwargs) -> ContextConsumer[T]:
168203
return wrapper
169204

170205

206+
@deprecated(
207+
"iter_node is deprecated and will be removed in a future release. "
208+
"Please use the .iter_chunks() method on elements/fragments instead."
209+
) # pyright: ignore [reportUntypedFunctionDecorator]
171210
def iter_node(x: Node) -> Iterator[str]:
172-
return _iter_node_context(x, {})
211+
return fragment[x].iter_chunks()
173212

174213

175-
def _iter_node_context(x: Node, context_dict: dict[Context[t.Any], t.Any]) -> Iterator[str]:
214+
def _iter_chunks_node(x: Node, context: Mapping[Context[t.Any], t.Any] | None) -> Iterator[str]:
176215
while not isinstance(x, BaseElement) and callable(x):
177216
x = x()
178217

@@ -185,27 +224,15 @@ def _iter_node_context(x: Node, context_dict: dict[Context[t.Any], t.Any]) -> It
185224
if x is False:
186225
return
187226

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
227+
if hasattr(x, "iter_chunks"):
228+
yield from x.iter_chunks(context) # pyright: ignore
202229
elif isinstance(x, str | _HasHtml):
203230
yield str(_escape(x))
204231
elif isinstance(x, int):
205232
yield str(x)
206233
elif isinstance(x, Iterable) and not isinstance(x, _KnownInvalidChildren): # pyright: ignore [reportUnnecessaryIsInstance]
207234
for child in x:
208-
yield from _iter_node_context(child, context_dict)
235+
yield from _iter_chunks_node(child, context)
209236
else:
210237
raise TypeError(f"{x!r} is not a valid child element")
211238

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

234261
def __str__(self) -> _Markup:
235-
return _Markup("".join(self))
262+
return _chunks_as_markup(self)
236263

237264
__html__ = __str__
238265

@@ -279,17 +306,18 @@ def __call__(self: BaseElementSelf, *args: t.Any, **kwargs: t.Any) -> BaseElemen
279306
self._children,
280307
)
281308

309+
@deprecated(
310+
"iterating over an element is deprecated and will be removed in a future release. "
311+
"Please use the element.iter_chunks() method instead."
312+
) # pyright: ignore [reportUntypedFunctionDecorator]
282313
def __iter__(self) -> Iterator[str]:
283-
return self._iter_context({})
314+
return self.iter_chunks()
284315

285-
def _iter_context(self, ctx: dict[Context[t.Any], t.Any]) -> Iterator[str]:
316+
def iter_chunks(self, context: Mapping[Context[t.Any], t.Any] | None = None) -> Iterator[str]:
286317
yield f"<{self._name}{self._attrs}>"
287-
yield from _iter_node_context(self._children, ctx)
318+
yield from _iter_chunks_node(self._children, context)
288319
yield f"</{self._name}>"
289320

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

@@ -334,13 +362,13 @@ def __repr__(self) -> str:
334362

335363

336364
class HTMLElement(Element):
337-
def _iter_context(self, ctx: dict[Context[t.Any], t.Any]) -> Iterator[str]:
365+
def iter_chunks(self, context: Mapping[Context[t.Any], t.Any] | None = None) -> Iterator[str]:
338366
yield "<!doctype html>"
339-
yield from super()._iter_context(ctx)
367+
yield from super().iter_chunks(context)
340368

341369

342370
class VoidElement(BaseElement):
343-
def _iter_context(self, ctx: dict[Context[t.Any], t.Any]) -> Iterator[str]:
371+
def iter_chunks(self, context: Mapping[Context[t.Any], t.Any] | None = None) -> Iterator[str]:
344372
yield f"<{self._name}{self._attrs}>"
345373

346374
def __repr__(self) -> str:
@@ -358,14 +386,24 @@ def __init__(self) -> None:
358386
# node directly via the constructor.
359387
self._node: Node = None
360388

389+
@deprecated(
390+
"iterating over a fragment is deprecated and will be removed in a future release. "
391+
"Please use the fragment.iter_chunks() method instead."
392+
) # pyright: ignore [reportUntypedFunctionDecorator]
361393
def __iter__(self) -> Iterator[str]:
362-
return iter_node(self)
394+
return self.iter_chunks()
363395

364-
def __str__(self) -> str:
365-
return render_node(self)
396+
def __str__(self) -> _Markup:
397+
return _chunks_as_markup(self)
366398

367399
__html__ = __str__
368400

401+
def iter_chunks(self, context: Mapping[Context[t.Any], t.Any] | None = None) -> Iterator[str]:
402+
yield from _iter_chunks_node(self._node, context)
403+
404+
def encode(self, encoding: str = "utf-8", errors: str = "strict") -> bytes:
405+
return str(self).encode(encoding, errors)
406+
369407

370408
class _FragmentGetter:
371409
def __getitem__(self, node: Node) -> Fragment:
@@ -377,8 +415,16 @@ def __getitem__(self, node: Node) -> Fragment:
377415
fragment = _FragmentGetter()
378416

379417

418+
def _chunks_as_markup(renderable: Renderable) -> _Markup:
419+
return _Markup("".join(renderable.iter_chunks()))
420+
421+
422+
@deprecated(
423+
"render_node is deprecated and will be removed in a future release. "
424+
"Please use Renderable.__str__() instead."
425+
) # pyright: ignore [reportUntypedFunctionDecorator]
380426
def render_node(node: Node) -> _Markup:
381-
return _Markup("".join(iter_node(node)))
427+
return _Markup(fragment[node])
382428

383429

384430
def comment(text: str) -> Fragment:
@@ -391,20 +437,23 @@ class _HasHtml(t.Protocol):
391437
def __html__(self) -> str: ...
392438

393439

440+
class Renderable(t.Protocol):
441+
def __str__(self) -> _Markup: ...
442+
def __html__(self) -> _Markup: ...
443+
def iter_chunks(
444+
self, context: Mapping[Context[t.Any], t.Any] | None = None
445+
) -> Iterator[str]: ...
446+
447+
# Allow starlette Response.render to directly render this element without
448+
# explicitly casting to str:
449+
# https://github.com/encode/starlette/blob/5ed55c441126687106109a3f5e051176f88cd3e6/starlette/responses.py#L44-L49
450+
def encode(self, encoding: str = "utf-8", errors: str = "strict") -> bytes: ...
451+
452+
394453
_ClassNamesDict: t.TypeAlias = dict[str, bool]
395454
_ClassNames: t.TypeAlias = Iterable[str | None | bool | _ClassNamesDict] | _ClassNamesDict
396455
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]
456+
Renderable | None | bool | str | int | _HasHtml | Iterable["Node"] | Callable[[], "Node"]
408457
)
409458

410459
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:

0 commit comments

Comments
 (0)