Skip to content

Commit 6777390

Browse files
authored
Add @with_children decorator (#113)
* Add @with_children decorator As discussed in #98 * Add @with_children section to common patterns docs * Update changelog
1 parent 7fd8e61 commit 6777390

File tree

6 files changed

+277
-0
lines changed

6 files changed

+277
-0
lines changed

docs/changelog.md

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

3+
## NEXT
4+
- Split source code into multiple private modules.
5+
See [PR #119](https://github.com/pelme/htpy/pull/119).
6+
Thanks to [Stein Magnus Jodal (@jodal)](https://github.com/jodal).
7+
- Add `@with_children` decorator to help creating custom components that get passed children in the same way as regular HTML elements.
8+
See [PR #113](https://github.com/pelme/htpy/pull/113).
9+
[Read the docs for more details](common-patterns.md#components-with-children).
10+
Thanks to [Stein Magnus Jodal (@jodal)](https://github.com/jodal).
11+
312
## 25.5.0 - 2025-05-25
413
- Allow multiple attribute dictionaries when defining `Elements`.
514
PR #117.

docs/common-patterns.md

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,3 +160,66 @@ print(
160160
)
161161
)
162162
```
163+
164+
### Components with children
165+
166+
When building their own set of components, some prefer to make their
167+
components accept children nodes in the same way as the HTML elements provided
168+
by htpy.
169+
170+
Making this work correctly in all cases can be tricky, so htpy provides a
171+
decorator called `@with_children`.
172+
173+
With the `@with_children` decorator you can convert a component like this:
174+
175+
```py
176+
from htpy import Node, Renderable
177+
178+
def my_component(*, title: str, children: Node) -> Renderable:
179+
...
180+
```
181+
182+
That is used like this:
183+
184+
```py
185+
my_component(title="My title", children=h.div["My content"])
186+
```
187+
188+
Into a component that is defined like this:
189+
190+
```py
191+
from htpy import Node, Renderable, with_children
192+
193+
@with_children
194+
def my_component(children: Node, *, title: str) -> Renderable:
195+
...
196+
```
197+
198+
And that is used like this, just like any HTML element:
199+
200+
```py
201+
my_component(title="My title")[h.div["My content"]]
202+
```
203+
204+
You can combine `@with_children` with other decorators, like context
205+
consumers, that also pass extra arguments to the function, but you must make
206+
sure that decorators and arguments are in the right order.
207+
208+
As the innermost decorator is the first to wrap the function, it maps to the
209+
first argument. With multiple decorators, the source code order of the
210+
decorators and arguments are the opposite of each other.
211+
212+
```py
213+
from typing import Literal
214+
215+
from htpy import Context, Node, Renderable, div, h1, with_children
216+
217+
Theme = Literal["light", "dark"]
218+
219+
theme_context: Context[Theme] = Context("theme", default="light")
220+
221+
@with_children
222+
@theme_context.consumer
223+
def my_component(theme: Theme, children: Node, *, extra: str) -> Renderable:
224+
...
225+
```

htpy/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from htpy._types import Attribute as Attribute
1616
from htpy._types import Node as Node
1717
from htpy._types import Renderable as Renderable
18+
from htpy._with_children import with_children as with_children
1819

1920
__version__ = "25.5.0"
2021
__all__: list[str] = []

htpy/_with_children.py

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
from __future__ import annotations
2+
3+
import functools
4+
import typing as t
5+
6+
from markupsafe import Markup as _Markup
7+
8+
if t.TYPE_CHECKING:
9+
from collections.abc import Callable, Iterator, Mapping
10+
11+
import htpy
12+
13+
14+
C = t.TypeVar("C", bound="htpy.Node")
15+
P = t.ParamSpec("P")
16+
R = t.TypeVar("R", bound="htpy.Renderable")
17+
18+
19+
class _WithChildrenUnbound(t.Generic[C, P, R]):
20+
"""Decorator to make a component support children nodes.
21+
22+
This decorator is used to create a component that can accept children nodes,
23+
just like native htpy components.
24+
25+
It lets you convert this:
26+
27+
```python
28+
def my_component(*, title: str, children: h.Node) -> h.Element:
29+
...
30+
31+
my_component(title="My title", children=h.div["My content"])
32+
```
33+
34+
To this:
35+
36+
```python
37+
@h.with_children
38+
def my_component(children: h.Node, *, title: str) -> h.Element:
39+
...
40+
41+
my_component(title="My title")[h.div["My content"]]
42+
```
43+
"""
44+
45+
wrapped: Callable[t.Concatenate[C | None, P], R]
46+
47+
def __init__(self, func: Callable[t.Concatenate[C | None, P], R]) -> None:
48+
# This instance is created at import time when decorating the component.
49+
# It means that this object is global, and shared between all renderings
50+
# of the same component.
51+
self.wrapped = func
52+
functools.update_wrapper(self, func)
53+
54+
def __repr__(self) -> str:
55+
return f"with_children({self.wrapped.__name__}, <unbound>)"
56+
57+
def __call__(self, *args: P.args, **kwargs: P.kwargs) -> _WithChildrenBound[C, P, R]:
58+
# This is the first call to the component, where we get the
59+
# component's args and kwargs:
60+
#
61+
# my_component(title="My title")
62+
#
63+
# It is important that we return a new instance bound to the args
64+
# and kwargs instead of mutating, so that state doesn't leak between
65+
# multiple renderings of the same component.
66+
#
67+
return _WithChildrenBound(self.wrapped, args, kwargs)
68+
69+
def __getitem__(self, children: C | None) -> R:
70+
# This is the unbound component being used with children:
71+
#
72+
# my_component["My content"]
73+
#
74+
return self.wrapped(children) # type: ignore[call-arg]
75+
76+
def __str__(self) -> _Markup:
77+
# This is the unbound component being rendered to a string:
78+
#
79+
# str(my_component)
80+
#
81+
return _Markup(self.wrapped(None)) # type: ignore[call-arg]
82+
83+
__html__ = __str__
84+
85+
def encode(self, encoding: str = "utf-8", errors: str = "strict") -> bytes:
86+
return str(self).encode(encoding, errors)
87+
88+
def iter_chunks(
89+
self,
90+
context: Mapping[htpy.Context[t.Any], t.Any] | None = None,
91+
) -> Iterator[str]:
92+
return self.wrapped(None).iter_chunks(context) # type: ignore[call-arg]
93+
94+
95+
class _WithChildrenBound(t.Generic[C, P, R]):
96+
_func: Callable[t.Concatenate[C | None, P], R]
97+
_args: tuple[t.Any, ...]
98+
_kwargs: Mapping[str, t.Any]
99+
100+
def __init__(
101+
self,
102+
func: Callable[t.Concatenate[C | None, P], R],
103+
args: tuple[t.Any, ...],
104+
kwargs: Mapping[str, t.Any],
105+
) -> None:
106+
# This is called at runtime when the component is being passed args and
107+
# kwargs. This instance is only used for the current rendering of the
108+
# component.
109+
self._func = func
110+
self._args = args
111+
self._kwargs = kwargs
112+
113+
def __repr__(self) -> str:
114+
return f"with_children({self._func.__name__}, {self._args}, {self._kwargs})"
115+
116+
def __getitem__(self, children: C | None) -> R:
117+
# This is a bound component being used with children:
118+
#
119+
# my_component(title="My title")["My content"]
120+
#
121+
return self._func(children, *self._args, **self._kwargs)
122+
123+
def __str__(self) -> _Markup:
124+
# This is a bound component being rendered to a string:
125+
#
126+
# str(my_component(title="My title"))
127+
#
128+
return _Markup(self._func(None, *self._args, **self._kwargs))
129+
130+
__html__ = __str__
131+
132+
def encode(self, encoding: str = "utf-8", errors: str = "strict") -> bytes:
133+
return str(self).encode(encoding, errors)
134+
135+
def iter_chunks(
136+
self,
137+
context: Mapping[htpy.Context[t.Any], t.Any] | None = None,
138+
) -> Iterator[str]:
139+
return self._func(None, *self._args, **self._kwargs).iter_chunks(context)
140+
141+
142+
with_children = _WithChildrenUnbound

tests/test_render.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,18 @@ def example_consumer(value: str) -> str:
1313
return value
1414

1515

16+
@h.with_children
17+
def example_with_children(
18+
content: h.Node,
19+
*,
20+
title: str = "default!",
21+
) -> h.Element:
22+
return h.div[
23+
h.h1[title],
24+
h.p[content],
25+
]
26+
27+
1628
@dataclass(frozen=True)
1729
class RenderableTestCase:
1830
renderable: h.Renderable
@@ -33,6 +45,22 @@ def expected_bytes(self) -> bytes:
3345
RenderableTestCase(h.fragment["fragment!"], ["fragment!"]),
3446
# comment() is a Fragment but test it anyways for completeness
3547
RenderableTestCase(h.comment("comment!"), ["<!-- comment! -->"]),
48+
RenderableTestCase(
49+
example_with_children,
50+
["<div>", "<h1>", "default!", "</h1>", "<p>", "</p>", "</div>"],
51+
),
52+
RenderableTestCase(
53+
example_with_children["children!"],
54+
["<div>", "<h1>", "default!", "</h1>", "<p>", "children!", "</p>", "</div>"],
55+
),
56+
RenderableTestCase(
57+
example_with_children(title="title!"),
58+
["<div>", "<h1>", "title!", "</h1>", "<p>", "</p>", "</div>"],
59+
),
60+
RenderableTestCase(
61+
example_with_children(title="title!")["children!"],
62+
["<div>", "<h1>", "title!", "</h1>", "<p>", "children!", "</p>", "</div>"],
63+
),
3664
]
3765

3866

tests/test_with_children.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
from __future__ import annotations
2+
3+
import pytest
4+
5+
import htpy as h
6+
7+
8+
@h.with_children
9+
def example_with_children(
10+
content: h.Node,
11+
*,
12+
title: str = "default!",
13+
) -> h.Element:
14+
return h.div[
15+
h.h1[title],
16+
h.p[content],
17+
]
18+
19+
20+
@pytest.mark.parametrize(
21+
("component", "expected"),
22+
[
23+
(
24+
example_with_children,
25+
"with_children(example_with_children, <unbound>)",
26+
),
27+
(
28+
example_with_children(title="title!"),
29+
"with_children(example_with_children, (), {'title': 'title!'})",
30+
),
31+
],
32+
)
33+
def test_with_children_repr(component: h.Renderable, expected: str) -> None:
34+
assert repr(component) == expected

0 commit comments

Comments
 (0)