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

Commit 88b1d8b

Browse files
committed
Back to full test pass after the refactor.
1 parent 02a01ac commit 88b1d8b

File tree

2 files changed

+81
-36
lines changed

2 files changed

+81
-36
lines changed

html_tstring/processor.py

Lines changed: 80 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,9 @@ def _placholder_index(s: str) -> int:
5050
return int(s[_PP_LEN:])
5151

5252

53-
def _instrument(strings: tuple[str, ...]) -> t.Iterable[str]:
53+
def _instrument(
54+
strings: tuple[str, ...], callable_ids: tuple[int | None, ...]
55+
) -> t.Iterable[str]:
5456
"""
5557
Join the strings with placeholders in between where interpolations go.
5658
@@ -62,27 +64,62 @@ def _instrument(strings: tuple[str, ...]) -> t.Iterable[str]:
6264
"""
6365
count = len(strings)
6466

65-
# TODO: special case callables() so that we use the same placeholder
66-
# to open *and* close tags.
67+
callable_placeholders: dict[int, str] = {}
6768

6869
for i, s in enumerate(strings):
6970
yield s
7071
# There are always count-1 placeholders between count strings.
7172
if i < count - 1:
72-
yield _placeholder(i)
73+
# Special case for component callables: if the interpolation
74+
# is a callable, we need to make sure that any matching closing
75+
# tag uses the same placeholder.
76+
callable_id = callable_ids[i]
77+
if callable_id is not None:
78+
# This interpolation is a callable, so we need to make sure
79+
# that any matching closing tag uses the same placeholder.
80+
if callable_id not in callable_placeholders:
81+
callable_placeholders[callable_id] = _placeholder(i)
82+
yield callable_placeholders[callable_id]
83+
else:
84+
yield _placeholder(i)
7385

7486

7587
@lru_cache()
76-
def _instrument_and_parse(strings: tuple[str, ...]) -> Node:
88+
def _instrument_and_parse_internal(
89+
strings: tuple[str, ...], callable_ids: tuple[int | None, ...]
90+
) -> Node:
7791
"""
7892
Instrument the strings and parse the resulting HTML.
7993
8094
The result is cached to avoid re-parsing the same template multiple times.
8195
"""
82-
instrumented = _instrument(strings)
96+
instrumented = _instrument(strings, callable_ids)
8397
return parse_html_iter(instrumented)
8498

8599

