Coverage for tdom/processor_test.py: 99%
1102 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-19 21:37 +0000
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-19 21:37 +0000
1import datetime
2import math
3import typing as t
4from collections import UserDict
5from collections.abc import Callable
6from dataclasses import dataclass
7from itertools import chain, product
8from string.templatelib import Template
10import pytest
11from markupsafe import Markup
12from markupsafe import escape as markupsafe_escape
14from .callables import get_callable_info
15from .escaping import escape_html_text
16from .processor import (
17 CachedTemplateParserProxy,
18 ProcessContext,
19 TemplateParserProxy,
20 TemplateProcessor,
21 _make_default_template_processor,
22)
23from .processor import (
24 _prep_component_kwargs as prep_component_kwargs,
25)
26from .protocols import HasHTMLDunder
28processor_api = _make_default_template_processor(
29 parser_api=TemplateParserProxy(), # do not use cache
30)
33def make_ctx(**kwargs):
34 return ProcessContext(**kwargs)
37def html(template: Template, assume_ctx: ProcessContext | None = None):
38 if assume_ctx is None:
39 assume_ctx = ProcessContext()
40 return processor_api.process(template, assume_ctx=assume_ctx)
43# --------------------------------------------------------------------------
44# Basic HTML parsing tests
45# --------------------------------------------------------------------------
48#
49# Text
50#
51class TestBareTemplate:
52 def test_empty(self):
53 assert html(t"") == ""
55 def test_text_literal(self):
56 assert html(t"Hello, world!") == "Hello, world!"
58 def test_text_singleton(self):
59 greeting = "Hello, Alice!"
60 assert html(t"{greeting}", make_ctx(parent_tag="div")) == "Hello, Alice!"
61 assert html(t"{greeting}", make_ctx(parent_tag="script")) == "Hello, Alice!"
62 assert html(t"{greeting}", make_ctx(parent_tag="style")) == "Hello, Alice!"
63 assert html(t"{greeting}", make_ctx(parent_tag="textarea")) == "Hello, Alice!"
64 assert html(t"{greeting}", make_ctx(parent_tag="title")) == "Hello, Alice!"
66 def test_text_singleton_without_parent(self):
67 greeting = "</script>"
68 res = html(t"{greeting}")
69 assert res == "</script>"
70 assert res != greeting
72 def test_text_singleton_explicit_parent_script(self):
73 greeting = "</script>"
74 res = html(t"{greeting}", assume_ctx=make_ctx(parent_tag="script"))
75 assert res == "\\x3c/script>"
76 assert res != "</script>"
78 def test_text_singleton_explicit_parent_div(self):
79 greeting = "</div>"
80 res = html(t"{greeting}", assume_ctx=make_ctx(parent_tag="div"))
81 assert res == "</div>"
82 assert res != "</div>"
84 def test_text_template(self):
85 name = "Alice"
86 assert (
87 html(t"Hello, {name}!", assume_ctx=make_ctx(parent_tag="div"))
88 == "Hello, Alice!"
89 )
91 def test_text_template_escaping(self):
92 name = "Alice & Bob"
93 assert (
94 html(t"Hello, {name}!", assume_ctx=make_ctx(parent_tag="div"))
95 == "Hello, Alice & Bob!"
96 )
98 def test_parse_entities_are_escaped_no_parent_tag(self):
99 res = html(t"</p>")
100 assert res == "</p>", "Default to standard escaping."
103class LiteralHTML:
104 """Text is returned as is by __html__."""
106 def __init__(self, text):
107 self.text = text
109 def __html__(self):
110 # In a real app, this would come from a sanitizer or trusted source
111 return self.text
114def test_literal_html_has_html_dunder():
115 assert isinstance(LiteralHTML, HasHTMLDunder)
118def test_markup_has_html_dunder():
119 assert isinstance(Markup, HasHTMLDunder)
122class TestComment:
123 def test_literal(self):
124 assert html(t"<!--This is a comment-->") == "<!--This is a comment-->"
126 #
127 # Singleton / Exact Match
128 #
129 def test_singleton_str(self):
130 text = "This is a comment"
131 assert html(t"<!--{text}-->") == "<!--This is a comment-->"
133 def test_singleton_object(self):
134 assert html(t"<!--{0}-->") == "<!--0-->"
136 def test_singleton_none(self):
137 assert html(t"<!--{None}-->") == "<!---->"
139 @pytest.mark.parametrize("bool_value", (True, False))
140 def test_singleton_bool(self, bool_value):
141 assert html(t"<!--{bool_value}-->") == "<!---->"
143 @pytest.mark.parametrize(
144 "html_dunder_cls",
145 (
146 LiteralHTML,
147 Markup,
148 ),
149 )
150 def test_singleton_has_html_dunder(self, html_dunder_cls):
151 content = html_dunder_cls("-->")
152 assert html(t"<!--{content}-->") == "<!---->-->", (
153 "DO NOT DO THIS! This is just an advanced escape hatch."
154 )
156 def test_singleton_escaping(self):
157 text = "-->comment"
158 assert html(t"<!--{text}-->") == "<!---->comment-->"
160 #
161 # Templated -- literal text mixed with interpolation(s)
162 #
163 def test_templated_str(self):
164 text = "comment"
165 assert html(t"<!--This is a {text}-->") == "<!--This is a comment-->"
167 def test_templated_object(self):
168 assert html(t"<!--This is a {0}-->") == "<!--This is a 0-->"
170 def test_templated_none(self):
171 assert html(t"<!--This is a {None}-->") == "<!--This is a -->"
173 @pytest.mark.parametrize("bool_value", (True, False))
174 def test_templated_bool(self, bool_value):
175 assert html(t"<!--This is a {bool_value}-->") == "<!--This is a -->"
177 @pytest.mark.parametrize(
178 "html_dunder_cls",
179 (
180 LiteralHTML,
181 Markup,
182 ),
183 )
184 def test_templated_has_html_dunder_error(self, html_dunder_cls):
185 """Objects with __html__ are not processed with literal text or other interpolations."""
186 text = html_dunder_cls("in a comment")
187 with pytest.raises(ValueError, match="not supported"):
188 _ = html(t"<!--This is a {text}-->")
189 with pytest.raises(ValueError, match="not supported"):
190 _ = html(t"<!--{None}{text}-->")
191 with pytest.raises(ValueError, match="not supported"):
192 _ = html(t"<!--This is a {Markup('Also check specialized cls.')}-->")
194 def test_templated_multiple_interpolations(self):
195 text = "comment"
196 assert (
197 html(t"<!--This is a {text} with {0} and {None}-->")
198 == "<!--This is a comment with 0 and -->"
199 )
201 def test_templated_escaping(self):
202 # @TODO: There doesn't seem to be a way to properly escape this
203 # so we just use an entity to break the special closing string
204 # even though it won't be actually unescaped by anything. There
205 # might be something better for this.
206 text = "-->comment"
207 assert html(t"<!--This is a {text}-->") == "<!--This is a -->comment-->"
209 def test_not_supported__recursive_template_error(self):
210 text_t = t"comment"
211 with pytest.raises(ValueError, match="not supported"):
212 _ = html(t"<!--{text_t}-->")
214 def test_not_supported_recursive_iterable_error(self):
215 texts = ["This", "is", "a", "comment"]
216 with pytest.raises(ValueError, match="not supported"):
217 _ = html(t"<!--{texts}-->")
220class TestDocumentType:
221 def test_literal(self):
222 assert html(t"<!doctype html>") == "<!DOCTYPE html>"
224 def test_literal_lowercase(self):
225 tp = TemplateProcessor(uppercase_doctype=False)
226 assert (
227 tp.process(t"<!doctype html>", assume_ctx=ProcessContext())
228 == "<!doctype html>"
229 )
232class TestVoidElementLiteral:
233 def test_void(self):
234 assert html(t"<br>") == "<br />"
236 def test_void_self_closed(self):
237 assert html(t"<br />") == "<br />"
239 def test_void_mixed_closing(self):
240 assert html(t"<br>Is this content?<br />") == "<br />Is this content?<br />"
242 def test_chain_of_void_elements(self):
243 # Make sure our handling of CPython issue #69445 is reasonable.
244 assert (
245 html(t"<br><hr><img src='image.png' /><br /><hr>")
246 == '<br /><hr /><img src="image.png" /><br /><hr />'
247 )
250class TestNormalTextElementLiteral:
251 def test_empty(self):
252 assert html(t"<div></div>") == "<div></div>"
254 def test_with_text(self):
255 assert html(t"<p>Hello, world!</p>") == "<p>Hello, world!</p>"
257 def test_nested_elements(self):
258 assert (
259 html(t"<div><p>Hello</p><p>World</p></div>")
260 == "<div><p>Hello</p><p>World</p></div>"
261 )
263 def test_entities_are_escaped(self):
264 """Literal entities interpreted by parser but escaped in output."""
265 res = html(t"<p></p></p>")
266 assert res == "<p></p></p>", res
269class TestNormalTextElementDynamic:
270 def test_singleton_None(self):
271 assert html(t"<p>{None}</p>") == "<p></p>"
273 def test_singleton_str(self):
274 name = "Alice"
275 assert html(t"<p>{name}</p>") == "<p>Alice</p>"
277 @pytest.mark.parametrize("bool_value", (True, False))
278 def test_singleton_bool(self, bool_value):
279 assert html(t"<p>{bool_value}</p>") == "<p></p>"
281 def test_singleton_object(self):
282 assert html(t"<p>{0}</p>") == "<p>0</p>"
284 @pytest.mark.parametrize(
285 "html_dunder_cls",
286 (
287 LiteralHTML,
288 Markup,
289 ),
290 )
291 def test_singleton_has_html_dunder(self, html_dunder_cls):
292 content = html_dunder_cls("<em>Alright!</em>")
293 assert html(t"<p>{content}</p>") == "<p><em>Alright!</em></p>"
295 def test_singleton_simple_template(self):
296 name = "Alice"
297 text_t = t"Hi {name}"
298 assert html(t"<p>{text_t}</p>") == "<p>Hi Alice</p>"
300 def test_singleton_simple_iterable(self):
301 strs = ["Strings", "...", "Yeah!", "Rock", "...", "Yeah!"]
302 assert html(t"<p>{strs}</p>") == "<p>Strings...Yeah!Rock...Yeah!</p>"
304 def test_singleton_escaping(self):
305 text = '''<>&'"'''
306 assert html(t"<p>{text}</p>") == "<p><>&'"</p>"
308 def test_templated_None(self):
309 assert html(t"<p>Response: {None}.</p>") == "<p>Response: .</p>"
311 def test_templated_str(self):
312 name = "Alice"
313 assert html(t"<p>Response: {name}.</p>") == "<p>Response: Alice.</p>"
315 @pytest.mark.parametrize("bool_value", (True, False))
316 def test_templated_bool(self, bool_value):
317 assert html(t"<p>Response: {bool_value}</p>") == "<p>Response: </p>"
319 def test_templated_object(self):
320 assert html(t"<p>Response: {0}.</p>") == "<p>Response: 0.</p>"
322 @pytest.mark.parametrize(
323 "html_dunder_cls",
324 (
325 LiteralHTML,
326 Markup,
327 ),
328 )
329 def test_templated_has_html_dunder(self, html_dunder_cls):
330 text = html_dunder_cls("<em>Alright!</em>")
331 assert (
332 html(t"<p>Response: {text}.</p>") == "<p>Response: <em>Alright!</em>.</p>"
333 )
335 def test_templated_simple_template(self):
336 name = "Alice"
337 text_t = t"Hi {name}"
338 assert html(t"<p>Response: {text_t}.</p>") == "<p>Response: Hi Alice.</p>"
340 def test_templated_simple_iterable(self):
341 strs = ["Strings", "...", "Yeah!", "Rock", "...", "Yeah!"]
342 assert (
343 html(t"<p>Response: {strs}.</p>")
344 == "<p>Response: Strings...Yeah!Rock...Yeah!.</p>"
345 )
347 def test_templated_escaping(self):
348 text = '''<>&'"'''
349 assert (
350 html(t"<p>Response: {text}.</p>")
351 == "<p>Response: <>&'".</p>"
352 )
354 def test_templated_escaping_in_literals(self):
355 text = "This text is fine"
356 assert (
357 html(t"<p>The literal has < in it: {text}.</p>")
358 == "<p>The literal has < in it: This text is fine.</p>"
359 )
361 def test_iterable_of_templates(self):
362 items = ["Apple", "Banana", "Cherry"]
363 assert (
364 html(t"<ul>{[t'<li>{item}</li>' for item in items]}</ul>")
365 == "<ul><li>Apple</li><li>Banana</li><li>Cherry</li></ul>"
366 )
368 def test_iterable_of_templates_of_iterable_of_templates(self):
369 outer = ["fruit", "more fruit"]
370 inner = ["apple", "banana", "cherry"]
371 inner_items = [t"<li>{item}</li>" for item in inner]
372 outer_items = [
373 t"<li>{category}<ul>{inner_items}</ul></li>" for category in outer
374 ]
375 assert (
376 html(t"<ul>{outer_items}</ul>")
377 == "<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>"
378 )
381class TestRawTextElementLiteral:
382 def test_script_empty(self):
383 assert html(t"<script></script>") == "<script></script>"
385 def test_style_empty(self):
386 assert html(t"<style></style>") == "<style></style>"
388 def test_script_with_content(self):
389 assert html(t"<script>var x = 1;</script>") == "<script>var x = 1;</script>"
391 def test_style_with_content(self):
392 # @NOTE: Double {{ and }} to avoid t-string interpolation.
393 assert (
394 html(t"<style>.red { color: red; } </style>")
395 == "<style>.red { color: red; }</style>"
396 )
398 def test_script_with_content_escaped_in_normal_text(self):
399 # @NOTE: Double {{ and }} to avoid t-string interpolation.
400 assert (
401 html(t"<script>function CompareNumbers(a, b) { return a < b; } </script>")
402 == "<script>function CompareNumbers(a, b) { return a < b; }</script>"
403 ), "The < should not be escaped."
405 def test_style_with_content_escaped_in_normal_text(self):
406 # @NOTE: Double {{ and }} to avoid t-string interpolation.
407 assert (
408 html(t"<style>section > h4 { background-color: red; } </style>")
409 == "<style>section > h4 { background-color: red; }</style>"
410 ), "The > should not be escaped."
412 def test_not_supported_recursive_template_error(self):
413 text_t = t"comment"
414 with pytest.raises(ValueError, match="not supported"):
415 _ = html(t"<!--{text_t}-->")
417 def test_not_supported_recursive_iterable_error(self):
418 texts = ["This", "is", "a", "comment"]
419 with pytest.raises(ValueError, match="not supported"):
420 _ = html(t"<!--{texts}-->")
423class TestEscapableRawTextElementLiteral:
424 def test_title_empty(self):
425 assert html(t"<title></title>") == "<title></title>"
427 def test_textarea_empty(self):
428 assert html(t"<textarea></textarea>") == "<textarea></textarea>"
430 def test_title_with_content(self):
431 assert html(t"<title>Content</title>") == "<title>Content</title>"
433 def test_textarea_with_content(self):
434 assert html(t"<textarea>Content</textarea>") == "<textarea>Content</textarea>"
436 def test_title_with_escapable_content(self):
437 assert (
438 html(t"<title>Are t-strings > everything?</title>")
439 == "<title>Are t-strings > everything?</title>"
440 ), "The > can be escaped in this content type."
442 def test_textarea_with_escapable_content(self):
443 assert (
444 html(t"<textarea><p>Welcome To TDOM</p></textarea>")
445 == "<textarea><p>Welcome To TDOM</p></textarea>"
446 ), "The p tags can be escaped in this content type."
449class TestRawTextScriptDynamic:
450 def test_singleton_none(self):
451 assert html(t"<script>{None}</script>") == "<script></script>"
453 def test_singleton_str(self):
454 content = "var x = 1;"
455 assert html(t"<script>{content}</script>") == "<script>var x = 1;</script>"
457 @pytest.mark.parametrize("bool_value", (True, False))
458 def test_singleton_bool(self, bool_value):
459 assert html(t"<script>{bool_value}</script>") == "<script></script>"
461 def test_singleton_object(self):
462 content = 0
463 assert html(t"<script>{content}</script>") == "<script>0</script>"
465 @pytest.mark.parametrize(
466 "html_dunder_cls",
467 (
468 LiteralHTML,
469 Markup,
470 ),
471 )
472 def test_singleton_has_html_dunder_pitfall(self, html_dunder_cls):
473 # @TODO: We should probably put some double override to prevent this by accident.
474 # Or just disable this and if people want to do this then put the
475 # content in a SCRIPT and inject the whole thing with a __html__?
476 content = html_dunder_cls("</script>")
477 assert html(t"<script>{content}</script>") == "<script></script></script>", (
478 "DO NOT DO THIS! This is just an advanced escape hatch! Use a data attribute and parseJSON!"
479 )
481 def test_singleton_escaping(self):
482 content = "</script>"
483 script_t = t"<script>{content}</script>"
484 bad_output = script_t.strings[0] + content + script_t.strings[1]
485 assert html(script_t) == "<script>\\x3c/script></script>"
486 assert html(script_t) != bad_output, "Sanity check."
488 def test_templated_none(self):
489 assert (
490 html(t"<script>var x = 1;{None};</script>")
491 == "<script>var x = 1;;</script>"
492 )
494 def test_templated_str(self):
495 content = "var x = 1"
496 assert (
497 html(t"<script>var x = 0;{content};</script>")
498 == "<script>var x = 0;var x = 1;</script>"
499 )
501 @pytest.mark.parametrize("bool_value", (True, False))
502 def test_templated_bool(self, bool_value):
503 assert (
504 html(t"<script>var x = 15; {bool_value}</script>")
505 == "<script>var x = 15; </script>"
506 )
508 def test_templated_object(self):
509 content = 0
510 assert (
511 html(t"<script>var x = {content};</script>")
512 == "<script>var x = 0;</script>"
513 )
515 @pytest.mark.parametrize(
516 "html_dunder_cls",
517 (
518 LiteralHTML,
519 Markup,
520 ),
521 )
522 def test_templated_has_html_dunder(self, html_dunder_cls):
523 content = html_dunder_cls("anything")
524 with pytest.raises(ValueError, match="not supported"):
525 _ = html(t"<script>var x = 1;{content}</script>")
527 def test_templated_escaping(self):
528 content = "</script>"
529 script_t = t"<script>var x = '{content}';</script>"
530 bad_output = script_t.strings[0] + content + script_t.strings[1]
531 assert html(script_t) == "<script>var x = '\\x3c/script>';</script>"
532 assert html(script_t) != bad_output, "Sanity check."
534 def test_templated_multiple_interpolations(self):
535 assert (
536 html(t"<script>var x = {1}; var y = {2};</script>")
537 == "<script>var x = 1; var y = 2;</script>"
538 )
540 def test_not_supported_recursive_template_error(self):
541 text_t = t"script"
542 with pytest.raises(ValueError, match="not supported"):
543 _ = html(t"<script>{text_t}</script>")
545 def test_not_supported_recursive_iterable_error(self):
546 texts = ["This", "is", "a", "script"]
547 with pytest.raises(ValueError, match="not supported"):
548 _ = html(t"<script>{texts}</script>")
551class TestRawTextStyleDynamic:
552 def test_singleton_none(self):
553 assert html(t"<style>{None}</style>") == "<style></style>"
555 def test_singleton_str(self):
556 content = "div { background-color: red; }"
557 assert (
558 html(t"<style>{content}</style>")
559 == "<style>div { background-color: red; }</style>"
560 )
562 @pytest.mark.parametrize("bool_value", (True, False))
563 def test_singleton_bool(self, bool_value):
564 assert html(t"<style>{bool_value}</style>") == "<style></style>"
566 def test_singleton_object(self):
567 content = 0
568 assert html(t"<style>{content}</style>") == "<style>0</style>"
570 @pytest.mark.parametrize(
571 "html_dunder_cls",
572 (
573 LiteralHTML,
574 Markup,
575 ),
576 )
577 def test_singleton_has_html_dunder_pitfall(self, html_dunder_cls):
578 # @TODO: We should probably put some double override to prevent this by accident.
579 # Or just disable this and if people want to do this then put the
580 # content in a STYLE and inject the whole thing with a __html__?
581 content = html_dunder_cls("</style>")
582 assert html(t"<style>{content}</style>") == "<style></style></style>", (
583 "DO NOT DO THIS! This is just an advanced escape hatch!"
584 )
586 def test_singleton_escaping(self):
587 content = "</style>"
588 style_t = t"<style>{content}</style>"
589 bad_output = style_t.strings[0] + content + style_t.strings[1]
590 assert html(style_t) == "<style></style></style>"
591 assert html(style_t) != bad_output, "Sanity check."
593 def test_templated_none(self):
594 assert (
595 html(t"<style>h1 { background-color: red; } {None}</style>")
596 == "<style>h1 { background-color: red; }</style>"
597 )
599 def test_templated_str(self):
600 content = " h2 { background-color: blue; }"
601 assert (
602 html(t"<style>h1 { background-color: red; } {content}</style>")
603 == "<style>h1 { background-color: red; } h2 { background-color: blue; }</style>"
604 )
606 @pytest.mark.parametrize("bool_value", (True, False))
607 def test_templated_bool(self, bool_value):
608 assert (
609 html(t"<style>h1 { background-color: red; } ;{bool_value}</style>")
610 == "<style>h1 { background-color: red; };</style>"
611 )
613 def test_templated_object(self):
614 padding_right = 0
615 assert (
616 html(t"<style>h1 { padding-right: {padding_right}px; } </style>")
617 == "<style>h1 { padding-right: 0px; }</style>"
618 )
620 @pytest.mark.parametrize(
621 "html_dunder_cls",
622 (
623 LiteralHTML,
624 Markup,
625 ),
626 )
627 def test_templated_has_html_dunder(self, html_dunder_cls):
628 content = html_dunder_cls("anything")
629 with pytest.raises(ValueError, match="not supported"):
630 _ = html(t"<style>h1 { color: red; } ;{content}</style>")
632 def test_templated_escaping(self):
633 content = "</style>"
634 style_t = t"<style>div { background-color: red; } {content}</style>"
635 bad_output = style_t.strings[0] + content + style_t.strings[1]
636 assert (
637 html(style_t) == "<style>div { background-color: red; } </style></style>"
638 )
639 assert html(style_t) != bad_output, "Sanity check."
641 def test_templated_multiple_interpolations(self):
642 assert (
643 html(
644 t"<style>h1 { background-color: {'red'}; } h2 { background-color: {'blue'}; } </style>"
645 )
646 == "<style>h1 { background-color: red; } h2 { background-color: blue; }</style>"
647 )
649 def test_exact_not_supported_recursive_template_error(self):
650 text_t = t"style"
651 with pytest.raises(ValueError, match="not supported"):
652 _ = html(t"<style>{text_t}</style>")
654 def test_inexact_not_supported_recursive_template_error(self):
655 text_t = t"style"
656 with pytest.raises(ValueError, match="not supported"):
657 _ = html(t"<style>{text_t} and more</style>")
659 def test_exact_not_supported_recursive_iterable_error(self):
660 texts = ["This", "is", "a", "style"]
661 with pytest.raises(ValueError, match="not supported"):
662 _ = html(t"<style>{texts}</style>")
664 def test_inexact_not_supported_recursive_iterable_error(self):
665 texts = ["This", "is", "a", "style"]
666 with pytest.raises(ValueError, match="not supported"):
667 _ = html(t"<style>{texts} and more</style>")
670class TestEscapableRawTextTitleDynamic:
671 def test_singleton_none(self):
672 assert html(t"<title>{None}</title>") == "<title></title>"
674 def test_singleton_str(self):
675 content = "Welcome To TDOM"
676 assert html(t"<title>{content}</title>") == "<title>Welcome To TDOM</title>"
678 @pytest.mark.parametrize("bool_value", (True, False))
679 def test_singleton_bool(self, bool_value):
680 assert html(t"<title>{bool_value}</title>") == "<title></title>"
682 def test_singleton_object(self):
683 content = 0
684 assert html(t"<title>{content}</title>") == "<title>0</title>"
686 @pytest.mark.parametrize(
687 "html_dunder_cls",
688 (
689 LiteralHTML,
690 Markup,
691 ),
692 )
693 def test_singleton_has_html_dunder_pitfall(self, html_dunder_cls):
694 # @TODO: We should probably put some double override to prevent this by accident.
695 content = html_dunder_cls("</title>")
696 assert html(t"<title>{content}</title>") == "<title></title></title>", (
697 "DO NOT DO THIS! This is just an advanced escape hatch!"
698 )
700 def test_singleton_escaping(self):
701 content = "</title>"
702 assert html(t"<title>{content}</title>") == "<title></title></title>"
704 def test_templated_none(self):
705 assert (
706 html(t"<title>A great story about: {None}</title>")
707 == "<title>A great story about: </title>"
708 )
710 def test_templated_str(self):
711 content = "TDOM"
712 assert (
713 html(t"<title>A great story about: {content}</title>")
714 == "<title>A great story about: TDOM</title>"
715 )
717 @pytest.mark.parametrize("bool_value", (True, False))
718 def test_templated_bool(self, bool_value):
719 assert (
720 html(t"<title>A great story; {bool_value}</title>")
721 == "<title>A great story; </title>"
722 )
724 def test_templated_object(self):
725 content = 0
726 assert (
727 html(t"<title>A great number: {content}</title>")
728 == "<title>A great number: 0</title>"
729 )
731 @pytest.mark.parametrize(
732 "html_dunder_cls",
733 (
734 LiteralHTML,
735 Markup,
736 ),
737 )
738 def test_templated_has_html_dunder(self, html_dunder_cls):
739 content = html_dunder_cls("No")
740 with pytest.raises(ValueError, match="not supported"):
741 _ = html(t"<title>Literal html?: {content}</title>")
743 def test_templated_escaping(self):
744 content = "</title>"
745 assert (
746 html(t"<title>The end tag: {content}.</title>")
747 == "<title>The end tag: </title>.</title>"
748 )
750 def test_templated_multiple_interpolations(self):
751 assert (
752 html(t"<title>The number {0} is less than {1}.</title>")
753 == "<title>The number 0 is less than 1.</title>"
754 )
756 def test_exact_not_supported_recursive_template_error(self):
757 text_t = t"title"
758 with pytest.raises(ValueError, match="not supported"):
759 _ = html(t"<title>{text_t}</title>")
761 def test_exact_not_supported_recursive_iterable_error(self):
762 texts = ["This", "is", "a", "title"]
763 with pytest.raises(ValueError, match="not supported"):
764 _ = html(t"<title>{texts}</title>")
766 def test_inexact_not_supported_recursive_template_error(self):
767 text_t = t"title"
768 with pytest.raises(ValueError, match="not supported"):
769 _ = html(t"<title>{text_t} and more</title>")
771 def test_inexact_not_supported_recursive_iterable_error(self):
772 texts = ["This", "is", "a", "title"]
773 with pytest.raises(ValueError, match="not supported"):
774 _ = html(t"<title>{texts} and more</title>")
777class TestEscapableRawTextTextareaDynamic:
778 def test_singleton_none(self):
779 assert html(t"<textarea>{None}</textarea>") == "<textarea></textarea>"
781 def test_singleton_str(self):
782 content = "Welcome To TDOM"
783 assert (
784 html(t"<textarea>{content}</textarea>")
785 == "<textarea>Welcome To TDOM</textarea>"
786 )
788 @pytest.mark.parametrize("bool_value", (True, False))
789 def test_singleton_bool(self, bool_value):
790 assert html(t"<textarea>{bool_value}</textarea>") == "<textarea></textarea>"
792 def test_singleton_object(self):
793 content = 0
794 assert html(t"<textarea>{content}</textarea>") == "<textarea>0</textarea>"
796 @pytest.mark.parametrize(
797 "html_dunder_cls",
798 (
799 LiteralHTML,
800 Markup,
801 ),
802 )
803 def test_singleton_has_html_dunder_pitfall(self, html_dunder_cls):
804 # @TODO: We should probably put some double override to prevent this by accident.
805 content = html_dunder_cls("</textarea>")
806 assert (
807 html(t"<textarea>{content}</textarea>")
808 == "<textarea></textarea></textarea>"
809 ), "DO NOT DO THIS! This is just an advanced escape hatch!"
811 def test_singleton_escaping(self):
812 content = "</textarea>"
813 assert (
814 html(t"<textarea>{content}</textarea>")
815 == "<textarea></textarea></textarea>"
816 )
818 def test_templated_none(self):
819 assert (
820 html(t"<textarea>A great story about: {None}</textarea>")
821 == "<textarea>A great story about: </textarea>"
822 )
824 def test_templated_str(self):
825 content = "TDOM"
826 assert (
827 html(t"<textarea>A great story about: {content}</textarea>")
828 == "<textarea>A great story about: TDOM</textarea>"
829 )
831 @pytest.mark.parametrize("bool_value", (True, False))
832 def test_templated_bool(self, bool_value):
833 assert (
834 html(t"<textarea>This is great.{bool_value}</textarea>")
835 == "<textarea>This is great.</textarea>"
836 )
838 def test_templated_object(self):
839 content = 0
840 assert (
841 html(t"<textarea>A great number: {content}</textarea>")
842 == "<textarea>A great number: 0</textarea>"
843 )
845 @pytest.mark.parametrize(
846 "html_dunder_cls",
847 (
848 LiteralHTML,
849 Markup,
850 ),
851 )
852 def test_templated_has_html_dunder(self, html_dunder_cls):
853 content = html_dunder_cls("No")
854 with pytest.raises(ValueError, match="not supported"):
855 _ = html(t"<textarea>Literal html?: {content}</textarea>")
857 def test_templated_multiple_interpolations(self):
858 assert (
859 html(t"<textarea>The number {0} is less than {1}.</textarea>")
860 == "<textarea>The number 0 is less than 1.</textarea>"
861 )
863 def test_templated_escaping(self):
864 content = "</textarea>"
865 assert (
866 html(t"<textarea>The end tag: {content}.</textarea>")
867 == "<textarea>The end tag: </textarea>.</textarea>"
868 )
870 def test_not_supported_recursive_template_error(self):
871 text_t = t"textarea"
872 with pytest.raises(ValueError, match="not supported"):
873 _ = html(t"<textarea>{text_t}</textarea>")
875 def test_not_supported_recursive_iterable_error(self):
876 texts = ["This", "is", "a", "textarea"]
877 with pytest.raises(ValueError, match="not supported"):
878 _ = html(t"<textarea>{texts}</textarea>")
881class Convertible:
882 def __str__(self):
883 return "string"
885 def __repr__(self):
886 return "repr"
889def test_convertible_fixture():
890 """Make sure test fixture is working correctly."""
891 c = Convertible()
892 assert f"{c!s}" == "string"
893 assert f"{c!r}" == "repr"
896def wrap_template_in_tags(
897 start_tag: str, template: Template, end_tag: str | None = None
898):
899 """Utility for testing templated text but with different containing tags."""
900 if end_tag is None:
901 end_tag = start_tag
902 return Template(f"<{start_tag}>") + template + Template(f"</{end_tag}>")
905def wrap_text_in_tags(start_tag: str, content: str, end_tag: str | None = None):
906 """Utility for testing expected text but with different containing tags."""
907 if end_tag is None:
908 end_tag = start_tag
909 # Stringify to flatten `Markup()`
910 content = str(content)
911 return f"<{start_tag}>" + content + f"</{end_tag}>"
914class TestInterpolationConversion:
915 def test_str(self):
916 c = Convertible()
917 for tag in ("p", "script", "title"):
918 assert html(wrap_template_in_tags(tag, t"{c!s}")) == wrap_text_in_tags(
919 tag, "string"
920 )
922 def test_repr(self):
923 c = Convertible()
924 for tag in ("p", "script", "title"):
925 assert html(wrap_template_in_tags(tag, t"{c!r}")) == wrap_text_in_tags(
926 tag, "repr"
927 )
929 def test_ascii_raw_text(self):
930 # single quotes are not escaped in raw text
931 assert html(wrap_template_in_tags("script", t"{'😊'!a}")) == wrap_text_in_tags(
932 "script", ascii("😊")
933 )
935 def test_ascii_escapable_normal_and_raw(self):
936 # single quotes are escaped
937 for tag in ("p", "title"):
938 assert html(wrap_template_in_tags(tag, t"{'😊'!a}")) == wrap_text_in_tags(
939 tag, escape_html_text(ascii("😊"))
940 )
943class TestInterpolationFormatSpec:
944 def test_normal_text_safe(self):
945 raw_content = "<u>underlined</u>"
946 assert (
947 html(t"<p>This is {raw_content:safe} text.</p>")
948 == "<p>This is <u>underlined</u> text.</p>"
949 )
951 def test_raw_text_safe(self):
952 # @TODO: What should even happen here?
953 raw_content = "</script>"
954 assert (
955 html(t"<script>{raw_content:safe}</script>") == "<script></script></script>"
956 ), "DO NOT DO THIS! This is an advanced escape hatch."
958 def test_escapable_raw_text_safe(self):
959 raw_content = "<u>underlined</u>"
960 assert (
961 html(t"<textarea>{raw_content:safe}</textarea>")
962 == "<textarea><u>underlined</u></textarea>"
963 )
965 def test_normal_text_unsafe(self):
966 supposedly_safe = Markup("<i>italic</i>")
967 assert (
968 html(t"<p>This is {supposedly_safe:unsafe} text.</p>")
969 == "<p>This is <i>italic</i> text.</p>"
970 )
972 def test_raw_text_unsafe(self):
973 # @TODO: What should even happen here?
974 supposedly_safe = "</script>"
975 assert (
976 html(t"<script>{supposedly_safe:unsafe}</script>")
977 == "<script>\\x3c/script></script>"
978 )
979 assert (
980 html(t"<script>{supposedly_safe:unsafe}</script>")
981 != "<script></script></script>"
982 ) # Sanity check
984 def test_escapable_raw_text_unsafe(self):
985 supposedly_safe = Markup("<i>italic</i>")
986 assert (
987 html(t"<textarea>{supposedly_safe:unsafe}</textarea>")
988 == "<textarea><i>italic</i></textarea>"
989 )
991 def test_all_text_callback(self):
992 def get_value():
993 return "dynamic"
995 for tag in ("p", "script", "style"):
996 assert (
997 html(
998 Template(f"<{tag}>")
999 + t"The value is {get_value:callback}."
1000 + Template(f"</{tag}>")
1001 )
1002 == f"<{tag}>The value is dynamic.</{tag}>"
1003 )
1005 def test_callback_nonzero_callable_error(self):
1006 def add(a, b):
1007 return a + b
1009 assert add(1, 2) == 3, "Make sure fixture could work..."
1011 for tag in ("p", "script", "style"):
1012 with pytest.raises(TypeError):
1013 _ = html(
1014 Template(f"<{tag}>")
1015 + t"The sum is {add:callback}."
1016 + Template(f"</{tag}>")
1017 )
1020# --------------------------------------------------------------------------
1021# Conditional rendering and control flow
1022# --------------------------------------------------------------------------
1025class TestUsagePatterns:
1026 def test_conditional_rendering_with_if_else(self):
1027 is_logged_in = True
1028 user_profile = t"<span>Welcome, User!</span>"
1029 login_prompt = t"<a href='/login'>Please log in</a>"
1030 assert (
1031 html(t"<div>{user_profile if is_logged_in else login_prompt}</div>")
1032 == "<div><span>Welcome, User!</span></div>"
1033 )
1035 is_logged_in = False
1036 assert (
1037 html(t"<div>{user_profile if is_logged_in else login_prompt}</div>")
1038 == '<div><a href="/login">Please log in</a></div>'
1039 )
1042# --------------------------------------------------------------------------
1043# Attributes
1044# --------------------------------------------------------------------------
1045class TestLiteralAttribute:
1046 """Test literal (non-dynamic) attributes."""
1048 def test_literal_attrs(self):
1049 assert (
1050 html(
1051 t"<a "
1052 t" id=example_link" # no quotes required if value has no surrounding whitespace
1053 t" autofocus" # bare / boolean
1054 t' title=""' # empty attribute
1055 t' href="https://example.com" target="_blank"'
1056 t"></a>"
1057 )
1058 == '<a id="example_link" autofocus title="" href="https://example.com" target="_blank"></a>'
1059 )
1061 def test_literal_attr_escaped(self):
1062 assert (
1063 html(t'<a title="<>&'""></a>')
1064 == '<a title="<>&'""></a>'
1065 )
1068class TestInterpolatedAttribute:
1069 """Test interpolated attributes, entire value is an exact interpolation."""
1071 def test_interpolated_attr(self):
1072 url = "https://example.com/"
1073 assert html(t'<a href="{url}"></a>') == '<a href="https://example.com/"></a>'
1075 def test_interpolated_attr_escaped(self):
1076 url = 'https://example.com/?q="test"&lang=en'
1077 assert (
1078 html(t'<a href="{url}"></a>')
1079 == '<a href="https://example.com/?q="test"&lang=en"></a>'
1080 )
1082 def test_interpolated_attr_unquoted(self):
1083 id = "roquefort"
1084 assert html(t"<div id={id}></div>") == '<div id="roquefort"></div>'
1086 def test_interpolated_attr_true(self):
1087 disabled = True
1088 assert (
1089 html(t"<button disabled={disabled}></button>")
1090 == "<button disabled></button>"
1091 )
1093 def test_interpolated_attr_false(self):
1094 disabled = False
1095 assert html(t"<button disabled={disabled}></button>") == "<button></button>"
1097 def test_interpolated_attr_none(self):
1098 disabled = None
1099 assert html(t"<button disabled={disabled}></button>") == "<button></button>"
1101 def test_interpolate_attr_empty_string(self):
1102 assert html(t'<div title=""></div>') == '<div title=""></div>'
1105class TestSpreadAttribute:
1106 """Test spread attributes."""
1108 def test_spread_attr(self):
1109 attrs = {"href": "https://example.com/", "target": "_blank"}
1110 assert (
1111 html(t"<a {attrs}></a>")
1112 == '<a href="https://example.com/" target="_blank"></a>'
1113 )
1115 def test_spread_attr_none(self):
1116 attrs = None
1117 assert html(t"<a {attrs}></a>") == "<a></a>"
1119 def test_spread_attr_type_errors(self):
1120 for attrs in (0, [], (), False, True):
1121 with pytest.raises(TypeError):
1122 _ = html(t"<a {attrs}></a>")
1125class TestTemplatedAttribute:
1126 def test_templated_attr_mixed_interpolations_start_end_and_nest(self):
1127 left, middle, right = 1, 3, 5
1128 prefix, suffix = t'<div data-range="', t'"></div>'
1129 # Check interpolations at start, middle and/or end of templated attr
1130 # or a combination of those to make sure text is not getting dropped.
1131 for left_part, middle_part, right_part in product(
1132 (t"{left}", Template(str(left))),
1133 (t"{middle}", Template(str(middle))),
1134 (t"{right}", Template(str(right))),
1135 ):
1136 test_t = (
1137 prefix + left_part + t"-" + middle_part + t"-" + right_part + suffix
1138 )
1139 assert html(test_t) == '<div data-range="1-3-5"></div>'
1141 def test_templated_attr_no_quotes(self):
1142 start = 1
1143 end = 5
1144 assert (
1145 html(t"<div data-range={start}-{end}></div>")
1146 == '<div data-range="1-5"></div>'
1147 )
1150class TestAttributeMerging:
1151 def test_attr_merge_disjoint_interpolated_attr_spread_attr(self):
1152 attrs = {"href": "https://example.com/", "id": "link1"}
1153 target = "_blank"
1154 assert (
1155 html(t"<a {attrs} target={target}></a>")
1156 == '<a href="https://example.com/" id="link1" target="_blank"></a>'
1157 )
1159 def test_attr_merge_overlapping_spread_attrs(self):
1160 attrs1 = {"href": "https://example.com/", "id": "overwrtten"}
1161 attrs2 = {"target": "_blank", "id": "link1"}
1162 assert (
1163 html(t"<a {attrs1} {attrs2}></a>")
1164 == '<a href="https://example.com/" target="_blank" id="link1"></a>'
1165 )
1167 def test_attr_merge_replace_literal_attr_str_str(self):
1168 assert (
1169 html(t'<div title="default" { {"title": "fresh"} }></div>')
1170 == '<div title="fresh"></div>'
1171 )
1173 def test_attr_merge_replace_literal_attr_str_true(self):
1174 assert (
1175 html(t'<div title="default" { {"title": True} }></div>')
1176 == "<div title></div>"
1177 )
1179 def test_attr_merge_replace_literal_attr_true_str(self):
1180 assert (
1181 html(t"<div title { {'title': 'fresh'} }></div>")
1182 == '<div title="fresh"></div>'
1183 )
1185 def test_attr_merge_remove_literal_attr_str_none(self):
1186 assert html(t'<div title="default" { {"title": None} }></div>') == "<div></div>"
1188 def test_attr_merge_remove_literal_attr_true_none(self):
1189 assert html(t"<div title { {'title': None} }></div>") == "<div></div>"
1191 def test_attr_merge_other_literal_attr_intact(self):
1192 assert (
1193 html(t'<img title="default" { {"alt": "fresh"} }>')
1194 == '<img title="default" alt="fresh" />'
1195 )
1198class TestSpecialDataAttribute:
1199 """Special data attribute handling."""
1201 def test_interpolated_data_attributes(self):
1202 data = {
1203 "user-id": 123,
1204 "role": "admin",
1205 "wild": True,
1206 "false": False,
1207 "none": None,
1208 }
1209 assert (
1210 html(t"<div data={data}>User Info</div>")
1211 == '<div data-user-id="123" data-role="admin" data-wild>User Info</div>'
1212 )
1214 def test_data_attr_toggle_to_str(self):
1215 for res in [
1216 html(t"<div data-selected data={ {'selected': 'yes'} }></div>"),
1217 html(t'<div data-selected="no" data={ {"selected": "yes"} }></div>'),
1218 ]:
1219 assert res == '<div data-selected="yes"></div>'
1221 def test_data_attr_toggle_to_true(self):
1222 res = html(t'<div data-selected="yes" data={ {"selected": True} }></div>')
1223 assert res == "<div data-selected></div>"
1225 def test_data_attr_unrelated_unaffected(self):
1226 res = html(t"<div data-selected data={ {'active': True} }></div>")
1227 assert res == "<div data-selected data-active></div>"
1229 def test_data_attr_templated_error(self):
1230 data1 = {"user-id": "user-123"}
1231 data2 = {"role": "admin"}
1232 with pytest.raises(TypeError):
1233 _ = html(t'<div data="{data1} {data2}"></div>')
1235 def test_data_attr_none(self):
1236 button_data = None
1237 res = html(t"<button data={button_data}>X</button>")
1238 assert res == "<button>X</button>"
1240 def test_data_attr_errors(self):
1241 for v in [False, [], (), 0, "data?"]:
1242 with pytest.raises(TypeError):
1243 _ = html(t"<button data={v}>X</button>")
1245 def test_data_literal_attr_bypass(self):
1246 # Trigger overall attribute resolution with an unrelated interpolated attr.
1247 res = html(t'<p data="passthru" id={"resolved"}></p>')
1248 assert res == '<p data="passthru" id="resolved"></p>', (
1249 "A single literal attribute should not trigger data expansion."
1250 )
1253class TestSpecialAriaAttribute:
1254 """Special aria attribute handling."""
1256 def test_aria_templated_attr_error(self):
1257 aria1 = {"label": "close"}
1258 aria2 = {"hidden": "true"}
1259 with pytest.raises(TypeError):
1260 _ = html(t'<div aria="{aria1} {aria2}"></div>')
1262 def test_interpolated_mapping(self):
1263 aria_dict = {"label": "Close", "hidden": True, "another": False, "more": None}
1264 for aria_mapping in (
1265 aria_dict,
1266 UserDict(aria_dict.items()),
1267 ): # dict and non-dict Mapping
1268 res = html(t"<button aria={aria_mapping}>X</button>")
1269 assert (
1270 res
1271 == '<button aria-label="Close" aria-hidden="true" aria-another="false">X</button>'
1272 )
1274 def test_aria_interpolate_attr_none(self):
1275 button_aria = None
1276 res = html(t"<button aria={button_aria}>X</button>")
1277 assert res == "<button>X</button>"
1279 def test_aria_attr_errors(self):
1280 for v in [False, [], (), 0, "aria?"]:
1281 with pytest.raises(TypeError):
1282 _ = html(t"<button aria={v}>X</button>")
1284 def test_aria_literal_attr_bypass(self):
1285 # Trigger overall attribute resolution with an unrelated interpolated attr.
1286 res = html(t'<p aria="passthru" id={"resolved"}></p>')
1287 assert res == '<p aria="passthru" id="resolved"></p>', (
1288 "A single literal attribute should not trigger aria expansion."
1289 )
1292class TestSpecialClassAttribute:
1293 """Special class attribute handling."""
1295 def test_interpolated_class_attribute(self):
1296 class_list = ["btn", "btn-primary", "one two", None]
1297 class_dict = {"active": True, "btn-secondary": False}
1298 class_str = "blue"
1299 class_space_sep_str = "green yellow"
1300 class_none = None
1301 class_empty_list = []
1302 class_empty_dict = {}
1303 button_t = (
1304 t"<button "
1305 t' class="red" class={class_list} class={class_dict}'
1306 t" class={class_empty_list} class={class_empty_dict}" # ignored
1307 t" class={class_none}" # ignored
1308 t" class={class_str} class={class_space_sep_str}"
1309 t" >Click me</button>"
1310 )
1311 res = html(button_t)
1312 assert (
1313 res
1314 == '<button class="red btn btn-primary one two active blue green yellow">Click me</button>'
1315 )
1317 def test_interpolated_mapping(self):
1318 class_dict = {"active": True, "btn-secondary": False}
1319 for class_mapping in (
1320 class_dict,
1321 UserDict(class_dict.items()),
1322 ): # dict and non-dict Mapping
1323 res = html(
1324 t"<button class='btn-secondary' class={class_mapping}>X</button>"
1325 )
1326 assert res == '<button class="active">X</button>'
1328 def test_interpolated_class_attribute_with_multiple_placeholders(self):
1329 classes1 = ["btn", "btn-primary"]
1330 classes2 = [None, {"active": True}]
1331 res = html(t'<button class="{classes1} {classes2}">Click me</button>')
1332 # CONSIDER: Is this what we want? Currently, when we have multiple
1333 # placeholders in a single attribute, we treat it as a string attribute.
1334 assert (
1335 res
1336 == f'<button class="{escape_html_text(str(classes1))} {escape_html_text(str(classes2))}">Click me</button>'
1337 ), (
1338 "Interpolations that are not exact, or singletons, are instead interpreted as templates and therefore these dictionaries are strified."
1339 )
1341 def test_interpolated_attribute_spread_with_class_attribute(self):
1342 attrs = {"id": "button1", "class": ["btn", "btn-primary"]}
1343 res = html(t"<button {attrs}>Click me</button>")
1344 assert res == '<button id="button1" class="btn btn-primary">Click me</button>'
1346 def test_class_literal_attr_bypass(self):
1347 # Trigger overall attribute resolution with an unrelated interpolated attr.
1348 res = html(t'<p class="red red" id={"veryred"}></p>')
1349 assert res == '<p class="red red" id="veryred"></p>', (
1350 "A single literal attribute should not trigger class accumulator."
1351 )
1353 def test_class_none_ignored(self):
1354 class_item = None
1355 res = html(t"<p class={class_item}></p>")
1356 assert res == "<p></p>"
1357 # Also ignored inside a sequence.
1358 res = html(t"<p class={[class_item]}></p>")
1359 assert res == "<p></p>"
1361 def test_class_type_errors(self):
1362 for class_item in (False, True, 0):
1363 with pytest.raises(TypeError):
1364 _ = html(t"<p class={class_item}></p>")
1365 with pytest.raises(TypeError):
1366 _ = html(t"<p class={[class_item]}></p>")
1368 def test_class_merge_literals(self):
1369 res = html(t'<p class="red" class="blue"></p>')
1370 assert res == '<p class="red blue"></p>'
1372 def test_class_merge_literal_then_interpolation(self):
1373 class_item = "blue"
1374 res = html(t'<p class="red" class="{[class_item]}"></p>')
1375 assert res == '<p class="red blue"></p>'
1378class TestSpecialStyleAttribute:
1379 """Special style attribute handling."""
1381 def test_style_literal_attr_passthru(self):
1382 p_id = "para1" # non-literal attribute to cause attr resolution
1383 res = html(t'<p style="color: red" id={p_id}>Warning!</p>')
1384 assert res == '<p style="color: red" id="para1">Warning!</p>'
1386 def test_style_in_interpolated_attr(self):
1387 styles = {"color": "red", "font-weight": "bold", "font-size": "16px"}
1388 res = html(t"<p style={styles}>Warning!</p>")
1389 assert (
1390 res
1391 == '<p style="color: red; font-weight: bold; font-size: 16px">Warning!</p>'
1392 )
1394 def test_style_in_templated_attr(self):
1395 color = "red"
1396 res = html(t'<p style="color: {color}">Warning!</p>')
1397 assert res == '<p style="color: red">Warning!</p>'
1399 def test_style_in_spread_attr(self):
1400 attrs = {"style": {"color": "red"}}
1401 res = html(t"<p {attrs}>Warning!</p>")
1402 assert res == '<p style="color: red">Warning!</p>'
1404 def test_style_merged_from_all_attrs(self):
1405 attrs = {"style": "font-size: 15px"}
1406 style = {"font-weight": "bold"}
1407 color = "red"
1408 res = html(
1409 t'<p style="font-family: serif" style="color: {color}" style={style} {attrs}></p>'
1410 )
1411 assert (
1412 res
1413 == '<p style="font-family: serif; color: red; font-weight: bold; font-size: 15px"></p>'
1414 )
1416 def test_style_override_left_to_right(self):
1417 suffix = t"></p>"
1418 parts = [
1419 (t'<p style="color: red"', "color: red"),
1420 (t" style={ {'color': 'blue'} }", "color: blue"),
1421 (t' style="color: {"green"}"', "color: green"),
1422 (t""" { {"style": {"color": "yellow"}} }""", "color: yellow"),
1423 ]
1424 for index in range(len(parts)):
1425 expected_style = parts[index][1]
1426 t = sum((part[0] for part in parts[: index + 1]), t"") + suffix
1427 res = html(t)
1428 assert res == f'<p style="{expected_style}"></p>'
1430 def test_interpolated_style_attribute_multiple_placeholders(self):
1431 styles1 = {"color": "red"}
1432 styles2 = {"font-weight": "bold"}
1433 # CONSIDER: Is this what we want? Currently, when we have multiple
1434 # placeholders in a single attribute, we treat it as a string attribute
1435 # which produces an invalid style attribute.
1436 with pytest.raises(ValueError):
1437 _ = html(t"<p style='{styles1} {styles2}'>Warning!</p>")
1439 def test_interpolated_style_attribute_merged(self):
1440 styles1 = {"color": "red"}
1441 styles2 = {"font-weight": "bold"}
1442 res = html(t"<p style={styles1} style={styles2}>Warning!</p>")
1443 assert res == '<p style="color: red; font-weight: bold">Warning!</p>'
1445 def test_interpolated_style_attribute_merged_override(self):
1446 styles1 = {"color": "red", "font-weight": "normal"}
1447 styles2 = {"font-weight": "bold"}
1448 res = html(t"<p style={styles1} style={styles2}>Warning!</p>")
1449 assert res == '<p style="color: red; font-weight: bold">Warning!</p>'
1451 def test_style_attribute_str(self):
1452 styles = "color: red; font-weight: bold;"
1453 res = html(t"<p style={styles}>Warning!</p>")
1454 assert res == '<p style="color: red; font-weight: bold">Warning!</p>'
1456 def test_style_attribute_non_str_non_dict(self):
1457 styles = [1, 2]
1458 with pytest.raises(TypeError):
1459 _ = html(t"<p style={styles}>Warning!</p>")
1461 def test_style_literal_attr_bypass(self):
1462 # Trigger overall attribute resolution with an unrelated interpolated attr.
1463 res = html(t'<p style="invalid;invalid:" id={"resolved"}></p>')
1464 assert res == '<p style="invalid;invalid:" id="resolved"></p>', (
1465 "A single literal attribute should bypass style accumulator."
1466 )
1468 def test_style_none(self):
1469 styles = None
1470 res = html(t"<p style={styles}></p>")
1471 assert res == "<p></p>"
1474class TestSpecialAttrMerging:
1475 """
1476 Attributes should be merged left to right and displayed at the last
1477 location they were updated.
1478 """
1480 def test_accumulator_order(self):
1481 # Accumlated attrs are flattened to a value at the end of the attribute
1482 # resolution process which caused them to jump but this asserts that fix.
1483 attrs = {
1484 "class": {"btn": True, "active": True}, # Accumulated
1485 "id": "act_now", # static
1486 "data": {"wow": "such-attr"}, # Expanded
1487 "title": "mega", # static
1488 }
1489 button = html(t"<button {attrs}>Click me</button>")
1490 assert (
1491 button
1492 == '<button class="btn active" id="act_now" data-wow="such-attr" title="mega">Click me</button>'
1493 )
1496class TestPrepComponentKwargs:
1497 def test_named(self):
1498 def InputElement(size=10, type="text"):
1499 pass
1501 callable_info = get_callable_info(InputElement)
1502 assert prep_component_kwargs(callable_info, {"size": 20}, children=t"") == {
1503 "size": 20
1504 }
1505 assert prep_component_kwargs(
1506 callable_info, {"type": "email"}, children=t""
1507 ) == {"type": "email"}
1508 assert prep_component_kwargs(callable_info, {}, children=t"") == {}
1510 def test_unused_kwargs(self):
1511 def InputElement(size=10, type="text"):
1512 pass
1514 callable_info = get_callable_info(InputElement)
1515 with pytest.raises(ValueError):
1516 assert (
1517 prep_component_kwargs(callable_info, {"type2": 15}, children=t"") == {}
1518 )
1520 def test_accepts_children(self):
1521 def DivWrapper(
1522 children: Template, add_classes: list[str] | None = None
1523 ) -> Template:
1524 return t"<div class={add_classes}>{children}</div>"
1526 callable_info = get_callable_info(DivWrapper)
1527 kwargs = prep_component_kwargs(callable_info, {}, children=t"")
1528 assert tuple(kwargs.keys()) == ("children",)
1529 assert isinstance(kwargs["children"], Template) and kwargs[
1530 "children"
1531 ].strings == ("",)
1533 add_classes = ["red"]
1534 kwargs = prep_component_kwargs(
1535 callable_info, {"add_classes": add_classes}, children=t"<span></span>"
1536 )
1537 assert set(kwargs.keys()) == {"children", "add_classes"}
1538 assert isinstance(kwargs["children"], Template) and kwargs[
1539 "children"
1540 ].strings == ("<span></span>",)
1541 assert kwargs["add_classes"] == add_classes
1543 def test_no_children(self):
1544 def SpanMaker(content_text: str) -> Template:
1545 return t"<span>{content_text}</span>"
1547 callable_info = get_callable_info(SpanMaker)
1548 content_text = "inner"
1549 kwargs = prep_component_kwargs(
1550 callable_info, {"content_text": content_text}, children=t"<div></div>"
1551 )
1552 assert kwargs == {"content_text": content_text} # no children
1554 def test_children_attr_error(self):
1555 def Comp(children: Template) -> Template:
1556 return t"<div>{children}</div>"
1558 callable_info = get_callable_info(Comp)
1559 with pytest.raises(ValueError, match="The children attribute is reserved"):
1560 _ = prep_component_kwargs(
1561 callable_info, {"children": t""}, children=t"<span></span>"
1562 )
1564 def test_to_snake(self):
1565 def Wrapper(mw_em, **kwargs):
1566 pass
1568 callable_info = get_callable_info(Wrapper)
1569 assert prep_component_kwargs(callable_info, {"mw-em": 20}, children=t"") == {
1570 "mw_em": 20
1571 }
1573 def test_to_snake_pass_through(self):
1574 def Wrapper(mw_em, **kwargs):
1575 pass
1577 callable_info = get_callable_info(Wrapper)
1578 assert prep_component_kwargs(
1579 callable_info,
1580 {"mw-em": 20, "hx-on:click": "alert('Clicked!')"},
1581 children=t"",
1582 ) == {
1583 "mw_em": 20, # mangled, passed as named param
1584 "hx-on:click": "alert('Clicked!')", # not mangled, will go in kwargs
1585 }
1587 @pytest.mark.parametrize(
1588 "attrs",
1589 [
1590 (
1591 ("prefix_attr_value", 10),
1592 ("prefix_attr-value", 20),
1593 ), # exact and not exact match
1594 (
1595 ("prefix-attr_value", 10),
1596 ("prefix_attr-value", 20),
1597 ), # both not exact match
1598 ],
1599 )
1600 def test_to_snake_ambiguous_collision(self, attrs):
1601 def Wrapper(prefix_attr_value, **kwargs):
1602 pass
1604 callable_info = get_callable_info(Wrapper)
1605 with pytest.raises(ValueError, match="Ambiguous attribute "):
1606 _ = prep_component_kwargs(
1607 callable_info,
1608 dict(attrs),
1609 children=t"",
1610 )
1613class TestFunctionComponent:
1614 @staticmethod
1615 def FunctionComponent(
1616 children: Template, first: str, second: int, third_arg: str, **attrs: t.Any
1617 ) -> Template:
1618 # Ensure type correctness of props at runtime for testing purposes
1619 assert isinstance(first, str)
1620 assert isinstance(second, int)
1621 assert isinstance(third_arg, str)
1622 new_attrs = {
1623 "id": third_arg,
1624 "data": {"first": first, "second": second},
1625 **attrs,
1626 }
1627 return t"<div {new_attrs}>Component: {children}</div>"
1629 def test_with_children(self):
1630 res = html(
1631 t'<{self.FunctionComponent} first=1 second={99} third-arg="comp1" class="my-comp">Hello, Component!</{self.FunctionComponent}>'
1632 )
1633 assert (
1634 res
1635 == '<div id="comp1" data-first="1" data-second="99" class="my-comp">Component: Hello, Component!</div>'
1636 )
1638 def test_with_no_children(self):
1639 """Same test, but the caller didn't provide any children."""
1640 res = html(
1641 t'<{self.FunctionComponent} first=1 second={99} third-arg="comp1" class="my-comp" />'
1642 )
1643 assert (
1644 res
1645 == '<div id="comp1" data-first="1" data-second="99" class="my-comp">Component: </div>'
1646 )
1648 def test_missing_props_error(self):
1649 with pytest.raises(TypeError):
1650 _ = html(
1651 t"<{self.FunctionComponent}>Missing props</{self.FunctionComponent}>"
1652 )
1655class TestFunctionComponentNoChildren:
1656 @staticmethod
1657 def FunctionComponentNoChildren(
1658 first: str, second: int, third_arg: str
1659 ) -> Template:
1660 # Ensure type correctness of props at runtime for testing purposes
1661 assert isinstance(first, str)
1662 assert isinstance(second, int)
1663 assert isinstance(third_arg, str)
1664 new_attrs = {
1665 "id": third_arg,
1666 "data": {"first": first, "second": second},
1667 }
1668 return t"<div {new_attrs}>Component: ignore children</div>"
1670 def test_interpolated_template_component_ignore_children(self):
1671 res = html(
1672 t'<{self.FunctionComponentNoChildren} first=1 second={99} third-arg="comp1">Hello, Component!</{self.FunctionComponentNoChildren}>'
1673 )
1674 assert (
1675 res
1676 == '<div id="comp1" data-first="1" data-second="99">Component: ignore children</div>'
1677 )
1680class TestFunctionComponentKeywordArgs:
1681 @staticmethod
1682 def FunctionComponentKeywordArgs(first: str, **attrs: t.Any) -> Template:
1683 # Ensure type correctness of props at runtime for testing purposes
1684 assert isinstance(first, str)
1685 if "children" in attrs:
1686 raise ValueError("Children not expected in attrs.")
1687 new_attrs = {"data-first": first, **attrs}
1688 return t"<div {new_attrs}>No children in kwargs</div>"
1690 def test_children_not_passed_via_kwargs(self):
1691 res = html(
1692 t'<{self.FunctionComponentKeywordArgs} first="value" extra="info">Child content</{self.FunctionComponentKeywordArgs}>'
1693 )
1694 assert res == '<div data-first="value" extra="info">No children in kwargs</div>'
1696 def test_children_not_passed_via_kwargs_even_when_empty(self):
1697 res = html(
1698 t'<{self.FunctionComponentKeywordArgs} first="value" extra="info" />'
1699 )
1700 assert res == '<div data-first="value" extra="info">No children in kwargs</div>'
1703class TestComponentSpecialUsage:
1704 @staticmethod
1705 def ColumnsComponent() -> Template:
1706 return t"""<td>Column 1</td><td>Column 2</td>"""
1708 def test_fragment_from_component(self):
1709 # This test assumes that if a component returns a template that parses
1710 # into multiple root elements, they are treated as a fragment.
1711 res = html(t"<table><tr><{self.ColumnsComponent} /></tr></table>")
1712 assert res == "<table><tr><td>Column 1</td><td>Column 2</td></tr></table>"
1714 def test_component_passed_as_attr_value(self):
1715 def Wrapper(
1716 children: Template, sub_component: Callable, **attrs: t.Any
1717 ) -> Template:
1718 return t"<{sub_component} {attrs}>{children}</{sub_component}>"
1720 res = html(
1721 t'<{Wrapper} sub-component={TestFunctionComponent.FunctionComponent} class="wrapped" first=1 second={99} third-arg="comp1"><p>Inside wrapper</p></{Wrapper}>'
1722 )
1723 assert (
1724 res
1725 == '<div id="comp1" data-first="1" data-second="99" class="wrapped">Component: <p>Inside wrapper</p></div>'
1726 )
1728 def test_nested_component_gh23(self):
1729 # @DESIGN: Do we need this? Should we recommend an alternative?
1730 # See https://github.com/t-strings/tdom/issues/23 for context
1731 def Header() -> Template:
1732 return t"{'Hello World'}"
1734 res = html(t"<{Header} />", assume_ctx=make_ctx(parent_tag="div"))
1735 assert res == "Hello World"
1738class TestClassComponent:
1739 @dataclass
1740 class ClassComponent:
1741 """Example class-based component."""
1743 user_name: str
1744 image_url: str
1745 children: Template
1746 homepage: str = "#"
1748 def __call__(self) -> Template:
1749 return (
1750 t"<div class='avatar'>"
1751 t"<a href={self.homepage}>"
1752 t"<img src='{self.image_url}' alt='{f'Avatar of {self.user_name}'}' />"
1753 t"</a>"
1754 t"<span>{self.user_name}</span>"
1755 t"{self.children}"
1756 t"</div>"
1757 )
1759 def test_class_component_implicit_invocation_with_children(self):
1760 res = html(
1761 t"<{self.ClassComponent} user-name='Alice' image-url='https://example.com/alice.png'>Fun times!</{self.ClassComponent}>"
1762 )
1763 assert (
1764 res
1765 == '<div class="avatar"><a href="#"><img src="https://example.com/alice.png" alt="Avatar of Alice" /></a><span>Alice</span>Fun times!</div>'
1766 )
1768 def test_class_component_direct_invocation(self):
1769 avatar = self.ClassComponent(
1770 user_name="Alice",
1771 image_url="https://example.com/alice.png",
1772 homepage="https://example.com/users/alice",
1773 children=t"", # Children is required so we set it to an empty template.
1774 )
1775 res = html(t"<{avatar} />")
1776 assert (
1777 res
1778 == '<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>'
1779 )
1781 @dataclass
1782 class ClassComponentNoChildren:
1783 """Example class-based component that does not ask for children."""
1785 user_name: str
1786 image_url: str
1787 homepage: str = "#"
1789 def __call__(self) -> Template:
1790 return (
1791 t"<div class='avatar'>"
1792 t"<a href={self.homepage}>"
1793 t"<img src='{self.image_url}' alt='{f'Avatar of {self.user_name}'}' />"
1794 t"</a>"
1795 t"<span>{self.user_name}</span>"
1796 t"ignore children"
1797 t"</div>"
1798 )
1800 def test_implicit_invocation_ignore_children(self):
1801 res = html(
1802 t"<{self.ClassComponentNoChildren} user-name='Alice' image-url='https://example.com/alice.png'>Fun times!</{self.ClassComponentNoChildren}>"
1803 )
1804 assert (
1805 res
1806 == '<div class="avatar"><a href="#"><img src="https://example.com/alice.png" alt="Avatar of Alice" /></a><span>Alice</span>ignore children</div>'
1807 )
1810def test_attribute_type_component():
1811 def AttributeTypeComponent(
1812 data_int: int,
1813 data_true: bool,
1814 data_false: bool,
1815 data_none: None,
1816 data_float: float,
1817 data_dt: datetime.datetime,
1818 **kws: dict[str, object | None],
1819 ) -> Template:
1820 """Component to test that we don't incorrectly convert attribute types."""
1821 assert isinstance(data_int, int)
1822 assert data_true is True
1823 assert data_false is False
1824 assert data_none is None
1825 assert isinstance(data_float, float)
1826 assert isinstance(data_dt, datetime.datetime)
1827 for kw, v_type in [
1828 ("spread_true", True),
1829 ("spread_false", False),
1830 ("spread_int", int),
1831 ("spread_none", None),
1832 ("spread_float", float),
1833 ("spread_dt", datetime.datetime),
1834 ("spread_dict", dict),
1835 ("spread_list", list),
1836 ]:
1837 if v_type in (True, False, None):
1838 assert kw in kws and kws[kw] is v_type, (
1839 f"{kw} should be {v_type} but got {kws=}"
1840 )
1841 else:
1842 assert kw in kws and isinstance(kws[kw], v_type), (
1843 f"{kw} should instance of {v_type} but got {kws=}"
1844 )
1845 return t"Looks good!"
1847 an_int: int = 42
1848 a_true: bool = True
1849 a_false: bool = False
1850 a_none: None = None
1851 a_float: float = math.pi
1852 a_dt: datetime.datetime = datetime.datetime(
1853 2024, 1, 1, 12, 0, 0, tzinfo=datetime.UTC
1854 )
1855 spread_attrs: dict[str, object | None] = {
1856 "spread_true": True,
1857 "spread_false": False,
1858 "spread_none": None,
1859 "spread_int": 0,
1860 "spread_float": 0.0,
1861 "spread_dt": datetime.datetime(2024, 1, 1, 12, 0, 1, tzinfo=datetime.UTC),
1862 "spread_dict": {},
1863 "spread_list": ["eggs", "milk"],
1864 }
1865 res = html(
1866 t"<{AttributeTypeComponent} data-int={an_int} data-true={a_true} "
1867 t"data-false={a_false} data-none={a_none} data-float={a_float} "
1868 t"data-dt={a_dt} {spread_attrs}/>"
1869 )
1870 assert res == "Looks good!"
1873class TestComponentErrors:
1874 def test_component_non_callable_fails(self):
1875 with pytest.raises(TypeError):
1876 _ = html(t"<{'not a function'} />")
1878 def test_component_requiring_positional_arg_fails(self):
1879 def RequiresPositional(whoops: int, /) -> Template: # pragma: no cover
1880 return t"<p>Positional arg: {whoops}</p>"
1882 with pytest.raises(TypeError):
1883 _ = html(t"<{RequiresPositional} />")
1885 def test_mismatched_component_closing_tag_fails(self):
1886 def OpenTag(children: Template) -> Template:
1887 return t"<div>open</div>"
1889 def CloseTag(children: Template) -> Template:
1890 return t"<div>close</div>"
1892 with pytest.raises(TypeError):
1893 _ = html(t"<{OpenTag}>Hello</{CloseTag}>")
1895 @pytest.mark.parametrize(
1896 "bad_value", ("", "text", None, 1, ("tuple", "of", "strs"))
1897 )
1898 def test_function_component_returns_nontemplate_fails(self, bad_value):
1899 def BadFunctionComp(children: Template):
1900 return bad_value
1902 with pytest.raises(
1903 TypeError, match="Component callable must return Template or Callable:"
1904 ):
1905 _ = html(t"<{BadFunctionComp}>Hello</{BadFunctionComp}>")
1907 @pytest.mark.parametrize(
1908 "bad_value", ("", "text", None, 1, ("tuple", "of", "strs"))
1909 )
1910 def test_component_object_returns_nontemplate_fails(self, bad_value):
1911 def BadFactoryComp(children: Template):
1912 def component_object():
1913 return bad_value
1915 return component_object
1917 with pytest.raises(
1918 TypeError, match="Component object must return Template when called:"
1919 ):
1920 _ = html(t"<{BadFactoryComp}>Hello</{BadFactoryComp}>")
1923def test_integration_basic():
1924 comment_text = "comment is not literal"
1925 interpolated_class = "red"
1926 text_in_element = "text is not literal"
1927 templated = "not literal"
1928 spread_attrs = {"data-on": True}
1929 markup_content = Markup("<div>safe</div>")
1931 def WrapperComponent(children):
1932 return t"<div>{children}</div>"
1934 smoke_t = t"""<!doctype html>
1935<html>
1936<body>
1937<!-- literal -->
1938<span attr="literal">literal</span>
1939<!-- {comment_text} -->
1940<span>{text_in_element}</span>
1941<span attr="literal" class={interpolated_class} title="is {templated}" {spread_attrs}>{text_in_element}</span>
1942<{WrapperComponent}><span>comp body</span></{WrapperComponent}>
1943{markup_content}
1944</body>
1945</html>"""
1946 smoke_str = """<!DOCTYPE html>
1947<html>
1948<body>
1949<!-- literal -->
1950<span attr="literal">literal</span>
1951<!-- comment is not literal -->
1952<span>text is not literal</span>
1953<span attr="literal" class="red" title="is not literal" data-on>text is not literal</span>
1954<div><span>comp body</span></div>
1955<div>safe</div>
1956</body>
1957</html>"""
1958 assert html(smoke_t) == smoke_str
1961def struct_repr(st):
1962 """Breakdown Templates into comparable parts for test verification."""
1963 return st.strings, tuple(
1964 (i.value, i.expression, i.conversion, i.format_spec) for i in st.interpolations
1965 )
1968def test_process_template_internal_cache():
1969 """Test that cache and non-cache both generally work as expected."""
1970 # @NOTE: We use a made-up custom element so that we can be sure to
1971 # miss the cache. If this element is used elsewhere than the global
1972 # cache might cache it and it will ruin our counting, specifically
1973 # the first miss will instead be a hit.
1974 sample_t = t"<div>{'content'}<tdom-cache-test-element /></div>"
1975 sample_diff_t = t"<div>{'diffcontent'}<tdom-cache-test-element /></div>"
1976 alt_t = t"<span>{'content'}</span>"
1977 process_api = TemplateProcessor(parser_api=TemplateParserProxy())
1978 cached_process_api = TemplateProcessor(parser_api=CachedTemplateParserProxy())
1979 # Because the cache is stored on the class itself this can be affect by
1980 # other tests, so save this off and take the difference to determine the result,
1981 # this is not great and hopefully we can find a better solution.
1982 assert isinstance(cached_process_api, TemplateProcessor)
1983 assert isinstance(cached_process_api.parser_api, CachedTemplateParserProxy)
1984 start_ci = cached_process_api.parser_api._to_ttree.cache_info()
1985 tnode1 = process_api.parser_api.to_ttree(sample_t).root
1986 tnode2 = process_api.parser_api.to_ttree(sample_t).root
1987 cached_tnode1 = cached_process_api.parser_api.to_ttree(sample_t).root
1988 cached_tnode2 = cached_process_api.parser_api.to_ttree(sample_t).root
1989 cached_tnode3 = cached_process_api.parser_api.to_ttree(sample_diff_t).root
1990 # Check that the uncached and cached services are actually
1991 # returning non-identical results.
1992 assert tnode1 is not cached_tnode1
1993 assert tnode1 is not cached_tnode2
1994 assert tnode1 is not cached_tnode3
1995 # Check that the uncached service returns a brand new result everytime.
1996 assert tnode1 is not tnode2
1997 # Check that the cached service is returning the exact same, identical, result.
1998 assert cached_tnode1 is cached_tnode2
1999 # Even if the input templates are not identical (but are still equivalent).
2000 assert cached_tnode1 is cached_tnode3 and sample_t is not sample_diff_t
2001 # Check that the cached service and uncached services return
2002 # results that are equivalent (even though they are not (id)entical).
2003 assert tnode1 == cached_tnode1
2004 assert tnode2 == cached_tnode1
2005 # Now that we are setup we check that the cache is internally
2006 # working as we intended.
2007 ci = cached_process_api.parser_api._to_ttree.cache_info()
2008 # cached_tnode2 and cached_tnode3 are hits after cached_tnode1
2009 assert ci.hits - start_ci.hits == 2
2010 # cached_tf1 was a miss because cache was empty (brand new)
2011 assert ci.misses - start_ci.misses == 1
2012 cached_tnode4 = cached_process_api.parser_api.to_ttree(alt_t).root
2013 # A different template produces a brand new tf.
2014 assert cached_tnode1 is not cached_tnode4
2015 # The template is new AND has a different structure so it also
2016 # produces an unequivalent tf.
2017 assert cached_tnode1 != cached_tnode4
2020def test_cached_component_span_extracts_current_interpolations():
2021 def Component(children):
2022 return children
2024 def make_template(child):
2025 return t"<{Component}><tdom-span-cache>{child}</tdom-span-cache></{Component}>"
2027 process_api = TemplateProcessor(parser_api=CachedTemplateParserProxy())
2029 assert process_api.process(make_template("first"), ProcessContext()) == (
2030 "<tdom-span-cache>first</tdom-span-cache>"
2031 )
2032 assert process_api.process(make_template("second"), ProcessContext()) == (
2033 "<tdom-span-cache>second</tdom-span-cache>"
2034 )
2037def test_repeat_calls():
2038 """Crude check for any unintended state being kept between calls."""
2040 def get_sample_t(idx, spread_attrs, button_text):
2041 return t"""<div><button data-key={idx} {spread_attrs}>{button_text}</button></div>"""
2043 for idx in range(3):
2044 spread_attrs = {"data-enabled": True}
2045 button_text = "PROCESS"
2046 sample_t = get_sample_t(idx, spread_attrs, button_text)
2047 assert (
2048 html(sample_t)
2049 == f'<div><button data-key="{idx}" data-enabled>PROCESS</button></div>'
2050 )
2053def get_select_t_with_list(options, selected_values):
2054 return t"""<select>{
2055 [
2056 t"<option value={opt[0]} selected={opt[0] in selected_values}>{opt[1]}</option>"
2057 for opt in options
2058 ]
2059 }</select>"""
2062def get_select_t_with_generator(options, selected_values):
2063 return t"""<select>{
2064 (
2065 t"<option value={opt[0]} selected={opt[0] in selected_values}>{opt[1]}</option>"
2066 for opt in options
2067 )
2068 }</select>"""
2071def get_select_t_with_concat(options, selected_values):
2072 parts = [t"<select>"]
2073 parts.extend(
2074 [
2075 t"<option value={opt[0]} selected={opt[0] in selected_values}>{opt[1]}</option>"
2076 for opt in options
2077 ]
2078 )
2079 parts.append(t"</select>")
2080 return sum(parts, t"")
2083@pytest.mark.parametrize(
2084 "provider",
2085 (
2086 get_select_t_with_list,
2087 get_select_t_with_generator,
2088 get_select_t_with_concat,
2089 ),
2090)
2091def test_process_template_iterables(provider):
2092 def get_color_select_t(selected_values: set, provider: Callable) -> Template:
2093 PRIMARY_COLORS = [("R", "Red"), ("Y", "Yellow"), ("B", "Blue")]
2094 assert set(selected_values).issubset({opt[0] for opt in PRIMARY_COLORS})
2095 return provider(PRIMARY_COLORS, selected_values)
2097 no_selection_t = get_color_select_t(set(), provider)
2098 assert (
2099 html(no_selection_t)
2100 == '<select><option value="R">Red</option><option value="Y">Yellow</option><option value="B">Blue</option></select>'
2101 )
2102 selected_yellow_t = get_color_select_t({"Y"}, provider)
2103 assert (
2104 html(selected_yellow_t)
2105 == '<select><option value="R">Red</option><option value="Y" selected>Yellow</option><option value="B">Blue</option></select>'
2106 )
2109def test_component_integration():
2110 """Broadly test that common template component usage works."""
2112 def PageComponent(children, root_attrs=None):
2113 return t"""<div class="content" {root_attrs}>{children}</div>"""
2115 def FooterComponent(classes=("footer-default",)):
2116 return t'<div class="footer" class={classes}><a href="about">About</a></div>'
2118 def LayoutComponent(children, body_classes=None):
2119 return t"""<!doctype html>
2120<html>
2121 <head>
2122 <meta charset="utf-8">
2123 <script src="scripts.js"></script>
2124 <link rel="stylesheet" href="styles.css">
2125 </head>
2126 <body class={body_classes}>
2127 {children}
2128 <{FooterComponent} />
2129 </body>
2130</html>
2131"""
2133 content = "HTML never goes out of style."
2134 content_str = html(
2135 t"<{LayoutComponent} body_classes={['theme-default']}><{PageComponent}>{content}</{PageComponent}></{LayoutComponent}>"
2136 )
2137 assert (
2138 content_str
2139 == """<!DOCTYPE html>
2140<html>
2141 <head>
2142 <meta charset="utf-8" />
2143 <script src="scripts.js"></script>
2144 <link rel="stylesheet" href="styles.css" />
2145 </head>
2146 <body class="theme-default">
2147 <div class="content">HTML never goes out of style.</div>
2148 <div class="footer footer-default"><a href="about">About</a></div>
2149 </body>
2150</html>
2151"""
2152 )
2155class TestInterpolatingHTMLInTemplateWithDynamicParentTag:
2156 """
2157 When a template does not have a parent tag we cannot determine the type
2158 of text that should be allowed and therefore we cannot determine how to
2159 escape that text. Once the type is known we should escape any
2160 interpolations in that text correctly.
2161 """
2163 def test_dynamic_raw_text(self):
2164 """Type raw text should fail because template is already not allowed."""
2165 content = '<script>console.log("123!");</script>'
2166 content_t = t"{content}"
2167 content_t = t'<script>console.log("{123}!");</script>'
2168 with pytest.raises(
2169 ValueError, match="Recursive includes are not supported within script"
2170 ):
2171 _ = html(t"<script>{content_t}</script>")
2173 def test_dynamic_escapable_raw_text(self):
2174 """Type escapable raw text should fail because template is already not allowed."""
2175 content = '<script>console.log("123!");</script>'
2176 content_t = t"{content}"
2177 with pytest.raises(
2178 ValueError, match="Recursive includes are not supported within textarea"
2179 ):
2180 _ = html(t"<textarea>{content_t}</textarea>")
2182 def test_dynamic_normal_text(self):
2183 """Escaping should be applied when normal text type is goes into effect."""
2184 content = '<script>console.log("123!");</script>'
2185 content_t = t"{content}"
2186 LT, GT, DQ = map(markupsafe_escape, ["<", ">", '"'])
2187 assert (
2188 html(t"<div>{content_t}</div>")
2189 == f"<div>{LT}script{GT}console.log({DQ}123!{DQ});{LT}/script{GT}</div>"
2190 )
2193class TestPagerComponentExample:
2194 @dataclass
2195 class Pager:
2196 left_pages: tuple = ()
2197 page: int = 0
2198 right_pages: tuple = ()
2199 prev_page: int | None = None
2200 next_page: int | None = None
2202 @dataclass
2203 class PagerDisplay:
2204 pager: TestPagerComponentExample.Pager
2205 paginate_url: Callable[[int], str]
2206 root_classes: tuple[str, ...] = ("cb", "tc", "w-100")
2207 part_classes: tuple[str, ...] = ("dib", "pa1")
2209 def __call__(self) -> Template:
2210 parts = [t"<div class={self.root_classes}>"]
2211 if self.pager.prev_page:
2212 parts.append(
2213 t"<a class={self.part_classes} href={self.paginate_url(self.pager.prev_page)}>Prev</a>"
2214 )
2215 for left_page in self.pager.left_pages:
2216 parts.append(
2217 t'<a class={self.part_classes} href="{self.paginate_url(left_page)}">{left_page}</a>'
2218 )
2219 parts.append(t"<span class={self.part_classes}>{self.pager.page}</span>")
2220 for right_page in self.pager.right_pages:
2221 parts.append(
2222 t'<a class={self.part_classes} href="{self.paginate_url(right_page)}">{right_page}</a>'
2223 )
2224 if self.pager.next_page:
2225 parts.append(
2226 t"<a class={self.part_classes} href={self.paginate_url(self.pager.next_page)}>Next</a>"
2227 )
2228 parts.append(t"</div>")
2229 return Template(*chain.from_iterable(parts))
2231 def test_example(self):
2232 def paginate_url(page: int) -> str:
2233 return f"/pages?page={page}"
2235 def Footer(pager, paginate_url, footer_classes=("footer",)) -> Template:
2236 return t"<div class={footer_classes}><{self.PagerDisplay} pager={pager} paginate_url={paginate_url} /></div>"
2238 pager = self.Pager(
2239 left_pages=(1, 2), page=3, right_pages=(4, 5), next_page=6, prev_page=None
2240 )
2241 content_t = t"<{Footer} pager={pager} paginate_url={paginate_url} />"
2242 res = html(content_t)
2243 print(res)
2244 assert (
2245 res
2246 == '<div class="footer"><div class="cb tc w-100"><a class="dib pa1" href="/pages?page=1">1</a><a class="dib pa1" href="/pages?page=2">2</a><span class="dib pa1">3</span><a class="dib pa1" href="/pages?page=4">4</a><a class="dib pa1" href="/pages?page=5">5</a><a class="dib pa1" href="/pages?page=6">Next</a></div></div>'
2247 )
2250def test_mathml():
2251 num = 1
2252 denom = 3
2253 mathml_t = t"""<p>
2254 The fraction
2255 <math>
2256 <mfrac>
2257 <mn>{num}</mn>
2258 <mn>{denom}</mn>
2259 </mfrac>
2260 </math>
2261 is not a decimal number.
2262</p>"""
2263 res = html(mathml_t)
2264 assert (
2265 str(res)
2266 == """<p>
2267 The fraction
2268 <math>
2269 <mfrac>
2270 <mn>1</mn>
2271 <mn>3</mn>
2272 </mfrac>
2273 </math>
2274 is not a decimal number.
2275</p>"""
2276 )
2279def test_issue_166():
2280 template = t"<button disabled={True}>x</button><button disabled={True}>y</button>"
2281 expected = "<button disabled>x</button><button disabled>y</button>"
2282 assert html(template) == expected