Coverage for tdom / processor_test.py: 99%
1055 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-05-03 21:23 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-05-03 21:23 +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 TestPrepComponentKwargs:
1458 def test_named(self):
1459 def InputElement(size=10, type="text"):
1460 pass
1462 callable_info = get_callable_info(InputElement)
1463 assert prep_component_kwargs(callable_info, {"size": 20}, children=t"") == {
1464 "size": 20
1465 }
1466 assert prep_component_kwargs(
1467 callable_info, {"type": "email"}, children=t""
1468 ) == {"type": "email"}
1469 assert prep_component_kwargs(callable_info, {}, children=t"") == {}
1471 @pytest.mark.skip("Should we just ignore unused user-specified kwargs?")
1472 def test_unused_kwargs(self):
1473 def InputElement(size=10, type="text"):
1474 pass
1476 callable_info = get_callable_info(InputElement)
1477 with pytest.raises(ValueError):
1478 assert (
1479 prep_component_kwargs(callable_info, {"type2": 15}, children=t"") == {}
1480 )
1482 def test_accepts_children(self):
1483 def DivWrapper(
1484 children: Template, add_classes: list[str] | None = None
1485 ) -> Template:
1486 return t"<div class={add_classes}>{children}</div>"
1488 callable_info = get_callable_info(DivWrapper)
1489 kwargs = prep_component_kwargs(callable_info, {}, children=t"")
1490 assert tuple(kwargs.keys()) == ("children",)
1491 assert isinstance(kwargs["children"], Template) and kwargs[
1492 "children"
1493 ].strings == ("",)
1495 add_classes = ["red"]
1496 kwargs = prep_component_kwargs(
1497 callable_info, {"add_classes": add_classes}, children=t"<span></span>"
1498 )
1499 assert set(kwargs.keys()) == {"children", "add_classes"}
1500 assert isinstance(kwargs["children"], Template) and kwargs[
1501 "children"
1502 ].strings == ("<span></span>",)
1503 assert kwargs["add_classes"] == add_classes
1505 def test_no_children(self):
1506 def SpanMaker(content_text: str) -> Template:
1507 return t"<span>{content_text}</span>"
1509 callable_info = get_callable_info(SpanMaker)
1510 content_text = "inner"
1511 kwargs = prep_component_kwargs(
1512 callable_info, {"content_text": content_text}, children=t"<div></div>"
1513 )
1514 assert kwargs == {"content_text": content_text} # no children
1517class TestFunctionComponent:
1518 @staticmethod
1519 def FunctionComponent(
1520 children: Template, first: str, second: int, third_arg: str, **attrs: t.Any
1521 ) -> Template:
1522 # Ensure type correctness of props at runtime for testing purposes
1523 assert isinstance(first, str)
1524 assert isinstance(second, int)
1525 assert isinstance(third_arg, str)
1526 new_attrs = {
1527 "id": third_arg,
1528 "data": {"first": first, "second": second},
1529 **attrs,
1530 }
1531 return t"<div {new_attrs}>Component: {children}</div>"
1533 def test_with_children(self):
1534 res = html(
1535 t'<{self.FunctionComponent} first=1 second={99} third-arg="comp1" class="my-comp">Hello, Component!</{self.FunctionComponent}>'
1536 )
1537 assert (
1538 res
1539 == '<div id="comp1" data-first="1" data-second="99" class="my-comp">Component: Hello, Component!</div>'
1540 )
1542 def test_with_no_children(self):
1543 """Same test, but the caller didn't provide any children."""
1544 res = html(
1545 t'<{self.FunctionComponent} first=1 second={99} third-arg="comp1" class="my-comp" />'
1546 )
1547 assert (
1548 res
1549 == '<div id="comp1" data-first="1" data-second="99" class="my-comp">Component: </div>'
1550 )
1552 def test_missing_props_error(self):
1553 with pytest.raises(TypeError):
1554 _ = html(
1555 t"<{self.FunctionComponent}>Missing props</{self.FunctionComponent}>"
1556 )
1559class TestFunctionComponentNoChildren:
1560 @staticmethod
1561 def FunctionComponentNoChildren(
1562 first: str, second: int, third_arg: str
1563 ) -> Template:
1564 # Ensure type correctness of props at runtime for testing purposes
1565 assert isinstance(first, str)
1566 assert isinstance(second, int)
1567 assert isinstance(third_arg, str)
1568 new_attrs = {
1569 "id": third_arg,
1570 "data": {"first": first, "second": second},
1571 }
1572 return t"<div {new_attrs}>Component: ignore children</div>"
1574 def test_interpolated_template_component_ignore_children(self):
1575 res = html(
1576 t'<{self.FunctionComponentNoChildren} first=1 second={99} third-arg="comp1">Hello, Component!</{self.FunctionComponentNoChildren}>'
1577 )
1578 assert (
1579 res
1580 == '<div id="comp1" data-first="1" data-second="99">Component: ignore children</div>'
1581 )
1584class TestFunctionComponentKeywordArgs:
1585 @staticmethod
1586 def FunctionComponentKeywordArgs(first: str, **attrs: t.Any) -> Template:
1587 # Ensure type correctness of props at runtime for testing purposes
1588 assert isinstance(first, str)
1589 assert "children" in attrs
1590 children = attrs.pop("children")
1591 new_attrs = {"data-first": first, **attrs}
1592 return t"<div {new_attrs}>Component with kwargs: {children}</div>"
1594 def test_children_always_passed_via_kwargs(self):
1595 res = html(
1596 t'<{self.FunctionComponentKeywordArgs} first="value" extra="info">Child content</{self.FunctionComponentKeywordArgs}>'
1597 )
1598 assert (
1599 res
1600 == '<div data-first="value" extra="info">Component with kwargs: Child content</div>'
1601 )
1603 def test_children_always_passed_via_kwargs_even_when_empty(self):
1604 res = html(
1605 t'<{self.FunctionComponentKeywordArgs} first="value" extra="info" />'
1606 )
1607 assert (
1608 res == '<div data-first="value" extra="info">Component with kwargs: </div>'
1609 )
1612class TestComponentSpecialUsage:
1613 @staticmethod
1614 def ColumnsComponent() -> Template:
1615 return t"""<td>Column 1</td><td>Column 2</td>"""
1617 def test_fragment_from_component(self):
1618 # This test assumes that if a component returns a template that parses
1619 # into multiple root elements, they are treated as a fragment.
1620 res = html(t"<table><tr><{self.ColumnsComponent} /></tr></table>")
1621 assert res == "<table><tr><td>Column 1</td><td>Column 2</td></tr></table>"
1623 def test_component_passed_as_attr_value(self):
1624 def Wrapper(
1625 children: Template, sub_component: Callable, **attrs: t.Any
1626 ) -> Template:
1627 return t"<{sub_component} {attrs}>{children}</{sub_component}>"
1629 res = html(
1630 t'<{Wrapper} sub-component={TestFunctionComponent.FunctionComponent} class="wrapped" first=1 second={99} third-arg="comp1"><p>Inside wrapper</p></{Wrapper}>'
1631 )
1632 assert (
1633 res
1634 == '<div id="comp1" data-first="1" data-second="99" class="wrapped">Component: <p>Inside wrapper</p></div>'
1635 )
1637 def test_nested_component_gh23(self):
1638 # @DESIGN: Do we need this? Should we recommend an alternative?
1639 # See https://github.com/t-strings/tdom/issues/23 for context
1640 def Header() -> Template:
1641 return t"{'Hello World'}"
1643 res = html(t"<{Header} />", assume_ctx=make_ctx(parent_tag="div"))
1644 assert res == "Hello World"
1647class TestClassComponent:
1648 @dataclass
1649 class ClassComponent:
1650 """Example class-based component."""
1652 user_name: str
1653 image_url: str
1654 children: Template
1655 homepage: str = "#"
1657 def __call__(self) -> Template:
1658 return (
1659 t"<div class='avatar'>"
1660 t"<a href={self.homepage}>"
1661 t"<img src='{self.image_url}' alt='{f'Avatar of {self.user_name}'}' />"
1662 t"</a>"
1663 t"<span>{self.user_name}</span>"
1664 t"{self.children}"
1665 t"</div>"
1666 )
1668 def test_class_component_implicit_invocation_with_children(self):
1669 res = html(
1670 t"<{self.ClassComponent} user-name='Alice' image-url='https://example.com/alice.png'>Fun times!</{self.ClassComponent}>"
1671 )
1672 assert (
1673 res
1674 == '<div class="avatar"><a href="#"><img src="https://example.com/alice.png" alt="Avatar of Alice" /></a><span>Alice</span>Fun times!</div>'
1675 )
1677 def test_class_component_direct_invocation(self):
1678 avatar = self.ClassComponent(
1679 user_name="Alice",
1680 image_url="https://example.com/alice.png",
1681 homepage="https://example.com/users/alice",
1682 children=t"", # Children is required so we set it to an empty template.
1683 )
1684 res = html(t"<{avatar} />")
1685 assert (
1686 res
1687 == '<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>'
1688 )
1690 @dataclass
1691 class ClassComponentNoChildren:
1692 """Example class-based component that does not ask for children."""
1694 user_name: str
1695 image_url: str
1696 homepage: str = "#"
1698 def __call__(self) -> Template:
1699 return (
1700 t"<div class='avatar'>"
1701 t"<a href={self.homepage}>"
1702 t"<img src='{self.image_url}' alt='{f'Avatar of {self.user_name}'}' />"
1703 t"</a>"
1704 t"<span>{self.user_name}</span>"
1705 t"ignore children"
1706 t"</div>"
1707 )
1709 def test_implicit_invocation_ignore_children(self):
1710 res = html(
1711 t"<{self.ClassComponentNoChildren} user-name='Alice' image-url='https://example.com/alice.png'>Fun times!</{self.ClassComponentNoChildren}>"
1712 )
1713 assert (
1714 res
1715 == '<div class="avatar"><a href="#"><img src="https://example.com/alice.png" alt="Avatar of Alice" /></a><span>Alice</span>ignore children</div>'
1716 )
1719def test_attribute_type_component():
1720 def AttributeTypeComponent(
1721 data_int: int,
1722 data_true: bool,
1723 data_false: bool,
1724 data_none: None,
1725 data_float: float,
1726 data_dt: datetime.datetime,
1727 **kws: dict[str, object | None],
1728 ) -> Template:
1729 """Component to test that we don't incorrectly convert attribute types."""
1730 assert isinstance(data_int, int)
1731 assert data_true is True
1732 assert data_false is False
1733 assert data_none is None
1734 assert isinstance(data_float, float)
1735 assert isinstance(data_dt, datetime.datetime)
1736 for kw, v_type in [
1737 ("spread_true", True),
1738 ("spread_false", False),
1739 ("spread_int", int),
1740 ("spread_none", None),
1741 ("spread_float", float),
1742 ("spread_dt", datetime.datetime),
1743 ("spread_dict", dict),
1744 ("spread_list", list),
1745 ]:
1746 if v_type in (True, False, None):
1747 assert kw in kws and kws[kw] is v_type, (
1748 f"{kw} should be {v_type} but got {kws=}"
1749 )
1750 else:
1751 assert kw in kws and isinstance(kws[kw], v_type), (
1752 f"{kw} should instance of {v_type} but got {kws=}"
1753 )
1754 return t"Looks good!"
1756 an_int: int = 42
1757 a_true: bool = True
1758 a_false: bool = False
1759 a_none: None = None
1760 a_float: float = 3.14
1761 a_dt: datetime.datetime = datetime.datetime(
1762 2024, 1, 1, 12, 0, 0, tzinfo=datetime.UTC
1763 )
1764 spread_attrs: dict[str, object | None] = {
1765 "spread_true": True,
1766 "spread_false": False,
1767 "spread_none": None,
1768 "spread_int": 0,
1769 "spread_float": 0.0,
1770 "spread_dt": datetime.datetime(2024, 1, 1, 12, 0, 1, tzinfo=datetime.UTC),
1771 "spread_dict": {},
1772 "spread_list": ["eggs", "milk"],
1773 }
1774 res = html(
1775 t"<{AttributeTypeComponent} data-int={an_int} data-true={a_true} "
1776 t"data-false={a_false} data-none={a_none} data-float={a_float} "
1777 t"data-dt={a_dt} {spread_attrs}/>"
1778 )
1779 assert res == "Looks good!"
1782class TestComponentErrors:
1783 def test_component_non_callable_fails(self):
1784 with pytest.raises(TypeError):
1785 _ = html(t"<{'not a function'} />")
1787 def test_component_requiring_positional_arg_fails(self):
1788 def RequiresPositional(whoops: int, /) -> Template: # pragma: no cover
1789 return t"<p>Positional arg: {whoops}</p>"
1791 with pytest.raises(TypeError):
1792 _ = html(t"<{RequiresPositional} />")
1794 def test_mismatched_component_closing_tag_fails(self):
1795 def OpenTag(children: Template) -> Template:
1796 return t"<div>open</div>"
1798 def CloseTag(children: Template) -> Template:
1799 return t"<div>close</div>"
1801 with pytest.raises(TypeError):
1802 _ = html(t"<{OpenTag}>Hello</{CloseTag}>")
1804 @pytest.mark.parametrize(
1805 "bad_value", ("", "text", None, 1, ("tuple", "of", "strs"))
1806 )
1807 def test_function_component_returns_nontemplate_fails(self, bad_value):
1808 def BadFunctionComp(children: Template):
1809 return bad_value
1811 with pytest.raises(
1812 TypeError, match="Component callable must return Template or Callable:"
1813 ):
1814 _ = html(t"<{BadFunctionComp}>Hello</{BadFunctionComp}>")
1816 @pytest.mark.parametrize(
1817 "bad_value", ("", "text", None, 1, ("tuple", "of", "strs"))
1818 )
1819 def test_component_object_returns_nontemplate_fails(self, bad_value):
1820 def BadFactoryComp(children: Template):
1821 def component_object():
1822 return bad_value
1824 return component_object
1826 with pytest.raises(
1827 TypeError, match="Component object must return Template when called:"
1828 ):
1829 _ = html(t"<{BadFactoryComp}>Hello</{BadFactoryComp}>")
1832def test_integration_basic():
1833 comment_text = "comment is not literal"
1834 interpolated_class = "red"
1835 text_in_element = "text is not literal"
1836 templated = "not literal"
1837 spread_attrs = {"data-on": True}
1838 markup_content = Markup("<div>safe</div>")
1840 def WrapperComponent(children):
1841 return t"<div>{children}</div>"
1843 smoke_t = t"""<!doctype html>
1844<html>
1845<body>
1846<!-- literal -->
1847<span attr="literal">literal</span>
1848<!-- {comment_text} -->
1849<span>{text_in_element}</span>
1850<span attr="literal" class={interpolated_class} title="is {templated}" {spread_attrs}>{text_in_element}</span>
1851<{WrapperComponent}><span>comp body</span></{WrapperComponent}>
1852{markup_content}
1853</body>
1854</html>"""
1855 smoke_str = """<!DOCTYPE html>
1856<html>
1857<body>
1858<!-- literal -->
1859<span attr="literal">literal</span>
1860<!-- comment is not literal -->
1861<span>text is not literal</span>
1862<span attr="literal" title="is not literal" data-on class="red">text is not literal</span>
1863<div><span>comp body</span></div>
1864<div>safe</div>
1865</body>
1866</html>"""
1867 assert html(smoke_t) == smoke_str
1870def struct_repr(st):
1871 """Breakdown Templates into comparable parts for test verification."""
1872 return st.strings, tuple(
1873 (i.value, i.expression, i.conversion, i.format_spec) for i in st.interpolations
1874 )
1877def test_process_template_internal_cache():
1878 """Test that cache and non-cache both generally work as expected."""
1879 # @NOTE: We use a made-up custom element so that we can be sure to
1880 # miss the cache. If this element is used elsewhere than the global
1881 # cache might cache it and it will ruin our counting, specifically
1882 # the first miss will instead be a hit.
1883 sample_t = t"<div>{'content'}<tdom-cache-test-element /></div>"
1884 sample_diff_t = t"<div>{'diffcontent'}<tdom-cache-test-element /></div>"
1885 alt_t = t"<span>{'content'}</span>"
1886 process_api = TemplateProcessor(parser_api=TemplateParserProxy())
1887 cached_process_api = TemplateProcessor(parser_api=CachedTemplateParserProxy())
1888 # Because the cache is stored on the class itself this can be affect by
1889 # other tests, so save this off and take the difference to determine the result,
1890 # this is not great and hopefully we can find a better solution.
1891 assert isinstance(cached_process_api, TemplateProcessor)
1892 assert isinstance(cached_process_api.parser_api, CachedTemplateParserProxy)
1893 start_ci = cached_process_api.parser_api._to_tnode.cache_info()
1894 tnode1 = process_api.parser_api.to_tnode(sample_t)
1895 tnode2 = process_api.parser_api.to_tnode(sample_t)
1896 cached_tnode1 = cached_process_api.parser_api.to_tnode(sample_t)
1897 cached_tnode2 = cached_process_api.parser_api.to_tnode(sample_t)
1898 cached_tnode3 = cached_process_api.parser_api.to_tnode(sample_diff_t)
1899 # Check that the uncached and cached services are actually
1900 # returning non-identical results.
1901 assert tnode1 is not cached_tnode1
1902 assert tnode1 is not cached_tnode2
1903 assert tnode1 is not cached_tnode3
1904 # Check that the uncached service returns a brand new result everytime.
1905 assert tnode1 is not tnode2
1906 # Check that the cached service is returning the exact same, identical, result.
1907 assert cached_tnode1 is cached_tnode2
1908 # Even if the input templates are not identical (but are still equivalent).
1909 assert cached_tnode1 is cached_tnode3 and sample_t is not sample_diff_t
1910 # Check that the cached service and uncached services return
1911 # results that are equivalent (even though they are not (id)entical).
1912 assert tnode1 == cached_tnode1
1913 assert tnode2 == cached_tnode1
1914 # Now that we are setup we check that the cache is internally
1915 # working as we intended.
1916 ci = cached_process_api.parser_api._to_tnode.cache_info()
1917 # cached_tnode2 and cached_tnode3 are hits after cached_tnode1
1918 assert ci.hits - start_ci.hits == 2
1919 # cached_tf1 was a miss because cache was empty (brand new)
1920 assert ci.misses - start_ci.misses == 1
1921 cached_tnode4 = cached_process_api.parser_api.to_tnode(alt_t)
1922 # A different template produces a brand new tf.
1923 assert cached_tnode1 is not cached_tnode4
1924 # The template is new AND has a different structure so it also
1925 # produces an unequivalent tf.
1926 assert cached_tnode1 != cached_tnode4
1929def test_repeat_calls():
1930 """Crude check for any unintended state being kept between calls."""
1932 def get_sample_t(idx, spread_attrs, button_text):
1933 return t"""<div><button data-key={idx} {spread_attrs}>{button_text}</button></div>"""
1935 for idx in range(3):
1936 spread_attrs = {"data-enabled": True}
1937 button_text = "PROCESS"
1938 sample_t = get_sample_t(idx, spread_attrs, button_text)
1939 assert (
1940 html(sample_t)
1941 == f'<div><button data-key="{idx}" data-enabled>PROCESS</button></div>'
1942 )
1945def get_select_t_with_list(options, selected_values):
1946 return t"""<select>{
1947 [
1948 t"<option value={opt[0]} selected={opt[0] in selected_values}>{opt[1]}</option>"
1949 for opt in options
1950 ]
1951 }</select>"""
1954def get_select_t_with_generator(options, selected_values):
1955 return t"""<select>{
1956 (
1957 t"<option value={opt[0]} selected={opt[0] in selected_values}>{opt[1]}</option>"
1958 for opt in options
1959 )
1960 }</select>"""
1963def get_select_t_with_concat(options, selected_values):
1964 parts = [t"<select>"]
1965 parts.extend(
1966 [
1967 t"<option value={opt[0]} selected={opt[0] in selected_values}>{opt[1]}</option>"
1968 for opt in options
1969 ]
1970 )
1971 parts.append(t"</select>")
1972 return sum(parts, t"")
1975@pytest.mark.parametrize(
1976 "provider",
1977 (
1978 get_select_t_with_list,
1979 get_select_t_with_generator,
1980 get_select_t_with_concat,
1981 ),
1982)
1983def test_process_template_iterables(provider):
1984 def get_color_select_t(selected_values: set, provider: Callable) -> Template:
1985 PRIMARY_COLORS = [("R", "Red"), ("Y", "Yellow"), ("B", "Blue")]
1986 assert set(selected_values).issubset({opt[0] for opt in PRIMARY_COLORS})
1987 return provider(PRIMARY_COLORS, selected_values)
1989 no_selection_t = get_color_select_t(set(), provider)
1990 assert (
1991 html(no_selection_t)
1992 == '<select><option value="R">Red</option><option value="Y">Yellow</option><option value="B">Blue</option></select>'
1993 )
1994 selected_yellow_t = get_color_select_t({"Y"}, provider)
1995 assert (
1996 html(selected_yellow_t)
1997 == '<select><option value="R">Red</option><option value="Y" selected>Yellow</option><option value="B">Blue</option></select>'
1998 )
2001def test_component_integration():
2002 """Broadly test that common template component usage works."""
2004 def PageComponent(children, root_attrs=None):
2005 return t"""<div class="content" {root_attrs}>{children}</div>"""
2007 def FooterComponent(classes=("footer-default",)):
2008 return t'<div class="footer" class={classes}><a href="about">About</a></div>'
2010 def LayoutComponent(children, body_classes=None):
2011 return t"""<!doctype html>
2012<html>
2013 <head>
2014 <meta charset="utf-8">
2015 <script src="scripts.js"></script>
2016 <link rel="stylesheet" href="styles.css">
2017 </head>
2018 <body class={body_classes}>
2019 {children}
2020 <{FooterComponent} />
2021 </body>
2022</html>
2023"""
2025 content = "HTML never goes out of style."
2026 content_str = html(
2027 t"<{LayoutComponent} body_classes={['theme-default']}><{PageComponent}>{content}</{PageComponent}></{LayoutComponent}>"
2028 )
2029 assert (
2030 content_str
2031 == """<!DOCTYPE html>
2032<html>
2033 <head>
2034 <meta charset="utf-8" />
2035 <script src="scripts.js"></script>
2036 <link rel="stylesheet" href="styles.css" />
2037 </head>
2038 <body class="theme-default">
2039 <div class="content">HTML never goes out of style.</div>
2040 <div class="footer footer-default"><a href="about">About</a></div>
2041 </body>
2042</html>
2043"""
2044 )
2047class TestInterpolatingHTMLInTemplateWithDynamicParentTag:
2048 """
2049 When a template does not have a parent tag we cannot determine the type
2050 of text that should be allowed and therefore we cannot determine how to
2051 escape that text. Once the type is known we should escape any
2052 interpolations in that text correctly.
2053 """
2055 def test_dynamic_raw_text(self):
2056 """Type raw text should fail because template is already not allowed."""
2057 content = '<script>console.log("123!");</script>'
2058 content_t = t"{content}"
2059 with pytest.raises(
2060 ValueError, match="Recursive includes are not supported within script"
2061 ):
2062 content_t = t'<script>console.log("{123}!");</script>'
2063 _ = html(t"<script>{content_t}</script>")
2065 def test_dynamic_escapable_raw_text(self):
2066 """Type escapable raw text should fail because template is already not allowed."""
2067 content = '<script>console.log("123!");</script>'
2068 content_t = t"{content}"
2069 with pytest.raises(
2070 ValueError, match="Recursive includes are not supported within textarea"
2071 ):
2072 _ = html(t"<textarea>{content_t}</textarea>")
2074 def test_dynamic_normal_text(self):
2075 """Escaping should be applied when normal text type is goes into effect."""
2076 content = '<script>console.log("123!");</script>'
2077 content_t = t"{content}"
2078 LT, GT, DQ = map(markupsafe_escape, ["<", ">", '"'])
2079 assert (
2080 html(t"<div>{content_t}</div>")
2081 == f"<div>{LT}script{GT}console.log({DQ}123!{DQ});{LT}/script{GT}</div>"
2082 )
2085class TestPagerComponentExample:
2086 @dataclass
2087 class Pager:
2088 left_pages: tuple = ()
2089 page: int = 0
2090 right_pages: tuple = ()
2091 prev_page: int | None = None
2092 next_page: int | None = None
2094 @dataclass
2095 class PagerDisplay:
2096 pager: TestPagerComponentExample.Pager
2097 paginate_url: Callable[[int], str]
2098 root_classes: tuple[str, ...] = ("cb", "tc", "w-100")
2099 part_classes: tuple[str, ...] = ("dib", "pa1")
2101 def __call__(self) -> Template:
2102 parts = [t"<div class={self.root_classes}>"]
2103 if self.pager.prev_page:
2104 parts.append(
2105 t"<a class={self.part_classes} href={self.paginate_url(self.pager.prev_page)}>Prev</a>"
2106 )
2107 for left_page in self.pager.left_pages:
2108 parts.append(
2109 t'<a class={self.part_classes} href="{self.paginate_url(left_page)}">{left_page}</a>'
2110 )
2111 parts.append(t"<span class={self.part_classes}>{self.pager.page}</span>")
2112 for right_page in self.pager.right_pages:
2113 parts.append(
2114 t'<a class={self.part_classes} href="{self.paginate_url(right_page)}">{right_page}</a>'
2115 )
2116 if self.pager.next_page:
2117 parts.append(
2118 t"<a class={self.part_classes} href={self.paginate_url(self.pager.next_page)}>Next</a>"
2119 )
2120 parts.append(t"</div>")
2121 return Template(*chain.from_iterable(parts))
2123 def test_example(self):
2124 def paginate_url(page: int) -> str:
2125 return f"/pages?page={page}"
2127 def Footer(pager, paginate_url, footer_classes=("footer",)) -> Template:
2128 return t"<div class={footer_classes}><{self.PagerDisplay} pager={pager} paginate_url={paginate_url} /></div>"
2130 pager = self.Pager(
2131 left_pages=(1, 2), page=3, right_pages=(4, 5), next_page=6, prev_page=None
2132 )
2133 content_t = t"<{Footer} pager={pager} paginate_url={paginate_url} />"
2134 res = html(content_t)
2135 print(res)
2136 assert (
2137 res
2138 == '<div class="footer"><div class="cb tc w-100"><a href="/pages?page=1" class="dib pa1">1</a><a href="/pages?page=2" class="dib pa1">2</a><span class="dib pa1">3</span><a href="/pages?page=4" class="dib pa1">4</a><a href="/pages?page=5" class="dib pa1">5</a><a href="/pages?page=6" class="dib pa1">Next</a></div></div>'
2139 )
2142def test_mathml():
2143 num = 1
2144 denom = 3
2145 mathml_t = t"""<p>
2146 The fraction
2147 <math>
2148 <mfrac>
2149 <mn>{num}</mn>
2150 <mn>{denom}</mn>
2151 </mfrac>
2152 </math>
2153 is not a decimal number.
2154</p>"""
2155 res = html(mathml_t)
2156 assert (
2157 str(res)
2158 == """<p>
2159 The fraction
2160 <math>
2161 <mfrac>
2162 <mn>1</mn>
2163 <mn>3</mn>
2164 </mfrac>
2165 </math>
2166 is not a decimal number.
2167</p>"""
2168 )