Coverage for tdom/processor_test.py: 99%
1065 statements
« prev ^ index » next coverage.py v7.14.3, created at 2026-06-23 04:35 +0000
« prev ^ index » next coverage.py v7.14.3, created at 2026-06-23 04:35 +0000
1import datetime
2import typing as t
3from collections.abc import Callable
4from dataclasses import dataclass
5from itertools import chain, product
6from string.templatelib import Template
8import pytest
9from markupsafe import Markup
10from markupsafe import escape as markupsafe_escape
12from .callables import get_callable_info
13from .escaping import escape_html_text
14from .processor import (
15 CachedTemplateParserProxy,
16 ProcessContext,
17 TemplateParserProxy,
18 TemplateProcessor,
19 _make_default_template_processor,
20)
21from .processor import (
22 _prep_component_kwargs as prep_component_kwargs,
23)
24from .protocols import HasHTMLDunder
26processor_api = _make_default_template_processor(
27 parser_api=TemplateParserProxy(), # do not use cache
28)
31def make_ctx(**kwargs):
32 return ProcessContext(**kwargs)
35def html(template: Template, assume_ctx: ProcessContext | None = None):
36 if assume_ctx is None:
37 assume_ctx = ProcessContext()
38 return processor_api.process(template, assume_ctx=assume_ctx)
41# --------------------------------------------------------------------------
42# Basic HTML parsing tests
43# --------------------------------------------------------------------------
46#
47# Text
48#
49class TestBareTemplate:
50 def test_empty(self):
51 assert html(t"") == ""
53 def test_text_literal(self):
54 assert html(t"Hello, world!") == "Hello, world!"
56 def test_text_singleton(self):
57 greeting = "Hello, Alice!"
58 assert html(t"{greeting}", make_ctx(parent_tag="div")) == "Hello, Alice!"
59 assert html(t"{greeting}", make_ctx(parent_tag="script")) == "Hello, Alice!"
60 assert html(t"{greeting}", make_ctx(parent_tag="style")) == "Hello, Alice!"
61 assert html(t"{greeting}", make_ctx(parent_tag="textarea")) == "Hello, Alice!"
62 assert html(t"{greeting}", make_ctx(parent_tag="title")) == "Hello, Alice!"
64 def test_text_singleton_without_parent(self):
65 greeting = "</script>"
66 res = html(t"{greeting}")
67 assert res == "</script>"
68 assert res != greeting
70 def test_text_singleton_explicit_parent_script(self):
71 greeting = "</script>"
72 res = html(t"{greeting}", assume_ctx=make_ctx(parent_tag="script"))
73 assert res == "\\x3c/script>"
74 assert res != "</script>"
76 def test_text_singleton_explicit_parent_div(self):
77 greeting = "</div>"
78 res = html(t"{greeting}", assume_ctx=make_ctx(parent_tag="div"))
79 assert res == "</div>"
80 assert res != "</div>"
82 def test_text_template(self):
83 name = "Alice"
84 assert (
85 html(t"Hello, {name}!", assume_ctx=make_ctx(parent_tag="div"))
86 == "Hello, Alice!"
87 )
89 def test_text_template_escaping(self):
90 name = "Alice & Bob"
91 assert (
92 html(t"Hello, {name}!", assume_ctx=make_ctx(parent_tag="div"))
93 == "Hello, Alice & Bob!"
94 )
96 def test_parse_entities_are_escaped_no_parent_tag(self):
97 res = html(t"</p>")
98 assert res == "</p>", "Default to standard escaping."
101class LiteralHTML:
102 """Text is returned as is by __html__."""
104 def __init__(self, text):
105 self.text = text
107 def __html__(self):
108 # In a real app, this would come from a sanitizer or trusted source
109 return self.text
112def test_literal_html_has_html_dunder():
113 assert isinstance(LiteralHTML, HasHTMLDunder)
116def test_markup_has_html_dunder():
117 assert isinstance(Markup, HasHTMLDunder)
120class TestComment:
121 def test_literal(self):
122 assert html(t"<!--This is a comment-->") == "<!--This is a comment-->"
124 #
125 # Singleton / Exact Match
126 #
127 def test_singleton_str(self):
128 text = "This is a comment"
129 assert html(t"<!--{text}-->") == "<!--This is a comment-->"
131 def test_singleton_object(self):
132 assert html(t"<!--{0}-->") == "<!--0-->"
134 def test_singleton_none(self):
135 assert html(t"<!--{None}-->") == "<!---->"
137 @pytest.mark.parametrize("bool_value", (True, False))
138 def test_singleton_bool(self, bool_value):
139 assert html(t"<!--{bool_value}-->") == "<!---->"
141 @pytest.mark.parametrize(
142 "html_dunder_cls",
143 (
144 LiteralHTML,
145 Markup,
146 ),
147 )
148 def test_singleton_has_html_dunder(self, html_dunder_cls):
149 content = html_dunder_cls("-->")
150 assert html(t"<!--{content}-->") == "<!---->-->", (
151 "DO NOT DO THIS! This is just an advanced escape hatch."
152 )
154 def test_singleton_escaping(self):
155 text = "-->comment"
156 assert html(t"<!--{text}-->") == "<!---->comment-->"
158 #
159 # Templated -- literal text mixed with interpolation(s)
160 #
161 def test_templated_str(self):
162 text = "comment"
163 assert html(t"<!--This is a {text}-->") == "<!--This is a comment-->"
165 def test_templated_object(self):
166 assert html(t"<!--This is a {0}-->") == "<!--This is a 0-->"
168 def test_templated_none(self):
169 assert html(t"<!--This is a {None}-->") == "<!--This is a -->"
171 @pytest.mark.parametrize("bool_value", (True, False))
172 def test_templated_bool(self, bool_value):
173 assert html(t"<!--This is a {bool_value}-->") == "<!--This is a -->"
175 @pytest.mark.parametrize(
176 "html_dunder_cls",
177 (
178 LiteralHTML,
179 Markup,
180 ),
181 )
182 def test_templated_has_html_dunder_error(self, html_dunder_cls):
183 """Objects with __html__ are not processed with literal text or other interpolations."""
184 text = html_dunder_cls("in a comment")
185 with pytest.raises(ValueError, match="not supported"):
186 _ = html(t"<!--This is a {text}-->")
187 with pytest.raises(ValueError, match="not supported"):
188 _ = html(t"<!--{None}{text}-->")
189 with pytest.raises(ValueError, match="not supported"):
190 _ = html(t"<!--This is a {Markup('Also check specialized cls.')}-->")
192 def test_templated_multiple_interpolations(self):
193 text = "comment"
194 assert (
195 html(t"<!--This is a {text} with {0} and {None}-->")
196 == "<!--This is a comment with 0 and -->"
197 )
199 def test_templated_escaping(self):
200 # @TODO: There doesn't seem to be a way to properly escape this
201 # so we just use an entity to break the special closing string
202 # even though it won't be actually unescaped by anything. There
203 # might be something better for this.
204 text = "-->comment"
205 assert html(t"<!--This is a {text}-->") == "<!--This is a -->comment-->"
207 def test_not_supported__recursive_template_error(self):
208 text_t = t"comment"
209 with pytest.raises(ValueError, match="not supported"):
210 _ = html(t"<!--{text_t}-->")
212 def test_not_supported_recursive_iterable_error(self):
213 texts = ["This", "is", "a", "comment"]
214 with pytest.raises(ValueError, match="not supported"):
215 _ = html(t"<!--{texts}-->")
218class TestDocumentType:
219 def test_literal(self):
220 assert html(t"<!doctype html>") == "<!DOCTYPE html>"
222 def test_literal_lowercase(self):
223 tp = TemplateProcessor(uppercase_doctype=False)
224 assert (
225 tp.process(t"<!doctype html>", assume_ctx=ProcessContext())
226 == "<!doctype html>"
227 )
230class TestVoidElementLiteral:
231 def test_void(self):
232 assert html(t"<br>") == "<br />"
234 def test_void_self_closed(self):
235 assert html(t"<br />") == "<br />"
237 def test_void_mixed_closing(self):
238 assert html(t"<br>Is this content?<br />") == "<br />Is this content?<br />"
240 def test_chain_of_void_elements(self):
241 # Make sure our handling of CPython issue #69445 is reasonable.
242 assert (
243 html(t"<br><hr><img src='image.png' /><br /><hr>")
244 == '<br /><hr /><img src="image.png" /><br /><hr />'
245 )
248class TestNormalTextElementLiteral:
249 def test_empty(self):
250 assert html(t"<div></div>") == "<div></div>"
252 def test_with_text(self):
253 assert html(t"<p>Hello, world!</p>") == "<p>Hello, world!</p>"
255 def test_nested_elements(self):
256 assert (
257 html(t"<div><p>Hello</p><p>World</p></div>")
258 == "<div><p>Hello</p><p>World</p></div>"
259 )
261 def test_entities_are_escaped(self):
262 """Literal entities interpreted by parser but escaped in output."""
263 res = html(t"<p></p></p>")
264 assert res == "<p></p></p>", res
267class TestNormalTextElementDynamic:
268 def test_singleton_None(self):
269 assert html(t"<p>{None}</p>") == "<p></p>"
271 def test_singleton_str(self):
272 name = "Alice"
273 assert html(t"<p>{name}</p>") == "<p>Alice</p>"
275 @pytest.mark.parametrize("bool_value", (True, False))
276 def test_singleton_bool(self, bool_value):
277 assert html(t"<p>{bool_value}</p>") == "<p></p>"
279 def test_singleton_object(self):
280 assert html(t"<p>{0}</p>") == "<p>0</p>"
282 @pytest.mark.parametrize(
283 "html_dunder_cls",
284 (
285 LiteralHTML,
286 Markup,
287 ),
288 )
289 def test_singleton_has_html_dunder(self, html_dunder_cls):
290 content = html_dunder_cls("<em>Alright!</em>")
291 assert html(t"<p>{content}</p>") == "<p><em>Alright!</em></p>"
293 def test_singleton_simple_template(self):
294 name = "Alice"
295 text_t = t"Hi {name}"
296 assert html(t"<p>{text_t}</p>") == "<p>Hi Alice</p>"
298 def test_singleton_simple_iterable(self):
299 strs = ["Strings", "...", "Yeah!", "Rock", "...", "Yeah!"]
300 assert html(t"<p>{strs}</p>") == "<p>Strings...Yeah!Rock...Yeah!</p>"
302 def test_singleton_escaping(self):
303 text = '''<>&'"'''
304 assert html(t"<p>{text}</p>") == "<p><>&'"</p>"
306 def test_templated_None(self):
307 assert html(t"<p>Response: {None}.</p>") == "<p>Response: .</p>"
309 def test_templated_str(self):
310 name = "Alice"
311 assert html(t"<p>Response: {name}.</p>") == "<p>Response: Alice.</p>"
313 @pytest.mark.parametrize("bool_value", (True, False))
314 def test_templated_bool(self, bool_value):
315 assert html(t"<p>Response: {bool_value}</p>") == "<p>Response: </p>"
317 def test_templated_object(self):
318 assert html(t"<p>Response: {0}.</p>") == "<p>Response: 0.</p>"
320 @pytest.mark.parametrize(
321 "html_dunder_cls",
322 (
323 LiteralHTML,
324 Markup,
325 ),
326 )
327 def test_templated_has_html_dunder(self, html_dunder_cls):
328 text = html_dunder_cls("<em>Alright!</em>")
329 assert (
330 html(t"<p>Response: {text}.</p>") == "<p>Response: <em>Alright!</em>.</p>"
331 )
333 def test_templated_simple_template(self):
334 name = "Alice"
335 text_t = t"Hi {name}"
336 assert html(t"<p>Response: {text_t}.</p>") == "<p>Response: Hi Alice.</p>"
338 def test_templated_simple_iterable(self):
339 strs = ["Strings", "...", "Yeah!", "Rock", "...", "Yeah!"]
340 assert (
341 html(t"<p>Response: {strs}.</p>")
342 == "<p>Response: Strings...Yeah!Rock...Yeah!.</p>"
343 )
345 def test_templated_escaping(self):
346 text = '''<>&'"'''
347 assert (
348 html(t"<p>Response: {text}.</p>")
349 == "<p>Response: <>&'".</p>"
350 )
352 def test_templated_escaping_in_literals(self):
353 text = "This text is fine"
354 assert (
355 html(t"<p>The literal has < in it: {text}.</p>")
356 == "<p>The literal has < in it: This text is fine.</p>"
357 )
359 def test_iterable_of_templates(self):
360 items = ["Apple", "Banana", "Cherry"]
361 assert (
362 html(t"<ul>{[t'<li>{item}</li>' for item in items]}</ul>")
363 == "<ul><li>Apple</li><li>Banana</li><li>Cherry</li></ul>"
364 )
366 def test_iterable_of_templates_of_iterable_of_templates(self):
367 outer = ["fruit", "more fruit"]
368 inner = ["apple", "banana", "cherry"]
369 inner_items = [t"<li>{item}</li>" for item in inner]
370 outer_items = [
371 t"<li>{category}<ul>{inner_items}</ul></li>" for category in outer
372 ]
373 assert (
374 html(t"<ul>{outer_items}</ul>")
375 == "<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>"
376 )
379class TestRawTextElementLiteral:
380 def test_script_empty(self):
381 assert html(t"<script></script>") == "<script></script>"
383 def test_style_empty(self):
384 assert html(t"<style></style>") == "<style></style>"
386 def test_script_with_content(self):
387 assert html(t"<script>var x = 1;</script>") == "<script>var x = 1;</script>"
389 def test_style_with_content(self):
390 # @NOTE: Double {{ and }} to avoid t-string interpolation.
391 assert (
392 html(t"<style>.red { color: red; } </style>")
393 == "<style>.red { color: red; }</style>"
394 )
396 def test_script_with_content_escaped_in_normal_text(self):
397 # @NOTE: Double {{ and }} to avoid t-string interpolation.
398 assert (
399 html(t"<script>function CompareNumbers(a, b) { return a < b; } </script>")
400 == "<script>function CompareNumbers(a, b) { return a < b; }</script>"
401 ), "The < should not be escaped."
403 def test_style_with_content_escaped_in_normal_text(self):
404 # @NOTE: Double {{ and }} to avoid t-string interpolation.
405 assert (
406 html(t"<style>section > h4 { background-color: red; } </style>")
407 == "<style>section > h4 { background-color: red; }</style>"
408 ), "The > should not be escaped."
410 def test_not_supported_recursive_template_error(self):
411 text_t = t"comment"
412 with pytest.raises(ValueError, match="not supported"):
413 _ = html(t"<!--{text_t}-->")
415 def test_not_supported_recursive_iterable_error(self):
416 texts = ["This", "is", "a", "comment"]
417 with pytest.raises(ValueError, match="not supported"):
418 _ = html(t"<!--{texts}-->")
421class TestEscapableRawTextElementLiteral:
422 def test_title_empty(self):
423 assert html(t"<title></title>") == "<title></title>"
425 def test_textarea_empty(self):
426 assert html(t"<textarea></textarea>") == "<textarea></textarea>"
428 def test_title_with_content(self):
429 assert html(t"<title>Content</title>") == "<title>Content</title>"
431 def test_textarea_with_content(self):
432 assert html(t"<textarea>Content</textarea>") == "<textarea>Content</textarea>"
434 def test_title_with_escapable_content(self):
435 assert (
436 html(t"<title>Are t-strings > everything?</title>")
437 == "<title>Are t-strings > everything?</title>"
438 ), "The > can be escaped in this content type."
440 def test_textarea_with_escapable_content(self):
441 assert (
442 html(t"<textarea><p>Welcome To TDOM</p></textarea>")
443 == "<textarea><p>Welcome To TDOM</p></textarea>"
444 ), "The p tags can be escaped in this content type."
447class TestRawTextScriptDynamic:
448 def test_singleton_none(self):
449 assert html(t"<script>{None}</script>") == "<script></script>"
451 def test_singleton_str(self):
452 content = "var x = 1;"
453 assert html(t"<script>{content}</script>") == "<script>var x = 1;</script>"
455 @pytest.mark.parametrize("bool_value", (True, False))
456 def test_singleton_bool(self, bool_value):
457 assert html(t"<script>{bool_value}</script>") == "<script></script>"
459 def test_singleton_object(self):
460 content = 0
461 assert html(t"<script>{content}</script>") == "<script>0</script>"
463 @pytest.mark.parametrize(
464 "html_dunder_cls",
465 (
466 LiteralHTML,
467 Markup,
468 ),
469 )
470 def test_singleton_has_html_dunder_pitfall(self, html_dunder_cls):
471 # @TODO: We should probably put some double override to prevent this by accident.
472 # Or just disable this and if people want to do this then put the
473 # content in a SCRIPT and inject the whole thing with a __html__?
474 content = html_dunder_cls("</script>")
475 assert html(t"<script>{content}</script>") == "<script></script></script>", (
476 "DO NOT DO THIS! This is just an advanced escape hatch! Use a data attribute and parseJSON!"
477 )
479 def test_singleton_escaping(self):
480 content = "</script>"
481 script_t = t"<script>{content}</script>"
482 bad_output = script_t.strings[0] + content + script_t.strings[1]
483 assert html(script_t) == "<script>\\x3c/script></script>"
484 assert html(script_t) != bad_output, "Sanity check."
486 def test_templated_none(self):
487 assert (
488 html(t"<script>var x = 1;{None};</script>")
489 == "<script>var x = 1;;</script>"
490 )
492 def test_templated_str(self):
493 content = "var x = 1"
494 assert (
495 html(t"<script>var x = 0;{content};</script>")
496 == "<script>var x = 0;var x = 1;</script>"
497 )
499 @pytest.mark.parametrize("bool_value", (True, False))
500 def test_templated_bool(self, bool_value):
501 assert (
502 html(t"<script>var x = 15; {bool_value}</script>")
503 == "<script>var x = 15; </script>"
504 )
506 def test_templated_object(self):
507 content = 0
508 assert (
509 html(t"<script>var x = {content};</script>")
510 == "<script>var x = 0;</script>"
511 )
513 @pytest.mark.parametrize(
514 "html_dunder_cls",
515 (
516 LiteralHTML,
517 Markup,
518 ),
519 )
520 def test_templated_has_html_dunder(self, html_dunder_cls):
521 content = html_dunder_cls("anything")
522 with pytest.raises(ValueError, match="not supported"):
523 _ = html(t"<script>var x = 1;{content}</script>")
525 def test_templated_escaping(self):
526 content = "</script>"
527 script_t = t"<script>var x = '{content}';</script>"
528 bad_output = script_t.strings[0] + content + script_t.strings[1]
529 assert html(script_t) == "<script>var x = '\\x3c/script>';</script>"
530 assert html(script_t) != bad_output, "Sanity check."
532 def test_templated_multiple_interpolations(self):
533 assert (
534 html(t"<script>var x = {1}; var y = {2};</script>")
535 == "<script>var x = 1; var y = 2;</script>"
536 )
538 def test_not_supported_recursive_template_error(self):
539 text_t = t"script"
540 with pytest.raises(ValueError, match="not supported"):
541 _ = html(t"<script>{text_t}</script>")
543 def test_not_supported_recursive_iterable_error(self):
544 texts = ["This", "is", "a", "script"]
545 with pytest.raises(ValueError, match="not supported"):
546 _ = html(t"<script>{texts}</script>")
549class TestRawTextStyleDynamic:
550 def test_singleton_none(self):
551 assert html(t"<style>{None}</style>") == "<style></style>"
553 def test_singleton_str(self):
554 content = "div { background-color: red; }"
555 assert (
556 html(t"<style>{content}</style>")
557 == "<style>div { background-color: red; }</style>"
558 )
560 @pytest.mark.parametrize("bool_value", (True, False))
561 def test_singleton_bool(self, bool_value):
562 assert html(t"<style>{bool_value}</style>") == "<style></style>"
564 def test_singleton_object(self):
565 content = 0
566 assert html(t"<style>{content}</style>") == "<style>0</style>"
568 @pytest.mark.parametrize(
569 "html_dunder_cls",
570 (
571 LiteralHTML,
572 Markup,
573 ),
574 )
575 def test_singleton_has_html_dunder_pitfall(self, html_dunder_cls):
576 # @TODO: We should probably put some double override to prevent this by accident.
577 # Or just disable this and if people want to do this then put the
578 # content in a STYLE and inject the whole thing with a __html__?
579 content = html_dunder_cls("</style>")
580 assert html(t"<style>{content}</style>") == "<style></style></style>", (
581 "DO NOT DO THIS! This is just an advanced escape hatch!"
582 )
584 def test_singleton_escaping(self):
585 content = "</style>"
586 style_t = t"<style>{content}</style>"
587 bad_output = style_t.strings[0] + content + style_t.strings[1]
588 assert html(style_t) == "<style></style></style>"
589 assert html(style_t) != bad_output, "Sanity check."
591 def test_templated_none(self):
592 assert (
593 html(t"<style>h1 { background-color: red; } {None}</style>")
594 == "<style>h1 { background-color: red; }</style>"
595 )
597 def test_templated_str(self):
598 content = " h2 { background-color: blue; }"
599 assert (
600 html(t"<style>h1 { background-color: red; } {content}</style>")
601 == "<style>h1 { background-color: red; } h2 { background-color: blue; }</style>"
602 )
604 @pytest.mark.parametrize("bool_value", (True, False))
605 def test_templated_bool(self, bool_value):
606 assert (
607 html(t"<style>h1 { background-color: red; } ;{bool_value}</style>")
608 == "<style>h1 { background-color: red; };</style>"
609 )
611 def test_templated_object(self):
612 padding_right = 0
613 assert (
614 html(t"<style>h1 { padding-right: {padding_right}px; } </style>")
615 == "<style>h1 { padding-right: 0px; }</style>"
616 )
618 @pytest.mark.parametrize(
619 "html_dunder_cls",
620 (
621 LiteralHTML,
622 Markup,
623 ),
624 )
625 def test_templated_has_html_dunder(self, html_dunder_cls):
626 content = html_dunder_cls("anything")
627 with pytest.raises(ValueError, match="not supported"):
628 _ = html(t"<style>h1 { color: red; } ;{content}</style>")
630 def test_templated_escaping(self):
631 content = "</style>"
632 style_t = t"<style>div { background-color: red; } {content}</style>"
633 bad_output = style_t.strings[0] + content + style_t.strings[1]
634 assert (
635 html(style_t) == "<style>div { background-color: red; } </style></style>"
636 )
637 assert html(style_t) != bad_output, "Sanity check."
639 def test_templated_multiple_interpolations(self):
640 assert (
641 html(
642 t"<style>h1 { background-color: {'red'}; } h2 { background-color: {'blue'}; } </style>"
643 )
644 == "<style>h1 { background-color: red; } h2 { background-color: blue; }</style>"
645 )
647 def test_exact_not_supported_recursive_template_error(self):
648 text_t = t"style"
649 with pytest.raises(ValueError, match="not supported"):
650 _ = html(t"<style>{text_t}</style>")
652 def test_inexact_not_supported_recursive_template_error(self):
653 text_t = t"style"
654 with pytest.raises(ValueError, match="not supported"):
655 _ = html(t"<style>{text_t} and more</style>")
657 def test_exact_not_supported_recursive_iterable_error(self):
658 texts = ["This", "is", "a", "style"]
659 with pytest.raises(ValueError, match="not supported"):
660 _ = html(t"<style>{texts}</style>")
662 def test_inexact_not_supported_recursive_iterable_error(self):
663 texts = ["This", "is", "a", "style"]
664 with pytest.raises(ValueError, match="not supported"):
665 _ = html(t"<style>{texts} and more</style>")
668class TestEscapableRawTextTitleDynamic:
669 def test_singleton_none(self):
670 assert html(t"<title>{None}</title>") == "<title></title>"
672 def test_singleton_str(self):
673 content = "Welcome To TDOM"
674 assert html(t"<title>{content}</title>") == "<title>Welcome To TDOM</title>"
676 @pytest.mark.parametrize("bool_value", (True, False))
677 def test_singleton_bool(self, bool_value):
678 assert html(t"<title>{bool_value}</title>") == "<title></title>"
680 def test_singleton_object(self):
681 content = 0
682 assert html(t"<title>{content}</title>") == "<title>0</title>"
684 @pytest.mark.parametrize(
685 "html_dunder_cls",
686 (
687 LiteralHTML,
688 Markup,
689 ),
690 )
691 def test_singleton_has_html_dunder_pitfall(self, html_dunder_cls):
692 # @TODO: We should probably put some double override to prevent this by accident.
693 content = html_dunder_cls("</title>")
694 assert html(t"<title>{content}</title>") == "<title></title></title>", (
695 "DO NOT DO THIS! This is just an advanced escape hatch!"
696 )
698 def test_singleton_escaping(self):
699 content = "</title>"
700 assert html(t"<title>{content}</title>") == "<title></title></title>"
702 def test_templated_none(self):
703 assert (
704 html(t"<title>A great story about: {None}</title>")
705 == "<title>A great story about: </title>"
706 )
708 def test_templated_str(self):
709 content = "TDOM"
710 assert (
711 html(t"<title>A great story about: {content}</title>")
712 == "<title>A great story about: TDOM</title>"
713 )
715 @pytest.mark.parametrize("bool_value", (True, False))
716 def test_templated_bool(self, bool_value):
717 assert (
718 html(t"<title>A great story; {bool_value}</title>")
719 == "<title>A great story; </title>"
720 )
722 def test_templated_object(self):
723 content = 0
724 assert (
725 html(t"<title>A great number: {content}</title>")
726 == "<title>A great number: 0</title>"
727 )
729 @pytest.mark.parametrize(
730 "html_dunder_cls",
731 (
732 LiteralHTML,
733 Markup,
734 ),
735 )
736 def test_templated_has_html_dunder(self, html_dunder_cls):
737 content = html_dunder_cls("No")
738 with pytest.raises(ValueError, match="not supported"):
739 _ = html(t"<title>Literal html?: {content}</title>")
741 def test_templated_escaping(self):
742 content = "</title>"
743 assert (
744 html(t"<title>The end tag: {content}.</title>")
745 == "<title>The end tag: </title>.</title>"
746 )
748 def test_templated_multiple_interpolations(self):
749 assert (
750 html(t"<title>The number {0} is less than {1}.</title>")
751 == "<title>The number 0 is less than 1.</title>"
752 )
754 def test_exact_not_supported_recursive_template_error(self):
755 text_t = t"title"
756 with pytest.raises(ValueError, match="not supported"):
757 _ = html(t"<title>{text_t}</title>")
759 def test_exact_not_supported_recursive_iterable_error(self):
760 texts = ["This", "is", "a", "title"]
761 with pytest.raises(ValueError, match="not supported"):
762 _ = html(t"<title>{texts}</title>")
764 def test_inexact_not_supported_recursive_template_error(self):
765 text_t = t"title"
766 with pytest.raises(ValueError, match="not supported"):
767 _ = html(t"<title>{text_t} and more</title>")
769 def test_inexact_not_supported_recursive_iterable_error(self):
770 texts = ["This", "is", "a", "title"]
771 with pytest.raises(ValueError, match="not supported"):
772 _ = html(t"<title>{texts} and more</title>")
775class TestEscapableRawTextTextareaDynamic:
776 def test_singleton_none(self):
777 assert html(t"<textarea>{None}</textarea>") == "<textarea></textarea>"
779 def test_singleton_str(self):
780 content = "Welcome To TDOM"
781 assert (
782 html(t"<textarea>{content}</textarea>")
783 == "<textarea>Welcome To TDOM</textarea>"
784 )
786 @pytest.mark.parametrize("bool_value", (True, False))
787 def test_singleton_bool(self, bool_value):
788 assert html(t"<textarea>{bool_value}</textarea>") == "<textarea></textarea>"
790 def test_singleton_object(self):
791 content = 0
792 assert html(t"<textarea>{content}</textarea>") == "<textarea>0</textarea>"
794 @pytest.mark.parametrize(
795 "html_dunder_cls",
796 (
797 LiteralHTML,
798 Markup,
799 ),
800 )
801 def test_singleton_has_html_dunder_pitfall(self, html_dunder_cls):
802 # @TODO: We should probably put some double override to prevent this by accident.
803 content = html_dunder_cls("</textarea>")
804 assert (
805 html(t"<textarea>{content}</textarea>")
806 == "<textarea></textarea></textarea>"
807 ), "DO NOT DO THIS! This is just an advanced escape hatch!"
809 def test_singleton_escaping(self):
810 content = "</textarea>"
811 assert (
812 html(t"<textarea>{content}</textarea>")
813 == "<textarea></textarea></textarea>"
814 )
816 def test_templated_none(self):
817 assert (
818 html(t"<textarea>A great story about: {None}</textarea>")
819 == "<textarea>A great story about: </textarea>"
820 )
822 def test_templated_str(self):
823 content = "TDOM"
824 assert (
825 html(t"<textarea>A great story about: {content}</textarea>")
826 == "<textarea>A great story about: TDOM</textarea>"
827 )
829 @pytest.mark.parametrize("bool_value", (True, False))
830 def test_templated_bool(self, bool_value):
831 assert (
832 html(t"<textarea>This is great.{bool_value}</textarea>")
833 == "<textarea>This is great.</textarea>"
834 )
836 def test_templated_object(self):
837 content = 0
838 assert (
839 html(t"<textarea>A great number: {content}</textarea>")
840 == "<textarea>A great number: 0</textarea>"
841 )
843 @pytest.mark.parametrize(
844 "html_dunder_cls",
845 (
846 LiteralHTML,
847 Markup,
848 ),
849 )
850 def test_templated_has_html_dunder(self, html_dunder_cls):
851 content = html_dunder_cls("No")
852 with pytest.raises(ValueError, match="not supported"):
853 _ = html(t"<textarea>Literal html?: {content}</textarea>")
855 def test_templated_multiple_interpolations(self):
856 assert (
857 html(t"<textarea>The number {0} is less than {1}.</textarea>")
858 == "<textarea>The number 0 is less than 1.</textarea>"
859 )
861 def test_templated_escaping(self):
862 content = "</textarea>"
863 assert (
864 html(t"<textarea>The end tag: {content}.</textarea>")
865 == "<textarea>The end tag: </textarea>.</textarea>"
866 )
868 def test_not_supported_recursive_template_error(self):
869 text_t = t"textarea"
870 with pytest.raises(ValueError, match="not supported"):
871 _ = html(t"<textarea>{text_t}</textarea>")
873 def test_not_supported_recursive_iterable_error(self):
874 texts = ["This", "is", "a", "textarea"]
875 with pytest.raises(ValueError, match="not supported"):
876 _ = html(t"<textarea>{texts}</textarea>")
879class Convertible:
880 def __str__(self):
881 return "string"
883 def __repr__(self):
884 return "repr"
887def test_convertible_fixture():
888 """Make sure test fixture is working correctly."""
889 c = Convertible()
890 assert f"{c!s}" == "string"
891 assert f"{c!r}" == "repr"
894def wrap_template_in_tags(
895 start_tag: str, template: Template, end_tag: str | None = None
896):
897 """Utility for testing templated text but with different containing tags."""
898 if end_tag is None:
899 end_tag = start_tag
900 return Template(f"<{start_tag}>") + template + Template(f"</{end_tag}>")
903def wrap_text_in_tags(start_tag: str, content: str, end_tag: str | None = None):
904 """Utility for testing expected text but with different containing tags."""
905 if end_tag is None:
906 end_tag = start_tag
907 # Stringify to flatten `Markup()`
908 content = str(content)
909 return f"<{start_tag}>" + content + f"</{end_tag}>"
912class TestInterpolationConversion:
913 def test_str(self):
914 c = Convertible()
915 for tag in ("p", "script", "title"):
916 assert html(wrap_template_in_tags(tag, t"{c!s}")) == wrap_text_in_tags(
917 tag, "string"
918 )
920 def test_repr(self):
921 c = Convertible()
922 for tag in ("p", "script", "title"):
923 assert html(wrap_template_in_tags(tag, t"{c!r}")) == wrap_text_in_tags(
924 tag, "repr"
925 )
927 def test_ascii_raw_text(self):
928 # single quotes are not escaped in raw text
929 assert html(wrap_template_in_tags("script", t"{'😊'!a}")) == wrap_text_in_tags(
930 "script", ascii("😊")
931 )
933 def test_ascii_escapable_normal_and_raw(self):
934 # single quotes are escaped
935 for tag in ("p", "title"):
936 assert html(wrap_template_in_tags(tag, t"{'😊'!a}")) == wrap_text_in_tags(
937 tag, escape_html_text(ascii("😊"))
938 )
941class TestInterpolationFormatSpec:
942 def test_normal_text_safe(self):
943 raw_content = "<u>underlined</u>"
944 assert (
945 html(t"<p>This is {raw_content:safe} text.</p>")
946 == "<p>This is <u>underlined</u> text.</p>"
947 )
949 def test_raw_text_safe(self):
950 # @TODO: What should even happen here?
951 raw_content = "</script>"
952 assert (
953 html(t"<script>{raw_content:safe}</script>") == "<script></script></script>"
954 ), "DO NOT DO THIS! This is an advanced escape hatch."
956 def test_escapable_raw_text_safe(self):
957 raw_content = "<u>underlined</u>"
958 assert (
959 html(t"<textarea>{raw_content:safe}</textarea>")
960 == "<textarea><u>underlined</u></textarea>"
961 )
963 def test_normal_text_unsafe(self):
964 supposedly_safe = Markup("<i>italic</i>")
965 assert (
966 html(t"<p>This is {supposedly_safe:unsafe} text.</p>")
967 == "<p>This is <i>italic</i> text.</p>"
968 )
970 def test_raw_text_unsafe(self):
971 # @TODO: What should even happen here?
972 supposedly_safe = "</script>"
973 assert (
974 html(t"<script>{supposedly_safe:unsafe}</script>")
975 == "<script>\\x3c/script></script>"
976 )
977 assert (
978 html(t"<script>{supposedly_safe:unsafe}</script>")
979 != "<script></script></script>"
980 ) # Sanity check
982 def test_escapable_raw_text_unsafe(self):
983 supposedly_safe = Markup("<i>italic</i>")
984 assert (
985 html(t"<textarea>{supposedly_safe:unsafe}</textarea>")
986 == "<textarea><i>italic</i></textarea>"
987 )
989 def test_all_text_callback(self):
990 def get_value():
991 return "dynamic"
993 for tag in ("p", "script", "style"):
994 assert (
995 html(
996 Template(f"<{tag}>")
997 + t"The value is {get_value:callback}."
998 + Template(f"</{tag}>")
999 )
1000 == f"<{tag}>The value is dynamic.</{tag}>"
1001 )
1003 def test_callback_nonzero_callable_error(self):
1004 def add(a, b):
1005 return a + b
1007 assert add(1, 2) == 3, "Make sure fixture could work..."
1009 with pytest.raises(TypeError):
1010 for tag in ("p", "script", "style"):
1011 _ = html(
1012 Template(f"<{tag}>")
1013 + t"The sum is {add:callback}."
1014 + Template(f"</{tag}>")
1015 )
1018# --------------------------------------------------------------------------
1019# Conditional rendering and control flow
1020# --------------------------------------------------------------------------
1023class TestUsagePatterns:
1024 def test_conditional_rendering_with_if_else(self):
1025 is_logged_in = True
1026 user_profile = t"<span>Welcome, User!</span>"
1027 login_prompt = t"<a href='/login'>Please log in</a>"
1028 assert (
1029 html(t"<div>{user_profile if is_logged_in else login_prompt}</div>")
1030 == "<div><span>Welcome, User!</span></div>"
1031 )
1033 is_logged_in = False
1034 assert (
1035 html(t"<div>{user_profile if is_logged_in else login_prompt}</div>")
1036 == '<div><a href="/login">Please log in</a></div>'
1037 )
1040# --------------------------------------------------------------------------
1041# Attributes
1042# --------------------------------------------------------------------------
1043class TestLiteralAttribute:
1044 """Test literal (non-dynamic) attributes."""
1046 def test_literal_attrs(self):
1047 assert (
1048 html(
1049 t"<a "
1050 t" id=example_link" # no quotes required if value has no surrounding whitespace
1051 t" autofocus" # bare / boolean
1052 t' title=""' # empty attribute
1053 t' href="https://example.com" target="_blank"'
1054 t"></a>"
1055 )
1056 == '<a id="example_link" autofocus title="" href="https://example.com" target="_blank"></a>'
1057 )
1059 def test_literal_attr_escaped(self):
1060 assert (
1061 html(t'<a title="<>&'""></a>')
1062 == '<a title="<>&'""></a>'
1063 )
1066class TestInterpolatedAttribute:
1067 """Test interpolated attributes, entire value is an exact interpolation."""
1069 def test_interpolated_attr(self):
1070 url = "https://example.com/"
1071 assert html(t'<a href="{url}"></a>') == '<a href="https://example.com/"></a>'
1073 def test_interpolated_attr_escaped(self):
1074 url = 'https://example.com/?q="test"&lang=en'
1075 assert (
1076 html(t'<a href="{url}"></a>')
1077 == '<a href="https://example.com/?q="test"&lang=en"></a>'
1078 )
1080 def test_interpolated_attr_unquoted(self):
1081 id = "roquefort"
1082 assert html(t"<div id={id}></div>") == '<div id="roquefort"></div>'
1084 def test_interpolated_attr_true(self):
1085 disabled = True
1086 assert (
1087 html(t"<button disabled={disabled}></button>")
1088 == "<button disabled></button>"
1089 )
1091 def test_interpolated_attr_false(self):
1092 disabled = False
1093 assert html(t"<button disabled={disabled}></button>") == "<button></button>"
1095 def test_interpolated_attr_none(self):
1096 disabled = None
1097 assert html(t"<button disabled={disabled}></button>") == "<button></button>"
1099 def test_interpolate_attr_empty_string(self):
1100 assert html(t'<div title=""></div>') == '<div title=""></div>'
1103class TestSpreadAttribute:
1104 """Test spread attributes."""
1106 def test_spread_attr(self):
1107 attrs = {"href": "https://example.com/", "target": "_blank"}
1108 assert (
1109 html(t"<a {attrs}></a>")
1110 == '<a href="https://example.com/" target="_blank"></a>'
1111 )
1113 def test_spread_attr_none(self):
1114 attrs = None
1115 assert html(t"<a {attrs}></a>") == "<a></a>"
1117 def test_spread_attr_type_errors(self):
1118 for attrs in (0, [], (), False, True):
1119 with pytest.raises(TypeError):
1120 _ = html(t"<a {attrs}></a>")
1123class TestTemplatedAttribute:
1124 def test_templated_attr_mixed_interpolations_start_end_and_nest(self):
1125 left, middle, right = 1, 3, 5
1126 prefix, suffix = t'<div data-range="', t'"></div>'
1127 # Check interpolations at start, middle and/or end of templated attr
1128 # or a combination of those to make sure text is not getting dropped.
1129 for left_part, middle_part, right_part in product(
1130 (t"{left}", Template(str(left))),
1131 (t"{middle}", Template(str(middle))),
1132 (t"{right}", Template(str(right))),
1133 ):
1134 test_t = (
1135 prefix + left_part + t"-" + middle_part + t"-" + right_part + suffix
1136 )
1137 assert html(test_t) == '<div data-range="1-3-5"></div>'
1139 def test_templated_attr_no_quotes(self):
1140 start = 1
1141 end = 5
1142 assert (
1143 html(t"<div data-range={start}-{end}></div>")
1144 == '<div data-range="1-5"></div>'
1145 )
1148class TestAttributeMerging:
1149 def test_attr_merge_disjoint_interpolated_attr_spread_attr(self):
1150 attrs = {"href": "https://example.com/", "id": "link1"}
1151 target = "_blank"
1152 assert (
1153 html(t"<a {attrs} target={target}></a>")
1154 == '<a href="https://example.com/" id="link1" target="_blank"></a>'
1155 )
1157 def test_attr_merge_overlapping_spread_attrs(self):
1158 attrs1 = {"href": "https://example.com/", "id": "overwrtten"}
1159 attrs2 = {"target": "_blank", "id": "link1"}
1160 assert (
1161 html(t"<a {attrs1} {attrs2}></a>")
1162 == '<a href="https://example.com/" target="_blank" id="link1"></a>'
1163 )
1165 def test_attr_merge_replace_literal_attr_str_str(self):
1166 assert (
1167 html(t'<div title="default" { {"title": "fresh"} }></div>')
1168 == '<div title="fresh"></div>'
1169 )
1171 def test_attr_merge_replace_literal_attr_str_true(self):
1172 assert (
1173 html(t'<div title="default" { {"title": True} }></div>')
1174 == "<div title></div>"
1175 )
1177 def test_attr_merge_replace_literal_attr_true_str(self):
1178 assert (
1179 html(t"<div title { {'title': 'fresh'} }></div>")
1180 == '<div title="fresh"></div>'
1181 )
1183 def test_attr_merge_remove_literal_attr_str_none(self):
1184 assert html(t'<div title="default" { {"title": None} }></div>') == "<div></div>"
1186 def test_attr_merge_remove_literal_attr_true_none(self):
1187 assert html(t"<div title { {'title': None} }></div>") == "<div></div>"
1189 def test_attr_merge_other_literal_attr_intact(self):
1190 assert (
1191 html(t'<img title="default" { {"alt": "fresh"} }>')
1192 == '<img title="default" alt="fresh" />'
1193 )
1196class TestSpecialDataAttribute:
1197 """Special data attribute handling."""
1199 def test_interpolated_data_attributes(self):
1200 data = {
1201 "user-id": 123,
1202 "role": "admin",
1203 "wild": True,
1204 "false": False,
1205 "none": None,
1206 }
1207 assert (
1208 html(t"<div data={data}>User Info</div>")
1209 == '<div data-user-id="123" data-role="admin" data-wild>User Info</div>'
1210 )
1212 def test_data_attr_toggle_to_str(self):
1213 for res in [
1214 html(t"<div data-selected data={ {'selected': 'yes'} }></div>"),
1215 html(t'<div data-selected="no" data={ {"selected": "yes"} }></div>'),
1216 ]:
1217 assert res == '<div data-selected="yes"></div>'
1219 def test_data_attr_toggle_to_true(self):
1220 res = html(t'<div data-selected="yes" data={ {"selected": True} }></div>')
1221 assert res == "<div data-selected></div>"
1223 def test_data_attr_unrelated_unaffected(self):
1224 res = html(t"<div data-selected data={ {'active': True} }></div>")
1225 assert res == "<div data-selected data-active></div>"
1227 def test_data_attr_templated_error(self):
1228 data1 = {"user-id": "user-123"}
1229 data2 = {"role": "admin"}
1230 with pytest.raises(TypeError):
1231 _ = html(t'<div data="{data1} {data2}"></div>')
1233 def test_data_attr_none(self):
1234 button_data = None
1235 res = html(t"<button data={button_data}>X</button>")
1236 assert res == "<button>X</button>"
1238 def test_data_attr_errors(self):
1239 for v in [False, [], (), 0, "data?"]:
1240 with pytest.raises(TypeError):
1241 _ = html(t"<button data={v}>X</button>")
1243 def test_data_literal_attr_bypass(self):
1244 # Trigger overall attribute resolution with an unrelated interpolated attr.
1245 res = html(t'<p data="passthru" id={"resolved"}></p>')
1246 assert res == '<p data="passthru" id="resolved"></p>', (
1247 "A single literal attribute should not trigger data expansion."
1248 )
1251class TestSpecialAriaAttribute:
1252 """Special aria attribute handling."""
1254 def test_aria_templated_attr_error(self):
1255 aria1 = {"label": "close"}
1256 aria2 = {"hidden": "true"}
1257 with pytest.raises(TypeError):
1258 _ = html(t'<div aria="{aria1} {aria2}"></div>')
1260 def test_aria_interpolated_attr_dict(self):
1261 aria = {"label": "Close", "hidden": True, "another": False, "more": None}
1262 res = html(t"<button aria={aria}>X</button>")
1263 assert (
1264 res
1265 == '<button aria-label="Close" aria-hidden="true" aria-another="false">X</button>'
1266 )
1268 def test_aria_interpolate_attr_none(self):
1269 button_aria = None
1270 res = html(t"<button aria={button_aria}>X</button>")
1271 assert res == "<button>X</button>"
1273 def test_aria_attr_errors(self):
1274 for v in [False, [], (), 0, "aria?"]:
1275 with pytest.raises(TypeError):
1276 _ = html(t"<button aria={v}>X</button>")
1278 def test_aria_literal_attr_bypass(self):
1279 # Trigger overall attribute resolution with an unrelated interpolated attr.
1280 res = html(t'<p aria="passthru" id={"resolved"}></p>')
1281 assert res == '<p aria="passthru" id="resolved"></p>', (
1282 "A single literal attribute should not trigger aria expansion."
1283 )
1286class TestSpecialClassAttribute:
1287 """Special class attribute handling."""
1289 def test_interpolated_class_attribute(self):
1290 class_list = ["btn", "btn-primary", "one two", None]
1291 class_dict = {"active": True, "btn-secondary": False}
1292 class_str = "blue"
1293 class_space_sep_str = "green yellow"
1294 class_none = None
1295 class_empty_list = []
1296 class_empty_dict = {}
1297 button_t = (
1298 t"<button "
1299 t' class="red" class={class_list} class={class_dict}'
1300 t" class={class_empty_list} class={class_empty_dict}" # ignored
1301 t" class={class_none}" # ignored
1302 t" class={class_str} class={class_space_sep_str}"
1303 t" >Click me</button>"
1304 )
1305 res = html(button_t)
1306 assert (
1307 res
1308 == '<button class="red btn btn-primary one two active blue green yellow">Click me</button>'
1309 )
1311 def test_interpolated_class_attribute_with_multiple_placeholders(self):
1312 classes1 = ["btn", "btn-primary"]
1313 classes2 = [None, {"active": True}]
1314 res = html(t'<button class="{classes1} {classes2}">Click me</button>')
1315 # CONSIDER: Is this what we want? Currently, when we have multiple
1316 # placeholders in a single attribute, we treat it as a string attribute.
1317 assert (
1318 res
1319 == f'<button class="{escape_html_text(str(classes1))} {escape_html_text(str(classes2))}">Click me</button>'
1320 ), (
1321 "Interpolations that are not exact, or singletons, are instead interpreted as templates and therefore these dictionaries are strified."
1322 )
1324 def test_interpolated_attribute_spread_with_class_attribute(self):
1325 attrs = {"id": "button1", "class": ["btn", "btn-primary"]}
1326 res = html(t"<button {attrs}>Click me</button>")
1327 assert res == '<button id="button1" class="btn btn-primary">Click me</button>'
1329 def test_class_literal_attr_bypass(self):
1330 # Trigger overall attribute resolution with an unrelated interpolated attr.
1331 res = html(t'<p class="red red" id={"veryred"}></p>')
1332 assert res == '<p class="red red" id="veryred"></p>', (
1333 "A single literal attribute should not trigger class accumulator."
1334 )
1336 def test_class_none_ignored(self):
1337 class_item = None
1338 res = html(t"<p class={class_item}></p>")
1339 assert res == "<p></p>"
1340 # Also ignored inside a sequence.
1341 res = html(t"<p class={[class_item]}></p>")
1342 assert res == "<p></p>"
1344 def test_class_type_errors(self):
1345 for class_item in (False, True, 0):
1346 with pytest.raises(TypeError):
1347 _ = html(t"<p class={class_item}></p>")
1348 with pytest.raises(TypeError):
1349 _ = html(t"<p class={[class_item]}></p>")
1351 def test_class_merge_literals(self):
1352 res = html(t'<p class="red" class="blue"></p>')
1353 assert res == '<p class="red blue"></p>'
1355 def test_class_merge_literal_then_interpolation(self):
1356 class_item = "blue"
1357 res = html(t'<p class="red" class="{[class_item]}"></p>')
1358 assert res == '<p class="red blue"></p>'
1361class TestSpecialStyleAttribute:
1362 """Special style attribute handling."""
1364 def test_style_literal_attr_passthru(self):
1365 p_id = "para1" # non-literal attribute to cause attr resolution
1366 res = html(t'<p style="color: red" id={p_id}>Warning!</p>')
1367 assert res == '<p style="color: red" id="para1">Warning!</p>'
1369 def test_style_in_interpolated_attr(self):
1370 styles = {"color": "red", "font-weight": "bold", "font-size": "16px"}
1371 res = html(t"<p style={styles}>Warning!</p>")
1372 assert (
1373 res
1374 == '<p style="color: red; font-weight: bold; font-size: 16px">Warning!</p>'
1375 )
1377 def test_style_in_templated_attr(self):
1378 color = "red"
1379 res = html(t'<p style="color: {color}">Warning!</p>')
1380 assert res == '<p style="color: red">Warning!</p>'
1382 def test_style_in_spread_attr(self):
1383 attrs = {"style": {"color": "red"}}
1384 res = html(t"<p {attrs}>Warning!</p>")
1385 assert res == '<p style="color: red">Warning!</p>'
1387 def test_style_merged_from_all_attrs(self):
1388 attrs = {"style": "font-size: 15px"}
1389 style = {"font-weight": "bold"}
1390 color = "red"
1391 res = html(
1392 t'<p style="font-family: serif" style="color: {color}" style={style} {attrs}></p>'
1393 )
1394 assert (
1395 res
1396 == '<p style="font-family: serif; color: red; font-weight: bold; font-size: 15px"></p>'
1397 )
1399 def test_style_override_left_to_right(self):
1400 suffix = t"></p>"
1401 parts = [
1402 (t'<p style="color: red"', "color: red"),
1403 (t" style={ {'color': 'blue'} }", "color: blue"),
1404 (t' style="color: {"green"}"', "color: green"),
1405 (t""" { {"style": {"color": "yellow"}} }""", "color: yellow"),
1406 ]
1407 for index in range(len(parts)):
1408 expected_style = parts[index][1]
1409 t = sum((part[0] for part in parts[: index + 1]), t"") + suffix
1410 res = html(t)
1411 assert res == f'<p style="{expected_style}"></p>'
1413 def test_interpolated_style_attribute_multiple_placeholders(self):
1414 styles1 = {"color": "red"}
1415 styles2 = {"font-weight": "bold"}
1416 # CONSIDER: Is this what we want? Currently, when we have multiple
1417 # placeholders in a single attribute, we treat it as a string attribute
1418 # which produces an invalid style attribute.
1419 with pytest.raises(ValueError):
1420 _ = html(t"<p style='{styles1} {styles2}'>Warning!</p>")
1422 def test_interpolated_style_attribute_merged(self):
1423 styles1 = {"color": "red"}
1424 styles2 = {"font-weight": "bold"}
1425 res = html(t"<p style={styles1} style={styles2}>Warning!</p>")
1426 assert res == '<p style="color: red; font-weight: bold">Warning!</p>'
1428 def test_interpolated_style_attribute_merged_override(self):
1429 styles1 = {"color": "red", "font-weight": "normal"}
1430 styles2 = {"font-weight": "bold"}
1431 res = html(t"<p style={styles1} style={styles2}>Warning!</p>")
1432 assert res == '<p style="color: red; font-weight: bold">Warning!</p>'
1434 def test_style_attribute_str(self):
1435 styles = "color: red; font-weight: bold;"
1436 res = html(t"<p style={styles}>Warning!</p>")
1437 assert res == '<p style="color: red; font-weight: bold">Warning!</p>'
1439 def test_style_attribute_non_str_non_dict(self):
1440 with pytest.raises(TypeError):
1441 styles = [1, 2]
1442 _ = html(t"<p style={styles}>Warning!</p>")
1444 def test_style_literal_attr_bypass(self):
1445 # Trigger overall attribute resolution with an unrelated interpolated attr.
1446 res = html(t'<p style="invalid;invalid:" id={"resolved"}></p>')
1447 assert res == '<p style="invalid;invalid:" id="resolved"></p>', (
1448 "A single literal attribute should bypass style accumulator."
1449 )
1451 def test_style_none(self):
1452 styles = None
1453 res = html(t"<p style={styles}></p>")
1454 assert res == "<p></p>"
1457class TestSpecialAttrMerging:
1458 """
1459 Attributes should be merged left to right and displayed at the last
1460 location they were updated.
1461 """
1463 def test_accumulator_order(self):
1464 # Accumlated attrs are flattened to a value at the end of the attribute
1465 # resolution process which caused them to jump but this asserts that fix.
1466 attrs = {
1467 "class": {"btn": True, "active": True}, # Accumulated
1468 "id": "act_now", # static
1469 "data": {"wow": "such-attr"}, # Expanded
1470 "title": "mega", # static
1471 }
1472 button = html(t"<button {attrs}>Click me</button>")
1473 assert (
1474 button
1475 == '<button class="btn active" id="act_now" data-wow="such-attr" title="mega">Click me</button>'
1476 )
1479class TestPrepComponentKwargs:
1480 def test_named(self):
1481 def InputElement(size=10, type="text"):
1482 pass
1484 callable_info = get_callable_info(InputElement)
1485 assert prep_component_kwargs(callable_info, {"size": 20}, children=t"") == {
1486 "size": 20
1487 }
1488 assert prep_component_kwargs(
1489 callable_info, {"type": "email"}, children=t""
1490 ) == {"type": "email"}
1491 assert prep_component_kwargs(callable_info, {}, children=t"") == {}
1493 def test_unused_kwargs(self):
1494 def InputElement(size=10, type="text"):
1495 pass
1497 callable_info = get_callable_info(InputElement)
1498 with pytest.raises(ValueError):
1499 assert (
1500 prep_component_kwargs(callable_info, {"type2": 15}, children=t"") == {}
1501 )
1503 def test_accepts_children(self):
1504 def DivWrapper(
1505 children: Template, add_classes: list[str] | None = None
1506 ) -> Template:
1507 return t"<div class={add_classes}>{children}</div>"
1509 callable_info = get_callable_info(DivWrapper)
1510 kwargs = prep_component_kwargs(callable_info, {}, children=t"")
1511 assert tuple(kwargs.keys()) == ("children",)
1512 assert isinstance(kwargs["children"], Template) and kwargs[
1513 "children"
1514 ].strings == ("",)
1516 add_classes = ["red"]
1517 kwargs = prep_component_kwargs(
1518 callable_info, {"add_classes": add_classes}, children=t"<span></span>"
1519 )
1520 assert set(kwargs.keys()) == {"children", "add_classes"}
1521 assert isinstance(kwargs["children"], Template) and kwargs[
1522 "children"
1523 ].strings == ("<span></span>",)
1524 assert kwargs["add_classes"] == add_classes
1526 def test_no_children(self):
1527 def SpanMaker(content_text: str) -> Template:
1528 return t"<span>{content_text}</span>"
1530 callable_info = get_callable_info(SpanMaker)
1531 content_text = "inner"
1532 kwargs = prep_component_kwargs(
1533 callable_info, {"content_text": content_text}, children=t"<div></div>"
1534 )
1535 assert kwargs == {"content_text": content_text} # no children
1537 def test_children_attr_error(self):
1538 def Comp(children: Template) -> Template:
1539 return t"<div>{children}</div>"
1541 callable_info = get_callable_info(Comp)
1542 with pytest.raises(ValueError, match="The children attribute is reserved"):
1543 _ = prep_component_kwargs(
1544 callable_info, {"children": t""}, children=t"<span></span>"
1545 )
1548class TestFunctionComponent:
1549 @staticmethod
1550 def FunctionComponent(
1551 children: Template, first: str, second: int, third_arg: str, **attrs: t.Any
1552 ) -> Template:
1553 # Ensure type correctness of props at runtime for testing purposes
1554 assert isinstance(first, str)
1555 assert isinstance(second, int)
1556 assert isinstance(third_arg, str)
1557 new_attrs = {
1558 "id": third_arg,
1559 "data": {"first": first, "second": second},
1560 **attrs,
1561 }
1562 return t"<div {new_attrs}>Component: {children}</div>"
1564 def test_with_children(self):
1565 res = html(
1566 t'<{self.FunctionComponent} first=1 second={99} third-arg="comp1" class="my-comp">Hello, Component!</{self.FunctionComponent}>'
1567 )
1568 assert (
1569 res
1570 == '<div id="comp1" data-first="1" data-second="99" class="my-comp">Component: Hello, Component!</div>'
1571 )
1573 def test_with_no_children(self):
1574 """Same test, but the caller didn't provide any children."""
1575 res = html(
1576 t'<{self.FunctionComponent} first=1 second={99} third-arg="comp1" class="my-comp" />'
1577 )
1578 assert (
1579 res
1580 == '<div id="comp1" data-first="1" data-second="99" class="my-comp">Component: </div>'
1581 )
1583 def test_missing_props_error(self):
1584 with pytest.raises(TypeError):
1585 _ = html(
1586 t"<{self.FunctionComponent}>Missing props</{self.FunctionComponent}>"
1587 )
1590class TestFunctionComponentNoChildren:
1591 @staticmethod
1592 def FunctionComponentNoChildren(
1593 first: str, second: int, third_arg: str
1594 ) -> Template:
1595 # Ensure type correctness of props at runtime for testing purposes
1596 assert isinstance(first, str)
1597 assert isinstance(second, int)
1598 assert isinstance(third_arg, str)
1599 new_attrs = {
1600 "id": third_arg,
1601 "data": {"first": first, "second": second},
1602 }
1603 return t"<div {new_attrs}>Component: ignore children</div>"
1605 def test_interpolated_template_component_ignore_children(self):
1606 res = html(
1607 t'<{self.FunctionComponentNoChildren} first=1 second={99} third-arg="comp1">Hello, Component!</{self.FunctionComponentNoChildren}>'
1608 )
1609 assert (
1610 res
1611 == '<div id="comp1" data-first="1" data-second="99">Component: ignore children</div>'
1612 )
1615class TestFunctionComponentKeywordArgs:
1616 @staticmethod
1617 def FunctionComponentKeywordArgs(first: str, **attrs: t.Any) -> Template:
1618 # Ensure type correctness of props at runtime for testing purposes
1619 assert isinstance(first, str)
1620 if "children" in attrs:
1621 raise ValueError("Children not expected in attrs.")
1622 new_attrs = {"data-first": first, **attrs}
1623 return t"<div {new_attrs}>No children in kwargs</div>"
1625 def test_children_not_passed_via_kwargs(self):
1626 res = html(
1627 t'<{self.FunctionComponentKeywordArgs} first="value" extra="info">Child content</{self.FunctionComponentKeywordArgs}>'
1628 )
1629 assert res == '<div data-first="value" extra="info">No children in kwargs</div>'
1631 def test_children_not_passed_via_kwargs_even_when_empty(self):
1632 res = html(
1633 t'<{self.FunctionComponentKeywordArgs} first="value" extra="info" />'
1634 )
1635 assert res == '<div data-first="value" extra="info">No children in kwargs</div>'
1638class TestComponentSpecialUsage:
1639 @staticmethod
1640 def ColumnsComponent() -> Template:
1641 return t"""<td>Column 1</td><td>Column 2</td>"""
1643 def test_fragment_from_component(self):
1644 # This test assumes that if a component returns a template that parses
1645 # into multiple root elements, they are treated as a fragment.
1646 res = html(t"<table><tr><{self.ColumnsComponent} /></tr></table>")
1647 assert res == "<table><tr><td>Column 1</td><td>Column 2</td></tr></table>"
1649 def test_component_passed_as_attr_value(self):
1650 def Wrapper(
1651 children: Template, sub_component: Callable, **attrs: t.Any
1652 ) -> Template:
1653 return t"<{sub_component} {attrs}>{children}</{sub_component}>"
1655 res = html(
1656 t'<{Wrapper} sub-component={TestFunctionComponent.FunctionComponent} class="wrapped" first=1 second={99} third-arg="comp1"><p>Inside wrapper</p></{Wrapper}>'
1657 )
1658 assert (
1659 res
1660 == '<div id="comp1" data-first="1" data-second="99" class="wrapped">Component: <p>Inside wrapper</p></div>'
1661 )
1663 def test_nested_component_gh23(self):
1664 # @DESIGN: Do we need this? Should we recommend an alternative?
1665 # See https://github.com/t-strings/tdom/issues/23 for context
1666 def Header() -> Template:
1667 return t"{'Hello World'}"
1669 res = html(t"<{Header} />", assume_ctx=make_ctx(parent_tag="div"))
1670 assert res == "Hello World"
1673class TestClassComponent:
1674 @dataclass
1675 class ClassComponent:
1676 """Example class-based component."""
1678 user_name: str
1679 image_url: str
1680 children: Template
1681 homepage: str = "#"
1683 def __call__(self) -> Template:
1684 return (
1685 t"<div class='avatar'>"
1686 t"<a href={self.homepage}>"
1687 t"<img src='{self.image_url}' alt='{f'Avatar of {self.user_name}'}' />"
1688 t"</a>"
1689 t"<span>{self.user_name}</span>"
1690 t"{self.children}"
1691 t"</div>"
1692 )
1694 def test_class_component_implicit_invocation_with_children(self):
1695 res = html(
1696 t"<{self.ClassComponent} user-name='Alice' image-url='https://example.com/alice.png'>Fun times!</{self.ClassComponent}>"
1697 )
1698 assert (
1699 res
1700 == '<div class="avatar"><a href="#"><img src="https://example.com/alice.png" alt="Avatar of Alice" /></a><span>Alice</span>Fun times!</div>'
1701 )
1703 def test_class_component_direct_invocation(self):
1704 avatar = self.ClassComponent(
1705 user_name="Alice",
1706 image_url="https://example.com/alice.png",
1707 homepage="https://example.com/users/alice",
1708 children=t"", # Children is required so we set it to an empty template.
1709 )
1710 res = html(t"<{avatar} />")
1711 assert (
1712 res
1713 == '<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>'
1714 )
1716 @dataclass
1717 class ClassComponentNoChildren:
1718 """Example class-based component that does not ask for children."""
1720 user_name: str
1721 image_url: str
1722 homepage: str = "#"
1724 def __call__(self) -> Template:
1725 return (
1726 t"<div class='avatar'>"
1727 t"<a href={self.homepage}>"
1728 t"<img src='{self.image_url}' alt='{f'Avatar of {self.user_name}'}' />"
1729 t"</a>"
1730 t"<span>{self.user_name}</span>"
1731 t"ignore children"
1732 t"</div>"
1733 )
1735 def test_implicit_invocation_ignore_children(self):
1736 res = html(
1737 t"<{self.ClassComponentNoChildren} user-name='Alice' image-url='https://example.com/alice.png'>Fun times!</{self.ClassComponentNoChildren}>"
1738 )
1739 assert (
1740 res
1741 == '<div class="avatar"><a href="#"><img src="https://example.com/alice.png" alt="Avatar of Alice" /></a><span>Alice</span>ignore children</div>'
1742 )
1745def test_attribute_type_component():
1746 def AttributeTypeComponent(
1747 data_int: int,
1748 data_true: bool,
1749 data_false: bool,
1750 data_none: None,
1751 data_float: float,
1752 data_dt: datetime.datetime,
1753 **kws: dict[str, object | None],
1754 ) -> Template:
1755 """Component to test that we don't incorrectly convert attribute types."""
1756 assert isinstance(data_int, int)
1757 assert data_true is True
1758 assert data_false is False
1759 assert data_none is None
1760 assert isinstance(data_float, float)
1761 assert isinstance(data_dt, datetime.datetime)
1762 for kw, v_type in [
1763 ("spread_true", True),
1764 ("spread_false", False),
1765 ("spread_int", int),
1766 ("spread_none", None),
1767 ("spread_float", float),
1768 ("spread_dt", datetime.datetime),
1769 ("spread_dict", dict),
1770 ("spread_list", list),
1771 ]:
1772 if v_type in (True, False, None):
1773 assert kw in kws and kws[kw] is v_type, (
1774 f"{kw} should be {v_type} but got {kws=}"
1775 )
1776 else:
1777 assert kw in kws and isinstance(kws[kw], v_type), (
1778 f"{kw} should instance of {v_type} but got {kws=}"
1779 )
1780 return t"Looks good!"
1782 an_int: int = 42
1783 a_true: bool = True
1784 a_false: bool = False
1785 a_none: None = None
1786 a_float: float = 3.14
1787 a_dt: datetime.datetime = datetime.datetime(
1788 2024, 1, 1, 12, 0, 0, tzinfo=datetime.UTC
1789 )
1790 spread_attrs: dict[str, object | None] = {
1791 "spread_true": True,
1792 "spread_false": False,
1793 "spread_none": None,
1794 "spread_int": 0,
1795 "spread_float": 0.0,
1796 "spread_dt": datetime.datetime(2024, 1, 1, 12, 0, 1, tzinfo=datetime.UTC),
1797 "spread_dict": {},
1798 "spread_list": ["eggs", "milk"],
1799 }
1800 res = html(
1801 t"<{AttributeTypeComponent} data-int={an_int} data-true={a_true} "
1802 t"data-false={a_false} data-none={a_none} data-float={a_float} "
1803 t"data-dt={a_dt} {spread_attrs}/>"
1804 )
1805 assert res == "Looks good!"
1808class TestComponentErrors:
1809 def test_component_non_callable_fails(self):
1810 with pytest.raises(TypeError):
1811 _ = html(t"<{'not a function'} />")
1813 def test_component_requiring_positional_arg_fails(self):
1814 def RequiresPositional(whoops: int, /) -> Template: # pragma: no cover
1815 return t"<p>Positional arg: {whoops}</p>"
1817 with pytest.raises(TypeError):
1818 _ = html(t"<{RequiresPositional} />")
1820 def test_mismatched_component_closing_tag_fails(self):
1821 def OpenTag(children: Template) -> Template:
1822 return t"<div>open</div>"
1824 def CloseTag(children: Template) -> Template:
1825 return t"<div>close</div>"
1827 with pytest.raises(TypeError):
1828 _ = html(t"<{OpenTag}>Hello</{CloseTag}>")
1830 @pytest.mark.parametrize(
1831 "bad_value", ("", "text", None, 1, ("tuple", "of", "strs"))
1832 )
1833 def test_function_component_returns_nontemplate_fails(self, bad_value):
1834 def BadFunctionComp(children: Template):
1835 return bad_value
1837 with pytest.raises(
1838 TypeError, match="Component callable must return Template or Callable:"
1839 ):
1840 _ = html(t"<{BadFunctionComp}>Hello</{BadFunctionComp}>")
1842 @pytest.mark.parametrize(
1843 "bad_value", ("", "text", None, 1, ("tuple", "of", "strs"))
1844 )
1845 def test_component_object_returns_nontemplate_fails(self, bad_value):
1846 def BadFactoryComp(children: Template):
1847 def component_object():
1848 return bad_value
1850 return component_object
1852 with pytest.raises(
1853 TypeError, match="Component object must return Template when called:"
1854 ):
1855 _ = html(t"<{BadFactoryComp}>Hello</{BadFactoryComp}>")
1858def test_integration_basic():
1859 comment_text = "comment is not literal"
1860 interpolated_class = "red"
1861 text_in_element = "text is not literal"
1862 templated = "not literal"
1863 spread_attrs = {"data-on": True}
1864 markup_content = Markup("<div>safe</div>")
1866 def WrapperComponent(children):
1867 return t"<div>{children}</div>"
1869 smoke_t = t"""<!doctype html>
1870<html>
1871<body>
1872<!-- literal -->
1873<span attr="literal">literal</span>
1874<!-- {comment_text} -->
1875<span>{text_in_element}</span>
1876<span attr="literal" class={interpolated_class} title="is {templated}" {spread_attrs}>{text_in_element}</span>
1877<{WrapperComponent}><span>comp body</span></{WrapperComponent}>
1878{markup_content}
1879</body>
1880</html>"""
1881 smoke_str = """<!DOCTYPE html>
1882<html>
1883<body>
1884<!-- literal -->
1885<span attr="literal">literal</span>
1886<!-- comment is not literal -->
1887<span>text is not literal</span>
1888<span attr="literal" class="red" title="is not literal" data-on>text is not literal</span>
1889<div><span>comp body</span></div>
1890<div>safe</div>
1891</body>
1892</html>"""
1893 assert html(smoke_t) == smoke_str
1896def struct_repr(st):
1897 """Breakdown Templates into comparable parts for test verification."""
1898 return st.strings, tuple(
1899 (i.value, i.expression, i.conversion, i.format_spec) for i in st.interpolations
1900 )
1903def test_process_template_internal_cache():
1904 """Test that cache and non-cache both generally work as expected."""
1905 # @NOTE: We use a made-up custom element so that we can be sure to
1906 # miss the cache. If this element is used elsewhere than the global
1907 # cache might cache it and it will ruin our counting, specifically
1908 # the first miss will instead be a hit.
1909 sample_t = t"<div>{'content'}<tdom-cache-test-element /></div>"
1910 sample_diff_t = t"<div>{'diffcontent'}<tdom-cache-test-element /></div>"
1911 alt_t = t"<span>{'content'}</span>"
1912 process_api = TemplateProcessor(parser_api=TemplateParserProxy())
1913 cached_process_api = TemplateProcessor(parser_api=CachedTemplateParserProxy())
1914 # Because the cache is stored on the class itself this can be affect by
1915 # other tests, so save this off and take the difference to determine the result,
1916 # this is not great and hopefully we can find a better solution.
1917 assert isinstance(cached_process_api, TemplateProcessor)
1918 assert isinstance(cached_process_api.parser_api, CachedTemplateParserProxy)
1919 start_ci = cached_process_api.parser_api._to_tnode.cache_info()
1920 tnode1 = process_api.parser_api.to_tnode(sample_t)
1921 tnode2 = process_api.parser_api.to_tnode(sample_t)
1922 cached_tnode1 = cached_process_api.parser_api.to_tnode(sample_t)
1923 cached_tnode2 = cached_process_api.parser_api.to_tnode(sample_t)
1924 cached_tnode3 = cached_process_api.parser_api.to_tnode(sample_diff_t)
1925 # Check that the uncached and cached services are actually
1926 # returning non-identical results.
1927 assert tnode1 is not cached_tnode1
1928 assert tnode1 is not cached_tnode2
1929 assert tnode1 is not cached_tnode3
1930 # Check that the uncached service returns a brand new result everytime.
1931 assert tnode1 is not tnode2
1932 # Check that the cached service is returning the exact same, identical, result.
1933 assert cached_tnode1 is cached_tnode2
1934 # Even if the input templates are not identical (but are still equivalent).
1935 assert cached_tnode1 is cached_tnode3 and sample_t is not sample_diff_t
1936 # Check that the cached service and uncached services return
1937 # results that are equivalent (even though they are not (id)entical).
1938 assert tnode1 == cached_tnode1
1939 assert tnode2 == cached_tnode1
1940 # Now that we are setup we check that the cache is internally
1941 # working as we intended.
1942 ci = cached_process_api.parser_api._to_tnode.cache_info()
1943 # cached_tnode2 and cached_tnode3 are hits after cached_tnode1
1944 assert ci.hits - start_ci.hits == 2
1945 # cached_tf1 was a miss because cache was empty (brand new)
1946 assert ci.misses - start_ci.misses == 1
1947 cached_tnode4 = cached_process_api.parser_api.to_tnode(alt_t)
1948 # A different template produces a brand new tf.
1949 assert cached_tnode1 is not cached_tnode4
1950 # The template is new AND has a different structure so it also
1951 # produces an unequivalent tf.
1952 assert cached_tnode1 != cached_tnode4
1955def test_repeat_calls():
1956 """Crude check for any unintended state being kept between calls."""
1958 def get_sample_t(idx, spread_attrs, button_text):
1959 return t"""<div><button data-key={idx} {spread_attrs}>{button_text}</button></div>"""
1961 for idx in range(3):
1962 spread_attrs = {"data-enabled": True}
1963 button_text = "PROCESS"
1964 sample_t = get_sample_t(idx, spread_attrs, button_text)
1965 assert (
1966 html(sample_t)
1967 == f'<div><button data-key="{idx}" data-enabled>PROCESS</button></div>'
1968 )
1971def get_select_t_with_list(options, selected_values):
1972 return t"""<select>{
1973 [
1974 t"<option value={opt[0]} selected={opt[0] in selected_values}>{opt[1]}</option>"
1975 for opt in options
1976 ]
1977 }</select>"""
1980def get_select_t_with_generator(options, selected_values):
1981 return t"""<select>{
1982 (
1983 t"<option value={opt[0]} selected={opt[0] in selected_values}>{opt[1]}</option>"
1984 for opt in options
1985 )
1986 }</select>"""
1989def get_select_t_with_concat(options, selected_values):
1990 parts = [t"<select>"]
1991 parts.extend(
1992 [
1993 t"<option value={opt[0]} selected={opt[0] in selected_values}>{opt[1]}</option>"
1994 for opt in options
1995 ]
1996 )
1997 parts.append(t"</select>")
1998 return sum(parts, t"")
2001@pytest.mark.parametrize(
2002 "provider",
2003 (
2004 get_select_t_with_list,
2005 get_select_t_with_generator,
2006 get_select_t_with_concat,
2007 ),
2008)
2009def test_process_template_iterables(provider):
2010 def get_color_select_t(selected_values: set, provider: Callable) -> Template:
2011 PRIMARY_COLORS = [("R", "Red"), ("Y", "Yellow"), ("B", "Blue")]
2012 assert set(selected_values).issubset({opt[0] for opt in PRIMARY_COLORS})
2013 return provider(PRIMARY_COLORS, selected_values)
2015 no_selection_t = get_color_select_t(set(), provider)
2016 assert (
2017 html(no_selection_t)
2018 == '<select><option value="R">Red</option><option value="Y">Yellow</option><option value="B">Blue</option></select>'
2019 )
2020 selected_yellow_t = get_color_select_t({"Y"}, provider)
2021 assert (
2022 html(selected_yellow_t)
2023 == '<select><option value="R">Red</option><option value="Y" selected>Yellow</option><option value="B">Blue</option></select>'
2024 )
2027def test_component_integration():
2028 """Broadly test that common template component usage works."""
2030 def PageComponent(children, root_attrs=None):
2031 return t"""<div class="content" {root_attrs}>{children}</div>"""
2033 def FooterComponent(classes=("footer-default",)):
2034 return t'<div class="footer" class={classes}><a href="about">About</a></div>'
2036 def LayoutComponent(children, body_classes=None):
2037 return t"""<!doctype html>
2038<html>
2039 <head>
2040 <meta charset="utf-8">
2041 <script src="scripts.js"></script>
2042 <link rel="stylesheet" href="styles.css">
2043 </head>
2044 <body class={body_classes}>
2045 {children}
2046 <{FooterComponent} />
2047 </body>
2048</html>
2049"""
2051 content = "HTML never goes out of style."
2052 content_str = html(
2053 t"<{LayoutComponent} body_classes={['theme-default']}><{PageComponent}>{content}</{PageComponent}></{LayoutComponent}>"
2054 )
2055 assert (
2056 content_str
2057 == """<!DOCTYPE html>
2058<html>
2059 <head>
2060 <meta charset="utf-8" />
2061 <script src="scripts.js"></script>
2062 <link rel="stylesheet" href="styles.css" />
2063 </head>
2064 <body class="theme-default">
2065 <div class="content">HTML never goes out of style.</div>
2066 <div class="footer footer-default"><a href="about">About</a></div>
2067 </body>
2068</html>
2069"""
2070 )
2073class TestInterpolatingHTMLInTemplateWithDynamicParentTag:
2074 """
2075 When a template does not have a parent tag we cannot determine the type
2076 of text that should be allowed and therefore we cannot determine how to
2077 escape that text. Once the type is known we should escape any
2078 interpolations in that text correctly.
2079 """
2081 def test_dynamic_raw_text(self):
2082 """Type raw text should fail because template is already not allowed."""
2083 content = '<script>console.log("123!");</script>'
2084 content_t = t"{content}"
2085 with pytest.raises(
2086 ValueError, match="Recursive includes are not supported within script"
2087 ):
2088 content_t = t'<script>console.log("{123}!");</script>'
2089 _ = html(t"<script>{content_t}</script>")
2091 def test_dynamic_escapable_raw_text(self):
2092 """Type escapable raw text should fail because template is already not allowed."""
2093 content = '<script>console.log("123!");</script>'
2094 content_t = t"{content}"
2095 with pytest.raises(
2096 ValueError, match="Recursive includes are not supported within textarea"
2097 ):
2098 _ = html(t"<textarea>{content_t}</textarea>")
2100 def test_dynamic_normal_text(self):
2101 """Escaping should be applied when normal text type is goes into effect."""
2102 content = '<script>console.log("123!");</script>'
2103 content_t = t"{content}"
2104 LT, GT, DQ = map(markupsafe_escape, ["<", ">", '"'])
2105 assert (
2106 html(t"<div>{content_t}</div>")
2107 == f"<div>{LT}script{GT}console.log({DQ}123!{DQ});{LT}/script{GT}</div>"
2108 )
2111class TestPagerComponentExample:
2112 @dataclass
2113 class Pager:
2114 left_pages: tuple = ()
2115 page: int = 0
2116 right_pages: tuple = ()
2117 prev_page: int | None = None
2118 next_page: int | None = None
2120 @dataclass
2121 class PagerDisplay:
2122 pager: TestPagerComponentExample.Pager
2123 paginate_url: Callable[[int], str]
2124 root_classes: tuple[str, ...] = ("cb", "tc", "w-100")
2125 part_classes: tuple[str, ...] = ("dib", "pa1")
2127 def __call__(self) -> Template:
2128 parts = [t"<div class={self.root_classes}>"]
2129 if self.pager.prev_page:
2130 parts.append(
2131 t"<a class={self.part_classes} href={self.paginate_url(self.pager.prev_page)}>Prev</a>"
2132 )
2133 for left_page in self.pager.left_pages:
2134 parts.append(
2135 t'<a class={self.part_classes} href="{self.paginate_url(left_page)}">{left_page}</a>'
2136 )
2137 parts.append(t"<span class={self.part_classes}>{self.pager.page}</span>")
2138 for right_page in self.pager.right_pages:
2139 parts.append(
2140 t'<a class={self.part_classes} href="{self.paginate_url(right_page)}">{right_page}</a>'
2141 )
2142 if self.pager.next_page:
2143 parts.append(
2144 t"<a class={self.part_classes} href={self.paginate_url(self.pager.next_page)}>Next</a>"
2145 )
2146 parts.append(t"</div>")
2147 return Template(*chain.from_iterable(parts))
2149 def test_example(self):
2150 def paginate_url(page: int) -> str:
2151 return f"/pages?page={page}"
2153 def Footer(pager, paginate_url, footer_classes=("footer",)) -> Template:
2154 return t"<div class={footer_classes}><{self.PagerDisplay} pager={pager} paginate_url={paginate_url} /></div>"
2156 pager = self.Pager(
2157 left_pages=(1, 2), page=3, right_pages=(4, 5), next_page=6, prev_page=None
2158 )
2159 content_t = t"<{Footer} pager={pager} paginate_url={paginate_url} />"
2160 res = html(content_t)
2161 print(res)
2162 assert (
2163 res
2164 == '<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>'
2165 )
2168def test_mathml():
2169 num = 1
2170 denom = 3
2171 mathml_t = t"""<p>
2172 The fraction
2173 <math>
2174 <mfrac>
2175 <mn>{num}</mn>
2176 <mn>{denom}</mn>
2177 </mfrac>
2178 </math>
2179 is not a decimal number.
2180</p>"""
2181 res = html(mathml_t)
2182 assert (
2183 str(res)
2184 == """<p>
2185 The fraction
2186 <math>
2187 <mfrac>
2188 <mn>1</mn>
2189 <mn>3</mn>
2190 </mfrac>
2191 </math>
2192 is not a decimal number.
2193</p>"""
2194 )