100+
def _callable_id(value: object) -> int | None:
101+
"""Return a unique identifier for a callable, or None if not callable."""
102+
return id(value) if callable(value) else None
103+
104+
105+
def _instrument_and_parse(template: Template) -> Node:
106+
"""Instrument and parse a template, returning a tree of Nodes."""
107+
# This is a thin wrapper around the cached internal function that does the
108+
# actual work. This exists to handle the syntax we've settled on for
109+
# component invocation, namely that callables are directly included as
110+
# interpolations both in the open *and* the close tags. We need to make
111+
# sure that matching tags... match!
112+
#
113+
# If we used `tdom`'s approach of component closing tags of <//> then we
114+
# wouldn't have to do this. But I worry that tdom's syntax is harder to read
115+
# (it's easy to miss the closing tag) and may prove unfamiliar for
116+
# users coming from other templating systems.
117+
callable_ids = tuple(
118+
_callable_id(interpolation.value) for interpolation in template.interpolations
119+
)
120+
return _instrument_and_parse_internal(template.strings, callable_ids)
121+
122+
86123
# --------------------------------------------------------------------------
87124
# Placeholder Substitution
88125
# --------------------------------------------------------------------------
@@ -181,38 +218,13 @@ def _substitute_attr(
181218
# General handling for all other attributes:
182219
match value:
183220
case str():
184-
yield (key, str(value))
221+
yield (key, value)
185222
case True:
186223
yield (key, None)
187224
case False | None:
188225
pass
189-
case dict() as d:
190-
for sub_k, sub_v in d.items():
191-
if sub_v is True:
192-
yield sub_k, None
193-
elif sub_v not in (False, None):
194-
yield sub_k, str(sub_v)
195-
case Iterable() as it:
196-
for item in it:
197-
match item:
198-
case tuple() if len(item) == 2:
199-
sub_k, sub_v = item
200-
if sub_v is True:
201-
yield sub_k, None
202-
elif sub_v not in (False, None):
203-
yield sub_k, str(sub_v)
204-
case str() | Markup():
205-
yield str(item), None
206-
case _:
207-
raise TypeError(
208-
f"Cannot use {type(item).__name__} as attribute "
209-
f"key-value pair in iterable for attribute '{key}'"
210-
)
211226
case _:
212-
raise TypeError(
213-
f"Cannot use {type(value).__name__} as attribute value for "
214-
f"attribute '{key}'"
215-
)
227+
yield (key, str(value))
216228

217229

218230
def _substitute_attrs(
@@ -279,6 +291,36 @@ def _node_from_value(value: object) -> Node:
279291
return Text(str(value))
280292

281293

294+
def _invoke_component(
295+
tag: str,
296+
new_attrs: dict[str, str | None],
297+
new_children: list[Node],
298+
interpolations: tuple[Interpolation, ...],
299+
) -> Node:
300+
"""Substitute a component invocation based on the corresponding interpolations."""
301+
index = _placholder_index(tag)
302+
interpolation = interpolations[index]
303+
value = format_interpolation(interpolation)
304+
if not callable(value):
305+
raise TypeError(
306+
f"Expected a callable for component invocation, got {type(value).__name__}"
307+
)
308+
# Call the component and return the resulting node
309+
result = value(*new_children, **new_attrs)
310+
match result:
311+
case Node():
312+
return result
313+
case Template():
314+
return html(result)
315+
case HasHTMLDunder() | str():
316+
return Text(result)
317+
case _:
318+
raise TypeError(
319+
f"Component callable must return a Node, Template, str, or "
320+
f"HasHTMLDunder, got {type(result).__name__}"
321+
)
322+
323+
282324
def _substitute_node(p_node: Node, interpolations: tuple[Interpolation, ...]) -> Node:
283325
"""Substitute placeholders in a node based on the corresponding interpolations."""
284326
match p_node:
@@ -290,7 +332,10 @@ def _substitute_node(p_node: Node, interpolations: tuple[Interpolation, ...]) ->
290332
case Element(tag=tag, attrs=attrs, children=children):
291333
new_attrs = _substitute_attrs(attrs, interpolations)
292334
new_children = _substitute_and_flatten_children(children, interpolations)
293-
return Element(tag=tag, attrs=new_attrs, children=new_children)
335+
if tag.startswith(_PLACEHOLDER_PREFIX):
336+
return _invoke_component(tag, new_attrs, new_children, interpolations)
337+
else:
338+
return Element(tag=tag, attrs=new_attrs, children=new_children)
294339
case Fragment(children=children):
295340
new_children = _substitute_and_flatten_children(children, interpolations)
296341
return Fragment(children=new_children)
@@ -307,5 +352,5 @@ def html(template: Template) -> Node:
307352
"""Parse a t-string and return a tree of Nodes."""
308353
# Parse the HTML, returning a tree of nodes with placeholders
309354
# where interpolations go.
310-
p_node = _instrument_and_parse(template.strings)
355+
p_node = _instrument_and_parse(template)
311356
return _substitute_node(p_node, template.interpolations)

html_tstring/processor_test.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -456,7 +456,7 @@ def test_interpolated_style_attribute():
456456

457457

458458
def TemplateComponent(
459-
*children: Element | str, first: int, second: int, third: str, **props: str
459+
*children: Element | str, first: int, second: int, third: str, **props: str | None
460460
) -> Template:
461461
attrs = {
462462
"id": third,

0 commit comments

Comments
 (0)