Skip to content
This repository was archived by the owner on Sep 15, 2025. It is now read-only.

Commit e7b1af5

Browse files
committed
Write a test for passing a component as an attribute.
1 parent 047af3d commit e7b1af5

File tree

6 files changed

+106
-10
lines changed

6 files changed

+106
-10
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,6 @@ wheels/
88

99
# Virtual environments
1010
.venv
11+
12+
# Coverage
13+
.coverage

html_tstring/parser.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ def close(self) -> None:
9595

9696
def get_node(self) -> Node:
9797
"""Get the Node tree parsed from the input HTML."""
98+
# CONSIDER: Should we invert things and offer streaming parsing?
9899
assert not self.stack, "Did you forget to call close()?"
99100
if len(self.root.children) > 1:
100101
# The parse structure results in multiple root elements, so we

html_tstring/processor.py

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,8 @@ def _instrument(
7777
"""
7878
count = len(strings)
7979

80+
print("Instrumenting:", strings, callable_ids) # DEBUG
81+
8082
callable_placeholders: dict[int, str] = {}
8183

8284
for i, s in enumerate(strings):
@@ -105,7 +107,9 @@ def _instrument_and_parse_internal(
105107
The result is cached to avoid re-parsing the same template multiple times.
106108
"""
107109
instrumented = _instrument(strings, callable_ids)
108-
return parse_html(instrumented)
110+
i_list = list(instrumented)
111+
print("Instrumented:", "".join(i_list)) # DEBUG
112+
return parse_html(i_list)
109113

110114

111115
def _callable_id(value: object) -> int | None:
@@ -185,7 +189,9 @@ def _substitute_style_attr(value: object) -> t.Iterable[tuple[str, str | None]]:
185189
yield ("style", str(value))
186190

187191

188-
def _substitute_spread_attrs(value: object) -> t.Iterable[tuple[str, str | None]]:
192+
def _substitute_spread_attrs(
193+
value: object,
194+
) -> t.Iterable[tuple[str, str | t.Callable | None]]:
189195
"""
190196
Substitute a spread attribute based on the interpolated value.
191197
@@ -212,7 +218,7 @@ def _substitute_spread_attrs(value: object) -> t.Iterable[tuple[str, str | None]
212218
def _substitute_attr(
213219
key: str,
214220
value: object,
215-
) -> t.Iterable[tuple[str, str | None]]:
221+
) -> t.Iterable[tuple[str, str | t.Callable | None]]:
216222
"""
217223
Substitute a single attribute based on its key and the interpolated value.
218224
@@ -234,15 +240,17 @@ def _substitute_attr(
234240
yield (key, None)
235241
case False | None:
236242
pass
243+
case _ if callable(value):
244+
yield (key, value)
237245
case _:
238246
yield (key, str(value))
239247

240248

241249
def _substitute_attrs(
242250
attrs: dict[str, str | None], interpolations: tuple[Interpolation, ...]
243-
) -> dict[str, str | None]:
251+
) -> dict[str, str | t.Callable | None]:
244252
"""Substitute placeholders in attributes based on the corresponding interpolations."""
245-
new_attrs: dict[str, str | None] = {}
253+
new_attrs: dict[str, str | ComponentCallable | None] = {}
246254
for key, value in attrs.items():
247255
if value and value.startswith(_PLACEHOLDER_PREFIX):
248256
index = _placholder_index(value)
@@ -297,27 +305,35 @@ def _node_from_value(value: object) -> Node:
297305
children = [_node_from_value(v) for v in value]
298306
return Fragment(children=children)
299307
case HasHTMLDunder():
308+
# CONSIDER: could we return a lazy Text?
300309
return Text(Markup(value.__html__()))
301310
case _:
311+
# CONSIDER: could we return a lazy Text?
302312
return Text(str(value))
303313

304314

315+
type ComponentReturn = Node | Template | str | HasHTMLDunder
316+
type ComponentCallable = t.Callable[..., ComponentReturn | t.Iterable[ComponentReturn]]
317+
318+
305319
def _invoke_component(
306320
tag: str,
307-
new_attrs: dict[str, str | None],
321+
new_attrs: dict[str, str | t.Callable | None],
308322
new_children: list[Node],
309323
interpolations: tuple[Interpolation, ...],
310324
) -> Node:
311325
"""Substitute a component invocation based on the corresponding interpolations."""
312326
index = _placholder_index(tag)
313327
interpolation = interpolations[index]
314328
value = format_interpolation(interpolation)
329+
# TODO: consider use of signature() or other approaches to validation.
315330
if not callable(value):
316331
raise TypeError(
317332
f"Expected a callable for component invocation, got {type(value).__name__}"
318333
)
319334
# Call the component and return the resulting node
320335
result = value(*new_children, **new_attrs)
336+
print("RESULTIS:", result) # DEBUG
321337
match result:
322338
case Node():
323339
return result
@@ -348,7 +364,10 @@ def _substitute_node(p_node: Node, interpolations: tuple[Interpolation, ...]) ->
348364
if tag.startswith(_PLACEHOLDER_PREFIX):
349365
return _invoke_component(tag, new_attrs, new_children, interpolations)
350366
else:
351-
return Element(tag=tag, attrs=new_attrs, children=new_children)
367+
final_attrs = {
368+
k: str(v) if v is not None else None for k, v in new_attrs.items()
369+
}
370+
return Element(tag=tag, attrs=final_attrs, children=new_children)
352371
case Fragment(children=children):
353372
new_children = _substitute_and_flatten_children(children, interpolations)
354373
return Fragment(children=new_children)
@@ -367,4 +386,3 @@ def html(template: Template) -> Node:
367386
# where interpolations go.
368387
p_node = _instrument_and_parse(template)
369388
return _substitute_node(p_node, template.interpolations)
370-
return _substitute_node(p_node, template.interpolations)

html_tstring/processor_test.py

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from markupsafe import Markup
66

77
from .nodes import Element, Fragment, Node, Text
8-
from .processor import html
8+
from .processor import ComponentCallable, html
99

1010
# --------------------------------------------------------------------------
1111
# Basic HTML parsing tests
@@ -476,7 +476,7 @@ def test_interpolated_style_attribute():
476476

477477

478478
def TemplateComponent(
479-
*children: Node, first: int, second: int, third: str, **attrs: t.Any
479+
*children: Node, first: str, second: int, third: str, **attrs: t.Any
480480
) -> Template:
481481
new_attrs = {
482482
"id": third,
@@ -532,3 +532,29 @@ def test_fragment_from_component():
532532
],
533533
)
534534
assert str(node) == "<table><tr><td>Column 1</td><td>Column 2</td></tr></table>"
535+
536+
537+
def test_component_passed_as_attr_value():
538+
def WrapperComponent(
539+
*children: Node, another: ComponentCallable, **attrs: t.Any
540+
) -> Template:
541+
print("WrapperComponent called with:", type(another), another)
542+
return t"<{another} {attrs}>{children}</{another}>"
543+
544+
node = html(
545+
t'<{WrapperComponent} another={TemplateComponent} class="wrapped" first=1 second={99} third="comp1"><p>Inside wrapper</p></{WrapperComponent}>'
546+
)
547+
assert node == Element(
548+
"div",
549+
attrs={
550+
"id": "comp1",
551+
"data-first": "1",
552+
"data-second": "99",
553+
"class": "wrapped",
554+
},
555+
children=[Text("Component: "), Element("p", children=[Text("Inside wrapper")])],
556+
)
557+
assert (
558+
str(node)
559+
== '<div id="comp1" data-first="1" data-second="99" class="wrapped">Component: <p>Inside wrapper</p></div>'
560+
)

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ CI = "https://github.com/t-strings/html-tstring/actions"
3434
dev = [
3535
"pyright>=1.1.404",
3636
"pytest>=8.4.1",
37+
"pytest-cov>=6.3.0",
3738
"pytest-watcher>=0.4.3",
3839
"ruff>=0.12.11",
3940
]

uv.lock

Lines changed: 47 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)