Coverage for tdom / processor_test.py: 99%
612 statements
« prev ^ index » next coverage.py v7.13.0, created at 2026-01-12 16:43 +0000
« prev ^ index » next coverage.py v7.13.0, created at 2026-01-12 16:43 +0000
1import datetime
2import typing as t
3from dataclasses import dataclass, field
4from string.templatelib import Interpolation, Template
5from itertools import product
7import pytest
8from markupsafe import Markup
10from .nodes import Comment, DocumentType, Element, Fragment, Node, Text
11from .placeholders import make_placeholder_config
12from .processor import html
14# --------------------------------------------------------------------------
15# Basic HTML parsing tests
16# --------------------------------------------------------------------------
19#
20# Text
21#
22def test_empty():
23 node = html(t"")
24 assert node == Fragment(children=[])
25 assert str(node) == ""
28def test_text_literal():
29 node = html(t"Hello, world!")
30 assert node == Text("Hello, world!")
31 assert str(node) == "Hello, world!"
34def test_text_singleton():
35 greeting = "Hello, Alice!"
36 node = html(t"{greeting}")
37 assert node == Text("Hello, Alice!")
38 assert str(node) == "Hello, Alice!"
41def test_text_template():
42 name = "Alice"
43 node = html(t"Hello, {name}!")
44 assert node == Fragment(children=[Text("Hello, "), Text("Alice"), Text("!")])
45 assert str(node) == "Hello, Alice!"
48def test_text_template_escaping():
49 name = "Alice & Bob"
50 node = html(t"Hello, {name}!")
51 assert node == Fragment(children=[Text("Hello, "), Text("Alice & Bob"), Text("!")])
52 assert str(node) == "Hello, Alice & Bob!"
55#
56# Comments.
57#
58def test_comment():
59 node = html(t"<!--This is a comment-->")
60 assert node == Comment("This is a comment")
61 assert str(node) == "<!--This is a comment-->"
64def test_comment_template():
65 text = "comment"
66 node = html(t"<!--This is a {text}-->")
67 assert node == Comment("This is a comment")
68 assert str(node) == "<!--This is a comment-->"
71def test_comment_template_escaping():
72 text = "-->comment"
73 node = html(t"<!--This is a {text}-->")
74 assert node == Comment("This is a -->comment")
75 assert str(node) == "<!--This is a -->comment-->"
78#
79# Document types.
80#
81def test_parse_document_type():
82 node = html(t"<!doctype html>")
83 assert node == DocumentType("html")
84 assert str(node) == "<!DOCTYPE html>"
87#
88# Elements
89#
90def test_parse_void_element():
91 node = html(t"<br>")
92 assert node == Element("br")
93 assert str(node) == "<br />"
96def test_parse_void_element_self_closed():
97 node = html(t"<br />")
98 assert node == Element("br")
99 assert str(node) == "<br />"
102def test_parse_chain_of_void_elements():
103 # Make sure our handling of CPython issue #69445 is reasonable.
104 node = html(t"<br><hr><img src='image.png' /><br /><hr>")
105 assert node == Fragment(
106 children=[
107 Element("br"),
108 Element("hr"),
109 Element("img", attrs={"src": "image.png"}),
110 Element("br"),
111 Element("hr"),
112 ],
113 )
114 assert str(node) == '<br /><hr /><img src="image.png" /><br /><hr />'
117def test_parse_element_with_text():
118 node = html(t"<p>Hello, world!</p>")
119 assert node == Element(
120 "p",
121 children=[
122 Text("Hello, world!"),
123 ],
124 )
125 assert str(node) == "<p>Hello, world!</p>"
128def test_parse_nested_elements():
129 node = html(t"<div><p>Hello</p><p>World</p></div>")
130 assert node == Element(
131 "div",
132 children=[
133 Element("p", children=[Text("Hello")]),
134 Element("p", children=[Text("World")]),
135 ],
136 )
137 assert str(node) == "<div><p>Hello</p><p>World</p></div>"
140def test_parse_entities_are_escaped():
141 node = html(t"<p></p></p>")
142 assert node == Element(
143 "p",
144 children=[Text("</p>")],
145 )
146 assert str(node) == "<p></p></p>"
149# --------------------------------------------------------------------------
150# Interpolated text content
151# --------------------------------------------------------------------------
154def test_interpolated_text_content():
155 name = "Alice"
156 node = html(t"<p>Hello, {name}!</p>")
157 assert node == Element("p", children=[Text("Hello, "), Text("Alice"), Text("!")])
158 assert str(node) == "<p>Hello, Alice!</p>"
161def test_escaping_of_interpolated_text_content():
162 name = "<Alice & Bob>"
163 node = html(t"<p>Hello, {name}!</p>")
164 assert node == Element(
165 "p", children=[Text("Hello, "), Text("<Alice & Bob>"), Text("!")]
166 )
167 assert str(node) == "<p>Hello, <Alice & Bob>!</p>"
170class Convertible:
171 def __str__(self):
172 return "string"
174 def __repr__(self):
175 return "repr"
178def test_conversions():
179 c = Convertible()
180 assert f"{c!s}" == "string"
181 assert f"{c!r}" == "repr"
182 node = html(t"<li>{c!s}</li><li>{c!r}</li><li>{'😊'!a}</li>")
183 assert node == Fragment(
184 children=[
185 Element("li", children=[Text("string")]),
186 Element("li", children=[Text("repr")]),
187 Element("li", children=[Text("'\\U0001f60a'")]),
188 ],
189 )
192def test_interpolated_in_content_node():
193 # https://github.com/t-strings/tdom/issues/68
194 evil = "</style><script>alert('whoops');</script><style>"
195 node = html(t"<style>{evil}{evil}</style>")
196 assert node == Element(
197 "style",
198 children=[
199 Text("</style><script>alert('whoops');</script><style>"),
200 Text("</style><script>alert('whoops');</script><style>"),
201 ],
202 )
203 LT = "<"
204 assert (
205 str(node)
206 == f"<style>{LT}/style><script>alert('whoops');</script><style>{LT}/style><script>alert('whoops');</script><style></style>"
207 )
210def test_interpolated_trusted_in_content_node():
211 # https://github.com/t-strings/tdom/issues/68
212 node = html(t"<script>if (a < b && c > d) { alert('wow'); } </script>")
213 assert node == Element(
214 "script",
215 children=[Text("if (a < b && c > d) { alert('wow'); }")],
216 )
217 assert str(node) == ("<script>if (a < b && c > d) { alert('wow'); }</script>")
220def test_script_elements_error():
221 nested_template = t"<div></div>"
222 # Putting non-text content inside a script is not allowed.
223 with pytest.raises(ValueError):
224 node = html(t"<script>{nested_template}</script>")
225 _ = str(node)
228# --------------------------------------------------------------------------
229# Interpolated non-text content
230# --------------------------------------------------------------------------
233def test_interpolated_false_content():
234 node = html(t"<div>{False}</div>")
235 assert node == Element("div")
236 assert str(node) == "<div></div>"
239def test_interpolated_none_content():
240 node = html(t"<div>{None}</div>")
241 assert node == Element("div", children=[])
242 assert str(node) == "<div></div>"
245def test_interpolated_zero_arg_function():
246 def get_value():
247 return "dynamic"
249 node = html(t"<p>The value is {get_value}.</p>")
250 assert node == Element(
251 "p", children=[Text("The value is "), Text("dynamic"), Text(".")]
252 )
255def test_interpolated_multi_arg_function_fails():
256 def add(a, b): # pragma: no cover
257 return a + b
259 with pytest.raises(TypeError):
260 _ = html(t"<p>The sum is {add}.</p>")
263# --------------------------------------------------------------------------
264# Raw HTML injection tests
265# --------------------------------------------------------------------------
268def test_raw_html_injection_with_markupsafe():
269 raw_content = Markup("<strong>I am bold</strong>")
270 node = html(t"<div>{raw_content}</div>")
271 assert node == Element("div", children=[Text(text=raw_content)])
272 assert str(node) == "<div><strong>I am bold</strong></div>"
275def test_raw_html_injection_with_dunder_html_protocol():
276 class SafeContent:
277 def __init__(self, text):
278 self._text = text
280 def __html__(self):
281 # In a real app, this would come from a sanitizer or trusted source
282 return f"<em>{self._text}</em>"
284 content = SafeContent("emphasized")
285 node = html(t"<p>Here is some {content}.</p>")
286 assert node == Element(
287 "p",
288 children=[
289 Text("Here is some "),
290 Text(Markup("<em>emphasized</em>")),
291 Text("."),
292 ],
293 )
294 assert str(node) == "<p>Here is some <em>emphasized</em>.</p>"
297def test_raw_html_injection_with_format_spec():
298 raw_content = "<u>underlined</u>"
299 node = html(t"<p>This is {raw_content:safe} text.</p>")
300 assert node == Element(
301 "p",
302 children=[
303 Text("This is "),
304 Text(Markup(raw_content)),
305 Text(" text."),
306 ],
307 )
308 assert str(node) == "<p>This is <u>underlined</u> text.</p>"
311def test_raw_html_injection_with_markupsafe_unsafe_format_spec():
312 supposedly_safe = Markup("<i>italic</i>")
313 node = html(t"<p>This is {supposedly_safe:unsafe} text.</p>")
314 assert node == Element(
315 "p",
316 children=[
317 Text("This is "),
318 Text(str(supposedly_safe)),
319 Text(" text."),
320 ],
321 )
322 assert str(node) == "<p>This is <i>italic</i> text.</p>"
325# --------------------------------------------------------------------------
326# Conditional rendering and control flow
327# --------------------------------------------------------------------------
330def test_conditional_rendering_with_if_else():
331 is_logged_in = True
332 user_profile = t"<span>Welcome, User!</span>"
333 login_prompt = t"<a href='/login'>Please log in</a>"
334 node = html(t"<div>{user_profile if is_logged_in else login_prompt}</div>")
336 assert node == Element(
337 "div", children=[Element("span", children=[Text("Welcome, User!")])]
338 )
339 assert str(node) == "<div><span>Welcome, User!</span></div>"
341 is_logged_in = False
342 node = html(t"<div>{user_profile if is_logged_in else login_prompt}</div>")
343 assert str(node) == '<div><a href="/login">Please log in</a></div>'
346def test_conditional_rendering_with_and():
347 show_warning = True
348 warning_message = t'<div class="warning">Warning!</div>'
349 node = html(t"<main>{show_warning and warning_message}</main>")
351 assert node == Element(
352 "main",
353 children=[
354 Element("div", attrs={"class": "warning"}, children=[Text("Warning!")]),
355 ],
356 )
357 assert str(node) == '<main><div class="warning">Warning!</div></main>'
359 show_warning = False
360 node = html(t"<main>{show_warning and warning_message}</main>")
361 # Assuming False renders nothing
362 assert str(node) == "<main></main>"
365# --------------------------------------------------------------------------
366# Interpolated nesting of templates and elements
367# --------------------------------------------------------------------------
370def test_interpolated_template_content():
371 child = t"<span>Child</span>"
372 node = html(t"<div>{child}</div>")
373 assert node == Element("div", children=[html(child)])
374 assert str(node) == "<div><span>Child</span></div>"
377def test_interpolated_element_content():
378 child = html(t"<span>Child</span>")
379 node = html(t"<div>{child}</div>")
380 assert node == Element("div", children=[child])
381 assert str(node) == "<div><span>Child</span></div>"
384def test_interpolated_nonstring_content():
385 number = 42
386 node = html(t"<p>The answer is {number}.</p>")
387 assert node == Element(
388 "p", children=[Text("The answer is "), Text("42"), Text(".")]
389 )
390 assert str(node) == "<p>The answer is 42.</p>"
393def test_list_items():
394 items = ["Apple", "Banana", "Cherry"]
395 node = html(t"<ul>{[t'<li>{item}</li>' for item in items]}</ul>")
396 assert node == Element(
397 "ul",
398 children=[
399 Element("li", children=[Text("Apple")]),
400 Element("li", children=[Text("Banana")]),
401 Element("li", children=[Text("Cherry")]),
402 ],
403 )
404 assert str(node) == "<ul><li>Apple</li><li>Banana</li><li>Cherry</li></ul>"
407def test_nested_list_items():
408 # TODO XXX this is a pretty abusrd test case; clean it up when refactoring
409 outer = ["fruit", "more fruit"]
410 inner = ["apple", "banana", "cherry"]
411 inner_items = [t"<li>{item}</li>" for item in inner]
412 outer_items = [t"<li>{category}<ul>{inner_items}</ul></li>" for category in outer]
413 node = html(t"<ul>{outer_items}</ul>")
414 assert node == Element(
415 "ul",
416 children=[
417 Element(
418 "li",
419 children=[
420 Text("fruit"),
421 Element(
422 "ul",
423 children=[
424 Element("li", children=[Text("apple")]),
425 Element("li", children=[Text("banana")]),
426 Element("li", children=[Text("cherry")]),
427 ],
428 ),
429 ],
430 ),
431 Element(
432 "li",
433 children=[
434 Text("more fruit"),
435 Element(
436 "ul",
437 children=[
438 Element("li", children=[Text("apple")]),
439 Element("li", children=[Text("banana")]),
440 Element("li", children=[Text("cherry")]),
441 ],
442 ),
443 ],
444 ),
445 ],
446 )
447 assert (
448 str(node)
449 == "<ul><li>fruit<ul><li>apple</li><li>banana</li><li>cherry</li></ul></li><li>more fruit<ul><li>apple</li><li>banana</li><li>cherry</li></ul></li></ul>"
450 )
453# --------------------------------------------------------------------------
454# Attributes
455# --------------------------------------------------------------------------
458def test_literal_attrs():
459 node = html(
460 (
461 t"<a "
462 t" id=example_link" # no quotes allowed without spaces
463 t" autofocus" # bare / boolean
464 t' title=""' # empty attribute
465 t' href="https://example.com" target="_blank"'
466 t"></a>"
467 )
468 )
469 assert node == Element(
470 "a",
471 attrs={
472 "id": "example_link",
473 "autofocus": None,
474 "title": "",
475 "href": "https://example.com",
476 "target": "_blank",
477 },
478 )
479 assert (
480 str(node)
481 == '<a id="example_link" autofocus title="" href="https://example.com" target="_blank"></a>'
482 )
485def test_literal_attr_escaped():
486 node = html(t'<a title="<"></a>')
487 assert node == Element(
488 "a",
489 attrs={"title": "<"},
490 )
491 assert str(node) == '<a title="<"></a>'
494def test_interpolated_attr():
495 url = "https://example.com/"
496 node = html(t'<a href="{url}"></a>')
497 assert node == Element("a", attrs={"href": "https://example.com/"})
498 assert str(node) == '<a href="https://example.com/"></a>'
501def test_interpolated_attr_escaped():
502 url = 'https://example.com/?q="test"&lang=en'
503 node = html(t'<a href="{url}"></a>')
504 assert node == Element(
505 "a",
506 attrs={"href": 'https://example.com/?q="test"&lang=en'},
507 )
508 assert (
509 str(node) == '<a href="https://example.com/?q="test"&lang=en"></a>'
510 )
513def test_interpolated_attr_unquoted():
514 id = "roquefort"
515 node = html(t"<div id={id}></div>")
516 assert node == Element("div", attrs={"id": "roquefort"})
517 assert str(node) == '<div id="roquefort"></div>'
520def test_interpolated_attr_true():
521 disabled = True
522 node = html(t"<button disabled={disabled}></button>")
523 assert node == Element("button", attrs={"disabled": None})
524 assert str(node) == "<button disabled></button>"
527def test_interpolated_attr_false():
528 disabled = False
529 node = html(t"<button disabled={disabled}></button>")
530 assert node == Element("button")
531 assert str(node) == "<button></button>"
534def test_interpolated_attr_none():
535 disabled = None
536 node = html(t"<button disabled={disabled}></button>")
537 assert node == Element("button")
538 assert str(node) == "<button></button>"
541def test_interpolate_attr_empty_string():
542 node = html(t'<div title=""></div>')
543 assert node == Element(
544 "div",
545 attrs={"title": ""},
546 )
547 assert str(node) == '<div title=""></div>'
550def test_spread_attr():
551 attrs = {"href": "https://example.com/", "target": "_blank"}
552 node = html(t"<a {attrs}></a>")
553 assert node == Element(
554 "a",
555 attrs={"href": "https://example.com/", "target": "_blank"},
556 )
557 assert str(node) == '<a href="https://example.com/" target="_blank"></a>'
560def test_spread_attr_none():
561 attrs = None
562 node = html(t"<a {attrs}></a>")
563 assert node == Element("a")
564 assert str(node) == "<a></a>"
567def test_spread_attr_type_errors():
568 for attrs in (0, [], (), False, True):
569 with pytest.raises(TypeError):
570 _ = html(t"<a {attrs}></a>")
573def test_templated_attr_mixed_interpolations_start_end_and_nest():
574 left, middle, right = 1, 3, 5
575 prefix, suffix = t'<div data-range="', t'"></div>'
576 # Check interpolations at start, middle and/or end of templated attr
577 # or a combination of those to make sure text is not getting dropped.
578 for left_part, middle_part, right_part in product(
579 (t"{left}", Template(str(left))),
580 (t"{middle}", Template(str(middle))),
581 (t"{right}", Template(str(right))),
582 ):
583 test_t = prefix + left_part + t"-" + middle_part + t"-" + right_part + suffix
584 node = html(test_t)
585 assert node == Element(
586 "div",
587 attrs={"data-range": "1-3-5"},
588 )
589 assert str(node) == '<div data-range="1-3-5"></div>'
592def test_templated_attr_no_quotes():
593 start = 1
594 end = 5
595 node = html(t"<div data-range={start}-{end}></div>")
596 assert node == Element(
597 "div",
598 attrs={"data-range": "1-5"},
599 )
600 assert str(node) == '<div data-range="1-5"></div>'
603def test_attr_merge_disjoint_interpolated_attr_spread_attr():
604 attrs = {"href": "https://example.com/", "id": "link1"}
605 target = "_blank"
606 node = html(t"<a {attrs} target={target}></a>")
607 assert node == Element(
608 "a",
609 attrs={"href": "https://example.com/", "id": "link1", "target": "_blank"},
610 )
611 assert str(node) == '<a href="https://example.com/" id="link1" target="_blank"></a>'
614def test_attr_merge_overlapping_spread_attrs():
615 attrs1 = {"href": "https://example.com/", "id": "overwrtten"}
616 attrs2 = {"target": "_blank", "id": "link1"}
617 node = html(t"<a {attrs1} {attrs2}></a>")
618 assert node == Element(
619 "a",
620 attrs={"href": "https://example.com/", "target": "_blank", "id": "link1"},
621 )
622 assert str(node) == '<a href="https://example.com/" target="_blank" id="link1"></a>'
625def test_attr_merge_replace_literal_attr_str_str():
626 node = html(t'<div title="default" {dict(title="fresh")}></div>')
627 assert node == Element("div", {"title": "fresh"})
628 assert str(node) == '<div title="fresh"></div>'
631def test_attr_merge_replace_literal_attr_str_true():
632 node = html(t'<div title="default" {dict(title=True)}></div>')
633 assert node == Element("div", {"title": None})
634 assert str(node) == "<div title></div>"
637def test_attr_merge_replace_literal_attr_true_str():
638 node = html(t"<div title {dict(title='fresh')}></div>")
639 assert node == Element("div", {"title": "fresh"})
640 assert str(node) == '<div title="fresh"></div>'
643def test_attr_merge_remove_literal_attr_str_none():
644 node = html(t'<div title="default" {dict(title=None)}></div>')
645 assert node == Element("div")
646 assert str(node) == "<div></div>"
649def test_attr_merge_remove_literal_attr_true_none():
650 node = html(t"<div title {dict(title=None)}></div>")
651 assert node == Element("div")
652 assert str(node) == "<div></div>"
655def test_attr_merge_other_literal_attr_intact():
656 node = html(t'<img title="default" {dict(alt="fresh")}>')
657 assert node == Element("img", {"title": "default", "alt": "fresh"})
658 assert str(node) == '<img title="default" alt="fresh" />'
661def test_placeholder_collision_avoidance():
662 config = make_placeholder_config()
663 # This test is to ensure that our placeholder detection avoids collisions
664 # even with content that might look like a placeholder.
665 tricky = "0"
666 template = Template(
667 f'<div data-tricky="{config.prefix}',
668 Interpolation(tricky, "tricky", None, ""),
669 f'{config.suffix}"></div>',
670 )
671 node = html(template)
672 assert node == Element(
673 "div",
674 attrs={"data-tricky": config.prefix + tricky + config.suffix},
675 children=[],
676 )
677 assert (
678 str(node) == f'<div data-tricky="{config.prefix}{tricky}{config.suffix}"></div>'
679 )
682#
683# Special data attribute handling.
684#
685def test_interpolated_data_attributes():
686 data = {"user-id": 123, "role": "admin", "wild": True, "false": False, "none": None}
687 node = html(t"<div data={data}>User Info</div>")
688 assert node == Element(
689 "div",
690 attrs={"data-user-id": "123", "data-role": "admin", "data-wild": None},
691 children=[Text("User Info")],
692 )
693 assert (
694 str(node)
695 == '<div data-user-id="123" data-role="admin" data-wild>User Info</div>'
696 )
699def test_data_attr_toggle_to_str():
700 for node in [
701 html(t"<div data-selected data={dict(selected='yes')}></div>"),
702 html(t'<div data-selected="no" data={dict(selected="yes")}></div>'),
703 ]:
704 assert node == Element("div", {"data-selected": "yes"})
705 assert str(node) == '<div data-selected="yes"></div>'
708def test_data_attr_toggle_to_true():
709 node = html(t'<div data-selected="yes" data={dict(selected=True)}></div>')
710 assert node == Element("div", {"data-selected": None})
711 assert str(node) == "<div data-selected></div>"
714def test_data_attr_unrelated_unaffected():
715 node = html(t"<div data-selected data={dict(active=True)}></div>")
716 assert node == Element("div", {"data-selected": None, "data-active": None})
717 assert str(node) == "<div data-selected data-active></div>"
720def test_data_attr_templated_error():
721 data1 = {"user-id": "user-123"}
722 data2 = {"role": "admin"}
723 with pytest.raises(TypeError):
724 node = html(t'<div data="{data1} {data2}"></div>')
725 print(str(node))
728def test_data_attr_none():
729 button_data = None
730 node = html(t"<button data={button_data}>X</button>")
731 assert node == Element("button", children=[Text("X")])
732 assert str(node) == "<button>X</button>"
735def test_data_attr_errors():
736 for v in [False, [], (), 0, "data?"]:
737 with pytest.raises(TypeError):
738 _ = html(t"<button data={v}>X</button>")
741def test_data_literal_attr_bypass():
742 # Trigger overall attribute resolution with an unrelated interpolated attr.
743 node = html(t'<p data="passthru" id={"resolved"}></p>')
744 assert node == Element(
745 "p",
746 attrs={"data": "passthru", "id": "resolved"},
747 ), "A single literal attribute should not trigger data expansion."
750#
751# Special aria attribute handling.
752#
753def test_aria_templated_attr_error():
754 aria1 = {"label": "close"}
755 aria2 = {"hidden": "true"}
756 with pytest.raises(TypeError):
757 node = html(t'<div aria="{aria1} {aria2}"></div>')
758 print(str(node))
761def test_aria_interpolated_attr_dict():
762 aria = {"label": "Close", "hidden": True, "another": False, "more": None}
763 node = html(t"<button aria={aria}>X</button>")
764 assert node == Element(
765 "button",
766 attrs={"aria-label": "Close", "aria-hidden": "true", "aria-another": "false"},
767 children=[Text("X")],
768 )
769 assert (
770 str(node)
771 == '<button aria-label="Close" aria-hidden="true" aria-another="false">X</button>'
772 )
775def test_aria_interpolate_attr_none():
776 button_aria = None
777 node = html(t"<button aria={button_aria}>X</button>")
778 assert node == Element("button", children=[Text("X")])
779 assert str(node) == "<button>X</button>"
782def test_aria_attr_errors():
783 for v in [False, [], (), 0, "aria?"]:
784 with pytest.raises(TypeError):
785 _ = html(t"<button aria={v}>X</button>")
788def test_aria_literal_attr_bypass():
789 # Trigger overall attribute resolution with an unrelated interpolated attr.
790 node = html(t'<p aria="passthru" id={"resolved"}></p>')
791 assert node == Element(
792 "p",
793 attrs={"aria": "passthru", "id": "resolved"},
794 ), "A single literal attribute should not trigger aria expansion."
797#
798# Special class attribute handling.
799#
800def test_interpolated_class_attribute():
801 class_list = ["btn", "btn-primary", "one two", None]
802 class_dict = {"active": True, "btn-secondary": False}
803 class_str = "blue"
804 class_space_sep_str = "green yellow"
805 class_none = None
806 class_empty_list = []
807 class_empty_dict = {}
808 button_t = (
809 t"<button "
810 t' class="red" class={class_list} class={class_dict}'
811 t" class={class_empty_list} class={class_empty_dict}" # ignored
812 t" class={class_none}" # ignored
813 t" class={class_str} class={class_space_sep_str}"
814 t" >Click me</button>"
815 )
816 node = html(button_t)
817 assert node == Element(
818 "button",
819 attrs={"class": "red btn btn-primary one two active blue green yellow"},
820 children=[Text("Click me")],
821 )
822 assert (
823 str(node)
824 == '<button class="red btn btn-primary one two active blue green yellow">Click me</button>'
825 )
828def test_interpolated_class_attribute_with_multiple_placeholders():
829 classes1 = ["btn", "btn-primary"]
830 classes2 = [False and "disabled", None, {"active": True}]
831 node = html(t'<button class="{classes1} {classes2}">Click me</button>')
832 # CONSIDER: Is this what we want? Currently, when we have multiple
833 # placeholders in a single attribute, we treat it as a string attribute.
834 assert node == Element(
835 "button",
836 attrs={"class": "['btn', 'btn-primary'] [False, None, {'active': True}]"},
837 children=[Text("Click me")],
838 )
841def test_interpolated_attribute_spread_with_class_attribute():
842 attrs = {"id": "button1", "class": ["btn", "btn-primary"]}
843 node = html(t"<button {attrs}>Click me</button>")
844 assert node == Element(
845 "button",
846 attrs={"id": "button1", "class": "btn btn-primary"},
847 children=[Text("Click me")],
848 )
849 assert str(node) == '<button id="button1" class="btn btn-primary">Click me</button>'
852def test_class_literal_attr_bypass():
853 # Trigger overall attribute resolution with an unrelated interpolated attr.
854 node = html(t'<p class="red red" id={"veryred"}></p>')
855 assert node == Element(
856 "p",
857 attrs={"class": "red red", "id": "veryred"},
858 ), "A single literal attribute should not trigger class accumulator."
861def test_class_none_ignored():
862 class_item = None
863 node = html(t"<p class={class_item}></p>")
864 assert node == Element("p")
865 # Also ignored inside a sequence.
866 node = html(t"<p class={[class_item]}></p>")
867 assert node == Element("p")
870def test_class_type_errors():
871 for class_item in (False, True, 0):
872 with pytest.raises(TypeError):
873 _ = html(t"<p class={class_item}></p>")
874 with pytest.raises(TypeError):
875 _ = html(t"<p class={[class_item]}></p>")
878def test_class_merge_literals():
879 node = html(t'<p class="red" class="blue"></p>')
880 assert node == Element("p", {"class": "red blue"})
883def test_class_merge_literal_then_interpolation():
884 class_item = "blue"
885 node = html(t'<p class="red" class="{[class_item]}"></p>')
886 assert node == Element("p", {"class": "red blue"})
889#
890# Special style attribute handling.
891#
892def test_style_literal_attr_passthru():
893 p_id = "para1" # non-literal attribute to cause attr resolution
894 node = html(t'<p style="color: red" id={p_id}>Warning!</p>')
895 assert node == Element(
896 "p",
897 attrs={"style": "color: red", "id": "para1"},
898 children=[Text("Warning!")],
899 )
900 assert str(node) == '<p style="color: red" id="para1">Warning!</p>'
903def test_style_in_interpolated_attr():
904 styles = {"color": "red", "font-weight": "bold", "font-size": "16px"}
905 node = html(t"<p style={styles}>Warning!</p>")
906 assert node == Element(
907 "p",
908 attrs={"style": "color: red; font-weight: bold; font-size: 16px"},
909 children=[Text("Warning!")],
910 )
911 assert (
912 str(node)
913 == '<p style="color: red; font-weight: bold; font-size: 16px">Warning!</p>'
914 )
917def test_style_in_templated_attr():
918 color = "red"
919 node = html(t'<p style="color: {color}">Warning!</p>')
920 assert node == Element(
921 "p",
922 attrs={"style": "color: red"},
923 children=[Text("Warning!")],
924 )
925 assert str(node) == '<p style="color: red">Warning!</p>'
928def test_style_in_spread_attr():
929 attrs = {"style": {"color": "red"}}
930 node = html(t"<p {attrs}>Warning!</p>")
931 assert node == Element(
932 "p",
933 attrs={"style": "color: red"},
934 children=[Text("Warning!")],
935 )
936 assert str(node) == '<p style="color: red">Warning!</p>'
939def test_style_merged_from_all_attrs():
940 attrs = dict(style="font-size: 15px")
941 style = {"font-weight": "bold"}
942 color = "red"
943 node = html(
944 t'<p style="font-family: serif" style="color: {color}" style={style} {attrs}></p>'
945 )
946 assert node == Element(
947 "p",
948 {"style": "font-family: serif; color: red; font-weight: bold; font-size: 15px"},
949 )
950 assert (
951 str(node)
952 == '<p style="font-family: serif; color: red; font-weight: bold; font-size: 15px"></p>'
953 )
956def test_style_override_left_to_right():
957 suffix = t"></p>"
958 parts = [
959 (t'<p style="color: red"', "color: red"),
960 (t" style={dict(color='blue')}", "color: blue"),
961 (t''' style="color: {"green"}"''', "color: green"),
962 (t""" {dict(style=dict(color="yellow"))}""", "color: yellow"),
963 ]
964 for index in range(len(parts)):
965 expected_style = parts[index][1]
966 t = sum([part[0] for part in parts[: index + 1]], t"") + suffix
967 node = html(t)
968 assert node == Element("p", {"style": expected_style})
969 assert str(node) == f'<p style="{expected_style}"></p>'
972def test_interpolated_style_attribute_multiple_placeholders():
973 styles1 = {"color": "red"}
974 styles2 = {"font-weight": "bold"}
975 # CONSIDER: Is this what we want? Currently, when we have multiple
976 # placeholders in a single attribute, we treat it as a string attribute
977 # which produces an invalid style attribute.
978 with pytest.raises(ValueError):
979 _ = html(t"<p style='{styles1} {styles2}'>Warning!</p>")
982def test_interpolated_style_attribute_merged():
983 styles1 = {"color": "red"}
984 styles2 = {"font-weight": "bold"}
985 node = html(t"<p style={styles1} style={styles2}>Warning!</p>")
986 assert node == Element(
987 "p",
988 attrs={"style": "color: red; font-weight: bold"},
989 children=[Text("Warning!")],
990 )
991 assert str(node) == '<p style="color: red; font-weight: bold">Warning!</p>'
994def test_interpolated_style_attribute_merged_override():
995 styles1 = {"color": "red", "font-weight": "normal"}
996 styles2 = {"font-weight": "bold"}
997 node = html(t"<p style={styles1} style={styles2}>Warning!</p>")
998 assert node == Element(
999 "p",
1000 attrs={"style": "color: red; font-weight: bold"},
1001 children=[Text("Warning!")],
1002 )
1003 assert str(node) == '<p style="color: red; font-weight: bold">Warning!</p>'
1006def test_style_attribute_str():
1007 styles = "color: red; font-weight: bold;"
1008 node = html(t"<p style={styles}>Warning!</p>")
1009 assert node == Element(
1010 "p",
1011 attrs={"style": "color: red; font-weight: bold"},
1012 children=[Text("Warning!")],
1013 )
1014 assert str(node) == '<p style="color: red; font-weight: bold">Warning!</p>'
1017def test_style_attribute_non_str_non_dict():
1018 with pytest.raises(TypeError):
1019 styles = [1, 2]
1020 _ = html(t"<p style={styles}>Warning!</p>")
1023def test_style_literal_attr_bypass():
1024 # Trigger overall attribute resolution with an unrelated interpolated attr.
1025 node = html(t'<p style="invalid;invalid:" id={"resolved"}></p>')
1026 assert node == Element(
1027 "p",
1028 attrs={"style": "invalid;invalid:", "id": "resolved"},
1029 ), "A single literal attribute should bypass style accumulator."
1032def test_style_none():
1033 styles = None
1034 node = html(t"<p style={styles}></p>")
1035 assert node == Element("p")
1038# --------------------------------------------------------------------------
1039# Function component interpolation tests
1040# --------------------------------------------------------------------------
1043def FunctionComponent(
1044 children: t.Iterable[Node], first: str, second: int, third_arg: str, **attrs: t.Any
1045) -> Template:
1046 # Ensure type correctness of props at runtime for testing purposes
1047 assert isinstance(first, str)
1048 assert isinstance(second, int)
1049 assert isinstance(third_arg, str)
1050 new_attrs = {
1051 "id": third_arg,
1052 "data": {"first": first, "second": second},
1053 **attrs,
1054 }
1055 return t"<div {new_attrs}>Component: {children}</div>"
1058def test_interpolated_template_component():
1059 node = html(
1060 t'<{FunctionComponent} first=1 second={99} third-arg="comp1" class="my-comp">Hello, Component!</{FunctionComponent}>'
1061 )
1062 assert node == Element(
1063 "div",
1064 attrs={
1065 "id": "comp1",
1066 "data-first": "1",
1067 "data-second": "99",
1068 "class": "my-comp",
1069 },
1070 children=[Text("Component: "), Text("Hello, Component!")],
1071 )
1072 assert (
1073 str(node)
1074 == '<div id="comp1" data-first="1" data-second="99" class="my-comp">Component: Hello, Component!</div>'
1075 )
1078def test_interpolated_template_component_no_children_provided():
1079 """Same test, but the caller didn't provide any children."""
1080 node = html(
1081 t'<{FunctionComponent} first=1 second={99} third-arg="comp1" class="my-comp" />'
1082 )
1083 assert node == Element(
1084 "div",
1085 attrs={
1086 "id": "comp1",
1087 "data-first": "1",
1088 "data-second": "99",
1089 "class": "my-comp",
1090 },
1091 children=[
1092 Text("Component: "),
1093 ],
1094 )
1095 assert (
1096 str(node)
1097 == '<div id="comp1" data-first="1" data-second="99" class="my-comp">Component: </div>'
1098 )
1101def test_invalid_component_invocation():
1102 with pytest.raises(TypeError):
1103 _ = html(t"<{FunctionComponent}>Missing props</{FunctionComponent}>")
1106def FunctionComponentNoChildren(first: str, second: int, third_arg: str) -> Template:
1107 # Ensure type correctness of props at runtime for testing purposes
1108 assert isinstance(first, str)
1109 assert isinstance(second, int)
1110 assert isinstance(third_arg, str)
1111 new_attrs = {
1112 "id": third_arg,
1113 "data": {"first": first, "second": second},
1114 }
1115 return t"<div {new_attrs}>Component: ignore children</div>"
1118def test_interpolated_template_component_ignore_children():
1119 node = html(
1120 t'<{FunctionComponentNoChildren} first=1 second={99} third-arg="comp1">Hello, Component!</{FunctionComponentNoChildren}>'
1121 )
1122 assert node == Element(
1123 "div",
1124 attrs={
1125 "id": "comp1",
1126 "data-first": "1",
1127 "data-second": "99",
1128 },
1129 children=[Text(text="Component: ignore children")],
1130 )
1131 assert (
1132 str(node)
1133 == '<div id="comp1" data-first="1" data-second="99">Component: ignore children</div>'
1134 )
1137def FunctionComponentKeywordArgs(first: str, **attrs: t.Any) -> Template:
1138 # Ensure type correctness of props at runtime for testing purposes
1139 assert isinstance(first, str)
1140 assert "children" in attrs
1141 _ = attrs.pop("children")
1142 new_attrs = {"data-first": first, **attrs}
1143 return t"<div {new_attrs}>Component with kwargs</div>"
1146def test_children_always_passed_via_kwargs():
1147 node = html(
1148 t'<{FunctionComponentKeywordArgs} first="value" extra="info">Child content</{FunctionComponentKeywordArgs}>'
1149 )
1150 assert node == Element(
1151 "div",
1152 attrs={
1153 "data-first": "value",
1154 "extra": "info",
1155 },
1156 children=[Text("Component with kwargs")],
1157 )
1158 assert (
1159 str(node) == '<div data-first="value" extra="info">Component with kwargs</div>'
1160 )
1163def test_children_always_passed_via_kwargs_even_when_empty():
1164 node = html(t'<{FunctionComponentKeywordArgs} first="value" extra="info" />')
1165 assert node == Element(
1166 "div",
1167 attrs={
1168 "data-first": "value",
1169 "extra": "info",
1170 },
1171 children=[Text("Component with kwargs")],
1172 )
1173 assert (
1174 str(node) == '<div data-first="value" extra="info">Component with kwargs</div>'
1175 )
1178def ColumnsComponent() -> Template:
1179 return t"""<td>Column 1</td><td>Column 2</td>"""
1182def test_fragment_from_component():
1183 # This test assumes that if a component returns a template that parses
1184 # into multiple root elements, they are treated as a fragment.
1185 node = html(t"<table><tr><{ColumnsComponent} /></tr></table>")
1186 assert node == Element(
1187 "table",
1188 children=[
1189 Element(
1190 "tr",
1191 children=[
1192 Element("td", children=[Text("Column 1")]),
1193 Element("td", children=[Text("Column 2")]),
1194 ],
1195 ),
1196 ],
1197 )
1198 assert str(node) == "<table><tr><td>Column 1</td><td>Column 2</td></tr></table>"
1201def test_component_passed_as_attr_value():
1202 def Wrapper(
1203 children: t.Iterable[Node], sub_component: t.Callable, **attrs: t.Any
1204 ) -> Template:
1205 return t"<{sub_component} {attrs}>{children}</{sub_component}>"
1207 node = html(
1208 t'<{Wrapper} sub-component={FunctionComponent} class="wrapped" first=1 second={99} third-arg="comp1"><p>Inside wrapper</p></{Wrapper}>'
1209 )
1210 assert node == Element(
1211 "div",
1212 attrs={
1213 "id": "comp1",
1214 "data-first": "1",
1215 "data-second": "99",
1216 "class": "wrapped",
1217 },
1218 children=[Text("Component: "), Element("p", children=[Text("Inside wrapper")])],
1219 )
1220 assert (
1221 str(node)
1222 == '<div id="comp1" data-first="1" data-second="99" class="wrapped">Component: <p>Inside wrapper</p></div>'
1223 )
1226def test_nested_component_gh23():
1227 # See https://github.com/t-strings/tdom/issues/23 for context
1228 def Header():
1229 return html(t"{'Hello World'}")
1231 node = html(t"<{Header} />")
1232 assert node == Text("Hello World")
1233 assert str(node) == "Hello World"
1236def test_component_returning_iterable():
1237 def Items() -> t.Iterable:
1238 for i in range(2):
1239 yield t"<li>Item {i + 1}</li>"
1240 yield html(t"<li>Item {3}</li>")
1242 node = html(t"<ul><{Items} /></ul>")
1243 assert node == Element(
1244 "ul",
1245 children=[
1246 Element("li", children=[Text("Item "), Text("1")]),
1247 Element("li", children=[Text("Item "), Text("2")]),
1248 Element("li", children=[Text("Item "), Text("3")]),
1249 ],
1250 )
1251 assert str(node) == "<ul><li>Item 1</li><li>Item 2</li><li>Item 3</li></ul>"
1254def test_component_returning_fragment():
1255 def Items() -> Node:
1256 return html(t"<li>Item {1}</li><li>Item {2}</li><li>Item {3}</li>")
1258 node = html(t"<ul><{Items} /></ul>")
1259 assert node == Element(
1260 "ul",
1261 children=[
1262 Element("li", children=[Text("Item "), Text("1")]),
1263 Element("li", children=[Text("Item "), Text("2")]),
1264 Element("li", children=[Text("Item "), Text("3")]),
1265 ],
1266 )
1267 assert str(node) == "<ul><li>Item 1</li><li>Item 2</li><li>Item 3</li></ul>"
1270@dataclass
1271class ClassComponent:
1272 """Example class-based component."""
1274 user_name: str
1275 image_url: str
1276 homepage: str = "#"
1277 children: t.Iterable[Node] = field(default_factory=list)
1279 def __call__(self) -> Node:
1280 return html(
1281 t"<div class='avatar'>"
1282 t"<a href={self.homepage}>"
1283 t"<img src='{self.image_url}' alt='{f'Avatar of {self.user_name}'}' />"
1284 t"</a>"
1285 t"<span>{self.user_name}</span>"
1286 t"{self.children}"
1287 t"</div>",
1288 )
1291def test_class_component_implicit_invocation_with_children():
1292 node = html(
1293 t"<{ClassComponent} user-name='Alice' image-url='https://example.com/alice.png'>Fun times!</{ClassComponent}>"
1294 )
1295 assert node == Element(
1296 "div",
1297 attrs={"class": "avatar"},
1298 children=[
1299 Element(
1300 "a",
1301 attrs={"href": "#"},
1302 children=[
1303 Element(
1304 "img",
1305 attrs={
1306 "src": "https://example.com/alice.png",
1307 "alt": "Avatar of Alice",
1308 },
1309 )
1310 ],
1311 ),
1312 Element("span", children=[Text("Alice")]),
1313 Text("Fun times!"),
1314 ],
1315 )
1316 assert (
1317 str(node)
1318 == '<div class="avatar"><a href="#"><img src="https://example.com/alice.png" alt="Avatar of Alice" /></a><span>Alice</span>Fun times!</div>'
1319 )
1322def test_class_component_direct_invocation():
1323 avatar = ClassComponent(
1324 user_name="Alice",
1325 image_url="https://example.com/alice.png",
1326 homepage="https://example.com/users/alice",
1327 )
1328 node = html(t"<{avatar} />")
1329 assert node == Element(
1330 "div",
1331 attrs={"class": "avatar"},
1332 children=[
1333 Element(
1334 "a",
1335 attrs={"href": "https://example.com/users/alice"},
1336 children=[
1337 Element(
1338 "img",
1339 attrs={
1340 "src": "https://example.com/alice.png",
1341 "alt": "Avatar of Alice",
1342 },
1343 )
1344 ],
1345 ),
1346 Element("span", children=[Text("Alice")]),
1347 ],
1348 )
1349 assert (
1350 str(node)
1351 == '<div class="avatar"><a href="https://example.com/users/alice"><img src="https://example.com/alice.png" alt="Avatar of Alice" /></a><span>Alice</span></div>'
1352 )
1355@dataclass
1356class ClassComponentNoChildren:
1357 """Example class-based component that does not ask for children."""
1359 user_name: str
1360 image_url: str
1361 homepage: str = "#"
1363 def __call__(self) -> Node:
1364 return html(
1365 t"<div class='avatar'>"
1366 t"<a href={self.homepage}>"
1367 t"<img src='{self.image_url}' alt='{f'Avatar of {self.user_name}'}' />"
1368 t"</a>"
1369 t"<span>{self.user_name}</span>"
1370 t"ignore children"
1371 t"</div>",
1372 )
1375def test_class_component_implicit_invocation_ignore_children():
1376 node = html(
1377 t"<{ClassComponentNoChildren} user-name='Alice' image-url='https://example.com/alice.png'>Fun times!</{ClassComponentNoChildren}>"
1378 )
1379 assert node == Element(
1380 "div",
1381 attrs={"class": "avatar"},
1382 children=[
1383 Element(
1384 "a",
1385 attrs={"href": "#"},
1386 children=[
1387 Element(
1388 "img",
1389 attrs={
1390 "src": "https://example.com/alice.png",
1391 "alt": "Avatar of Alice",
1392 },
1393 )
1394 ],
1395 ),
1396 Element("span", children=[Text("Alice")]),
1397 Text("ignore children"),
1398 ],
1399 )
1400 assert (
1401 str(node)
1402 == '<div class="avatar"><a href="#"><img src="https://example.com/alice.png" alt="Avatar of Alice" /></a><span>Alice</span>ignore children</div>'
1403 )
1406def AttributeTypeComponent(
1407 data_int: int,
1408 data_true: bool,
1409 data_false: bool,
1410 data_none: None,
1411 data_float: float,
1412 data_dt: datetime.datetime,
1413 **kws: dict[str, object | None],
1414) -> Template:
1415 """Component to test that we don't incorrectly convert attribute types."""
1416 assert isinstance(data_int, int)
1417 assert data_true is True
1418 assert data_false is False
1419 assert data_none is None
1420 assert isinstance(data_float, float)
1421 assert isinstance(data_dt, datetime.datetime)
1422 for kw, v_type in [
1423 ("spread_true", True),
1424 ("spread_false", False),
1425 ("spread_int", int),
1426 ("spread_none", None),
1427 ("spread_float", float),
1428 ("spread_dt", datetime.datetime),
1429 ("spread_dict", dict),
1430 ("spread_list", list),
1431 ]:
1432 if v_type in (True, False, None):
1433 assert kw in kws and kws[kw] is v_type, (
1434 f"{kw} should be {v_type} but got {kws=}"
1435 )
1436 else:
1437 assert kw in kws and isinstance(kws[kw], v_type), (
1438 f"{kw} should instance of {v_type} but got {kws=}"
1439 )
1440 return t"Looks good!"
1443def test_attribute_type_component():
1444 an_int: int = 42
1445 a_true: bool = True
1446 a_false: bool = False
1447 a_none: None = None
1448 a_float: float = 3.14
1449 a_dt: datetime.datetime = datetime.datetime(2024, 1, 1, 12, 0, 0)
1450 spread_attrs: dict[str, object | None] = {
1451 "spread_true": True,
1452 "spread_false": False,
1453 "spread_none": None,
1454 "spread_int": 0,
1455 "spread_float": 0.0,
1456 "spread_dt": datetime.datetime(2024, 1, 1, 12, 0, 1),
1457 "spread_dict": dict(),
1458 "spread_list": ["eggs", "milk"],
1459 }
1460 node = html(
1461 t"<{AttributeTypeComponent} data-int={an_int} data-true={a_true} "
1462 t"data-false={a_false} data-none={a_none} data-float={a_float} "
1463 t"data-dt={a_dt} {spread_attrs}/>"
1464 )
1465 assert node == Text("Looks good!")
1466 assert str(node) == "Looks good!"
1469def test_component_non_callable_fails():
1470 with pytest.raises(TypeError):
1471 _ = html(t"<{'not a function'} />")
1474def RequiresPositional(whoops: int, /) -> Template: # pragma: no cover
1475 return t"<p>Positional arg: {whoops}</p>"
1478def test_component_requiring_positional_arg_fails():
1479 with pytest.raises(TypeError):
1480 _ = html(t"<{RequiresPositional} />")
1483def test_mismatched_component_closing_tag_fails():
1484 with pytest.raises(TypeError):
1485 _ = html(
1486 t"<{FunctionComponent} first=1 second={99} third-arg='comp1'>Hello</{ClassComponent}>"
1487 )