Repository: pelme/htpy
Pull request #92: Add a consistent rendering protocol.
Author: login: pelme
Created: 2025-03-08T15:37:50Z | Merged: 2025-04-12T08:44:35Z

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: https://github.com/pelme/htpy/pull/86#discussion_r1986098069


Diff:
diff --git a/docs/changelog.md b/docs/changelog.md
index 120b0ed..52987a0 100644
--- a/docs/changelog.md
+++ b/docs/changelog.md
@@ -1,5 +1,9 @@
 # Changelog
 
+## NEXT
+- 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`.
+- 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).
+
 ## 25.4.0 - 2025-04-10
 - 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)).
 - Strip whitespace around id and class values in CSS selector. Fixes
diff --git a/docs/static-typing.md b/docs/static-typing.md
index 774fbc3..4675aa3 100644
--- a/docs/static-typing.md
+++ b/docs/static-typing.md
@@ -47,6 +47,27 @@ def bootstrap_badge(
 
 ```
 
+## Renderable
+
+htpy elements, fragments and context objects provides are "renderable". The `Renderable` type provides a consistent API to render a `htpy` object as HTML.
+
+The `Renderable` protocol defines these methods:
+
+ - `.__str__()` - render as a HTML string by calling `str()`
+ - `.__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).
+ - `.iter_chunks()` - stream the contents as string "chunks". See [Streaming](streaming.md) for more information.
+
+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:
+
+```pycon
+>>> from htpy import div, h1, Renderable
+>>> def my_component(name: str) -> Renderable:
+...     return div[h1[f"Hello {name}!"]]
+>>> print(my_component("Dave"))
+<div><h1>Hello Dave!</h1></div>
+
+```
+
 ## Node
 
 `Node` is a type alias for all possible objects that can be used as a child
diff --git a/docs/usage.md b/docs/usage.md
index 6ba29a8..420ffd1 100644
--- a/docs/usage.md
+++ b/docs/usage.md
@@ -163,7 +163,7 @@ library. markupsafe is a dependency of htpy and is automatically installed:
 
 ```
 
