Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions docs/changelog.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
# Changelog

## NEXT
- Split source code into multiple private modules.
See [PR #119](https://github.com/pelme/htpy/pull/119).
Thanks to [Stein Magnus Jodal (@jodal)](https://github.com/jodal).
- Add `@with_children` decorator to help creating custom components that get passed children in the same way as regular HTML elements.
See [PR #113](https://github.com/pelme/htpy/pull/113).
[Read the docs for more details](common-patterns.md#components-with-children).
Thanks to [Stein Magnus Jodal (@jodal)](https://github.com/jodal).

## 25.5.0 - 2025-05-25
- Allow multiple attribute dictionaries when defining `Elements`. [PR #117](https://github.com/pelme/htpy/pull/117). Thanks to [Chase Sterling @gazpachoking](https://github.com/gazpachoking).

Expand Down
63 changes: 63 additions & 0 deletions docs/common-patterns.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,3 +160,66 @@ print(
)
)
```

### Components with children

When building their own set of components, some prefer to make their
components accept children nodes in the same way as the HTML elements provided
by htpy.

Making this work correctly in all cases can be tricky, so htpy provides a
decorator called `@with_children`.

With the `@with_children` decorator you can convert a component like this:

```py
from htpy import Node, Renderable

def my_component(*, title: str, children: Node) -> Renderable:
...
```

That is used like this:

```py
my_component(title="My title", children=h.div["My content"])
```

Into a component that is defined like this:

```py
from htpy import Node, Renderable, with_children

@with_children
def my_component(children: Node, *, title: str) -> Renderable:
...
```

And that is used like this, just like any HTML element:

```py
my_component(title="My title")[h.div["My content"]]
```

You can combine `@with_children` with other decorators, like context
consumers, that also pass extra arguments to the function, but you must make
sure that decorators and arguments are in the right order.

As the innermost decorator is the first to wrap the function, it maps to the
first argument. With multiple decorators, the source code order of the
decorators and arguments are the opposite of each other.

```py
from typing import Literal

from htpy import Context, Node, Renderable, div, h1, with_children

Theme = Literal["light", "dark"]

theme_context: Context[Theme] = Context("theme", default="light")

@with_children
@theme_context.consumer
def my_component(theme: Theme, children: Node, *, extra: str) -> Renderable:
...
```
1 change: 1 addition & 0 deletions htpy/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from htpy._types import Attribute as Attribute
from htpy._types import Node as Node
from htpy._types import Renderable as Renderable
from htpy._with_children import with_children as with_children
Comment thread
pelme marked this conversation as resolved.

__version__ = "25.5.0"
__all__: list[str] = []
Expand Down
142 changes: 142 additions & 0 deletions htpy/_with_children.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
from __future__ import annotations

import functools
import typing as t

from markupsafe import Markup as _Markup

if t.TYPE_CHECKING:
from collections.abc import Callable, Iterator, Mapping

import htpy


C = t.TypeVar("C", bound="htpy.Node")
P = t.ParamSpec("P")
R = t.TypeVar("R", bound="htpy.Renderable")


class _WithChildrenUnbound(t.Generic[C, P, R]):
"""Decorator to make a component support children nodes.

This decorator is used to create a component that can accept children nodes,
just like native htpy components.

It lets you convert this:

```python
def my_component(*, title: str, children: h.Node) -> h.Element:
...

my_component(title="My title", children=h.div["My content"])
```

To this:

```python
@h.with_children
def my_component(children: h.Node, *, title: str) -> h.Element:
...

my_component(title="My title")[h.div["My content"]]
```
"""

wrapped: Callable[t.Concatenate[C | None, P], R]

def __init__(self, func: Callable[t.Concatenate[C | None, P], R]) -> None:
# This instance is created at import time when decorating the component.
# It means that this object is global, and shared between all renderings
# of the same component.
self.wrapped = func
functools.update_wrapper(self, func)

def __repr__(self) -> str:
return f"with_children({self.wrapped.__name__}, <unbound>)"

def __call__(self, *args: P.args, **kwargs: P.kwargs) -> _WithChildrenBound[C, P, R]:
# This is the first call to the component, where we get the
# component's args and kwargs:
#
# my_component(title="My title")
#
# It is important that we return a new instance bound to the args
# and kwargs instead of mutating, so that state doesn't leak between
# multiple renderings of the same component.
#
return _WithChildrenBound(self.wrapped, args, kwargs)

def __getitem__(self, children: C | None) -> R:
# This is the unbound component being used with children:
#
# my_component["My content"]
#
return self.wrapped(children) # type: ignore[call-arg]

def __str__(self) -> _Markup:
# This is the unbound component being rendered to a string:
#
# str(my_component)
#
return _Markup(self.wrapped(None)) # type: ignore[call-arg]

__html__ = __str__

def encode(self, encoding: str = "utf-8", errors: str = "strict") -> bytes:
return str(self).encode(encoding, errors)

def iter_chunks(
self,
context: Mapping[htpy.Context[t.Any], t.Any] | None = None,
) -> Iterator[str]:
return self.wrapped(None).iter_chunks(context) # type: ignore[call-arg]


class _WithChildrenBound(t.Generic[C, P, R]):
_func: Callable[t.Concatenate[C | None, P], R]
_args: tuple[t.Any, ...]
_kwargs: Mapping[str, t.Any]

def __init__(
self,
func: Callable[t.Concatenate[C | None, P], R],
args: tuple[t.Any, ...],
kwargs: Mapping[str, t.Any],
) -> None:
# This is called at runtime when the component is being passed args and
# kwargs. This instance is only used for the current rendering of the
# component.
self._func = func
self._args = args
self._kwargs = kwargs

def __repr__(self) -> str:
return f"with_children({self._func.__name__}, {self._args}, {self._kwargs})"

def __getitem__(self, children: C | None) -> R:
# This is a bound component being used with children:
#
# my_component(title="My title")["My content"]
#
return self._func(children, *self._args, **self._kwargs)

def __str__(self) -> _Markup:
# This is a bound component being rendered to a string:
#
# str(my_component(title="My title"))
#
return _Markup(self._func(None, *self._args, **self._kwargs))

__html__ = __str__

def encode(self, encoding: str = "utf-8", errors: str = "strict") -> bytes:
return str(self).encode(encoding, errors)

def iter_chunks(
self,
context: Mapping[htpy.Context[t.Any], t.Any] | None = None,
) -> Iterator[str]:
return self._func(None, *self._args, **self._kwargs).iter_chunks(context)


with_children = _WithChildrenUnbound
28 changes: 28 additions & 0 deletions tests/test_render.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,18 @@ def example_consumer(value: str) -> str:
return value


@h.with_children
def example_with_children(
content: h.Node,
*,
title: str = "default!",
) -> h.Element:
return h.div[
h.h1[title],
h.p[content],
]


@dataclass(frozen=True)
class RenderableTestCase:
renderable: h.Renderable
Expand All @@ -33,6 +45,22 @@ def expected_bytes(self) -> bytes:
RenderableTestCase(h.fragment["fragment!"], ["fragment!"]),
# comment() is a Fragment but test it anyways for completeness
RenderableTestCase(h.comment("comment!"), ["<!-- comment! -->"]),
RenderableTestCase(
example_with_children,
["<div>", "<h1>", "default!", "</h1>", "<p>", "</p>", "</div>"],
),
RenderableTestCase(
example_with_children["children!"],
["<div>", "<h1>", "default!", "</h1>", "<p>", "children!", "</p>", "</div>"],
),
RenderableTestCase(
example_with_children(title="title!"),
["<div>", "<h1>", "title!", "</h1>", "<p>", "</p>", "</div>"],
),
RenderableTestCase(
example_with_children(title="title!")["children!"],
["<div>", "<h1>", "title!", "</h1>", "<p>", "children!", "</p>", "</div>"],
),
]


Expand Down
34 changes: 34 additions & 0 deletions tests/test_with_children.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
from __future__ import annotations

import pytest

import htpy as h


@h.with_children
def example_with_children(
content: h.Node,
*,
title: str = "default!",
) -> h.Element:
return h.div[
h.h1[title],
h.p[content],
]


@pytest.mark.parametrize(
("component", "expected"),
[
(
example_with_children,
"with_children(example_with_children, <unbound>)",
),
(
example_with_children(title="title!"),
"with_children(example_with_children, (), {'title': 'title!'})",
),
],
)
def test_with_children_repr(component: h.Renderable, expected: str) -> None:
assert repr(component) == expected