Skip to content

Commit 552c90b

Browse files
committed
Add @with_children decorator
As discussed in #98
1 parent 0b4a160 commit 552c90b

File tree

3 files changed

+296
-0
lines changed

3 files changed

+296
-0
lines changed

htpy/__init__.py

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

12+
from htpy._with_children import with_children as with_children
13+
1214
try:
1315
from warnings import deprecated # type: ignore[attr-defined,unused-ignore]
1416
except ImportError:

htpy/_with_children.py

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

tests/test_with_children.py

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
from __future__ import annotations
2+
3+
from importlib.metadata import version
4+
5+
import pytest
6+
7+
import htpy as h
8+
9+
10+
@h.with_children
11+
def my_component(
12+
content: h.Node,
13+
*,
14+
title: str = "Default title",
15+
) -> h.Element:
16+
return h.div[
17+
h.h1[title],
18+
h.p[content],
19+
]
20+
21+
22+
@pytest.mark.parametrize(
23+
("component", "expected"),
24+
[
25+
(
26+
my_component,
27+
"with_children(my_component, <unbound>)",
28+
),
29+
(
30+
my_component(title="My title"),
31+
"with_children(my_component, (), {'title': 'My title'})",
32+
),
33+
],
34+
)
35+
def test_with_children_repr(component: h.Renderable, expected: str) -> None:
36+
assert repr(component) == expected
37+
38+
39+
@pytest.mark.parametrize(
40+
("component", "expected"),
41+
[
42+
(
43+
my_component,
44+
"<div><h1>Default title</h1><p></p></div>",
45+
),
46+
(
47+
my_component["My ", "content"],
48+
"<div><h1>Default title</h1><p>My content</p></div>",
49+
),
50+
(
51+
my_component(title="My title"),
52+
"<div><h1>My title</h1><p></p></div>",
53+
),
54+
(
55+
my_component(title="My title")["My ", "content"],
56+
"<div><h1>My title</h1><p>My content</p></div>",
57+
),
58+
],
59+
)
60+
def test_with_children_str(component: h.Renderable, expected: str) -> None:
61+
assert str(component) == expected
62+
63+
64+
@pytest.mark.parametrize(
65+
("component", "expected"),
66+
[
67+
(
68+
my_component,
69+
"<div><h1>Default title</h1><p></p></div>",
70+
),
71+
(
72+
my_component["My ", "content"],
73+
"<div><h1>Default title</h1><p>My content</p></div>",
74+
),
75+
(
76+
my_component(title="My title"),
77+
"<div><h1>My title</h1><p></p></div>",
78+
),
79+
(
80+
my_component(title="My title")["My ", "content"],
81+
"<div><h1>My title</h1><p>My content</p></div>",
82+
),
83+
],
84+
)
85+
def test_with_children_html(component: h.Renderable, expected: str) -> None:
86+
assert component.__html__() == expected
87+
88+
89+
@pytest.mark.parametrize(
90+
("component", "expected"),
91+
[
92+
(
93+
my_component,
94+
b"<div><h1>Default title</h1><p></p></div>",
95+
),
96+
(
97+
my_component["My ", "content"],
98+
b"<div><h1>Default title</h1><p>My content</p></div>",
99+
),
100+
(
101+
my_component(title="My title"),
102+
b"<div><h1>My title</h1><p></p></div>",
103+
),
104+
(
105+
my_component(title="My title")["My ", "content"],
106+
b"<div><h1>My title</h1><p>My content</p></div>",
107+
),
108+
],
109+
)
110+
def test_with_children_encode(component: h.Renderable, expected: bytes) -> None:
111+
assert component.encode() == expected
112+
113+
114+
@pytest.mark.skipif(
115+
version("htpy") < "25.4.0",
116+
reason="Requires the htpy.Renderable protocol.",
117+
)
118+
@pytest.mark.parametrize(
119+
("component", "expected"),
120+
[
121+
(
122+
my_component,
123+
["<div>", "<h1>", "Default title", "</h1>", "<p>", "</p>", "</div>"],
124+
),
125+
(
126+
my_component["My ", "content"],
127+
[
128+
"<div>",
129+
"<h1>",
130+
"Default title",
131+
"</h1>",
132+
"<p>",
133+
"My ",
134+
"content",
135+
"</p>",
136+
"</div>",
137+
],
138+
),
139+
(
140+
my_component(title="My title"),
141+
["<div>", "<h1>", "My title", "</h1>", "<p>", "</p>", "</div>"],
142+
),
143+
(
144+
my_component(title="My title")["My ", "content"],
145+
[
146+
"<div>",
147+
"<h1>",
148+
"My title",
149+
"</h1>",
150+
"<p>",
151+
"My ",
152+
"content",
153+
"</p>",
154+
"</div>",
155+
],
156+
),
157+
],
158+
)
159+
def test_with_children_iter_chunks(component: h.Renderable, expected: list[str]) -> None:
160+
assert list(component.iter_chunks()) == expected

0 commit comments

Comments
 (0)