-If you are generating [Markdown](https://pypi.org/project/Markdown/) and want to insert it into an element, 
+If you are generating [Markdown](https://pypi.org/project/Markdown/) and want to insert it into an element,
 use `Markup` to mark it as safe:
 
 ```pycon title="Injecting generated markdown"
@@ -378,14 +378,14 @@ snippets as attributes:
 
 ```
 
-## Iterating of the Output
+## Streaming chunks
 
-Iterating over a htpy element will yield the resulting contents in chunks as
-they are rendered:
+htpy objects provide the `iter_chunks()` method to render an element with its
+children one piece at a time.
 
 ```pycon
 >>> from htpy import ul, li
->>> for chunk in ul[li["a"], li["b"]]:
+>>> for chunk in ul[li["a"], li["b"]].iter_chunks():
 ...     print(f"got a chunk: {chunk!r}")
 ...
 got a chunk: '<ul>'
@@ -399,13 +399,11 @@ got a chunk: '</ul>'
 
 ```
 
-Just like [render_node()](#render-elements-without-a-parent-orphans), there is
-`iter_node()` that can be used when you need to iterate over a list of elements
-without a parent:
+If you need to get the chunks of an element without parents, wrap it in a `Fragment`:
 
 ```pycon
->>> from htpy import li, Fragment
->>> for chunk in fragment[li["a"], li["b"]]:
+>>> from htpy import li, fragment
+>>> for chunk in fragment[li["a"], li["b"]].iter_chunks():
 ...     print(f"got a chunk: {chunk!r}")
 ...
 got a chunk: '<li>'
@@ -427,10 +425,10 @@ React.
 Using contexts in htpy involves:
 
 - Creating a context object with `my_context = Context(name[, *, default])` to
-define the type and optional default value of a context variable.
+  define the type and optional default value of a context variable.
 - Using `my_context.provider(value, children)` to set the value of a context variable for a subtree.
 - Adding the `@my_context.consumer` decorator to a component that requires the
-context value. The decorator will add the context value as the first argument to the decorated function:
+  context value. The decorator will add the context value as the first argument to the decorated function:
 
 The `Context` class is a generic and fully supports static type checking.
 
@@ -456,16 +454,16 @@ def my_component(a, b):
 This example shows how context can be used to pass data between components:
 
 - `theme_context: Context[Theme] = Context("theme", default="light")` creates a
-context object that can later be used to define/retrieve the value. In this
-case, `"light"` acts as the default value if no other value is provided.
+  context object that can later be used to define/retrieve the value. In this
+  case, `"light"` acts as the default value if no other value is provided.
 - `theme_context.provider(value, subtree)` defines the value of the
-`theme_context` for the subtree. In this case the value is set to `"dark"` which
-overrides the default value.
+  `theme_context` for the subtree. In this case the value is set to `"dark"` which
+  overrides the default value.
 - The `sidebar` component uses the `@theme_context.consumer` decorator. This
-will make htpy pass the current context value as the first argument to the
-component function.
+  will make htpy pass the current context value as the first argument to the
+  component function.
 - In this example, a `Theme` type is used to ensure that the correct types are
-used when providing the value as well as when it is consumed.
+  used when providing the value as well as when it is consumed.
 
 ```py
 from typing import Literal
@@ -498,5 +496,8 @@ print(my_page())
 Output:
 
 ```html
-<div><h1>Hello!</h1><div class="theme-dark">The Sidebar!</div></div>
+<div>
+  <h1>Hello!</h1>
+  <div class="theme-dark">The Sidebar!</div>
+</div>
 ```
diff --git a/htpy/__init__.py b/htpy/__init__.py
index 531c593..0fc0007 100644
--- a/htpy/__init__.py
+++ b/htpy/__init__.py
@@ -9,6 +9,11 @@
 from markupsafe import Markup as _Markup
 from markupsafe import escape as _escape
 
+try:
+    from typing import deprecated  # type: ignore[attr-defined]
+except ImportError:
+    from typing_extensions import deprecated
+
 if t.TYPE_CHECKING:
     from types import UnionType
 
@@ -130,11 +135,23 @@ class ContextProvider(t.Generic[T]):
     value: T
     node: Node
 
+    @deprecated(
+        "iterating over a context provider is deprecated and will be removed in a future release. "
+        "Please use the context_provider.iter_chunks() method instead."
+    )  # pyright: ignore [reportUntypedFunctionDecorator]
     def __iter__(self) -> Iterator[str]:
-        return iter_node(self)
+        return self.iter_chunks()
+
+    def __str__(self) -> _Markup:
+        return _chunks_as_markup(self)
+
+    __html__ = __str__
+
+    def iter_chunks(self, context: Mapping[Context[t.Any], t.Any] | None = None) -> Iterator[str]:
+        return _iter_chunks_node(self.node, {**(context or {}), self.context: self.value})  # pyright: ignore [reportUnknownMemberType]
 
-    def __str__(self) -> str:
-        return render_node(self)
+    def encode(self, encoding: str = "utf-8", errors: str = "strict") -> bytes:
+        return str(self).encode(encoding, errors)
 
 
 @dataclasses.dataclass(frozen=True)
@@ -143,6 +160,24 @@ class ContextConsumer(t.Generic[T]):
     debug_name: str
     func: Callable[[T], Node]
 
+    def __str__(self) -> _Markup:
+        return _chunks_as_markup(self)
+
+    __html__ = __str__
+
+    def iter_chunks(self, context: Mapping[Context[t.Any], t.Any] | None = None) -> Iterator[str]:
+        context_value = (context or {}).get(self.context, self.context.default)
+
+        if context_value is _NO_DEFAULT:
+            raise LookupError(
+                f'Context value for "{self.context.name}" does not exist, '  # pyright: ignore
+                f"requested by {self.debug_name}()."
+            )
+        return _iter_chunks_node(self.func(context_value), context)  # pyright: ignore
+
+    def encode(self, encoding: str = "utf-8", errors: str = "strict") -> bytes:
+        return str(self).encode(encoding, errors)
+
 
 class _NO_DEFAULT:
     pass
@@ -168,11 +203,15 @@ def wrapper(*args: P.args, **kwargs: P.kwargs) -> ContextConsumer[T]:
         return wrapper
 
 
+@deprecated(
+    "iter_node is deprecated and will be removed in a future release. "
+    "Please use the .iter_chunks() method on elements/fragments instead."
+)  # pyright: ignore [reportUntypedFunctionDecorator]
 def iter_node(x: Node) -> Iterator[str]:
-    return _iter_node_context(x, {})
+    return fragment[x].iter_chunks()
 
 
-def _iter_node_context(x: Node, context_dict: dict[Context[t.Any], t.Any]) -> Iterator[str]:
+def _iter_chunks_node(x: Node, context: Mapping[Context[t.Any], t.Any] | None) -> Iterator[str]:
     while not isinstance(x, BaseElement) and callable(x):
         x = x()
 
@@ -185,27 +224,15 @@ def _iter_node_context(x: Node, context_dict: dict[Context[t.Any], t.Any]) -> It
     if x is False:
         return
 
-    if isinstance(x, BaseElement):
-        yield from x._iter_context(context_dict)  # pyright: ignore [reportPrivateUsage]
-    elif isinstance(x, ContextProvider):
-        yield from _iter_node_context(x.node, {**context_dict, x.context: x.value})  # pyright: ignore [reportUnknownMemberType]
-    elif isinstance(x, ContextConsumer):
-        context_value = context_dict.get(x.context, x.context.default)
-        if context_value is _NO_DEFAULT:
-            raise LookupError(
-                f'Context value for "{x.context.name}" does not exist, '
-                f"requested by {x.debug_name}()."
-            )
-        yield from _iter_node_context(x.func(context_value), context_dict)
-    elif isinstance(x, Fragment):
-        yield from _iter_node_context(x._node, context_dict)  # pyright: ignore
+    if hasattr(x, "iter_chunks"):
+        yield from x.iter_chunks(context)  # pyright: ignore
     elif isinstance(x, str | _HasHtml):
         yield str(_escape(x))
     elif isinstance(x, int):
         yield str(x)
     elif isinstance(x, Iterable) and not isinstance(x, _KnownInvalidChildren):  # pyright: ignore [reportUnnecessaryIsInstance]
         for child in x:
-            yield from _iter_node_context(child, context_dict)
+            yield from _iter_chunks_node(child, context)
     else:
         raise TypeError(f"{x!r} is not a valid child element")
 
@@ -232,7 +259,7 @@ def __init__(self, name: str, attrs_str: str = "", children: Node = None) -> Non
         self._children = children
 
     def __str__(self) -> _Markup:
-        return _Markup("".join(self))
+        return _chunks_as_markup(self)
 
     __html__ = __str__
 
@@ -279,17 +306,18 @@ def __call__(self: BaseElementSelf, *args: t.Any, **kwargs: t.Any) -> BaseElemen
             self._children,
         )
 
+    @deprecated(
+        "iterating over an element is deprecated and will be removed in a future release. "
+        "Please use the element.iter_chunks() method instead."
+    )  # pyright: ignore [reportUntypedFunctionDecorator]
     def __iter__(self) -> Iterator[str]:
-        return self._iter_context({})
+        return self.iter_chunks()
 
-    def _iter_context(self, ctx: dict[Context[t.Any], t.Any]) -> Iterator[str]:
+    def iter_chunks(self, context: Mapping[Context[t.Any], t.Any] | None = None) -> Iterator[str]:
         yield f"<{self._name}{self._attrs}>"
-        yield from _iter_node_context(self._children, ctx)
+        yield from _iter_chunks_node(self._children, context)
         yield f"</{self._name}>"
 
-    # Allow starlette Response.render to directly render this element without
-    # explicitly casting to str:
-    # https://github.com/encode/starlette/blob/5ed55c441126687106109a3f5e051176f88cd3e6/starlette/responses.py#L44-L49
     def encode(self, encoding: str = "utf-8", errors: str = "strict") -> bytes:
         return str(self).encode(encoding, errors)
 
@@ -334,13 +362,13 @@ def __repr__(self) -> str:
 
 
 class HTMLElement(Element):
-    def _iter_context(self, ctx: dict[Context[t.Any], t.Any]) -> Iterator[str]:
+    def iter_chunks(self, context: Mapping[Context[t.Any], t.Any] | None = None) -> Iterator[str]:
         yield "<!doctype html>"
-        yield from super()._iter_context(ctx)
+        yield from super().iter_chunks(context)
 
 
 class VoidElement(BaseElement):
-    def _iter_context(self, ctx: dict[Context[t.Any], t.Any]) -> Iterator[str]:
+    def iter_chunks(self, context: Mapping[Context[t.Any], t.Any] | None = None) -> Iterator[str]:
         yield f"<{self._name}{self._attrs}>"
 
     def __repr__(self) -> str:
@@ -358,14 +386,24 @@ def __init__(self) -> None:
         # node directly via the constructor.
         self._node: Node = None
 
+    @deprecated(
+        "iterating over a fragment is deprecated and will be removed in a future release. "
+        "Please use the fragment.iter_chunks() method instead."
+    )  # pyright: ignore [reportUntypedFunctionDecorator]
     def __iter__(self) -> Iterator[str]:
-        return iter_node(self)
+        return self.iter_chunks()
 
-    def __str__(self) -> str:
-        return render_node(self)
+    def __str__(self) -> _Markup:
+        return _chunks_as_markup(self)
 
     __html__ = __str__
 
+    def iter_chunks(self, context: Mapping[Context[t.Any], t.Any] | None = None) -> Iterator[str]:
+        return _iter_chunks_node(self._node, context)
+
+    def encode(self, encoding: str = "utf-8", errors: str = "strict") -> bytes:
+        return str(self).encode(encoding, errors)
+
 
 class _FragmentGetter:
     def __getitem__(self, node: Node) -> Fragment:
@@ -377,8 +415,16 @@ def __getitem__(self, node: Node) -> Fragment:
 fragment = _FragmentGetter()
 
 
+def _chunks_as_markup(renderable: Renderable) -> _Markup:
+    return _Markup("".join(renderable.iter_chunks()))
+
+
+@deprecated(
+    "render_node is deprecated and will be removed in a future release. "
+    "Please use fragment instead: https://htpy.dev/usage/#fragments"
+)  # pyright: ignore [reportUntypedFunctionDecorator]
 def render_node(node: Node) -> _Markup:
-    return _Markup("".join(iter_node(node)))
+    return _Markup(fragment[node])
 
 
 def comment(text: str) -> Fragment:
@@ -391,20 +437,23 @@ class _HasHtml(t.Protocol):
     def __html__(self) -> str: ...
 
 
+class Renderable(t.Protocol):
+    def __str__(self) -> _Markup: ...
+    def __html__(self) -> _Markup: ...
+    def iter_chunks(
+        self, context: Mapping[Context[t.Any], t.Any] | None = None
+    ) -> Iterator[str]: ...
+
+    # Allow starlette Response.render to directly render this element without
+    # explicitly casting to str:
+    # https://github.com/encode/starlette/blob/5ed55c441126687106109a3f5e051176f88cd3e6/starlette/responses.py#L44-L49
+    def encode(self, encoding: str = "utf-8", errors: str = "strict") -> bytes: ...
+
+
 _ClassNamesDict: t.TypeAlias = dict[str, bool]
 _ClassNames: t.TypeAlias = Iterable[str | None | bool | _ClassNamesDict] | _ClassNamesDict
 Node: t.TypeAlias = (
-    None
-    | bool
-    | str
-    | int
-    | BaseElement
-    | _HasHtml
-    | Fragment
-    | Iterable["Node"]
-    | Callable[[], "Node"]
-    | ContextProvider[t.Any]
-    | ContextConsumer[t.Any]
+    Renderable | None | bool | str | int | _HasHtml | Iterable["Node"] | Callable[[], "Node"]
 )
 
 Attribute: t.TypeAlias = None | bool | str | int | _HasHtml | _ClassNames
diff --git a/htpy/django.py b/htpy/django.py
index 23148c9..6031a1d 100644
--- a/htpy/django.py
+++ b/htpy/django.py
@@ -5,7 +5,7 @@
 from django.template import Context, TemplateDoesNotExist
 from django.utils.module_loading import import_string
 
-from . import Element, render_node
+from . import Element, fragment
 
 if t.TYPE_CHECKING:
     from collections.abc import Callable
@@ -19,7 +19,7 @@ def __init__(self, func: Callable[[Context | None, HttpRequest | None], Element]
         self.func = func
 
     def render(self, context: Context | None, request: HttpRequest | None) -> str:
-        return render_node(self.func(context, request))
+        return str(fragment[self.func(context, request)])
 
 
 class HtpyTemplateBackend:
diff --git a/pyproject.toml b/pyproject.toml
index 6672c21..eba434e 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,9 @@ description = "htpy - HTML in Python"
 requires-python = ">=3.10"
 dynamic = ["version"]
 dependencies = [
-    "markupsafe>=2.0.0"
+    "markupsafe>=2.0.0",
+    # typing_extensions is used for @typing.deprecated introduced in Python 3.13
+    "typing_extensions>=4.13.2 ; python_version<'3.13'",
 ]
 readme = "docs/README.md"
 authors = [
diff --git a/tests/conftest.py b/tests/conftest.py
index 48a7eb7..8a25f3c 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -5,7 +5,7 @@
 
 import pytest
 
-from htpy import Node, iter_node
+import htpy as h
 
 if t.TYPE_CHECKING:
     from collections.abc import Callable, Generator
@@ -17,7 +17,7 @@ class Trace:
 
 
 RenderResult: t.TypeAlias = list[str | Trace]
-RenderFixture: t.TypeAlias = t.Callable[[Node], RenderResult]
+RenderFixture: t.TypeAlias = t.Callable[[h.Renderable], RenderResult]
 TraceFixture: t.TypeAlias = t.Callable[[str], None]
 
 
@@ -52,14 +52,14 @@ def func(description: str) -> None:
 def render(render_result: RenderResult) -> Generator[RenderFixture, None, None]:
     called = False
 
-    def func(node: Node) -> RenderResult:
+    def func(renderable: h.Renderable) -> RenderResult:
         nonlocal called
 
         if called:
             raise AssertionError("render() must only be called once per test")
 
         called = True
-        for chunk in iter_node(node):
+        for chunk in renderable.iter_chunks():
             render_result.append(chunk)
 
         return render_result
diff --git a/tests/test_comment.py b/tests/test_comment.py
index db75ed7..ee75665 100644
--- a/tests/test_comment.py
+++ b/tests/test_comment.py
@@ -22,7 +22,3 @@ def test_escape_three_dashes(render: RenderFixture) -> None:
 
 def test_escape_four_dashes(render: RenderFixture) -> None:
     assert render(div[comment("foo----bar")]) == ["<div>", "<!-- foobar -->", "</div>"]
-
-
-def test_str() -> None:
-    assert str(comment("foo")) == "<!-- foo -->"
diff --git a/tests/test_context.py b/tests/test_context.py
index 17d43cf..4d75d65 100644
--- a/tests/test_context.py
+++ b/tests/test_context.py
@@ -2,7 +2,6 @@
 
 import typing as t
 
-import markupsafe
 import pytest
 
 from htpy import Context, Node, div, fragment
@@ -34,19 +33,6 @@ def test_context_provider(render: RenderFixture) -> None:
     assert render(result) == ["<div>", "Hello: c!", "</div>"]
 
 
-class Test_provider_outer_api:
-    """Ensure provider implements __iter__/__str__"""
-
-    def test_iter(self) -> None:
-        result = letter_ctx.provider("c", div[display_letter("Hello")])
-        assert list(result) == ["<div>", "Hello: c!", "</div>"]
-
-    def test_str(self) -> None:
-        result = str(letter_ctx.provider("c", div[display_letter("Hello")]))
-        assert result == "<div>Hello: c!</div>"
-        assert isinstance(result, markupsafe.Markup)
-
-
 def test_no_default(render: RenderFixture) -> None:
     with pytest.raises(
         LookupError,
diff --git a/tests/test_element.py b/tests/test_element.py
index bfc0d77..29c4be8 100644
--- a/tests/test_element.py
+++ b/tests/test_element.py
@@ -26,13 +26,6 @@ def test_void_element_repr() -> None:
     assert repr(htpy.hr("#a")) == """<VoidElement '<hr id="a">'>"""
 
 
-def test_markup_str() -> None:
-    result = str(div(id="a"))
-    assert isinstance(result, str)
-    assert isinstance(result, markupsafe.Markup)
-    assert result == '<div id="a"></div>'
-
-
 def test_element_type() -> None:
     assert_type(div, Element)
     assert isinstance(div, Element)
@@ -44,13 +37,6 @@ def test_element_type() -> None:
     assert isinstance(div()["a"], Element)
 
 
-def test_html_protocol() -> None:
-    element = div["test"]
-    result = element.__html__()
-    assert result == "<div>test</div>"
-    assert isinstance(result, markupsafe.Markup)
-
-
 def test_markupsafe_escape() -> None:
     result = markupsafe.escape(div["test"])
     assert result == "<div>test</div>"
diff --git a/tests/test_fragment.py b/tests/test_fragment.py
index 9da5a99..09f1b0a 100644
--- a/tests/test_fragment.py
+++ b/tests/test_fragment.py
@@ -39,5 +39,10 @@ def test_safe() -> None:
     assert markupsafe.escape(fragment[i["hi"]]) == "<i>hi</i>"
 
 
-def test_iter() -> None:
-    assert list(fragment["Hello ", None, i["World"]]) == ["Hello ", "<i>", "World", "</i>"]
+def test_fragment_iter_chunks() -> None:
+    assert list(fragment["Hello ", None, i["World"]].iter_chunks()) == [
+        "Hello ",
+        "<i>",
+        "World",
+        "</i>",
+    ]
diff --git a/tests/test_legacy_render.py b/tests/test_legacy_render.py
new file mode 100644
index 0000000..7cf64ea
--- /dev/null
+++ b/tests/test_legacy_render.py
@@ -0,0 +1,27 @@
+import pytest
+from markupsafe import Markup
+
+from htpy import fragment, iter_node, p, render_node  # pyright: ignore [reportDeprecated]
+
+
+def test_render_node() -> None:
+    with pytest.deprecated_call():
+        result = render_node(p["hi"])  # pyright: ignore [reportDeprecated]
+    assert isinstance(result, Markup)
+
+
+def test_iter_node() -> None:
+    with pytest.deprecated_call():
+        result = list(iter_node(p["hi"]))  # pyright: ignore [reportDeprecated]
+    assert result == ["<p>", "hi", "</p>"]
+
+
+def test_element_iter() -> None:
+    with pytest.deprecated_call():
+        result = list(p["hi"])
+    assert result == ["<p>", "hi", "</p>"]
+
+
+def test_fragment_iter() -> None:
+    with pytest.deprecated_call():
+        assert list(fragment["foo"]) == ["foo"]
diff --git a/tests/test_nodes.py b/tests/test_nodes.py
deleted file mode 100644
index 14c28b2..0000000
--- a/tests/test_nodes.py
+++ /dev/null
@@ -1,37 +0,0 @@
-from __future__ import annotations
-
-import typing as t
-
-from markupsafe import Markup
-
-from htpy import div, render_node, tr
-
-if t.TYPE_CHECKING:
-    from .conftest import RenderFixture
-
-
-def test_render_node_element() -> None:
-    result = render_node(div["a"])
-    assert result == "<div>a</div>"
-    assert isinstance(result, Markup)
-
-
-class Test_node_iteration:
-    def test_element(self, render: RenderFixture) -> None:
-        result = render(div["a"])
-
-        # Ensure we get str back, not markup.
-        assert type(result[0]) is str
-        assert result == ["<div>", "a", "</div>"]
-
-    def test_list(self, render: RenderFixture) -> None:
-        result = render([tr["a"], tr["b"]])
-        assert result == ["<tr>", "a", "</tr>", "<tr>", "b", "</tr>"]
-
-    def test_none(self, render: RenderFixture) -> None:
-        result = render(None)
-        assert result == []
-
-    def test_string(self, render: RenderFixture) -> None:
-        result = render("hej!")
-        assert result == ["hej!"]
diff --git a/tests/test_render.py b/tests/test_render.py
new file mode 100644
index 0000000..47f78fc
--- /dev/null
+++ b/tests/test_render.py
@@ -0,0 +1,69 @@
+from dataclasses import dataclass
+
+import markupsafe
+import pytest
+
+import htpy as h
+
+example_ctx: h.Context[str] = h.Context("example_ctx", default="default!")
+
+
+@example_ctx.consumer
+def example_consumer(value: str) -> str:
+    return value
+
+
+@dataclass(frozen=True)
+class RenderableTestCase:
+    renderable: h.Renderable
+    expected_chunks: list[str]
+
+    def expected_str(self) -> str:
+        return "".join(self.expected_chunks)
+
+    def expected_bytes(self) -> bytes:
+        return self.expected_str().encode("utf8")
+
+
+cases = [
+    RenderableTestCase(h.a, ["<a>", "</a>"]),
+    RenderableTestCase(h.img, ["<img>"]),
+    RenderableTestCase(example_ctx.provider("hi!", "stuff!"), ["stuff!"]),
+    RenderableTestCase(example_consumer(), ["default!"]),
+    RenderableTestCase(h.fragment["fragment!"], ["fragment!"]),
+    # comment() is a Fragment but test it anyways for completeness
+    RenderableTestCase(h.comment("comment!"), ["<!-- comment! -->"]),
+]
+
+
+@pytest.mark.parametrize("case", cases)
+def test_str(case: RenderableTestCase) -> None:
+    result = str(case.renderable)
+    assert isinstance(result, str)
+    assert isinstance(result, markupsafe.Markup)
+    assert result == case.expected_str()
+
+
+@pytest.mark.parametrize("case", cases)
+def test_html(case: RenderableTestCase) -> None:
+    result = case.renderable.__html__()
+    assert isinstance(result, str)
+    assert isinstance(result, markupsafe.Markup)
+    assert result == case.expected_str()
+
+
+@pytest.mark.parametrize("case", cases)
+def test_encode(case: RenderableTestCase) -> None:
+    result = case.renderable.encode()
+    assert isinstance(result, bytes)
+    assert result == case.expected_bytes()
+
+
+@pytest.mark.parametrize("case", cases)
+def test_iter_chunks(case: RenderableTestCase) -> None:
+    result = list(case.renderable.iter_chunks())
+
+    # Ensure we get str back, not markup.
+    assert type(result[0]) is str
+
+    assert result == case.expected_chunks


Reviews:
user: login: jodal
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-10T21:05:29Z
user: login: pelme
state: COMMENTED
author_association: OWNER
submitted_at: 2025-04-11T09:55:32Z
user: login: pelme
state: COMMENTED
author_association: OWNER
submitted_at: 2025-04-11T10:04:46Z
user: login: pelme
state: COMMENTED
author_association: OWNER
submitted_at: 2025-04-11T12:32:33Z
user: login: jodal
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-11T15:08:48Z
user: login: jodal
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-11T15:10:24Z
user: login: davepeck
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-11T15:14:26Z
user: login: pelme
state: COMMENTED
author_association: OWNER
submitted_at: 2025-04-11T15:21:34Z
user: login: pelme
state: COMMENTED
author_association: OWNER
submitted_at: 2025-04-11T15:23:09Z
user: login: pelme
state: COMMENTED
author_association: OWNER
submitted_at: 2025-04-11T17:44:58Z
user: login: pelme
state: COMMENTED
author_association: OWNER
submitted_at: 2025-04-11T19:05:41Z
user: login: jodal
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-11T20:40:13Z
user: login: davepeck
body: I'm really happy where this landed!
state: APPROVED
author_association: NONE
submitted_at: 2025-04-11T22:12:51Z
user: login: davepeck
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-11T22:14:31Z

Review comments:
diff_hunk: @@ -4,7 +4,9 @@ description = "htpy - HTML in Python"
 requires-python = ">=3.10"
 dynamic = ["version"]
 dependencies = [
-    "markupsafe>=2.0.0"
+    "markupsafe>=2.0.0",
+    # typing_extensions is used for @typing.deprecated introduced in Python 3.13
+    "typing_extensions>=4.13.2",
path: pyproject.toml
user: login: jodal
body: If we do:

```python
try:
    from typing import deprecated
except ImportError:
    from typing_extensions import deprecated
```

Then we can get rid of the dependency on Python 3.13.

```suggestion
    "typing_extensions>=4.13.2 ; python_version<'3.13'",
```

That said, `typing_extensions` is probably already present in any existing Python web app that cares about typing.
created_at: 2025-04-10T20:49:22Z
author_association: CONTRIBUTOR
diff_hunk: @@ -391,20 +442,12 @@ class _HasHtml(t.Protocol):
     def __html__(self) -> str: ...
 
 
+Renderable = BaseElement | ContextConsumer[t.Any] | ContextProvider[t.Any] | Fragment
path: htpy/__init__.py
user: login: jodal
body: If this remained an actual `Protocol` and `_iter_chunks()` checked for the protocol, like it checks for `_HasHtml`, then it would be easier to create our own renderable types outside htpy without subclassing any of these four to get `_iter_context()` to do the right thing.

- Use case A: To gradually migrate to htpy, we have built wrappers to be able to mix and match htpy and our previous component solution in both directions. The wrapper that exposes the old components to htpy is currently a subclass of `htpy.Element`, but I was kind of hoping it could just implement the `Renderable` protocol in the future.

- Use case B: When wrapping htpy components for use in the old templates, we have a type alias that is basically exactly the same as this type alias, only that it adds `@with_children` decorator from #98, which the wrapper also needs to recognize as something renderable. If `@with_children` was to remain external to htpy, we would have to keep that type alias and special-casing. If `Renderable` was a protocol, and `_iter_chunks()` had a branch where it checked for the protocol, it would just work as `@with_children` already implements `Renderable`.

That said, it works as is, it just feels a little less clean than what it could be, so feel free to implement this as a type alias if it has other benefits.
created_at: 2025-04-10T20:55:52Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,5 +1,9 @@
 # Changelog
 
+## NEXT
+- 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`.
+- 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).
+
 ## 25.4.0 - 2025-04-10
path: docs/changelog.md
user: login: jodal
body: Missing #96 ;-)
created_at: 2025-04-10T21:03:37Z
author_association: CONTRIBUTOR
diff_hunk: @@ -47,6 +47,27 @@ def bootstrap_badge(
 
 ```
 
+## Renderable
+
+htpy elements, fragments and context objects provides are "renderable". The `Renderable` type provides a consistent API to render a `htpy` object as HTML.
+
+The Renderable protocol defines these methods:
path: docs/static-typing.md
user: login: jodal
body: ```suggestion
The `Renderable` protocol defines these methods:
```
created_at: 2025-04-10T21:04:10Z
author_association: CONTRIBUTOR
diff_hunk: @@ -47,6 +47,27 @@ def bootstrap_badge(
 
 ```
 
+## Renderable
+
+htpy elements, fragments and context objects provides are "renderable". The `Renderable` type provides a consistent API to render a `htpy` object as HTML.
+
+The Renderable protocol defines these methods:
+
+ - `.__str__()` - render as a HTML string by calling `str()`
+ - `.__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).
path: docs/static-typing.md
user: login: jodal
body: ```suggestion
 - `.__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).
```
created_at: 2025-04-10T21:04:29Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,5 +1,9 @@
 # Changelog
 
+## NEXT
+- 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`.
+- 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).
+
 ## 25.4.0 - 2025-04-10
path: docs/changelog.md
user: login: pelme
body: oops! will try to make sure changelogs are always part of PRs going forward so I do not forget. Fixed in main+updated docs+github release.
created_at: 2025-04-11T09:55:32Z
author_association: OWNER
diff_hunk: @@ -391,20 +442,12 @@ class _HasHtml(t.Protocol):
     def __html__(self) -> str: ...
 
 
+Renderable = BaseElement | ContextConsumer[t.Any] | ContextProvider[t.Any] | Fragment
path: htpy/__init__.py
user: login: pelme
body: Hmm, interesting!

I am not really against it but would also not like to make a backward compatibility commitment for `_iter_context()` at this point. What do you think about:

- Make `Renderable` a Protocol again which includes the `_iter_chunks_context()` method.
- Do not document or and explicitly state (with a comment in the Protocol definition) that `_iter_chunks()` is private and that there is no backward compatible guarantee.
- If you are indeed making your own Renderables, you need to know how the internals on htpy works and deal with potential changes.
- You and others can then play with the ability to implement arbitrary htpy objects.
- If this is successful and works out good and there are good use cases for this, we can make it a public API and document how you can extend htpy.

I updated the PR with this now, what do you think about that?
created_at: 2025-04-11T10:04:45Z
author_association: OWNER
diff_hunk: @@ -4,7 +4,9 @@ description = "htpy - HTML in Python"
 requires-python = ">=3.10"
 dynamic = ["version"]
 dependencies = [
-    "markupsafe>=2.0.0"
+    "markupsafe>=2.0.0",
+    # typing_extensions is used for @typing.deprecated introduced in Python 3.13
+    "typing_extensions>=4.13.2",
path: pyproject.toml
user: login: pelme
body: good point! I also think it is kind of impossible to have a project without typing_extensions but it is nice to avoid it like that. updated the PR with your suggestion now.
created_at: 2025-04-11T12:32:33Z
author_association: OWNER
diff_hunk: @@ -391,20 +450,27 @@ class _HasHtml(t.Protocol):
     def __html__(self) -> str: ...
 
 
+class Renderable(t.Protocol):
+    def __str__(self) -> _Markup: ...
+    def __html__(self) -> _Markup: ...
+    def iter_chunks(self) -> Iterator[str]: ...
+
+    # Allow starlette Response.render to directly render this element without
+    # explicitly casting to str:
+    # https://github.com/encode/starlette/blob/5ed55c441126687106109a3f5e051176f88cd3e6/starlette/responses.py#L44-L49
+    def encode(self, encoding: str = "utf-8", errors: str = "strict") -> bytes: ...
+
+    # The _iter_chunks_context method is called internally to render the element.
+    # This method is not a public API with any backward compatibilty and is
+    # subject to change without any n. Feel free to use it at your own risk
path: htpy/__init__.py
user: login: jodal
body: Assuming the intention is something like this:

```suggestion
    # subject to change without any notice. Feel free to use it at your own risk
```
created_at: 2025-04-11T14:58:30Z
author_association: CONTRIBUTOR
diff_hunk: @@ -130,11 +135,26 @@ class ContextProvider(t.Generic[T]):
     value: T
     node: Node
 
+    @deprecated(
+        "iterating over a context provider is deprecated and will be removed in a future release. "
+        "Please use the context_provider.iter_chunks() method instead."
+    )  # pyright: ignore [reportUntypedFunctionDecorator]
     def __iter__(self) -> Iterator[str]:
-        return iter_node(self)
+        return self.iter_chunks()
+
+    def __str__(self) -> _Markup:
+        return _as_markup(self)
 
-    def __str__(self) -> str:
-        return render_node(self)
+    __html__ = __str__
+
+    def iter_chunks(self) -> Iterator[str]:
+        return _iter_chunks_node(self, {})
path: htpy/__init__.py
user: login: jodal
body: I guess we could skip a level in the call stack here and go directly for:

```suggestion
        return self._iter_chunks_context({})
```

Same for `ContextConsumer`, `BaseElement` and `Fragment`.
created_at: 2025-04-11T15:07:47Z
author_association: CONTRIBUTOR
diff_hunk: @@ -391,20 +442,12 @@ class _HasHtml(t.Protocol):
     def __html__(self) -> str: ...
 
 
+Renderable = BaseElement | ContextConsumer[t.Any] | ContextProvider[t.Any] | Fragment
path: htpy/__init__.py
user: login: jodal
body: Moving the core of the `_iter_chunks()` method out into each of the renderables is an obvious improvement once you see it, which I haven't until now. Love it, as this will give full control to any externally defined element as long as they get past the initial callable check in `_iter_chunks()`.
created_at: 2025-04-11T15:10:23Z
author_association: CONTRIBUTOR
diff_hunk: @@ -4,7 +4,9 @@ description = "htpy - HTML in Python"
 requires-python = ">=3.10"
 dynamic = ["version"]
 dependencies = [
-    "markupsafe>=2.0.0"
+    "markupsafe>=2.0.0",
+    # typing_extensions is used for @typing.deprecated introduced in Python 3.13
+    "typing_extensions>=4.13.2",
path: pyproject.toml
user: login: davepeck
body: Argh, sorry I missed this. Living in Python 3.14a land at the moment and apparently I've forgotten everything I knew about 3.12 and earlier. :-)
created_at: 2025-04-11T15:14:26Z
author_association: NONE
diff_hunk: @@ -130,11 +135,26 @@ class ContextProvider(t.Generic[T]):
     value: T
     node: Node
 
+    @deprecated(
+        "iterating over a context provider is deprecated and will be removed in a future release. "
+        "Please use the context_provider.iter_chunks() method instead."
+    )  # pyright: ignore [reportUntypedFunctionDecorator]
     def __iter__(self) -> Iterator[str]:
-        return iter_node(self)
+        return self.iter_chunks()
+
+    def __str__(self) -> _Markup:
+        return _as_markup(self)
 
-    def __str__(self) -> str:
-        return render_node(self)
+    __html__ = __str__
+
+    def iter_chunks(self) -> Iterator[str]:
+        return _iter_chunks_node(self, {})
path: htpy/__init__.py
user: login: pelme
body: very true, changed now!

we could also change `def iter_chunks(self) -> Iterator[str]:` to something like `def iter_chunks(self, context: dict[Context[t.Any], t.Any]=None) -> Iterator[str]:` and get rid of _iter_chunks_context altogether. then implementing Renderable would just need to implement public methods.

to make anything useful a custom implementation would still need to call into _iter_chunks_node (which we could rename to `iter_chunks_node` or something at that point. 

but maybe it is nicer to keep it as _iter_chunks_context for now and then see what other uses that comes up
created_at: 2025-04-11T15:21:34Z
author_association: OWNER
diff_hunk: @@ -391,20 +442,12 @@ class _HasHtml(t.Protocol):
     def __html__(self) -> str: ...
 
 
+Renderable = BaseElement | ContextConsumer[t.Any] | ContextProvider[t.Any] | Fragment
path: htpy/__init__.py
user: login: pelme
body: agreed, it turned out very nice. also surprised it was not obvious before 😆 
created_at: 2025-04-11T15:23:09Z
author_association: OWNER
diff_hunk: @@ -4,7 +4,9 @@ description = "htpy - HTML in Python"
 requires-python = ">=3.10"
 dynamic = ["version"]
 dependencies = [
-    "markupsafe>=2.0.0"
+    "markupsafe>=2.0.0",
+    # typing_extensions is used for @typing.deprecated introduced in Python 3.13
+    "typing_extensions>=4.13.2",
path: pyproject.toml
user: login: pelme
body: Congrats on having the PEP 750 accepted btw! Do you think we should add a `htpy.html()` function that turns t strings to htpy objects? We can continue that discussion in another issue.
created_at: 2025-04-11T17:44:58Z
author_association: OWNER
diff_hunk: @@ -130,11 +135,26 @@ class ContextProvider(t.Generic[T]):
     value: T
     node: Node
 
+    @deprecated(
+        "iterating over a context provider is deprecated and will be removed in a future release. "
+        "Please use the context_provider.iter_chunks() method instead."
+    )  # pyright: ignore [reportUntypedFunctionDecorator]
     def __iter__(self) -> Iterator[str]:
-        return iter_node(self)
+        return self.iter_chunks()
+
+    def __str__(self) -> _Markup:
+        return _as_markup(self)
 
-    def __str__(self) -> str:
-        return render_node(self)
+    __html__ = __str__
+
+    def iter_chunks(self) -> Iterator[str]:
+        return _iter_chunks_node(self, {})
path: htpy/__init__.py
user: login: pelme
body: I went ahead and changed `iter_chunks()` to accept the context dict and got rid of `._iter_chunks_context()`. So now it is completely possible to implement anything outside of htpy by implementing `Renderable`. `_iter_chunks_node` is still private but easy to access via a fragment if one needs that from a custom Renderable.
created_at: 2025-04-11T19:05:41Z
author_association: OWNER
diff_hunk: @@ -4,7 +4,9 @@ description = "htpy - HTML in Python"
 requires-python = ">=3.10"
 dynamic = ["version"]
 dependencies = [
-    "markupsafe>=2.0.0"
+    "markupsafe>=2.0.0",
+    # typing_extensions is used for @typing.deprecated introduced in Python 3.13
+    "typing_extensions>=4.13.2",
path: pyproject.toml
user: login: davepeck
body: > add a htpy.html() function that turns t strings to htpy objects?

Yes! I've been thinking a bit about how to tie the two together. Definitely a separate discussion, but a fun one. :-)
created_at: 2025-04-11T22:14:31Z
author_association: NONE

PR comments:
user: login: davepeck
created_at: 2025-03-20T20:23:33Z
body: I've been playing with these changes (well, after fixing that one lint issue) and: so far, so good. All my "template" functions return `h.Renderable` now and everything feels that much simpler.

As a random aside: these changes also caused me to slightly update my `@with_children` decorator, which I still use to make regular functions support `__getitem__` and look a bit more like any other htpy Element:

```python
@dataclass(frozen=True)
class with_children[C, R: h.Renderable, **P]:
    """Wrap a function to make it look more like an htpy.Element."""

    _f: t.Callable[t.Concatenate[C, P], R]
    _args: tuple[t.Any, ...] = field(default_factory=tuple)
    _kwargs: t.Mapping[str, t.Any] = field(default_factory=dict)

    def __getitem__(self, children: C) -> R:
        """Render the component with the given children."""
        return self._f(children, *self._args, **self._kwargs)

    def __call__(self, *args: P.args, **kwargs: P.kwargs) -> t.Self:
        """Return a new instance of the class with the given arguments."""
        return replace(self, _args=args, _kwargs=kwargs)

    def __str__(self) -> str:
        """Return the name of the function being wrapped."""
        # CONSIDER: alternatively, invoke the function here? If the function
        # provides a default value for its arguments, it'll work; otherwise,
        # it will blow up... which might be a good thing?
        return f"with_children[{self._f.__name__}]"
```


author_association: NONE
user: login: pelme
created_at: 2025-03-20T21:06:02Z
body: @davepeck awesome, thanks for the update! pushed an updated version that fixes the lint problem.

I renamed `stream_chunks()` to `iter_chunks()` which I like better, thoughts about that?

I will also add some kind of DeprecationWarning to render_node/iter_node and then hopefully merge this PR in the next few days. Then we can hopefully also get #38 done. Feel free to have a look there too!
author_association: OWNER
user: login: davepeck
created_at: 2025-03-20T22:19:05Z
body: > I renamed stream_chunks() to iter_chunks() which I like better, thoughts about that?

I prefer `iter_chunks()` too!

> Then we can hopefully also get #38 done. Feel free to have a look there too!

Will do...
author_association: NONE
user: login: jodal
created_at: 2025-04-02T11:43:57Z
body: I'm trying to get our version of `with_children` to work on components that also use contexts. I'm trying to juggle both the current implementation of htpy and these changes in my head at the same time, so that I won't have to redo it once this is merged.

What I'm saying is that I'd love for this to be merged and released, so that I can garbage collect half of my mental state :sweat_smile: 
author_association: CONTRIBUTOR
user: login: pelme
created_at: 2025-04-02T12:07:09Z
body: have not been able to finish up these changes yet, not sure when I will get around to it. if someone wants to look into deprecation warnings for render_node/iter_node and update the docs/changelog, that would be helpful!
author_association: OWNER
user: login: davepeck
created_at: 2025-04-02T18:28:34Z
body: > have not been able to finish up these changes yet, not sure when I will get around to it. if someone wants to look into deprecation warnings for render_node/iter_node and update the docs/changelog, that would be helpful!

I took a quick first stab at this: https://github.com/davepeck/htpy/tree/pelme-zsxolror

Tried to match your house style but let me know what's most useful.

(Update: my branch has a couple small updates as of 4/3 @ 9AM Pacific)
author_association: NONE
user: login: pelme
created_at: 2025-04-10T19:45:23Z
body: I have updated the PR based on you commits @davepeck , I think it may be good to go.

I changed Renderable to be a type alias rather than a Protocol. I think that makes more sense. Then a Renderable can always be a Node.

@davepeck  @jodal Can you please have another look?
author_association: OWNER