Skip to content

Commit f7486d4

Browse files
Add Fragment node for node grouping (#86)
1 parent 0509ec6 commit f7486d4

File tree

6 files changed

+111
-30
lines changed

6 files changed

+111
-30
lines changed

docs/changelog.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
# Changelog
22

3-
# 25.2.0 - 2025-02-01
3+
## Next (minor)
4+
- Add a `Fragment` node for explicitly grouping a collection of nodes. Fixes
5+
[#82](https://github.com/pelme/htpy/issues/82)
6+
7+
## 25.2.0 - 2025-02-01
48
- Context providers longer require wrapping nodes in a function/lambda. This
59
simplifies context usage while still being backward compatible. Thanks to Thomas
610
Scholtes ([@geigerzaehler](https://github.com/geigerzaehler)) for the patch. [PR #83](https://github.com/pelme/htpy/pull/83).
711

8-
# 25.1.0 - 2025-01-27
12+
## 25.1.0 - 2025-01-27
913
- Adjust typing for attributes: Allow Mapping instead of just dict. Thanks to
1014
David Svenson ([@Majsvaffla](https://github.com/Majsvaffla)) for the initial report+patch. [PR #80](https://github.com/pelme/htpy/pull/80).
1115

docs/usage.md

Lines changed: 16 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,22 @@ You can use this to conditionally render content with inline `and` and
9292

9393
```
9494

95+
### Fragments
96+
97+
Fragments allow you to wrap a group of nodes (not necessarily elements) so that
98+
they can be rendered without a wrapping element.
99+
100+
```pycon
101+
>>> from htpy import p, i, Fragment
102+
>>> content = Fragment("Hello ", None, i["world!"])
103+
>>> print(content)
104+
Hello <i>world!</i>
105+
106+
>>> print(p[content])
107+
<p>Hello <i>world!</i></p>
108+
109+
```
110+
95111
### Loops / Iterating Over Children
96112

97113
You can pass a list, tuple or generator to generate multiple children:
@@ -362,31 +378,6 @@ snippets as attributes:
362378

363379
```
364380

365-
## Render elements without a parent (orphans)
366-
367-
In some cases such as returning partial content it is useful to render elements
368-
without a parent element. This is useful in HTMX partial responses.
369-
370-
You may use `render_node` to achieve this:
371-
372-
```pycon title="Render elements without a parent"
373-
>>> from htpy import render_node, tr
374-
>>> print(render_node([tr["a"], tr["b"]]))
375-
<tr>a</tr><tr>b</tr>
376-
377-
```
378-
379-
`render_node()` accepts all kinds of [`Node`](static-typing.md#node) objects.
380-
You may use it to render anything that would normally be a children of another
381-
element.
382-
383-
!!! note "Best practice: Only use render_node() to render non-Elements"
384-
385-
You can render regular elements by using `str()`, e.g. `str(p["hi"])`. While
386-
`render_node()` would give the same result, it is more straightforward and
387-
better practice to just use `str()` when rendering a regular element. Only
388-
use `render_node()` when you do not have a parent element.
389-
390381
## Iterating of the Output
391382

392383
Iterating over a htpy element will yield the resulting contents in chunks as

htpy/__init__.py

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,9 @@ def _iter_node_context(x: Node, context_dict: dict[Context[t.Any], t.Any]) -> It
196196
f"requested by {x.debug_name}()."
197197
)
198198
yield from _iter_node_context(x.func(context_value), context_dict)
199+
elif isinstance(x, Fragment):
200+
for node in x._nodes: # pyright: ignore [reportPrivateUsage]
201+
yield from _iter_node_context(node, context_dict)
199202
elif isinstance(x, str | _HasHtml):
200203
yield str(_escape(x))
201204
elif isinstance(x, int):
@@ -344,13 +347,35 @@ def __repr__(self) -> str:
344347
return f"<{self.__class__.__name__} '<{self._name}{self._attrs}>'>"
345348

346349

350+
class Fragment:
351+
"""A collection of nodes without a wrapping element.
352+
353+
>>> content = Fragment("Hello ", None, i["world!"])
354+
>>> print(content)
355+
Hello <i>world!</i>
356+
"""
357+
358+
__slots__ = ("_nodes",)
359+
360+
def __init__(self, *nodes: Node) -> None:
361+
self._nodes = nodes
362+
363+
def __iter__(self) -> Iterator[str]:
364+
return iter_node(self)
365+
366+
def __str__(self) -> str:
367+
return render_node(self)
368+
369+
__html__ = __str__
370+
371+
347372
def render_node(node: Node) -> _Markup:
348373
return _Markup("".join(iter_node(node)))
349374

350375

351-
def comment(text: str) -> _Markup:
376+
def comment(text: str) -> Fragment:
352377
escaped_text = text.replace("--", "")
353-
return _Markup(f"<!-- {escaped_text} -->")
378+
return Fragment(_Markup(f"<!-- {escaped_text} -->"))
354379

355380

356381
@t.runtime_checkable
@@ -367,6 +392,7 @@ def __html__(self) -> str: ...
367392
| int
368393
| BaseElement
369394
| _HasHtml
395+
| Fragment
370396
| Iterable["Node"]
371397
| Callable[[], "Node"]
372398
| ContextProvider[t.Any]
@@ -507,6 +533,7 @@ def __html__(self) -> str: ...
507533
| ContextConsumer # pyright: ignore [reportMissingTypeArgument]
508534
| str
509535
| int
536+
| Fragment
510537
| _HasHtml
511538
| Callable
512539
| Iterable

tests/test_comment.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,7 @@ def test_escape_three_dashes(render: RenderFixture) -> None:
2222

2323
def test_escape_four_dashes(render: RenderFixture) -> None:
2424
assert render(div[comment("foo----bar")]) == ["<div>", "<!-- foobar -->", "</div>"]
25+
26+
27+
def test_str() -> None:
28+
assert str(comment("foo")) == "<!-- foo -->"

tests/test_context.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import markupsafe
66
import pytest
77

8-
from htpy import Context, Node, div
8+
from htpy import Context, Fragment, Node, div
99

1010
if t.TYPE_CHECKING:
1111
from .conftest import RenderFixture
@@ -107,3 +107,15 @@ def echo(value: str) -> str:
107107
result = div[ctx.provider("foo", [echo()])]
108108

109109
assert render(result) == ["<div>", "foo", "</div>"]
110+
111+
112+
def test_context_passed_via_fragment(render: RenderFixture) -> None:
113+
ctx: Context[str] = Context("ctx")
114+
115+
@ctx.consumer
116+
def echo(value: str) -> str:
117+
return value
118+
119+
result = div[ctx.provider("foo", Fragment(echo()))]
120+
121+
assert render(result) == ["<div>", "foo", "</div>"]

tests/test_fragment.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import markupsafe
2+
3+
from htpy import Fragment, i, p
4+
5+
from .conftest import RenderFixture
6+
7+
8+
def test_render_direct() -> None:
9+
assert str(Fragment("Hello ", None, i["World"])) == "Hello <i>World</i>"
10+
11+
12+
def test_render_as_child(render: RenderFixture) -> None:
13+
assert render(p["Say: ", Fragment("Hello ", None, i["World"]), "!"]) == [
14+
"<p>",
15+
"Say: ",
16+
"Hello ",
17+
"<i>",
18+
"World",
19+
"</i>",
20+
"!",
21+
"</p>",
22+
]
23+
24+
25+
def test_render_nested(render: RenderFixture) -> None:
26+
assert render(Fragment(Fragment("Hel", "lo "), "World")) == ["Hel", "lo ", "World"]
27+
28+
29+
def test_render_chunks(render: RenderFixture) -> None:
30+
assert render(Fragment("Hello ", None, i["World"])) == [
31+
"Hello ",
32+
"<i>",
33+
"World",
34+
"</i>",
35+
]
36+
37+
38+
def test_safe() -> None:
39+
assert markupsafe.escape(Fragment(i["hi"])) == "<i>hi</i>"
40+
41+
42+
def test_iter() -> None:
43+
assert list(Fragment("Hello ", None, i["World"])) == ["Hello ", "<i>", "World", "</i>"]

0 commit comments

Comments
 (0)