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