From c64202f47ea5609dfcff90a658bc4a01d15e9fcb Mon Sep 17 00:00:00 2001
From: Ian Wilson
Date: Tue, 10 Feb 2026 11:44:06 -0800
Subject: [PATCH 01/30] Conditionally allow Markup for tags with non-normal
text.
---
tdom/escaping.py | 18 +++++++++++++++---
tdom/escaping_test.py | 40 +++++++++++++++++++++++++++++++++++++++-
2 files changed, 54 insertions(+), 4 deletions(-)
diff --git a/tdom/escaping.py b/tdom/escaping.py
index d23ce65..de82228 100644
--- a/tdom/escaping.py
+++ b/tdom/escaping.py
@@ -2,6 +2,8 @@
from markupsafe import escape as markup_escape
+from .protocols import HasHTMLDunder
+
escape_html_text = markup_escape # unify api for test of project
@@ -9,10 +11,16 @@
LT = "<"
-def escape_html_comment(text: str) -> str:
+def escape_html_comment(text: str, allow_markup: bool = False) -> str:
"""Escape text injected into an HTML comment."""
if not text:
return text
+ elif allow_markup and isinstance(text, HasHTMLDunder):
+ return text.__html__()
+ elif not allow_markup and type(text) is not str:
+ # text manipulation triggers regular html escapes on Markup
+ text = str(text)
+
# - text must not start with the string ">"
if text[0] == ">":
text = GT + text[1:]
@@ -44,8 +52,10 @@ def escape_html_comment(text: str) -> str:
)
-def escape_html_style(text: str) -> str:
+def escape_html_style(text: str, allow_markup: bool = False) -> str:
"""Escape text injected into an HTML style element."""
+ if allow_markup and isinstance(text, HasHTMLDunder):
+ return text.__html__()
for matche_re, replace_text in STYLE_RES:
text = re.sub(matche_re, replace_text, text)
return text
@@ -70,7 +80,7 @@ def escape_html_style(text: str) -> str:
)
-def escape_html_script(text: str) -> str:
+def escape_html_script(text: str, allow_markup: bool = False) -> str:
"""
Escape text injected into an HTML script element.
@@ -83,6 +93,8 @@ def escape_html_script(text: str) -> str:
- " "
expected_output = "\\x3c!-- \\x3cscript>var a = 1;\\x3c/script> \\x3c/SCRIPT>"
@@ -42,3 +71,12 @@ def test_escape_html_script() -> None:
# Smoketest that escaping is working and we are not just escaping back to the same value.
for text in ("")
- assert node == Element(
- "style",
- children=[
- Text(""
- )
-
-
-def test_interpolated_trusted_in_content_node():
- # https://github.com/t-strings/tdom/issues/68
- node = html(t"")
- assert node == Element(
- "script",
- children=[Text("if (a < b && c > d) { alert('wow'); }")],
- )
- assert str(node) == ("")
+#
+# Text
+#
+class TestBareTemplate:
+ def test_empty(self):
+ assert html(t"") == ""
+
+ def test_text_literal(self):
+ assert html(t"Hello, world!") == "Hello, world!"
+
+ def test_text_singleton(self):
+ greeting = "Hello, Alice!"
+ assert html(t"{greeting}", make_ctx(parent_tag="div")) == "Hello, Alice!"
+ assert html(t"{greeting}", make_ctx(parent_tag="script")) == "Hello, Alice!"
+ assert html(t"{greeting}", make_ctx(parent_tag="style")) == "Hello, Alice!"
+ assert html(t"{greeting}", make_ctx(parent_tag="textarea")) == "Hello, Alice!"
+ assert html(t"{greeting}", make_ctx(parent_tag="title")) == "Hello, Alice!"
+
+ def test_text_singleton_without_parent(self):
+ greeting = ""
+ with pytest.raises(NotImplementedError):
+ # Explicitly set the parent tag as None.
+ ctx = make_ctx(parent_tag=None, ns="html")
+ _ = html(t"{greeting}", assume_ctx=ctx)
+
+ def test_text_singleton_explicit_parent_script(self):
+ greeting = ""
+ res = html(t"{greeting}", assume_ctx=make_ctx(parent_tag="script"))
+ assert res == "\\x3c/script>"
+ assert res != ""
+
+ def test_text_singleton_explicit_parent_div(self):
+ greeting = ""
+ res = html(t"{greeting}", assume_ctx=make_ctx(parent_tag="div"))
+ assert res == "</div>"
+ assert res != ""
+
+ def test_text_template(self):
+ name = "Alice"
+ assert (
+ html(t"Hello, {name}!", assume_ctx=make_ctx(parent_tag="div"))
+ == "Hello, Alice!"
+ )
+ def test_text_template_escaping(self):
+ name = "Alice & Bob"
+ assert (
+ html(t"Hello, {name}!", assume_ctx=make_ctx(parent_tag="div"))
+ == "Hello, Alice & Bob!"
+ )
-def test_script_elements_error():
- nested_template = t"
"
- # Putting non-text content inside a script is not allowed.
- with pytest.raises(TypeError):
- node = html(t"")
- _ = str(node)
+ def test_parse_entities_are_escaped_no_parent_tag(self):
+ res = html(t"</p>")
+ assert res == "</p>", "Default to standard escaping."
-# --------------------------------------------------------------------------
-# Interpolated non-text content
-# --------------------------------------------------------------------------
+class LiteralHTML:
+ """Text is returned as is by __html__."""
+ def __init__(self, text):
+ self.text = text
-def test_interpolated_false_content():
- node = html(t"{False}
")
- assert node == Element("div")
- assert str(node) == "
"
+ def __html__(self):
+ # In a real app, this would come from a sanitizer or trusted source
+ return self.text
-def test_interpolated_none_content():
- node = html(t"{None}
")
- assert node == Element("div", children=[])
- assert str(node) == "
"
+class TestComment:
+ def test_literal(self):
+ assert html(t"") == ""
+ #
+ # Singleton / Exact Match
+ #
+ def test_singleton_str(self):
+ text = "This is a comment"
+ assert html(t"") == ""
-def test_interpolated_zero_arg_function():
- def get_value():
- return "dynamic"
+ def test_singleton_object(self):
+ assert html(t"") == ""
- node = html(t"The value is {get_value:callback}.
")
- assert node == Element(
- "p", children=[Text("The value is "), Text("dynamic"), Text(".")]
- )
+ def test_singleton_none(self):
+ assert html(t"") == ""
+ def test_singleton_has_dunder_html(self):
+ content = LiteralHTML("-->")
+ assert html(t"") == "-->", (
+ "DO NOT DO THIS! This is just an advanced escape hatch."
+ )
-def test_interpolated_multi_arg_function_fails():
- def add(a, b): # pragma: no cover
- return a + b
+ def test_singleton_escaping(self):
+ text = "-->comment"
+ assert html(t"") == ""
+
+ #
+ # Templated -- literal text mixed with interpolation(s)
+ #
+ def test_templated_str(self):
+ text = "comment"
+ assert html(t"") == ""
+
+ def test_templated_object(self):
+ assert html(t"") == ""
+
+ def test_templated_none(self):
+ assert html(t"") == ""
+
+ def test_templated_has_dunder_html_error(self):
+ """Objects with __html__ are not processed with literal text or other interpolations."""
+ text = LiteralHTML("in a comment")
+ with pytest.raises(ValueError, match="not supported"):
+ _ = html(t"")
+ with pytest.raises(ValueError, match="not supported"):
+ _ = html(t"")
+ with pytest.raises(ValueError, match="not supported"):
+ _ = html(t"")
+
+ def test_templated_multiple_interpolations(self):
+ text = "comment"
+ assert (
+ html(t"")
+ == ""
+ )
- with pytest.raises(TypeError):
- _ = html(t"The sum is {add:callback}.
")
+ def test_templated_escaping(self):
+ # @TODO: There doesn't seem to be a way to properly escape this
+ # so we just use an entity to break the special closing string
+ # even though it won't be actually unescaped by anything. There
+ # might be something better for this.
+ text = "-->comment"
+ assert html(t"") == ""
+ def test_not_supported__recursive_template_error(self):
+ text_t = t"comment"
+ with pytest.raises(ValueError, match="not supported"):
+ _ = html(t"")
-# --------------------------------------------------------------------------
-# Raw HTML injection tests
-# --------------------------------------------------------------------------
+ def test_not_supported_recursive_iterable_error(self):
+ texts = ["This", "is", "a", "comment"]
+ with pytest.raises(ValueError, match="not supported"):
+ _ = html(t"")
-def test_raw_html_injection_with_markupsafe():
- raw_content = Markup("I am bold ")
- node = html(t"{raw_content}
")
- assert node == Element("div", children=[Text(text=raw_content)])
- assert str(node) == "I am bold
"
+class TestDocumentType:
+ def test_literal(self):
+ assert html(t"") == ""
-def test_raw_html_injection_with_dunder_html_protocol():
- class SafeContent:
- def __init__(self, text):
- self._text = text
+class TestVoidElementLiteral:
+ def test_void(self):
+ assert html(t" ") == " "
- def __html__(self):
- # In a real app, this would come from a sanitizer or trusted source
- return f"{self._text} "
+ def test_void_self_closed(self):
+ assert html(t" ") == " "
- content = SafeContent("emphasized")
- node = html(t"Here is some {content}.
")
- assert node == Element(
- "p",
- children=[
- Text("Here is some "),
- Text(Markup("emphasized ")),
- Text("."),
- ],
- )
- assert str(node) == "Here is some emphasized .
"
-
-
-def test_raw_html_injection_with_format_spec():
- raw_content = "underlined "
- node = html(t"This is {raw_content:safe} text.
")
- assert node == Element(
- "p",
- children=[
- Text("This is "),
- Text(Markup(raw_content)),
- Text(" text."),
- ],
- )
- assert str(node) == "This is underlined text.
"
-
-
-def test_raw_html_injection_with_markupsafe_unsafe_format_spec():
- supposedly_safe = Markup("italic ")
- node = html(t"This is {supposedly_safe:unsafe} text.
")
- assert node == Element(
- "p",
- children=[
- Text("This is "),
- Text(str(supposedly_safe)),
- Text(" text."),
- ],
- )
- assert str(node) == "This is <i>italic</i> text.
"
+ def test_void_mixed_closing(self):
+ assert html(t" Is this content? ") == " Is this content? "
+ def test_chain_of_void_elements(self):
+ # Make sure our handling of CPython issue #69445 is reasonable.
+ assert (
+ html(t" ")
+ == ' '
+ )
-# --------------------------------------------------------------------------
-# Conditional rendering and control flow
-# --------------------------------------------------------------------------
+class TestNormalTextElementLiteral:
+ def test_empty(self):
+ assert html(t"
") == "
"
-def test_conditional_rendering_with_if_else():
- is_logged_in = True
- user_profile = t"Welcome, User! "
- login_prompt = t"Please log in "
- node = html(t"{user_profile if is_logged_in else login_prompt}
")
+ def test_with_text(self):
+ assert html(t"Hello, world!
") == "Hello, world!
"
- assert node == Element(
- "div", children=[Element("span", children=[Text("Welcome, User!")])]
- )
- assert str(node) == "Welcome, User!
"
+ def test_nested_elements(self):
+ assert (
+ html(t"")
+ == ""
+ )
- is_logged_in = False
- node = html(t"{user_profile if is_logged_in else login_prompt}
")
- assert str(node) == ''
+ def test_entities_are_escaped(self):
+ """Literal entities interpreted by parser but escaped in output."""
+ res = html(t"</p>
")
+ assert res == "</p>
", res
-def test_conditional_rendering_with_and():
- show_warning = True
- warning_message = t'Warning!
'
- node = html(t"{show_warning and warning_message} ")
+class TestNormalTextElementDynamic:
+ def test_singleton_None(self):
+ assert html(t"{None}
") == "
"
- assert node == Element(
- "main",
- children=[
- Element("div", attrs={"class": "warning"}, children=[Text("Warning!")]),
- ],
- )
- assert str(node) == 'Warning!
'
+ def test_singleton_str(self):
+ name = "Alice"
+ assert html(t"{name}
") == "Alice
"
- show_warning = False
- node = html(t"{show_warning and warning_message} ")
- # Assuming False renders nothing
- assert str(node) == " "
+ def test_singleton_object(self):
+ assert html(t"{0}
") == "0
"
+ def test_singleton_has_dunder_html(self):
+ content = LiteralHTML("Alright! ")
+ assert html(t"{content}
") == "Alright!
"
-# --------------------------------------------------------------------------
-# Interpolated nesting of templates and elements
-# --------------------------------------------------------------------------
+ def test_singleton_simple_template(self):
+ name = "Alice"
+ text_t = t"Hi {name}"
+ assert html(t"{text_t}
") == "Hi Alice
"
+ def test_singleton_simple_iterable(self):
+ strs = ["Strings", "...", "Yeah!", "Rock", "...", "Yeah!"]
+ assert html(t"{strs}
") == "Strings...Yeah!Rock...Yeah!
"
-def test_interpolated_template_content():
- child = t"Child "
- node = html(t"{child}
")
- assert node == Element("div", children=[html(child)])
- assert str(node) == "Child
"
+ def test_singleton_escaping(self):
+ text = '''<>&'"'''
+ assert html(t"{text}
") == "<>&'"
"
+ def test_templated_None(self):
+ assert html(t"Response: {None}.
") == "Response: .
"
-def test_interpolated_element_content():
- child = html(t"Child ")
- node = html(t"{child}
")
- assert node == Element("div", children=[child])
- assert str(node) == "Child
"
+ def test_templated_str(self):
+ name = "Alice"
+ assert html(t"Response: {name}.
") == "Response: Alice.
"
+ def test_templated_object(self):
+ assert html(t"Response: {0}.
") == "Response: 0.
"
-def test_interpolated_nonstring_content():
- number = 42
- node = html(t"The answer is {number}.
")
- assert node == Element(
- "p", children=[Text("The answer is "), Text("42"), Text(".")]
- )
- assert str(node) == "The answer is 42.
"
-
-
-def test_list_items():
- items = ["Apple", "Banana", "Cherry"]
- node = html(t"{[t'{item} ' for item in items]} ")
- assert node == Element(
- "ul",
- children=[
- Element("li", children=[Text("Apple")]),
- Element("li", children=[Text("Banana")]),
- Element("li", children=[Text("Cherry")]),
- ],
- )
- assert str(node) == ""
-
-
-def test_nested_list_items():
- # TODO XXX this is a pretty abusrd test case; clean it up when refactoring
- outer = ["fruit", "more fruit"]
- inner = ["apple", "banana", "cherry"]
- inner_items = [t"{item} " for item in inner]
- outer_items = [t"{category} " for category in outer]
- node = html(t"")
- assert node == Element(
- "ul",
- children=[
- Element(
- "li",
- children=[
- Text("fruit"),
- Element(
- "ul",
- children=[
- Element("li", children=[Text("apple")]),
- Element("li", children=[Text("banana")]),
- Element("li", children=[Text("cherry")]),
- ],
- ),
- ],
- ),
- Element(
- "li",
- children=[
- Text("more fruit"),
- Element(
- "ul",
- children=[
- Element("li", children=[Text("apple")]),
- Element("li", children=[Text("banana")]),
- Element("li", children=[Text("cherry")]),
- ],
- ),
- ],
- ),
- ],
- )
- assert (
- str(node)
- == ""
- )
+ def test_templated_has_dunder_html(self):
+ text = LiteralHTML("Alright! ")
+ assert (
+ html(t"Response: {text}.
") == "Response: Alright! .
"
+ )
+ def test_templated_simple_template(self):
+ name = "Alice"
+ text_t = t"Hi {name}"
+ assert html(t"Response: {text_t}.
") == "Response: Hi Alice.
"
-# --------------------------------------------------------------------------
-# Attributes
-# --------------------------------------------------------------------------
+ def test_templated_simple_iterable(self):
+ strs = ["Strings", "...", "Yeah!", "Rock", "...", "Yeah!"]
+ assert (
+ html(t"Response: {strs}.
")
+ == "Response: Strings...Yeah!Rock...Yeah!.
"
+ )
+ def test_templated_escaping(self):
+ text = '''<>&'"'''
+ assert (
+ html(t"Response: {text}.
")
+ == "Response: <>&'".
"
+ )
-def test_literal_attrs():
- node = html(
- t" "
- )
- assert node == Element(
- "a",
- attrs={
- "id": "example_link",
- "autofocus": None,
- "title": "",
- "href": "https://example.com",
- "target": "_blank",
- },
- )
- assert (
- str(node)
- == ' '
- )
+ def test_templated_escaping_in_literals(self):
+ text = "This text is fine"
+ assert (
+ html(t"The literal has < in it: {text}.
")
+ == "The literal has < in it: This text is fine.
"
+ )
+ def test_iterable_of_templates(self):
+ items = ["Apple", "Banana", "Cherry"]
+ assert (
+ html(t"{[t'{item} ' for item in items]} ")
+ == ""
+ )
-def test_literal_attr_escaped():
- node = html(t' ')
- assert node == Element(
- "a",
- attrs={"title": "<"},
- )
- assert str(node) == ' '
+ def test_iterable_of_templates_of_iterable_of_templates(self):
+ outer = ["fruit", "more fruit"]
+ inner = ["apple", "banana", "cherry"]
+ inner_items = [t"{item} " for item in inner]
+ outer_items = [
+ t"{category} " for category in outer
+ ]
+ assert (
+ html(t"")
+ == ""
+ )
-def test_interpolated_attr():
- url = "https://example.com/"
- node = html(t' ')
- assert node == Element("a", attrs={"href": "https://example.com/"})
- assert str(node) == ' '
+class TestRawTextElementLiteral:
+ def test_script_empty(self):
+ assert html(t"") == ""
+ def test_style_empty(self):
+ assert html(t"") == ""
-def test_interpolated_attr_escaped():
- url = 'https://example.com/?q="test"&lang=en'
- node = html(t' ')
- assert node == Element(
- "a",
- attrs={"href": 'https://example.com/?q="test"&lang=en'},
- )
- assert (
- str(node) == ' '
- )
+ def test_script_with_content(self):
+ assert html(t"") == ""
+ def test_style_with_content(self):
+ # @NOTE: Double {{ and }} to avoid t-string interpolation.
+ assert (
+ html(t"")
+ == ""
+ )
-def test_interpolated_attr_unquoted():
- id = "roquefort"
- node = html(t"
")
- assert node == Element("div", attrs={"id": "roquefort"})
- assert str(node) == '
'
+ def test_script_with_content_escaped_in_normal_text(self):
+ # @NOTE: Double {{ and }} to avoid t-string interpolation.
+ assert (
+ html(t"")
+ == ""
+ ), "The < should not be escaped."
+
+ def test_style_with_content_escaped_in_normal_text(self):
+ # @NOTE: Double {{ and }} to avoid t-string interpolation.
+ assert (
+ html(t"")
+ == ""
+ ), "The > should not be escaped."
+
+ def test_not_supported_recursive_template_error(self):
+ text_t = t"comment"
+ with pytest.raises(ValueError, match="not supported"):
+ _ = html(t"")
+
+ def test_not_supported_recursive_iterable_error(self):
+ texts = ["This", "is", "a", "comment"]
+ with pytest.raises(ValueError, match="not supported"):
+ _ = html(t"")
+
+
+class TestEscapableRawTextElementLiteral:
+ def test_title_empty(self):
+ assert html(t" ") == " "
+
+ def test_textarea_empty(self):
+ assert html(t"") == ""
+
+ def test_title_with_content(self):
+ assert html(t"Content ") == "Content "
+
+ def test_textarea_with_content(self):
+ assert html(t"") == ""
+
+ def test_title_with_escapable_content(self):
+ assert (
+ html(t"Are t-strings > everything? ")
+ == "Are t-strings > everything? "
+ ), "The > can be escaped in this content type."
+
+ def test_textarea_with_escapable_content(self):
+ assert (
+ html(t"")
+ == ""
+ ), "The p tags can be escaped in this content type."
+
+
+class TestRawTextScriptDynamic:
+ def test_singleton_none(self):
+ assert html(t"") == ""
+
+ def test_singleton_str(self):
+ content = "var x = 1;"
+ assert html(t"") == ""
+
+ def test_singleton_object(self):
+ content = 0
+ assert html(t"") == ""
+
+ def test_singleton_has_dunder_html_pitfall(self):
+ # @TODO: We should probably put some double override to prevent this by accident.
+ # Or just disable this and if people want to do this then put the
+ # content in a SCRIPT and inject the whole thing with a __html__?
+ content = LiteralHTML("")
+ assert html(t"") == "", (
+ "DO NOT DO THIS! This is just an advanced escape hatch! Use a data attribute and parseJSON!"
+ )
+ def test_singleton_escaping(self):
+ content = ""
+ script_t = t""
+ bad_output = script_t.strings[0] + content + script_t.strings[1]
+ assert html(script_t) == ""
+ assert html(script_t) != bad_output, "Sanity check."
+
+ def test_templated_none(self):
+ assert (
+ html(t"")
+ == ""
+ )
-def test_interpolated_attr_true():
- disabled = True
- node = html(t" ")
- assert node == Element("button", attrs={"disabled": None})
- assert str(node) == " "
+ def test_templated_str(self):
+ content = "var x = 1"
+ assert (
+ html(t"")
+ == ""
+ )
+ def test_templated_object(self):
+ content = 0
+ assert (
+ html(t"")
+ == ""
+ )
-def test_interpolated_attr_false():
- disabled = False
- node = html(t" ")
- assert node == Element("button")
- assert str(node) == " "
+ def test_templated_has_dunder_html(self):
+ content = LiteralHTML("anything")
+ with pytest.raises(ValueError, match="not supported"):
+ _ = html(t"")
+
+ def test_templated_escaping(self):
+ content = ""
+ script_t = t""
+ bad_output = script_t.strings[0] + content + script_t.strings[1]
+ assert html(script_t) == ""
+ assert html(script_t) != bad_output, "Sanity check."
+
+ def test_templated_multiple_interpolations(self):
+ assert (
+ html(t"")
+ == ""
+ )
+ def test_not_supported_recursive_template_error(self):
+ text_t = t"script"
+ with pytest.raises(ValueError, match="not supported"):
+ _ = html(t"")
-def test_interpolated_attr_none():
- disabled = None
- node = html(t" ")
- assert node == Element("button")
- assert str(node) == " "
+ def test_not_supported_recursive_iterable_error(self):
+ texts = ["This", "is", "a", "script"]
+ with pytest.raises(ValueError, match="not supported"):
+ _ = html(t"")
-def test_interpolate_attr_empty_string():
- node = html(t'
')
- assert node == Element(
- "div",
- attrs={"title": ""},
- )
- assert str(node) == '
'
+class TestRawTextStyleDynamic:
+ def test_singleton_none(self):
+ assert html(t"") == ""
+ def test_singleton_str(self):
+ content = "div { background-color: red; }"
+ assert (
+ html(t"")
+ == ""
+ )
-def test_spread_attr():
- attrs = {"href": "https://example.com/", "target": "_blank"}
- node = html(t" ")
- assert node == Element(
- "a",
- attrs={"href": "https://example.com/", "target": "_blank"},
- )
- assert str(node) == ' '
+ def test_singleton_object(self):
+ content = 0
+ assert html(t"") == ""
+
+ def test_singleton_has_dunder_html_pitfall(self):
+ # @TODO: We should probably put some double override to prevent this by accident.
+ # Or just disable this and if people want to do this then put the
+ # content in a STYLE and inject the whole thing with a __html__?
+ content = LiteralHTML("")
+ assert html(t"") == "", (
+ "DO NOT DO THIS! This is just an advanced escape hatch!"
+ )
+ def test_singleton_escaping(self):
+ content = ""
+ style_t = t""
+ bad_output = style_t.strings[0] + content + style_t.strings[1]
+ assert html(style_t) == ""
+ assert html(style_t) != bad_output, "Sanity check."
+
+ def test_templated_none(self):
+ assert (
+ html(t"")
+ == ""
+ )
-def test_spread_attr_none():
- attrs = None
- node = html(t" ")
- assert node == Element("a")
- assert str(node) == " "
+ def test_templated_str(self):
+ content = " h2 { background-color: blue; }"
+ assert (
+ html(t"")
+ == ""
+ )
+ def test_templated_object(self):
+ padding_right = 0
+ assert (
+ html(t"")
+ == ""
+ )
-def test_spread_attr_type_errors():
- for attrs in (0, [], (), False, True):
- with pytest.raises(TypeError):
- _ = html(t" ")
-
-
-def test_templated_attr_mixed_interpolations_start_end_and_nest():
- left, middle, right = 1, 3, 5
- prefix, suffix = t'
'
- # Check interpolations at start, middle and/or end of templated attr
- # or a combination of those to make sure text is not getting dropped.
- for left_part, middle_part, right_part in product(
- (t"{left}", Template(str(left))),
- (t"{middle}", Template(str(middle))),
- (t"{right}", Template(str(right))),
- ):
- test_t = prefix + left_part + t"-" + middle_part + t"-" + right_part + suffix
- node = html(test_t)
- assert node == Element(
- "div",
- attrs={"data-range": "1-3-5"},
- )
- assert str(node) == '
'
-
-
-def test_templated_attr_no_quotes():
- start = 1
- end = 5
- node = html(t"
")
- assert node == Element(
- "div",
- attrs={"data-range": "1-5"},
- )
- assert str(node) == '
'
+ def test_templated_has_dunder_html(self):
+ content = LiteralHTML("anything")
+ with pytest.raises(ValueError, match="not supported"):
+ _ = html(t"")
+
+ def test_templated_escaping(self):
+ content = ""
+ style_t = t""
+ bad_output = style_t.strings[0] + content + style_t.strings[1]
+ assert (
+ html(style_t) == ""
+ )
+ assert html(style_t) != bad_output, "Sanity check."
+ def test_templated_multiple_interpolations(self):
+ assert (
+ html(
+ t""
+ )
+ == ""
+ )
-def test_attr_merge_disjoint_interpolated_attr_spread_attr():
- attrs = {"href": "https://example.com/", "id": "link1"}
- target = "_blank"
- node = html(t" ")
- assert node == Element(
- "a",
- attrs={"href": "https://example.com/", "id": "link1", "target": "_blank"},
- )
- assert str(node) == ' '
+ def test_not_supported_recursive_template_error(self):
+ text_t = t"style"
+ with pytest.raises(ValueError, match="not supported"):
+ _ = html(t"")
+ def test_not_supported_recursive_iterable_error(self):
+ texts = ["This", "is", "a", "style"]
+ with pytest.raises(ValueError, match="not supported"):
+ _ = html(t"")
-def test_attr_merge_overlapping_spread_attrs():
- attrs1 = {"href": "https://example.com/", "id": "overwrtten"}
- attrs2 = {"target": "_blank", "id": "link1"}
- node = html(t" ")
- assert node == Element(
- "a",
- attrs={"href": "https://example.com/", "target": "_blank", "id": "link1"},
- )
- assert str(node) == ' '
+class TestEscapableRawTextTitleDynamic:
+ def test_singleton_none(self):
+ assert html(t"{None} ") == " "
-def test_attr_merge_replace_literal_attr_str_str():
- attrs = {"title": "fresh"}
- node = html(t'
')
- assert node == Element("div", {"title": "fresh"})
- assert str(node) == '
'
+ def test_singleton_str(self):
+ content = "Welcome To TDOM"
+ assert html(t"{content} ") == "Welcome To TDOM "
+ def test_singleton_object(self):
+ content = 0
+ assert html(t"{content} ") == "0 "
-def test_attr_merge_replace_literal_attr_str_true():
- attrs = {"title": True}
- node = html(t'
')
- assert node == Element("div", {"title": None})
- assert str(node) == "
"
+ def test_singleton_has_dunder_html_pitfall(self):
+ # @TODO: We should probably put some double override to prevent this by accident.
+ content = LiteralHTML("")
+ assert html(t"{content} ") == " ", (
+ "DO NOT DO THIS! This is just an advanced escape hatch!"
+ )
+ def test_singleton_escaping(self):
+ content = ""
+ assert html(t"{content} ") == "</title> "
-def test_attr_merge_replace_literal_attr_true_str():
- attrs = {"title": "fresh"}
- node = html(t"
")
- assert node == Element("div", {"title": "fresh"})
- assert str(node) == '
'
+ def test_templated_none(self):
+ assert (
+ html(t"A great story about: {None} ")
+ == "A great story about: "
+ )
+ def test_templated_str(self):
+ content = "TDOM"
+ assert (
+ html(t"A great story about: {content} ")
+ == "A great story about: TDOM "
+ )
-def test_attr_merge_remove_literal_attr_str_none():
- attrs = {"title": None}
- node = html(t'
')
- assert node == Element("div")
- assert str(node) == "
"
+ def test_templated_object(self):
+ content = 0
+ assert (
+ html(t"A great number: {content} ")
+ == "A great number: 0 "
+ )
+ def test_templated_has_dunder_html(self):
+ content = LiteralHTML("No")
+ with pytest.raises(ValueError, match="not supported"):
+ _ = html(t"Literal html?: {content} ")
-def test_attr_merge_remove_literal_attr_true_none():
- attrs = {"title": None}
- node = html(t"
")
- assert node == Element("div")
- assert str(node) == "
"
+ def test_templated_escaping(self):
+ content = ""
+ assert (
+ html(t"The end tag: {content}. ")
+ == "The end tag: </title>. "
+ )
+ def test_templated_multiple_interpolations(self):
+ assert (
+ html(t"The number {0} is less than {1}. ")
+ == "The number 0 is less than 1. "
+ )
-def test_attr_merge_other_literal_attr_intact():
- attrs = {"alt": "fresh"}
- node = html(t' ')
- assert node == Element("img", {"title": "default", "alt": "fresh"})
- assert str(node) == ' '
+ def test_not_supported_recursive_template_error(self):
+ text_t = t"title"
+ with pytest.raises(ValueError, match="not supported"):
+ _ = html(t"{text_t} ")
+ def test_not_supported_recursive_iterable_error(self):
+ texts = ["This", "is", "a", "title"]
+ with pytest.raises(ValueError, match="not supported"):
+ _ = html(t"{texts} ")
-#
-# Special data attribute handling.
-#
-def test_interpolated_data_attributes():
- data = {"user-id": 123, "role": "admin", "wild": True, "false": False, "none": None}
- node = html(t"User Info
")
- assert node == Element(
- "div",
- attrs={"data-user-id": "123", "data-role": "admin", "data-wild": None},
- children=[Text("User Info")],
- )
- assert (
- str(node)
- == 'User Info
'
- )
+class TestEscapableRawTextTextareaDynamic:
+ def test_singleton_none(self):
+ assert html(t"") == ""
-def test_data_attr_toggle_to_str():
- data_dict = {"selected": "yes"}
- for node in [
- html(t"
"),
- html(t'
'),
- ]:
- assert node == Element("div", {"data-selected": "yes"})
- assert str(node) == '
'
+ def test_singleton_str(self):
+ content = "Welcome To TDOM"
+ assert (
+ html(t"")
+ == ""
+ )
+ def test_singleton_object(self):
+ content = 0
+ assert html(t"") == ""
+
+ def test_singleton_has_dunder_html_pitfall(self):
+ # @TODO: We should probably put some double override to prevent this by accident.
+ content = LiteralHTML("")
+ assert (
+ html(t"")
+ == ""
+ ), "DO NOT DO THIS! This is just an advanced escape hatch!"
+
+ def test_singleton_escaping(self):
+ content = ""
+ assert (
+ html(t"")
+ == ""
+ )
-def test_data_attr_toggle_to_true():
- data_dict = {"selected": True}
- node = html(t'
')
- assert node == Element("div", {"data-selected": None})
- assert str(node) == "
"
+ def test_templated_none(self):
+ assert (
+ html(t"")
+ == ""
+ )
+ def test_templated_str(self):
+ content = "TDOM"
+ assert (
+ html(t"")
+ == ""
+ )
-def test_data_attr_unrelated_unaffected():
- data_dict = {"active": True}
- node = html(t"
")
- assert node == Element("div", {"data-selected": None, "data-active": None})
- assert str(node) == "
"
+ def test_templated_object(self):
+ content = 0
+ assert (
+ html(t"")
+ == ""
+ )
+ def test_templated_has_dunder_html(self):
+ content = LiteralHTML("No")
+ with pytest.raises(ValueError, match="not supported"):
+ _ = html(t"")
-def test_data_attr_templated_error():
- data1 = {"user-id": "user-123"}
- data2 = {"role": "admin"}
- with pytest.raises(TypeError):
- node = html(t'
')
- print(str(node))
+ def test_templated_multiple_interpolations(self):
+ assert (
+ html(t"")
+ == ""
+ )
+ def test_templated_escaping(self):
+ content = ""
+ assert (
+ html(t"")
+ == ""
+ )
-def test_data_attr_none():
- button_data = None
- node = html(t"X ")
- assert node == Element("button", children=[Text("X")])
- assert str(node) == "X "
+ def test_not_supported_recursive_template_error(self):
+ text_t = t"textarea"
+ with pytest.raises(ValueError, match="not supported"):
+ _ = html(t"")
+ def test_not_supported_recursive_iterable_error(self):
+ texts = ["This", "is", "a", "textarea"]
+ with pytest.raises(ValueError, match="not supported"):
+ _ = html(t"")
-def test_data_attr_errors():
- for v in [False, [], (), 0, "data?"]:
- with pytest.raises(TypeError):
- _ = html(t"X ")
+class Convertible:
+ def __str__(self):
+ return "string"
-def test_data_literal_attr_bypass():
- # Trigger overall attribute resolution with an unrelated interpolated attr.
- node = html(t'
')
- assert node == Element(
- "p",
- attrs={"data": "passthru", "id": "resolved"},
- ), "A single literal attribute should not trigger data expansion."
+ def __repr__(self):
+ return "repr"
-#
-# Special aria attribute handling.
-#
-def test_aria_templated_attr_error():
- aria1 = {"label": "close"}
- aria2 = {"hidden": "true"}
- with pytest.raises(TypeError):
- node = html(t'
')
- print(str(node))
-
-
-def test_aria_interpolated_attr_dict():
- aria = {"label": "Close", "hidden": True, "another": False, "more": None}
- node = html(t"X ")
- assert node == Element(
- "button",
- attrs={"aria-label": "Close", "aria-hidden": "true", "aria-another": "false"},
- children=[Text("X")],
- )
- assert (
- str(node)
- == 'X '
- )
+def test_convertible_fixture():
+ """Make sure test fixture is working correctly."""
+ c = Convertible()
+ assert f"{c!s}" == "string"
+ assert f"{c!r}" == "repr"
-def test_aria_interpolate_attr_none():
- button_aria = None
- node = html(t"X ")
- assert node == Element("button", children=[Text("X")])
- assert str(node) == "X "
+def wrap_template_in_tags(
+ start_tag: str, template: Template, end_tag: str | None = None
+):
+ """Utility for testing templated text but with different containing tags."""
+ if end_tag is None:
+ end_tag = start_tag
+ return Template(f"<{start_tag}>") + template + Template(f"{end_tag}>")
-def test_aria_attr_errors():
- for v in [False, [], (), 0, "aria?"]:
- with pytest.raises(TypeError):
- _ = html(t"X ")
+def wrap_text_in_tags(start_tag: str, content: str, end_tag: str | None = None):
+ """Utility for testing expected text but with different containing tags."""
+ if end_tag is None:
+ end_tag = start_tag
+ # Stringify to flatten `Markup()`
+ content = str(content)
+ return f"<{start_tag}>" + content + f"{end_tag}>"
-def test_aria_literal_attr_bypass():
- # Trigger overall attribute resolution with an unrelated interpolated attr.
- node = html(t'
')
- assert node == Element(
- "p",
- attrs={"aria": "passthru", "id": "resolved"},
- ), "A single literal attribute should not trigger aria expansion."
+class TestInterpolationConversion:
+ def test_str(self):
+ c = Convertible()
+ for tag in ("p", "script", "title"):
+ assert html(wrap_template_in_tags(tag, t"{c!s}")) == wrap_text_in_tags(
+ tag, "string"
+ )
+ def test_repr(self):
+ c = Convertible()
+ for tag in ("p", "script", "title"):
+ assert html(wrap_template_in_tags(tag, t"{c!r}")) == wrap_text_in_tags(
+ tag, "repr"
+ )
-#
-# Special class attribute handling.
-#
-def test_interpolated_class_attribute():
- class_list = ["btn", "btn-primary", "one two", None]
- class_dict = {"active": True, "btn-secondary": False}
- class_str = "blue"
- class_space_sep_str = "green yellow"
- class_none = None
- class_empty_list = []
- class_empty_dict = {}
- button_t = (
- t"Click me "
- )
- node = html(button_t)
- assert node == Element(
- "button",
- attrs={"class": "red btn btn-primary one two active blue green yellow"},
- children=[Text("Click me")],
- )
- assert (
- str(node)
- == 'Click me '
- )
+ def test_ascii_raw_text(self):
+ # single quotes are not escaped in raw text
+ assert html(wrap_template_in_tags("script", t"{'😊'!a}")) == wrap_text_in_tags(
+ "script", ascii("😊")
+ )
+ def test_ascii_escapable_normal_and_raw(self):
+ # single quotes are escaped
+ for tag in ("p", "title"):
+ assert html(wrap_template_in_tags(tag, t"{'😊'!a}")) == wrap_text_in_tags(
+ tag, escape_html_text(ascii("😊"))
+ )
-def test_interpolated_class_attribute_with_multiple_placeholders():
- classes1 = ["btn", "btn-primary"]
- classes2 = [False, None, {"active": True}]
- node = html(t'Click me ')
- # CONSIDER: Is this what we want? Currently, when we have multiple
- # placeholders in a single attribute, we treat it as a string attribute.
- assert node == Element(
- "button",
- attrs={"class": "['btn', 'btn-primary'] [False, None, {'active': True}]"},
- children=[Text("Click me")],
- )
+class TestInterpolationFormatSpec:
+ def test_normal_text_safe(self):
+ raw_content = "underlined "
+ assert (
+ html(t"This is {raw_content:safe} text.
")
+ == "This is underlined text.
"
+ )
-def test_interpolated_attribute_spread_with_class_attribute():
- attrs = {"id": "button1", "class": ["btn", "btn-primary"]}
- node = html(t"Click me ")
- assert node == Element(
- "button",
- attrs={"id": "button1", "class": "btn btn-primary"},
- children=[Text("Click me")],
- )
- assert str(node) == 'Click me '
+ def test_raw_text_safe(self):
+ # @TODO: What should even happen here?
+ raw_content = ""
+ assert (
+ html(t"") == ""
+ ), "DO NOT DO THIS! This is an advanced escape hatch."
+
+ def test_escapable_raw_text_safe(self):
+ raw_content = "underlined "
+ assert (
+ html(t"")
+ == ""
+ )
+ def test_normal_text_unsafe(self):
+ supposedly_safe = Markup("italic ")
+ assert (
+ html(t"This is {supposedly_safe:unsafe} text.
")
+ == "This is <i>italic</i> text.
"
+ )
-def test_class_literal_attr_bypass():
- # Trigger overall attribute resolution with an unrelated interpolated attr.
- node = html(t'
')
- assert node == Element(
- "p",
- attrs={"class": "red red", "id": "veryred"},
- ), "A single literal attribute should not trigger class accumulator."
+ def test_raw_text_unsafe(self):
+ # @TODO: What should even happen here?
+ supposedly_safe = ""
+ assert (
+ html(t"")
+ == ""
+ )
+ assert (
+ html(t"")
+ != ""
+ ) # Sanity check
+
+ def test_escapable_raw_text_unsafe(self):
+ supposedly_safe = Markup("italic ")
+ assert (
+ html(t"")
+ == ""
+ )
+ def test_all_text_callback(self):
+ def get_value():
+ return "dynamic"
+
+ for tag in ("p", "script", "style"):
+ assert (
+ html(
+ Template(f"<{tag}>")
+ + t"The value is {get_value:callback}."
+ + Template(f"{tag}>")
+ )
+ == f"<{tag}>The value is dynamic.{tag}>"
+ )
-def test_class_none_ignored():
- class_item = None
- node = html(t"
")
- assert node == Element("p")
- # Also ignored inside a sequence.
- node = html(t"
")
- assert node == Element("p")
+ def test_callback_nonzero_callable_error(self):
+ def add(a, b):
+ return a + b
+ assert add(1, 2) == 3, "Make sure fixture could work..."
-def test_class_type_errors():
- for class_item in (False, True, 0):
with pytest.raises(TypeError):
- _ = html(t"
")
- with pytest.raises(TypeError):
- _ = html(t"
")
+ for tag in ("p", "script", "style"):
+ _ = html(
+ Template(f"<{tag}>")
+ + t"The sum is {add:callback}."
+ + Template(f"{tag}>")
+ )
-def test_class_merge_literals():
- node = html(t'
')
- assert node == Element("p", {"class": "red blue"})
+# --------------------------------------------------------------------------
+# Conditional rendering and control flow
+# --------------------------------------------------------------------------
-def test_class_merge_literal_then_interpolation():
- class_item = "blue"
- node = html(t'
')
- assert node == Element("p", {"class": "red blue"})
+class TestUsagePatterns:
+ def test_conditional_rendering_with_if_else(self):
+ is_logged_in = True
+ user_profile = t"Welcome, User! "
+ login_prompt = t"Please log in "
+ assert (
+ html(t"{user_profile if is_logged_in else login_prompt}
")
+ == "Welcome, User!
"
+ )
+ is_logged_in = False
+ assert (
+ html(t"{user_profile if is_logged_in else login_prompt}
")
+ == ''
+ )
-#
-# Special style attribute handling.
-#
-def test_style_literal_attr_passthru():
- p_id = "para1" # non-literal attribute to cause attr resolution
- node = html(t'Warning!
')
- assert node == Element(
- "p",
- attrs={"style": "color: red", "id": "para1"},
- children=[Text("Warning!")],
- )
- assert str(node) == 'Warning!
'
+# --------------------------------------------------------------------------
+# Attributes
+# --------------------------------------------------------------------------
+class TestLiteralAttribute:
+ """Test literal (non-dynamic) attributes."""
+
+ def test_literal_attrs(self):
+ assert (
+ html(
+ t" "
+ )
+ == ' '
+ )
-def test_style_in_interpolated_attr():
- styles = {"color": "red", "font-weight": "bold", "font-size": "16px"}
- node = html(t"Warning!
")
- assert node == Element(
- "p",
- attrs={"style": "color: red; font-weight: bold; font-size: 16px"},
- children=[Text("Warning!")],
- )
- assert (
- str(node)
- == 'Warning!
'
- )
+ def test_literal_attr_escaped(self):
+ assert (
+ html(t' ')
+ == ' '
+ )
-def test_style_in_templated_attr():
- color = "red"
- node = html(t'Warning!
')
- assert node == Element(
- "p",
- attrs={"style": "color: red"},
- children=[Text("Warning!")],
- )
- assert str(node) == 'Warning!
'
+class TestInterpolatedAttribute:
+ """Test interpolated attributes, entire value is an exact interpolation."""
+ def test_interpolated_attr(self):
+ url = "https://example.com/"
+ assert html(t' ') == ' '
-def test_style_in_spread_attr():
- attrs = {"style": {"color": "red"}}
- node = html(t"Warning!
")
- assert node == Element(
- "p",
- attrs={"style": "color: red"},
- children=[Text("Warning!")],
- )
- assert str(node) == 'Warning!
'
+ def test_interpolated_attr_escaped(self):
+ url = 'https://example.com/?q="test"&lang=en'
+ assert (
+ html(t' ')
+ == ' '
+ )
+ def test_interpolated_attr_unquoted(self):
+ id = "roquefort"
+ assert html(t"
") == '
'
-def test_style_merged_from_all_attrs():
- attrs = {"style": {"font-size": "15px"}}
- style = {"font-weight": "bold"}
- color = "red"
- node = html(
- t'
'
- )
- assert node == Element(
- "p",
- {"style": "font-family: serif; color: red; font-weight: bold; font-size: 15px"},
- )
- assert (
- str(node)
- == '
'
- )
+ def test_interpolated_attr_true(self):
+ disabled = True
+ assert (
+ html(t" ")
+ == " "
+ )
+ def test_interpolated_attr_false(self):
+ disabled = False
+ assert html(t" ") == " "
-def test_style_override_left_to_right():
- suffix = t">
"
- cb_dict = {"color": "blue"}
- scy_dict = {"style": {"color": "yellow"}}
- parts = [
- (t'
'
-
-
-def test_interpolated_style_attribute_multiple_placeholders():
- styles1 = {"color": "red"}
- styles2 = {"font-weight": "bold"}
- # CONSIDER: Is this what we want? Currently, when we have multiple
- # placeholders in a single attribute, we treat it as a string attribute
- # which produces an invalid style attribute.
- with pytest.raises(ValueError):
- _ = html(t"Warning!
")
-
-
-def test_interpolated_style_attribute_merged():
- styles1 = {"color": "red"}
- styles2 = {"font-weight": "bold"}
- node = html(t"Warning!
")
- assert node == Element(
- "p",
- attrs={"style": "color: red; font-weight: bold"},
- children=[Text("Warning!")],
- )
- assert str(node) == 'Warning!
'
+ def test_interpolated_attr_none(self):
+ disabled = None
+ assert html(t" ") == " "
+ def test_interpolate_attr_empty_string(self):
+ assert html(t'
') == '
'
-def test_interpolated_style_attribute_merged_override():
- styles1 = {"color": "red", "font-weight": "normal"}
- styles2 = {"font-weight": "bold"}
- node = html(t"Warning!
")
- assert node == Element(
- "p",
- attrs={"style": "color: red; font-weight: bold"},
- children=[Text("Warning!")],
- )
- assert str(node) == 'Warning!
'
+class TestSpreadAttribute:
+ """Test spread attributes."""
-def test_style_attribute_str():
- styles = "color: red; font-weight: bold;"
- node = html(t"Warning!
")
- assert node == Element(
- "p",
- attrs={"style": "color: red; font-weight: bold"},
- children=[Text("Warning!")],
- )
- assert str(node) == 'Warning!
'
+ def test_spread_attr(self):
+ attrs = {"href": "https://example.com/", "target": "_blank"}
+ assert (
+ html(t" ")
+ == ' '
+ )
+ def test_spread_attr_none(self):
+ attrs = None
+ assert html(t" ") == " "
+
+ def test_spread_attr_type_errors(self):
+ for attrs in (0, [], (), False, True):
+ with pytest.raises(TypeError):
+ _ = html(t" ")
+
+
+class TestTemplatedAttribute:
+ def test_templated_attr_mixed_interpolations_start_end_and_nest(self):
+ left, middle, right = 1, 3, 5
+ prefix, suffix = t'
'
+ # Check interpolations at start, middle and/or end of templated attr
+ # or a combination of those to make sure text is not getting dropped.
+ for left_part, middle_part, right_part in product(
+ (t"{left}", Template(str(left))),
+ (t"{middle}", Template(str(middle))),
+ (t"{right}", Template(str(right))),
+ ):
+ test_t = (
+ prefix + left_part + t"-" + middle_part + t"-" + right_part + suffix
+ )
+ assert html(test_t) == '
'
+
+ def test_templated_attr_no_quotes(self):
+ start = 1
+ end = 5
+ assert (
+ html(t"
")
+ == '
'
+ )
-def test_style_attribute_non_str_non_dict():
- with pytest.raises(TypeError):
- styles = [1, 2]
- _ = html(t"Warning!
")
+class TestAttributeMerging:
+ def test_attr_merge_disjoint_interpolated_attr_spread_attr(self):
+ attrs = {"href": "https://example.com/", "id": "link1"}
+ target = "_blank"
+ assert (
+ html(t" ")
+ == ' '
+ )
-def test_style_literal_attr_bypass():
- # Trigger overall attribute resolution with an unrelated interpolated attr.
- node = html(t'
')
- assert node == Element(
- "p",
- attrs={"style": "invalid;invalid:", "id": "resolved"},
- ), "A single literal attribute should bypass style accumulator."
+ def test_attr_merge_overlapping_spread_attrs(self):
+ attrs1 = {"href": "https://example.com/", "id": "overwrtten"}
+ attrs2 = {"target": "_blank", "id": "link1"}
+ assert (
+ html(t" ")
+ == ' '
+ )
+ def test_attr_merge_replace_literal_attr_str_str(self):
+ assert (
+ html(t'
')
+ == '
'
+ )
-def test_style_none():
- styles = None
- node = html(t"
")
- assert node == Element("p")
+ def test_attr_merge_replace_literal_attr_str_true(self):
+ assert (
+ html(t'
')
+ == "
"
+ )
+ def test_attr_merge_replace_literal_attr_true_str(self):
+ assert (
+ html(t"
")
+ == '
'
+ )
-# --------------------------------------------------------------------------
-# Function component interpolation tests
-# --------------------------------------------------------------------------
+ def test_attr_merge_remove_literal_attr_str_none(self):
+ assert html(t'
') == "
"
+ def test_attr_merge_remove_literal_attr_true_none(self):
+ assert html(t"
") == "
"
-def FunctionComponent(
- children: Iterable[Node], first: str, second: int, third_arg: str, **attrs: t.Any
-) -> Template:
- # Ensure type correctness of props at runtime for testing purposes
- assert isinstance(first, str)
- assert isinstance(second, int)
- assert isinstance(third_arg, str)
- new_attrs = {
- "id": third_arg,
- "data": {"first": first, "second": second},
- **attrs,
- }
- return t"Component: {children}
"
+ def test_attr_merge_other_literal_attr_intact(self):
+ assert (
+ html(t' ')
+ == ' '
+ )
-def test_interpolated_template_component():
- node = html(
- t'<{FunctionComponent} first=1 second={99} third-arg="comp1" class="my-comp">Hello, Component!{FunctionComponent}>'
- )
- assert node == Element(
- "div",
- attrs={
- "id": "comp1",
- "data-first": "1",
- "data-second": "99",
- "class": "my-comp",
- },
- children=[Text("Component: "), Text("Hello, Component!")],
- )
- assert (
- str(node)
- == 'Component: Hello, Component!
'
- )
+class TestSpecialDataAttribute:
+ """Special data attribute handling."""
+
+ def test_interpolated_data_attributes(self):
+ data = {
+ "user-id": 123,
+ "role": "admin",
+ "wild": True,
+ "false": False,
+ "none": None,
+ }
+ assert (
+ html(t"User Info
")
+ == 'User Info
'
+ )
+ def test_data_attr_toggle_to_str(self):
+ for res in [
+ html(t"
"),
+ html(t'
'),
+ ]:
+ assert res == '
'
-def test_interpolated_template_component_no_children_provided():
- """Same test, but the caller didn't provide any children."""
- node = html(
- t'<{FunctionComponent} first=1 second={99} third-arg="comp1" class="my-comp" />'
- )
- assert node == Element(
- "div",
- attrs={
- "id": "comp1",
- "data-first": "1",
- "data-second": "99",
- "class": "my-comp",
- },
- children=[
- Text("Component: "),
- ],
- )
- assert (
- str(node)
- == 'Component:
'
- )
+ def test_data_attr_toggle_to_true(self):
+ res = html(t'
')
+ assert res == "
"
+ def test_data_attr_unrelated_unaffected(self):
+ res = html(t"
")
+ assert res == "
"
-def test_invalid_component_invocation():
- with pytest.raises(TypeError):
- _ = html(t"<{FunctionComponent}>Missing props{FunctionComponent}>")
+ def test_data_attr_templated_error(self):
+ data1 = {"user-id": "user-123"}
+ data2 = {"role": "admin"}
+ with pytest.raises(TypeError):
+ _ = html(t'
')
+
+ def test_data_attr_none(self):
+ button_data = None
+ res = html(t"X ")
+ assert res == "X "
+
+ def test_data_attr_errors(self):
+ for v in [False, [], (), 0, "data?"]:
+ with pytest.raises(TypeError):
+ _ = html(t"X ")
+
+ def test_data_literal_attr_bypass(self):
+ # Trigger overall attribute resolution with an unrelated interpolated attr.
+ res = html(t'
')
+ assert res == '
', (
+ "A single literal attribute should not trigger data expansion."
+ )
-def test_prep_component_kwargs_named():
- def InputElement(size=10, type="text"):
- pass
+class TestSpecialAriaAttribute:
+ """Special aria attribute handling."""
- callable_info = get_callable_info(InputElement)
- assert _prep_component_kwargs(callable_info, {"size": 20}, system_kwargs={}) == {
- "size": 20
- }
- assert _prep_component_kwargs(
- callable_info, {"type": "email"}, system_kwargs={}
- ) == {"type": "email"}
- assert _prep_component_kwargs(callable_info, {}, system_kwargs={}) == {}
-
-
-def FunctionComponentNoChildren(first: str, second: int, third_arg: str) -> Template:
- # Ensure type correctness of props at runtime for testing purposes
- assert isinstance(first, str)
- assert isinstance(second, int)
- assert isinstance(third_arg, str)
- new_attrs = {
- "id": third_arg,
- "data": {"first": first, "second": second},
- }
- return t"Component: ignore children
"
+ def test_aria_templated_attr_error(self):
+ aria1 = {"label": "close"}
+ aria2 = {"hidden": "true"}
+ with pytest.raises(TypeError):
+ _ = html(t'
')
+
+ def test_aria_interpolated_attr_dict(self):
+ aria = {"label": "Close", "hidden": True, "another": False, "more": None}
+ res = html(t"X ")
+ assert (
+ res
+ == 'X '
+ )
+ def test_aria_interpolate_attr_none(self):
+ button_aria = None
+ res = html(t"X ")
+ assert res == "X "
+
+ def test_aria_attr_errors(self):
+ for v in [False, [], (), 0, "aria?"]:
+ with pytest.raises(TypeError):
+ _ = html(t"X ")
+
+ def test_aria_literal_attr_bypass(self):
+ # Trigger overall attribute resolution with an unrelated interpolated attr.
+ res = html(t'
')
+ assert res == '
', (
+ "A single literal attribute should not trigger aria expansion."
+ )
-def test_interpolated_template_component_ignore_children():
- node = html(
- t'<{FunctionComponentNoChildren} first=1 second={99} third-arg="comp1">Hello, Component!{FunctionComponentNoChildren}>'
- )
- assert node == Element(
- "div",
- attrs={
- "id": "comp1",
- "data-first": "1",
- "data-second": "99",
- },
- children=[Text(text="Component: ignore children")],
- )
- assert (
- str(node)
- == 'Component: ignore children
'
- )
+class TestSpecialClassAttribute:
+ """Special class attribute handling."""
+
+ def test_interpolated_class_attribute(self):
+ class_list = ["btn", "btn-primary", "one two", None]
+ class_dict = {"active": True, "btn-secondary": False}
+ class_str = "blue"
+ class_space_sep_str = "green yellow"
+ class_none = None
+ class_empty_list = []
+ class_empty_dict = {}
+ button_t = (
+ t"Click me "
+ )
+ res = html(button_t)
+ assert (
+ res
+ == 'Click me '
+ )
-def FunctionComponentKeywordArgs(first: str, **attrs: t.Any) -> Template:
- # Ensure type correctness of props at runtime for testing purposes
- assert isinstance(first, str)
- assert "children" in attrs
- _ = attrs.pop("children")
- new_attrs = {"data-first": first, **attrs}
- return t"Component with kwargs
"
+ def test_interpolated_class_attribute_with_multiple_placeholders(self):
+ classes1 = ["btn", "btn-primary"]
+ classes2 = [None, {"active": True}]
+ res = html(t'Click me ')
+ # CONSIDER: Is this what we want? Currently, when we have multiple
+ # placeholders in a single attribute, we treat it as a string attribute.
+ assert (
+ res
+ == f'Click me '
+ ), (
+ "Interpolations that are not exact, or singletons, are instead interpreted as templates and therefore these dictionaries are strified."
+ )
+ def test_interpolated_attribute_spread_with_class_attribute(self):
+ attrs = {"id": "button1", "class": ["btn", "btn-primary"]}
+ res = html(t"Click me ")
+ assert res == 'Click me '
-def test_children_always_passed_via_kwargs():
- node = html(
- t'<{FunctionComponentKeywordArgs} first="value" extra="info">Child content{FunctionComponentKeywordArgs}>'
- )
- assert node == Element(
- "div",
- attrs={
- "data-first": "value",
- "extra": "info",
- },
- children=[Text("Component with kwargs")],
- )
- assert (
- str(node) == 'Component with kwargs
'
- )
+ def test_class_literal_attr_bypass(self):
+ # Trigger overall attribute resolution with an unrelated interpolated attr.
+ res = html(t'
')
+ assert res == '
', (
+ "A single literal attribute should not trigger class accumulator."
+ )
+ def test_class_none_ignored(self):
+ class_item = None
+ res = html(t"
")
+ assert res == "
"
+ # Also ignored inside a sequence.
+ res = html(t"
")
+ assert res == "
"
+
+ def test_class_type_errors(self):
+ for class_item in (False, True, 0):
+ with pytest.raises(TypeError):
+ _ = html(t"
")
+ with pytest.raises(TypeError):
+ _ = html(t"
")
+
+ def test_class_merge_literals(self):
+ res = html(t'
')
+ assert res == '
'
+
+ def test_class_merge_literal_then_interpolation(self):
+ class_item = "blue"
+ res = html(t'
')
+ assert res == '
'
+
+
+class TestSpecialStyleAttribute:
+ """Special style attribute handling."""
+
+ def test_style_literal_attr_passthru(self):
+ p_id = "para1" # non-literal attribute to cause attr resolution
+ res = html(t'Warning!
')
+ assert res == 'Warning!
'
+
+ def test_style_in_interpolated_attr(self):
+ styles = {"color": "red", "font-weight": "bold", "font-size": "16px"}
+ res = html(t"Warning!
")
+ assert (
+ res
+ == 'Warning!
'
+ )
-def test_children_always_passed_via_kwargs_even_when_empty():
- node = html(t'<{FunctionComponentKeywordArgs} first="value" extra="info" />')
- assert node == Element(
- "div",
- attrs={
- "data-first": "value",
- "extra": "info",
- },
- children=[Text("Component with kwargs")],
- )
- assert (
- str(node) == 'Component with kwargs
'
- )
+ def test_style_in_templated_attr(self):
+ color = "red"
+ res = html(t'Warning!
')
+ assert res == 'Warning!
'
+
+ def test_style_in_spread_attr(self):
+ attrs = {"style": {"color": "red"}}
+ res = html(t"Warning!
")
+ assert res == 'Warning!
'
+
+ def test_style_merged_from_all_attrs(self):
+ attrs = {"style": "font-size: 15px"}
+ style = {"font-weight": "bold"}
+ color = "red"
+ res = html(
+ t'
'
+ )
+ assert (
+ res
+ == '
'
+ )
+ def test_style_override_left_to_right(self):
+ suffix = t">"
+ parts = [
+ (t'
'
+
+ def test_interpolated_style_attribute_multiple_placeholders(self):
+ styles1 = {"color": "red"}
+ styles2 = {"font-weight": "bold"}
+ # CONSIDER: Is this what we want? Currently, when we have multiple
+ # placeholders in a single attribute, we treat it as a string attribute
+ # which produces an invalid style attribute.
+ with pytest.raises(ValueError):
+ _ = html(t"Warning!
")
+
+ def test_interpolated_style_attribute_merged(self):
+ styles1 = {"color": "red"}
+ styles2 = {"font-weight": "bold"}
+ res = html(t"Warning!
")
+ assert res == 'Warning!
'
+
+ def test_interpolated_style_attribute_merged_override(self):
+ styles1 = {"color": "red", "font-weight": "normal"}
+ styles2 = {"font-weight": "bold"}
+ res = html(t"Warning!
")
+ assert res == 'Warning!
'
+
+ def test_style_attribute_str(self):
+ styles = "color: red; font-weight: bold;"
+ res = html(t"Warning!
")
+ assert res == 'Warning!
'
+
+ def test_style_attribute_non_str_non_dict(self):
+ with pytest.raises(TypeError):
+ styles = [1, 2]
+ _ = html(t"Warning!
")
+
+ def test_style_literal_attr_bypass(self):
+ # Trigger overall attribute resolution with an unrelated interpolated attr.
+ res = html(t'
')
+ assert res == '
', (
+ "A single literal attribute should bypass style accumulator."
+ )
-def ColumnsComponent() -> Template:
- return t"""Column 1 Column 2 """
-
-
-def test_fragment_from_component():
- # This test assumes that if a component returns a template that parses
- # into multiple root elements, they are treated as a fragment.
- node = html(t"")
- assert node == Element(
- "table",
- children=[
- Element(
- "tr",
- children=[
- Element("td", children=[Text("Column 1")]),
- Element("td", children=[Text("Column 2")]),
- ],
- ),
- ],
- )
- assert str(node) == ""
+ def test_style_none(self):
+ styles = None
+ res = html(t"
")
+ assert res == "
"
+
+
+class TestPrepComponentKwargs:
+ def test_named(self):
+ def InputElement(size=10, type="text"):
+ pass
+
+ callable_info = get_callable_info(InputElement)
+ assert prep_component_kwargs(callable_info, {"size": 20}, system_kwargs={}) == {
+ "size": 20
+ }
+ assert prep_component_kwargs(
+ callable_info, {"type": "email"}, system_kwargs={}
+ ) == {"type": "email"}
+ assert prep_component_kwargs(callable_info, {}, system_kwargs={}) == {}
+
+ @pytest.mark.skip("Should we just ignore unused user-specified kwargs?")
+ def test_unused_kwargs(self):
+ def InputElement(size=10, type="text"):
+ pass
+
+ callable_info = get_callable_info(InputElement)
+ with pytest.raises(ValueError):
+ assert (
+ prep_component_kwargs(callable_info, {"type2": 15}, system_kwargs={})
+ == {}
+ )
-def test_component_passed_as_attr_value():
- def Wrapper(
- children: Iterable[Node], sub_component: Callable, **attrs: t.Any
+class TestFunctionComponent:
+ @staticmethod
+ def FunctionComponent(
+ children: Template, first: str, second: int, third_arg: str, **attrs: t.Any
) -> Template:
- return t"<{sub_component} {attrs}>{children}{sub_component}>"
-
- node = html(
- t'<{Wrapper} sub-component={FunctionComponent} class="wrapped" first=1 second={99} third-arg="comp1">Inside wrapper
{Wrapper}>'
- )
- assert node == Element(
- "div",
- attrs={
- "id": "comp1",
- "data-first": "1",
- "data-second": "99",
- "class": "wrapped",
- },
- children=[Text("Component: "), Element("p", children=[Text("Inside wrapper")])],
- )
- assert (
- str(node)
- == 'Component:
Inside wrapper
'
- )
-
-
-def test_nested_component_gh23():
- # See https://github.com/t-strings/tdom/issues/23 for context
- def Header():
- return html(t"{'Hello World'}")
-
- node = html(t"<{Header} />")
- assert node == Text("Hello World")
- assert str(node) == "Hello World"
-
-
-def test_component_returning_iterable():
- def Items() -> Iterable:
- for i in range(2):
- yield t"Item {i + 1} "
- yield html(t"Item {3} ")
+ # Ensure type correctness of props at runtime for testing purposes
+ assert isinstance(first, str)
+ assert isinstance(second, int)
+ assert isinstance(third_arg, str)
+ new_attrs = {
+ "id": third_arg,
+ "data": {"first": first, "second": second},
+ **attrs,
+ }
+ return t"Component: {children}
"
+
+ def test_with_children(self):
+ res = html(
+ t'<{self.FunctionComponent} first=1 second={99} third-arg="comp1" class="my-comp">Hello, Component!{self.FunctionComponent}>'
+ )
+ assert (
+ res
+ == 'Component: Hello, Component!
'
+ )
- node = html(t"")
- assert node == Element(
- "ul",
- children=[
- Element("li", children=[Text("Item "), Text("1")]),
- Element("li", children=[Text("Item "), Text("2")]),
- Element("li", children=[Text("Item "), Text("3")]),
- ],
- )
- assert str(node) == ""
+ def test_with_no_children(self):
+ """Same test, but the caller didn't provide any children."""
+ res = html(
+ t'<{self.FunctionComponent} first=1 second={99} third-arg="comp1" class="my-comp" />'
+ )
+ assert (
+ res
+ == 'Component:
'
+ )
+ def test_missing_props_error(self):
+ with pytest.raises(TypeError):
+ _ = html(
+ t"<{self.FunctionComponent}>Missing props{self.FunctionComponent}>"
+ )
-def test_component_returning_fragment():
- def Items() -> Node:
- return html(t"Item {1} Item {2} Item {3} ")
-
- node = html(t"")
- assert node == Element(
- "ul",
- children=[
- Element("li", children=[Text("Item "), Text("1")]),
- Element("li", children=[Text("Item "), Text("2")]),
- Element("li", children=[Text("Item "), Text("3")]),
- ],
- )
- assert str(node) == ""
+class TestFunctionComponentNoChildren:
+ @staticmethod
+ def FunctionComponentNoChildren(
+ first: str, second: int, third_arg: str
+ ) -> Template:
+ # Ensure type correctness of props at runtime for testing purposes
+ assert isinstance(first, str)
+ assert isinstance(second, int)
+ assert isinstance(third_arg, str)
+ new_attrs = {
+ "id": third_arg,
+ "data": {"first": first, "second": second},
+ }
+ return t"Component: ignore children
"
+
+ def test_interpolated_template_component_ignore_children(self):
+ res = html(
+ t'<{self.FunctionComponentNoChildren} first=1 second={99} third-arg="comp1">Hello, Component!{self.FunctionComponentNoChildren}>'
+ )
+ assert (
+ res
+ == 'Component: ignore children
'
+ )
-@dataclass
-class ClassComponent:
- """Example class-based component."""
- user_name: str
- image_url: str
- homepage: str = "#"
- children: Iterable[Node] = field(default_factory=list)
+class TestFunctionComponentKeywordArgs:
+ @staticmethod
+ def FunctionComponentKeywordArgs(first: str, **attrs: t.Any) -> Template:
+ # Ensure type correctness of props at runtime for testing purposes
+ assert isinstance(first, str)
+ assert "children" in attrs
+ children = attrs.pop("children")
+ new_attrs = {"data-first": first, **attrs}
+ return t"Component with kwargs: {children}
"
- def __call__(self) -> Node:
- return html(
- t""
- t"
"
- t" "
- t" "
- t"
{self.user_name} "
- t"{self.children}"
- t"
",
+ def test_children_always_passed_via_kwargs(self):
+ res = html(
+ t'<{self.FunctionComponentKeywordArgs} first="value" extra="info">Child content{self.FunctionComponentKeywordArgs}>'
+ )
+ assert (
+ res
+ == 'Component with kwargs: Child content
'
)
+ def test_children_always_passed_via_kwargs_even_when_empty(self):
+ res = html(
+ t'<{self.FunctionComponentKeywordArgs} first="value" extra="info" />'
+ )
+ assert (
+ res == 'Component with kwargs:
'
+ )
-def test_class_component_implicit_invocation_with_children():
- node = html(
- t"<{ClassComponent} user-name='Alice' image-url='https://example.com/alice.png'>Fun times!{ClassComponent}>"
- )
- assert node == Element(
- "div",
- attrs={"class": "avatar"},
- children=[
- Element(
- "a",
- attrs={"href": "#"},
- children=[
- Element(
- "img",
- attrs={
- "src": "https://example.com/alice.png",
- "alt": "Avatar of Alice",
- },
- )
- ],
- ),
- Element("span", children=[Text("Alice")]),
- Text("Fun times!"),
- ],
- )
- assert (
- str(node)
- == 'Alice Fun times!
'
- )
-
-
-def test_class_component_direct_invocation():
- avatar = ClassComponent(
- user_name="Alice",
- image_url="https://example.com/alice.png",
- homepage="https://example.com/users/alice",
- )
- node = html(t"<{avatar} />")
- assert node == Element(
- "div",
- attrs={"class": "avatar"},
- children=[
- Element(
- "a",
- attrs={"href": "https://example.com/users/alice"},
- children=[
- Element(
- "img",
- attrs={
- "src": "https://example.com/alice.png",
- "alt": "Avatar of Alice",
- },
- )
- ],
- ),
- Element("span", children=[Text("Alice")]),
- ],
- )
- assert (
- str(node)
- == 'Alice '
- )
+class TestComponentSpecialUsage:
+ @staticmethod
+ def ColumnsComponent() -> Template:
+ return t"""Column 1 Column 2 """
-@dataclass
-class ClassComponentNoChildren:
- """Example class-based component that does not ask for children."""
+ def test_fragment_from_component(self):
+ # This test assumes that if a component returns a template that parses
+ # into multiple root elements, they are treated as a fragment.
+ res = html(t"<{self.ColumnsComponent} />
")
+ assert res == ""
- user_name: str
- image_url: str
- homepage: str = "#"
+ def test_component_passed_as_attr_value(self):
+ def Wrapper(
+ children: Template, sub_component: Callable, **attrs: t.Any
+ ) -> Template:
+ return t"<{sub_component} {attrs}>{children}{sub_component}>"
- def __call__(self) -> Node:
- return html(
- t""
- t"
"
- t" "
- t" "
- t"
{self.user_name} "
- t"ignore children"
- t"
",
+ res = html(
+ t'<{Wrapper} sub-component={TestFunctionComponent.FunctionComponent} class="wrapped" first=1 second={99} third-arg="comp1">Inside wrapper
{Wrapper}>'
+ )
+ assert (
+ res
+ == 'Component:
Inside wrapper
'
)
+ def test_nested_component_gh23(self):
+ # @DESIGN: Do we need this? Should we recommend an alternative?
+ # See https://github.com/t-strings/tdom/issues/23 for context
+ def Header() -> Template:
+ return t"{'Hello World'}"
+
+ res = html(t"<{Header} />", assume_ctx=make_ctx(parent_tag="div"))
+ assert res == "Hello World"
+
+
+class TestClassComponent:
+ @dataclass
+ class ClassComponent:
+ """Example class-based component."""
+
+ user_name: str
+ image_url: str
+ children: Template
+ homepage: str = "#"
+
+ def __call__(self) -> Template:
+ return (
+ t""
+ t"
"
+ t" "
+ t" "
+ t"
{self.user_name} "
+ t"{self.children}"
+ t"
"
+ )
-def test_class_component_implicit_invocation_ignore_children():
- node = html(
- t"<{ClassComponentNoChildren} user-name='Alice' image-url='https://example.com/alice.png'>Fun times!{ClassComponentNoChildren}>"
- )
- assert node == Element(
- "div",
- attrs={"class": "avatar"},
- children=[
- Element(
- "a",
- attrs={"href": "#"},
- children=[
- Element(
- "img",
- attrs={
- "src": "https://example.com/alice.png",
- "alt": "Avatar of Alice",
- },
- )
- ],
- ),
- Element("span", children=[Text("Alice")]),
- Text("ignore children"),
- ],
- )
- assert (
- str(node)
- == 'Alice ignore children
'
- )
+ def test_class_component_implicit_invocation_with_children(self):
+ res = html(
+ t"<{self.ClassComponent} user-name='Alice' image-url='https://example.com/alice.png'>Fun times!{self.ClassComponent}>"
+ )
+ assert (
+ res
+ == 'Alice Fun times!
'
+ )
+ def test_class_component_direct_invocation(self):
+ avatar = self.ClassComponent(
+ user_name="Alice",
+ image_url="https://example.com/alice.png",
+ homepage="https://example.com/users/alice",
+ children=t"", # Children is required so we set it to an empty template.
+ )
+ res = html(t"<{avatar} />")
+ assert (
+ res
+ == 'Alice '
+ )
-def AttributeTypeComponent(
- data_int: int,
- data_true: bool,
- data_false: bool,
- data_none: None,
- data_float: float,
- data_dt: datetime.datetime,
- **kws: dict[str, object | None],
-) -> Template:
- """Component to test that we don't incorrectly convert attribute types."""
- assert isinstance(data_int, int)
- assert data_true is True
- assert data_false is False
- assert data_none is None
- assert isinstance(data_float, float)
- assert isinstance(data_dt, datetime.datetime)
- for kw, v_type in [
- ("spread_true", True),
- ("spread_false", False),
- ("spread_int", int),
- ("spread_none", None),
- ("spread_float", float),
- ("spread_dt", datetime.datetime),
- ("spread_dict", dict),
- ("spread_list", list),
- ]:
- if v_type in (True, False, None):
- assert kw in kws and kws[kw] is v_type, (
- f"{kw} should be {v_type} but got {kws=}"
+ @dataclass
+ class ClassComponentNoChildren:
+ """Example class-based component that does not ask for children."""
+
+ user_name: str
+ image_url: str
+ homepage: str = "#"
+
+ def __call__(self) -> Template:
+ return (
+ t""
+ t"
"
+ t" "
+ t" "
+ t"
{self.user_name} "
+ t"ignore children"
+ t"
"
)
- else:
- assert kw in kws and isinstance(kws[kw], v_type), (
- f"{kw} should instance of {v_type} but got {kws=}"
- )
- return t"Looks good!"
+
+ def test_implicit_invocation_ignore_children(self):
+ res = html(
+ t"<{self.ClassComponentNoChildren} user-name='Alice' image-url='https://example.com/alice.png'>Fun times!{self.ClassComponentNoChildren}>"
+ )
+ assert (
+ res
+ == 'Alice ignore children
'
+ )
def test_attribute_type_component():
+ def AttributeTypeComponent(
+ data_int: int,
+ data_true: bool,
+ data_false: bool,
+ data_none: None,
+ data_float: float,
+ data_dt: datetime.datetime,
+ **kws: dict[str, object | None],
+ ) -> Template:
+ """Component to test that we don't incorrectly convert attribute types."""
+ assert isinstance(data_int, int)
+ assert data_true is True
+ assert data_false is False
+ assert data_none is None
+ assert isinstance(data_float, float)
+ assert isinstance(data_dt, datetime.datetime)
+ for kw, v_type in [
+ ("spread_true", True),
+ ("spread_false", False),
+ ("spread_int", int),
+ ("spread_none", None),
+ ("spread_float", float),
+ ("spread_dt", datetime.datetime),
+ ("spread_dict", dict),
+ ("spread_list", list),
+ ]:
+ if v_type in (True, False, None):
+ assert kw in kws and kws[kw] is v_type, (
+ f"{kw} should be {v_type} but got {kws=}"
+ )
+ else:
+ assert kw in kws and isinstance(kws[kw], v_type), (
+ f"{kw} should instance of {v_type} but got {kws=}"
+ )
+ return t"Looks good!"
+
an_int: int = 42
a_true: bool = True
a_false: bool = False
@@ -1462,31 +1550,413 @@ def test_attribute_type_component():
"spread_dict": {},
"spread_list": ["eggs", "milk"],
}
- node = html(
+ res = html(
t"<{AttributeTypeComponent} data-int={an_int} data-true={a_true} "
t"data-false={a_false} data-none={a_none} data-float={a_float} "
t"data-dt={a_dt} {spread_attrs}/>"
)
- assert node == Text("Looks good!")
- assert str(node) == "Looks good!"
+ assert res == "Looks good!"
+
+
+class TestComponentErrors:
+ def test_component_non_callable_fails(self):
+ with pytest.raises(TypeError):
+ _ = html(t"<{'not a function'} />")
+
+ def test_component_requiring_positional_arg_fails(self):
+ def RequiresPositional(whoops: int, /) -> Template: # pragma: no cover
+ return t"Positional arg: {whoops}
"
+
+ with pytest.raises(TypeError):
+ _ = html(t"<{RequiresPositional} />")
+
+ def test_mismatched_component_closing_tag_fails(self):
+ def OpenTag(children: Template) -> Template:
+ return t"open
"
+
+ def CloseTag(children: Template) -> Template:
+ return t"close
"
+
+ with pytest.raises(TypeError):
+ _ = html(t"<{OpenTag}>Hello{CloseTag}>")
+
+
+def test_integration_basic():
+ comment_text = "comment is not literal"
+ interpolated_class = "red"
+ text_in_element = "text is not literal"
+ templated = "not literal"
+ spread_attrs = {"data-on": True}
+ markup_content = Markup("safe
")
+
+ def WrapperComponent(children):
+ return t"{children}
"
+
+ smoke_t = t"""
+
+
+
+literal
+
+{text_in_element}
+{text_in_element}
+<{WrapperComponent}>comp body {WrapperComponent}>
+{markup_content}
+
+"""
+ smoke_str = """
+
+
+
+literal
+
+text is not literal
+text is not literal
+comp body
+safe
+
+"""
+ assert html(smoke_t) == smoke_str
+
+
+def struct_repr(st):
+ """Breakdown Templates into comparable parts for test verification."""
+ return st.strings, tuple(
+ (i.value, i.expression, i.conversion, i.format_spec) for i in st.interpolations
+ )
+
+
+def test_process_template_internal_cache():
+ """Test that cache and non-cache both generally work as expected."""
+ # @NOTE: We use a made-up custom element so that we can be sure to
+ # miss the cache. If this element is used elsewhere than the global
+ # cache might cache it and it will ruin our counting, specifically
+ # the first miss will instead be a hit.
+ sample_t = t"{'content'}
"
+ sample_diff_t = t"{'diffcontent'}
"
+ alt_t = t"{'content'} "
+ process_api = processor_service_factory()
+ cached_process_api = cached_processor_service_factory()
+ # Because the cache is stored on the class itself this can be affect by
+ # other tests, so save this off and take the difference to determine the result,
+ # this is not great and hopefully we can find a better solution.
+ assert isinstance(cached_process_api.parser_api, CachedParserService)
+ start_ci = cached_process_api.parser_api._to_tnode.cache_info()
+ tnode1 = process_api.parser_api.to_tnode(sample_t)
+ tnode2 = process_api.parser_api.to_tnode(sample_t)
+ cached_tnode1 = cached_process_api.parser_api.to_tnode(sample_t)
+ cached_tnode2 = cached_process_api.parser_api.to_tnode(sample_t)
+ cached_tnode3 = cached_process_api.parser_api.to_tnode(sample_diff_t)
+ # Check that the uncached and cached services are actually
+ # returning non-identical results.
+ assert tnode1 is not cached_tnode1
+ assert tnode1 is not cached_tnode2
+ assert tnode1 is not cached_tnode3
+ # Check that the uncached service returns a brand new result everytime.
+ assert tnode1 is not tnode2
+ # Check that the cached service is returning the exact same, identical, result.
+ assert cached_tnode1 is cached_tnode2
+ # Even if the input templates are not identical (but are still equivalent).
+ assert cached_tnode1 is cached_tnode3 and sample_t is not sample_diff_t
+ # Check that the cached service and uncached services return
+ # results that are equivalent (even though they are not (id)entical).
+ assert tnode1 == cached_tnode1
+ assert tnode2 == cached_tnode1
+ # Now that we are setup we check that the cache is internally
+ # working as we intended.
+ ci = cached_process_api.parser_api._to_tnode.cache_info()
+ # cached_tnode2 and cached_tnode3 are hits after cached_tnode1
+ assert ci.hits - start_ci.hits == 2
+ # cached_tf1 was a miss because cache was empty (brand new)
+ assert ci.misses - start_ci.misses == 1
+ cached_tnode4 = cached_process_api.parser_api.to_tnode(alt_t)
+ # A different template produces a brand new tf.
+ assert cached_tnode1 is not cached_tnode4
+ # The template is new AND has a different structure so it also
+ # produces an unequivalent tf.
+ assert cached_tnode1 != cached_tnode4
+
+
+def test_repeat_calls():
+ """Crude check for any unintended state being kept between calls."""
+
+ def get_sample_t(idx, spread_attrs, button_text):
+ return t"""{button_text}
"""
+
+ for idx in range(3):
+ spread_attrs = {"data-enabled": True}
+ button_text = "PROCESS"
+ sample_t = get_sample_t(idx, spread_attrs, button_text)
+ assert (
+ html(sample_t)
+ == f'PROCESS
'
+ )
+
+
+def get_select_t_with_list(options, selected_values):
+ return t"""{
+ [
+ t"{opt[1]} "
+ for opt in options
+ ]
+ } """
+
+
+def get_select_t_with_generator(options, selected_values):
+ return t"""{
+ (
+ t"{opt[1]} "
+ for opt in options
+ )
+ } """
+
+
+def get_select_t_with_concat(options, selected_values):
+ parts = [t""]
+ parts.extend(
+ [
+ t"{opt[1]} "
+ for opt in options
+ ]
+ )
+ parts.append(t" ")
+ return sum(parts, t"")
+
+
+@pytest.mark.parametrize(
+ "provider",
+ (
+ get_select_t_with_list,
+ get_select_t_with_generator,
+ get_select_t_with_concat,
+ ),
+)
+def test_process_template_iterables(provider):
+ def get_color_select_t(selected_values: set, provider: Callable) -> Template:
+ PRIMARY_COLORS = [("R", "Red"), ("Y", "Yellow"), ("B", "Blue")]
+ assert set(selected_values).issubset({opt[0] for opt in PRIMARY_COLORS})
+ return provider(PRIMARY_COLORS, selected_values)
+
+ no_selection_t = get_color_select_t(set(), provider)
+ assert (
+ html(no_selection_t)
+ == 'Red Yellow Blue '
+ )
+ selected_yellow_t = get_color_select_t({"Y"}, provider)
+ assert (
+ html(selected_yellow_t)
+ == 'Red Yellow Blue '
+ )
-def test_component_non_callable_fails():
- with pytest.raises(TypeError):
- _ = html(t"<{'not a function'} />")
+def test_component_integration():
+ """Broadly test that common template component usage works."""
+ def PageComponent(children, root_attrs=None):
+ return t"""{children}
"""
-def RequiresPositional(whoops: int, /) -> Template: # pragma: no cover
- return t"Positional arg: {whoops}
"
+ def FooterComponent(classes=("footer-default",)):
+ return t''
+ def LayoutComponent(children, body_classes=None):
+ return t"""
+
+
+
+
+
+
+
+ {children}
+ <{FooterComponent} />
+
+
+"""
-def test_component_requiring_positional_arg_fails():
- with pytest.raises(TypeError):
- _ = html(t"<{RequiresPositional} />")
+ content = "HTML never goes out of style."
+ content_str = html(
+ t"<{LayoutComponent} body_classes={['theme-default']}><{PageComponent}>{content}{PageComponent}>{LayoutComponent}>"
+ )
+ assert (
+ content_str
+ == """
+
+
+
+
+
+
+
+ HTML never goes out of style.
+
+
+
+"""
+ )
+
+
+class TestInterpolatingHTMLInTemplateWithDynamicParentTag:
+ """
+ When a template does not have a parent tag we cannot determine the type
+ of text that should be allowed and therefore we cannot determine how to
+ escape that text. Once the type is known we should escape any
+ interpolations in that text correctly.
+ """
+
+ def test_dynamic_raw_text(self):
+ """Type raw text should fail because template is already not allowed."""
+ content = ''
+ content_t = t"{content}"
+ with pytest.raises(
+ ValueError, match="Recursive includes are not supported within script"
+ ):
+ content_t = t''
+ _ = html(t"")
+
+ def test_dynamic_escapable_raw_text(self):
+ """Type escapable raw text should fail because template is already not allowed."""
+ content = ''
+ content_t = t"{content}"
+ with pytest.raises(
+ ValueError, match="Recursive includes are not supported within textarea"
+ ):
+ _ = html(t"")
+
+ def test_dynamic_normal_text(self):
+ """Escaping should be applied when normal text type is goes into effect."""
+ content = ''
+ content_t = t"{content}"
+ LT, GT, DQ = map(markupsafe_escape, ["<", ">", '"'])
+ assert (
+ html(t"{content_t}
")
+ == f"{LT}script{GT}console.log({DQ}123!{DQ});{LT}/script{GT}
"
+ )
-def test_mismatched_component_closing_tag_fails():
- with pytest.raises(TypeError):
- _ = html(
- t"<{FunctionComponent} first=1 second={99} third-arg='comp1'>Hello{ClassComponent}>"
+class TestPagerComponentExample:
+ @dataclass
+ class Pager:
+ left_pages: tuple = ()
+ page: int = 0
+ right_pages: tuple = ()
+ prev_page: int | None = None
+ next_page: int | None = None
+
+ @dataclass
+ class PagerDisplay:
+ pager: TestPagerComponentExample.Pager
+ paginate_url: Callable[[int], str]
+ root_classes: tuple[str, ...] = ("cb", "tc", "w-100")
+ part_classes: tuple[str, ...] = ("dib", "pa1")
+
+ def __call__(self) -> Template:
+ parts = [t""]
+ if self.pager.prev_page:
+ parts.append(
+ t"
Prev "
+ )
+ for left_page in self.pager.left_pages:
+ parts.append(
+ t'
{left_page} '
+ )
+ parts.append(t"
{self.pager.page} ")
+ for right_page in self.pager.right_pages:
+ parts.append(
+ t'
{right_page} '
+ )
+ if self.pager.next_page:
+ parts.append(
+ t"
Next "
+ )
+ parts.append(t"
")
+ return Template(*chain.from_iterable(parts))
+
+ def test_example(self):
+ def paginate_url(page: int) -> str:
+ return f"/pages?page={page}"
+
+ def Footer(pager, paginate_url, footer_classes=("footer",)) -> Template:
+ return t""
+
+ pager = self.Pager(
+ left_pages=(1, 2), page=3, right_pages=(4, 5), next_page=6, prev_page=None
)
+ content_t = t"<{Footer} pager={pager} paginate_url={paginate_url} />"
+ res = html(content_t)
+ print(res)
+ assert (
+ res
+ == ''
+ )
+
+
+@pytest.mark.skip(
+ "SVG+MATHML: This needs ns context for case correcting tags and attributes."
+)
+def test_mathml():
+ num = 1
+ denom = 3
+ mathml_t = t"""
+ The fraction
+
+
+ {num}
+ {denom}
+
+
+ is not a decimal number.
+
"""
+ res = html(mathml_t)
+ assert (
+ str(res)
+ == """
+ The fraction
+
+
+ 1
+ 3
+
+
+ is not a decimal number.
+
"""
+ )
+
+
+@pytest.mark.skip(
+ "SVG+MATHML: This needs ns context for case correcting tags and attributes."
+)
+def test_svg():
+ cx, cy, r, fill = 150, 100, 80, "green"
+ svg_t = t"""
+
+
+ SVG
+ """
+ res = html(svg_t)
+ assert (
+ res
+ == """
+
+
+ SVG
+ """
+ )
+
+
+@pytest.mark.skip("SVG+MATHML: This needs ns context for closing empty tags.")
+def test_svg_self_closing_empty_elements():
+ cx, cy, r, fill = 150, 100, 80, "green"
+ svg_t = t"""
+
+
+ SVG
+ """
+ res = html(svg_t)
+ assert (
+ res
+ == """
+
+
+ SVG
+ """
+ )
diff --git a/tdom/svg_test.py b/tdom/svg_test.py
index db48236..2eb7084 100644
--- a/tdom/svg_test.py
+++ b/tdom/svg_test.py
@@ -1,3 +1,5 @@
+from string.templatelib import Template
+
from tdom import html, svg
# svg() — tag case-fixing
@@ -100,11 +102,11 @@ def test_html_full_svg_document_still_works():
def test_svg_fragment_embedded_in_html():
- def icon():
- return svg(t' ')
+ def icon() -> Template:
+ return t' '
node = html(t'{icon()}
')
assert (
str(node)
- == '
'
+ == '
'
)
From 1ebf11c6c65bd8164270aa9887c4cbd03c289b6e Mon Sep 17 00:00:00 2001
From: Ian Wilson
Date: Sat, 28 Mar 2026 22:03:47 -0700
Subject: [PATCH 03/30] Start updating docs.
---
README.md | 116 +++++++++++-------------------------
docs/usage/components.md | 51 +++++-----------
docs/usage/looping.md | 8 +--
docs/usage/static_string.md | 61 +++----------------
4 files changed, 61 insertions(+), 175 deletions(-)
diff --git a/README.md b/README.md
index 2265d27..1bdd58a 100644
--- a/README.md
+++ b/README.md
@@ -42,9 +42,7 @@ T-strings work just like f-strings but use a `t` prefix and
instead of strings.
Once you have a `Template`, you can call this package's `html()` function to
-convert it into a tree of `Node` objects that represent your HTML structure.
-From there, you can render it to a string, manipulate it programmatically, or
-compose it with other templates for maximum flexibility.
+render it to a string.
### Getting Started
@@ -53,7 +51,6 @@ Import the `html` function and start creating templates:
```python
from tdom import html
greeting = html(t"Hello, World! ")
-print(type(greeting)) #
print(greeting) # Hello, World!
```
@@ -145,7 +142,7 @@ classes:
```python
classes = {"btn-primary": True, "btn-secondary": False}
button = html(t'Click me ')
-assert str(button) == 'Click me '
+assert button == 'Click me '
```
#### The `style` Attribute
@@ -166,7 +163,7 @@ Style attributes can also be merged to extend a base style:
```python
add_styles = {"font-weight": "bold"}
para = html(t'Important text
')
-assert str(para) == 'Important text
'
+assert para == 'Important text
'
```
#### The `data` and `aria` Attributes
@@ -332,11 +329,13 @@ content and attributes. Use these like custom HTML elements in your templates.
The basic form of all component functions is:
```python
+from string.templatelib import Template
+
from typing import Any, Iterable
-from tdom import Node, html
+from tdom import html
-def MyComponent(children: Iterable[Node], **attrs: Any) -> Node:
- return html(t"Cool: {children}
")
+def MyComponent(children: Template, **attrs: Any) -> Template:
+ return t"Cool: {children}
"
```
To _invoke_ your component within an HTML template, use the special
@@ -351,11 +350,13 @@ Because attributes are passed as keyword arguments, you can explicitly provide
type hints for better editor support:
```python
+from string.templatelib import Template
+
from typing import Any
-from tdom import Node, html
+from tdom import html
-def Link(*, href: str, text: str, data_value: int, **attrs: Any) -> Node:
- return html(t'{text}: {data_value} ')
+def Link(*, href: str, text: str, data_value: int, **attrs: Any) -> Template:
+ return t'{text}: {data_value} '
result = html(t'<{Link} href="https://example.com" text="Example" data-value={42} target="_blank" />')
# Example: 42
@@ -380,23 +381,7 @@ def Greeting(name: str) -> Template:
return t"Hello, {name}! "
result = html(t"<{Greeting} name='Alice' />")
-assert str(result) == "Hello, Alice! "
-```
-
-You may also return an iterable:
-
-
-
-```python
-from typing import Iterable
-
-def Items() -> Iterable[Template]:
- return [t"first ", t"second "]
-
-result = html(t"")
-assert str(result) == ""
+assert result == "Hello, Alice! "
```
#### Class-based components
@@ -410,24 +395,25 @@ One particularly useful pattern is to build class-based components with
dataclasses:
```python
+from string.templatelib import Template
from dataclasses import dataclass, field
from typing import Any, Iterable
-from tdom import Node, html
+from tdom import html
@dataclass
class Card:
- children: Iterable[Node]
+ children: Template
title: str
subtitle: str | None = None
- def __call__(self) -> Node:
- return html(t"""
+ def __call__(self) -> Template:
+ return t"""
{self.title}
{self.subtitle and t'
{self.subtitle} '}
{self.children}
- """)
+ """
result = html(t"<{Card} title='My Card' subtitle='A subtitle'>Card content
{Card}>")
#
@@ -452,7 +438,8 @@ syntax as HTML. You can create inline SVG graphics by simply including SVG tags
in your templates:
```python
@@ -462,24 +449,24 @@ icon = html(t"""
""")
-assert '
Node:
- return html(t"""
+def Icon(*, size: int = 24, color: str = "currentColor") -> Template:
+ return t"""
- """)
+ """
result = html(t'<{Icon} size={48} color="blue" />')
-assert 'width="48"' in str(result)
-assert 'stroke="blue"' in str(result)
+assert 'width="48"' in result
+assert 'stroke="blue"' in result
```
#### Context
@@ -497,14 +484,14 @@ options:
```python
theme = {"primary": "blue", "spacing": "10px"}
-def Button(text: str) -> Node:
+def Button(text: str) -> Template:
# Button has access to theme from enclosing scope
- return html(t'{text} ')
+ return t'{text} '
result = html(t'<{Button} text="Click me" />')
-assert 'color: blue' in str(result)
-assert 'margin: 10px' in str(result)
-assert '>Click me' in str(result)
+assert 'color: blue' in result
+assert 'margin: 10px' in result
+assert '>Click me' in result
```
3. **Use module-level or global state**: For truly application-wide
@@ -518,41 +505,6 @@ This explicit approach makes it clear where data comes from and avoids the
### The `tdom` Module
-#### Working with `Node` Objects
-
-While `html()` is the primary way to create nodes, you can also construct them
-directly for programmatic HTML generation:
-
-```python
-from tdom import Element, Text, Fragment, Comment, DocumentType
-
-# Create elements directly
-div = Element("div", attrs={"class": "container"}, children=[
- Text("Hello, "),
- Element("strong", children=[Text("World")]),
-])
-assert str(div) == 'Hello, World
'
-
-# Create fragments to group multiple nodes
-fragment = Fragment(children=[
- Element("h1", children=[Text("Title")]),
- Element("p", children=[Text("Paragraph")]),
-])
-assert str(fragment) == "Title Paragraph
"
-
-# Add comments
-page = Element("body", children=[
- Comment("Navigation section"),
- Element("nav", children=[Text("Nav content")]),
-])
-assert str(page) == "Nav content "
-```
-
-All nodes implement the `__html__()` protocol, which means they can be used
-anywhere that expects an object with HTML representation. Converting a node to a
-string (via `str()` or `print()`) automatically renders it as HTML with proper
-escaping.
-
#### Utilities
The `tdom` package includes several utility functions for working with
diff --git a/docs/usage/components.md b/docs/usage/components.md
index cc341d4..5e7f04d 100644
--- a/docs/usage/components.md
+++ b/docs/usage/components.md
@@ -25,11 +25,11 @@ function with normal Python arguments and return values.
## Simple Heading
Here is a component callable — a `Heading` function — which returns
-a `Node`:
+a `Template`:
@@ -39,7 +39,7 @@ def Heading() -> Template:
result = html(t"<{Heading} />")
-assert str(result) == 'My Title '
+assert result == 'My Title '
```
## Simple Props
@@ -54,7 +54,7 @@ def Heading(title: str) -> Template:
result = html(t'<{Heading} title="My Title">{Heading}>')
-assert str(result) == 'My Title '
+assert result == 'My Title '
```
## Children As Props
@@ -63,32 +63,29 @@ If your template has children inside the component element, your component will
receive them as a keyword argument:
```python
-def Heading(children: Iterable[Node], title: str) -> Node:
- return html(t"{title} {children}
")
+def Heading(children: Template, title: str) -> Template:
+ return t"{title} {children}
"
result = html(t'<{Heading} title="My Title">Child{Heading}>')
-assert str(result) == 'My Title Child
'
+assert result == 'My Title Child
'
```
Note how the component closes with `{Heading}>` when it contains nested
children, as opposed to the self-closing form in the first example. If no
children are provided, the value of children is an empty tuple.
-Note also that components functions can return `Node` or `Template` values as
-they wish. Iterables of nodes and templates are also supported.
-
The component does not have to list a `children` keyword argument. If it is
omitted from the function parameters and passed in by the usage, it is silently
ignored:
```python
-def Heading(title: str) -> Node:
- return html(t"{title} Ignore the children.
")
+def Heading(title: str) -> Template:
+ return t"{title} Ignore the children.
"
result = html(t'<{Heading} title="My Title">Child{Heading}>')
-assert str(result) == 'My Title Ignore the children.
'
+assert result == 'My Title Ignore the children.
'
```
## Optional Props
@@ -102,7 +99,7 @@ def Heading(title: str = "My Title") -> Template:
result = html(t"<{Heading} />")
-assert str(result) == 'My Title '
+assert result == 'My Title '
```
## Passsing Another Component as a Prop
@@ -121,7 +118,7 @@ def Body(heading: Callable) -> Template:
result = html(t"<{Body} heading={DefaultHeading} />")
-assert str(result) == 'Default Heading '
+assert result == 'Default Heading '
```
## Default Component for Prop
@@ -139,11 +136,11 @@ def OtherHeading() -> Template:
def Body(heading: Callable) -> Template:
- return html(t"<{heading} />")
+ return t"<{heading} />"
result = html(t"<{Body} heading={OtherHeading}>{Body}>")
-assert str(result) == 'Other Heading '
+assert result == 'Other Heading '
```
## Conditional Default
@@ -165,23 +162,7 @@ def Body(heading: Callable | None = None) -> Template:
result = html(t"<{Body} heading={OtherHeading}>{Body}>")
-assert str(result) == 'Other Heading '
-```
-
-## Generators as Components
-
-You can also have components that act as generators. For example, imagine you
-have a todo list. There might be a lot of todos, so you want to generate them in
-a memory-efficient way:
-
-```python
-def Todos() -> Iterable[Template]:
- for todo in ["first", "second", "third"]:
- yield t"{todo} "
-
-
-result = html(t"")
-assert str(result) == ''
+assert result == 'Other Heading '
```
## Nested Components
@@ -200,5 +181,5 @@ def TodoList(labels: Iterable[str]) -> Template:
title = "My Todos"
labels = ["first", "second", "third"]
result = html(t"{title} <{TodoList} labels={labels} />")
-assert str(result) == 'My Todos '
+assert result == 'My Todos '
```
diff --git a/docs/usage/looping.md b/docs/usage/looping.md
index 5b9b80b..ae91c36 100644
--- a/docs/usage/looping.md
+++ b/docs/usage/looping.md
@@ -25,7 +25,7 @@ result = html(
"""
)
-assert str(result) == """
+assert result == """
@@ -35,12 +35,12 @@ assert str(result) == """
## Rendered Looping
You could also move the generation of the items out of the "parent" template,
-then use that `Node` result in the next template:
+then use that result in the next template:
```python
message = "Hello"
names = ["World", "Universe"]
-items = [html(t"{label} ") for label in names]
+items = [t"{label} " for label in names]
result = html(t"")
-assert str(result) == ''
+assert result == ''
```
diff --git a/docs/usage/static_string.md b/docs/usage/static_string.md
index 4f869d3..9237677 100644
--- a/docs/usage/static_string.md
+++ b/docs/usage/static_string.md
@@ -8,19 +8,18 @@ Let's start with the simplest form of templating: just a string, no tags, no
attributes:
```python
result = html(t"Hello World")
-assert str(result) == 'Hello World'
+assert result == 'Hello World'
```
We start by importing the `html` function from `tdom`.
It takes a [Python 3.14 t-string](https://t-strings.help/introduction.html) and
-returns a `Element` with an `__str__` that converts to HTML. In this case, the
-node is an instance of `tdom.nodes.Text`, a subclass of `Element`.
+returns a `str()`.
## Simple Render
@@ -29,32 +28,9 @@ but done in one step:
```python
result = html(t"Hello World
")
-assert str(result) == 'Hello World
'
+assert result == 'Hello World
'
```
-## Show the `Element` Itself
-
-Let's take a look at that `Element` structure.
-
-This time, we'll inspect the returned value rather than rendering it to a
-string:
-
-```python
-result = html(t'Hello World
')
-assert result == Element(
- "div",
- attrs={"class": "container"},
- children=[Text("Hello World")]
-)
-```
-
-In our test we see that we got back an `Element`. What does it look like?
-
-- The `result` is of type `tdom.nodes.Element` (a subclass of `Node`)
-- The name of the node (``)
-- The properties passed to that tag (in this case, `{"class": "container"}`)
-- The children of this tag (in this case, a `Text` node of `Hello World`)
-
## Interpolations as Attribute Values
We can go one step further with this and use interpolations from PEP 750
@@ -64,36 +40,13 @@ braces:
```python
my_class = "active"
result = html(t'
Hello World
')
-assert str(result) == '
Hello World
'
+assert result == '
Hello World
'
```
TODO: describe all the many many ways to express attribute values, including
`tdom`'s special handling of boolean attributes, whole-tag spreads, `class`,
`style`, `data` and `aria` attributes, etc.
-## Child Nodes in an `Element`
-
-Let's look at what more nesting would look like:
-
-```python
-result = html(t"
Hello World!
")
-assert result == Element(
- "div",
- children=[
- Text("Hello "),
- Element(
- "span",
- children=[
- Text("World"),
- Element("em", children=[Text("!")])
- ]
- )
- ]
-)
-```
-
-It's a nested Python datastructure -- pretty simple to look at.
-
## Expressing the Document Type
One last point: the HTML doctype can be a tricky one to get into the template.
@@ -101,7 +54,7 @@ In `tdom` this is straightforward:
```python
result = html(t"
Hello World
")
-assert str(result) == '
Hello World
'
+assert result == '
Hello World
'
```
## Reducing Boolean Attribute Values
@@ -112,5 +65,5 @@ without a _value_:
```python
result = html(t"
Hello World
")
-assert str(result) == '
Hello World
'
+assert result == '
Hello World
'
```
From 67019111747f1fddf46f4f1a72acd684455309ec Mon Sep 17 00:00:00 2001
From: Ian Wilson
Date: Sun, 29 Mar 2026 17:24:37 -0700
Subject: [PATCH 04/30] Handle svg during processing.
---
tdom/htmlspec.py | 1 -
tdom/parser.py | 54 ++++++++-------------------------
tdom/processor.py | 25 +++++++---------
tdom/svg_test.py | 76 +++++++++++++++++++++++++++--------------------
tdom/utils.py | 10 ++-----
5 files changed, 68 insertions(+), 98 deletions(-)
diff --git a/tdom/htmlspec.py b/tdom/htmlspec.py
index f57eaab..941dfb2 100644
--- a/tdom/htmlspec.py
+++ b/tdom/htmlspec.py
@@ -23,7 +23,6 @@
RCDATA_CONTENT_ELEMENTS = frozenset(["textarea", "title"])
CONTENT_ELEMENTS = CDATA_CONTENT_ELEMENTS | RCDATA_CONTENT_ELEMENTS
-
SVG_TAG_FIX = {
"altglyph": "altGlyph",
"altglyphdef": "altGlyphDef",
diff --git a/tdom/parser.py b/tdom/parser.py
index e1fc743..5ac501b 100644
--- a/tdom/parser.py
+++ b/tdom/parser.py
@@ -3,7 +3,7 @@
from html.parser import HTMLParser
from string.templatelib import Interpolation, Template
-from .htmlspec import SVG_ATTR_FIX, SVG_TAG_FIX, VOID_ELEMENTS
+from .htmlspec import VOID_ELEMENTS
from .placeholders import PlaceholderState
from .template_utils import combine_template_refs
from .tnodes import (
@@ -86,10 +86,8 @@ class TemplateParser(HTMLParser):
stack: list[OpenTag]
placeholders: PlaceholderState
source: SourceTracker | None
- _svg_depth: int = 0
- def __init__(self, *, convert_charrefs: bool = True, svg_context: bool = False):
- self._initial_svg_depth = 1 if svg_context else 0
+ def __init__(self, *, convert_charrefs: bool = True):
# This calls HTMLParser.reset() which we override to set up our state.
super().__init__(convert_charrefs=convert_charrefs)
@@ -109,12 +107,10 @@ def append_child(self, child: TNode) -> None:
# Attribute Helpers
# ------------------------------------------
- def make_tattr(self, attr: HTMLAttribute, svg_context: bool = False) -> TAttribute:
+ def make_tattr(self, attr: HTMLAttribute) -> TAttribute:
"""Build a TAttribute from a raw attribute tuple."""
name, value = attr
- if svg_context:
- name = SVG_ATTR_FIX.get(name, name)
name_ref = self.placeholders.remove_placeholders(name)
value_ref = (
@@ -140,24 +136,20 @@ def make_tattr(self, attr: HTMLAttribute, svg_context: bool = False) -> TAttribu
)
return TSpreadAttribute(i_index=name_ref.i_indexes[0])
- def make_tattrs(
- self, attrs: Sequence[HTMLAttribute], svg_context: bool = False
- ) -> tuple[TAttribute, ...]:
+ def make_tattrs(self, attrs: Sequence[HTMLAttribute]) -> tuple[TAttribute, ...]:
"""Build TAttributes from raw attribute tuples."""
- return tuple(self.make_tattr(attr, svg_context) for attr in attrs)
+ return tuple(self.make_tattr(attr) for attr in attrs)
# ------------------------------------------
# Tag Helpers
# ------------------------------------------
- def make_open_tag(
- self, tag: str, attrs: Sequence[HTMLAttribute], svg_context: bool = False
- ) -> OpenTag:
+ def make_open_tag(self, tag: str, attrs: Sequence[HTMLAttribute]) -> OpenTag:
"""Build an OpenTag from a raw tag and attribute tuples."""
tag_ref = self.placeholders.remove_placeholders(tag)
if tag_ref.is_literal:
- return OpenTElement(tag=tag, attrs=self.make_tattrs(attrs, svg_context))
+ return OpenTElement(tag=tag, attrs=self.make_tattrs(attrs))
if not tag_ref.is_singleton:
raise ValueError(
@@ -170,7 +162,7 @@ def make_open_tag(
i_index = tag_ref.i_indexes[0]
return OpenTComponent(
start_i_index=i_index,
- attrs=self.make_tattrs(attrs, svg_context),
+ attrs=self.make_tattrs(attrs),
)
def finalize_tag(
@@ -233,13 +225,7 @@ def validate_end_tag(self, tag: str, open_tag: OpenTag) -> int | None:
# ------------------------------------------
def handle_starttag(self, tag: str, attrs: Sequence[HTMLAttribute]) -> None:
- if tag == "svg":
- self._svg_depth += 1
-
- if self._svg_depth > 0:
- tag = SVG_TAG_FIX.get(tag, tag)
-
- open_tag = self.make_open_tag(tag, attrs, svg_context=(self._svg_depth > 0))
+ open_tag = self.make_open_tag(tag, attrs)
if isinstance(open_tag, OpenTElement) and open_tag.tag in VOID_ELEMENTS:
final_tag = self.finalize_tag(open_tag)
self.append_child(final_tag)
@@ -248,13 +234,7 @@ def handle_starttag(self, tag: str, attrs: Sequence[HTMLAttribute]) -> None:
def handle_startendtag(self, tag: str, attrs: Sequence[HTMLAttribute]) -> None:
"""Dispatch a self-closing tag, ` ` to specialized handlers."""
- is_svg_tag = tag == "svg"
- effective_svg_context = (self._svg_depth > 0) or is_svg_tag
-
- if effective_svg_context:
- tag = SVG_TAG_FIX.get(tag, tag)
-
- open_tag = self.make_open_tag(tag, attrs, svg_context=effective_svg_context)
+ open_tag = self.make_open_tag(tag, attrs)
final_tag = self.finalize_tag(open_tag)
self.append_child(final_tag)
@@ -262,12 +242,6 @@ def handle_endtag(self, tag: str) -> None:
if not self.stack:
raise ValueError(f"Unexpected closing tag {tag}> with no open tag.")
- if self._svg_depth > 0:
- tag = SVG_TAG_FIX.get(tag, tag)
-
- if tag == "svg":
- self._svg_depth -= 1
-
open_tag = self.stack.pop()
endtag_i_index = self.validate_end_tag(tag, open_tag)
final_tag = self.finalize_tag(open_tag, endtag_i_index)
@@ -311,7 +285,6 @@ def reset(self):
self.stack = []
self.placeholders = PlaceholderState()
self.source = None
- self._svg_depth = getattr(self, "_initial_svg_depth", 0)
def close(self) -> None:
if self.stack:
@@ -364,16 +337,13 @@ def feed_template(self, template: Template) -> None:
self.feed_str(template.strings[-1])
@staticmethod
- def parse(t: Template, *, svg_context: bool = False) -> TNode:
+ def parse(t: Template) -> TNode:
"""
Parse a Template containing valid HTML and substitutions and return
a TNode tree representing its structure. This cachable structure can later
be resolved against actual interpolation values to produce a Node tree.
-
- Pass ``svg_context=True`` for SVG fragments that have no ````
- wrapper, so that tag and attribute case-fixing applies from the root.
"""
- parser = TemplateParser(svg_context=svg_context)
+ parser = TemplateParser()
parser.feed_template(t)
parser.close()
return parser.get_tnode()
diff --git a/tdom/processor.py b/tdom/processor.py
index e15175a..79f7d87 100644
--- a/tdom/processor.py
+++ b/tdom/processor.py
@@ -25,11 +25,11 @@
CDATA_CONTENT_ELEMENTS,
DEFAULT_NORMAL_TEXT_ELEMENT,
RCDATA_CONTENT_ELEMENTS,
+ SVG_ATTR_FIX,
+ SVG_TAG_FIX,
VOID_ELEMENTS,
)
from .parser import (
- SVG_ATTR_FIX,
- SVG_TAG_FIX,
HTMLAttribute,
TAttribute,
TComment,
@@ -428,9 +428,7 @@ def serialize_html_attrs(
)
-def fix_svg_attrs(
- html_attrs: Iterable[HTMLAttribute], tag: str | None
-) -> Iterable[HTMLAttribute]:
+def _fix_svg_attrs(html_attrs: Iterable[HTMLAttribute]) -> Iterable[HTMLAttribute]:
"""
Fix the attr name-case of any html attributes on a tag within an SVG namespace.
"""
@@ -587,14 +585,15 @@ def walk_from_tnode(
if res is not None:
yield res
case TElement(tag, attrs, children):
- if last_ctx.ns == "svg" and tag in SVG_TAG_FIX:
- bf.append(f"<{SVG_TAG_FIX[tag]}")
- else:
- bf.append(f"<{tag}")
if tag == "svg":
our_ctx = last_ctx.copy(parent_tag=tag, ns="svg")
else:
our_ctx = last_ctx.copy(parent_tag=tag)
+ if our_ctx.ns == "svg":
+ starttag = endtag = SVG_TAG_FIX.get(tag, tag)
+ else:
+ starttag = endtag = tag
+ bf.append(f"<{starttag}")
if attrs:
self._process_attrs(bf, template, our_ctx, attrs)
# @TODO: How can we tell if we write out children or not in
@@ -604,11 +603,7 @@ def walk_from_tnode(
else:
bf.append(">")
if tag not in VOID_ELEMENTS:
- if last_ctx.ns == "svg" and tag in SVG_TAG_FIX:
- et = SVG_TAG_FIX[tag]
- else:
- et = tag
- q.append((last_ctx, EndTag(f"{et}>")))
+ q.append((last_ctx, EndTag(f"{endtag}>")))
q.extend([(our_ctx, child) for child in reversed(children)])
case TText(ref):
if last_ctx.parent_tag is None:
@@ -664,7 +659,7 @@ def _process_attrs(
resolved_attrs = _resolve_t_attrs(attrs, template.interpolations)
if last_ctx.ns == "svg":
attrs_str = serialize_html_attrs(
- fix_svg_attrs(_resolve_html_attrs(resolved_attrs), last_ctx.parent_tag)
+ _fix_svg_attrs(_resolve_html_attrs(resolved_attrs))
)
else:
attrs_str = serialize_html_attrs(_resolve_html_attrs(resolved_attrs))
diff --git a/tdom/svg_test.py b/tdom/svg_test.py
index 2eb7084..40924ee 100644
--- a/tdom/svg_test.py
+++ b/tdom/svg_test.py
@@ -6,23 +6,23 @@
def test_svg_clippath_case_fixed():
- node = svg(t" ")
- assert str(node) == ' '
+ result = svg(t" ")
+ assert result == ' '
def test_svg_lineargradient_case_fixed():
- node = svg(t" ")
- assert str(node) == ' '
+ result = svg(t" ")
+ assert result == ' '
def test_svg_femergenode_self_closing_case_fixed():
- node = svg(t" ")
- assert str(node) == " "
+ result = svg(t" ")
+ assert result == " "
def test_svg_nested_tags_case_fixed():
- node = svg(t" ")
- assert str(node) == ' '
+ result = svg(t" ")
+ assert result == ' '
# ------------------------------
@@ -31,50 +31,50 @@ def test_svg_nested_tags_case_fixed():
def test_svg_viewbox_attr_case_fixed():
- node = svg(t' ')
- assert str(node) == ' '
+ result = svg(t' ')
+ assert result == ' '
def test_svg_case_sensitivity():
# SVG attributes like viewBox are case-sensitive
- node = html(t' ')
+ result = html(t' ')
# We expect viewBox, not viewbox
- assert "viewBox" in str(node)
+ assert "viewBox" in result
def test_svg_tag_case_sensitivity():
# SVG tags like linearGradient are case-sensitive
- node = html(t" ")
- assert "linearGradient" in str(node)
+ result = html(t" ")
+ assert "linearGradient" in result
def test_svg_tag_case_sensitivity_outside_svg():
# Outside SVG, tags should be lowercased
- node = html(t" ")
- assert "lineargradient" in str(node)
+ result = html(t" ")
+ assert "lineargradient" in result
def test_svg_attr_case_sensitivity_outside_svg():
# Outside SVG, attributes should be lowercased
- node = html(t'
')
- assert "viewbox" in str(node)
+ result = html(t'
')
+ assert "viewbox" in result
def test_svg_interpolated_attr():
cx, cy, r = 50, 50, 40
- node = svg(t' ')
- assert str(node) == ' '
+ result = svg(t' ')
+ assert result == ' '
def test_svg_interpolated_child():
label = "hello"
- node = svg(t"{label} ")
- assert str(node) == "hello "
+ result = svg(t"{label} ")
+ assert result == "hello "
def test_svg_fragment_multiple_roots():
- node = svg(t" ")
- assert str(node) == " "
+ result = svg(t" ")
+ assert result == " "
# ---------------------------------------------------------
@@ -84,16 +84,16 @@ def test_svg_fragment_multiple_roots():
def test_svg_and_html_produce_different_results_for_same_strings():
# html() lowercases clipPath (no SVG context); svg() preserves it.
- html_node = html(t" ")
- svg_node = svg(t" ")
- assert str(html_node) == " "
- assert str(svg_node) == " "
+ html_result = html(t" ")
+ svg_result = svg(t" ")
+ assert html_result == " "
+ assert svg_result == " "
def test_html_full_svg_document_still_works():
# html() auto-detects SVG context when is present — no regression.
- node = html(t" ")
- assert str(node) == ' '
+ result = html(t" ")
+ assert result == ' '
# -------------------------------
@@ -105,8 +105,18 @@ def test_svg_fragment_embedded_in_html():
def icon() -> Template:
return t' '
- node = html(t'{icon()}
')
+ result = html(t'{icon()}
')
assert (
- str(node)
- == '
'
+ result == '
'
+ )
+
+
+def test_svg_fragment_with_spread_attr():
+ def icon(attrs: dict[str, str]) -> Template:
+ return t" "
+
+ rect_attrs = {"viewbox": "0 0 10 10"}
+ result = html(t'{icon(attrs=rect_attrs)}
')
+ assert (
+ result == '
'
)
diff --git a/tdom/utils.py b/tdom/utils.py
index 0062ab6..5f69c83 100644
--- a/tdom/utils.py
+++ b/tdom/utils.py
@@ -15,17 +15,13 @@ class CachableTemplate:
# CONSIDER: what about interpolation format specs, convsersions, etc.?
- def __init__(self, template: Template, svg_context: bool = False) -> None:
+ def __init__(self, template: Template) -> None:
self.template = template
- self.svg_context = svg_context
def __eq__(self, other: object) -> bool:
if not isinstance(other, CachableTemplate):
return NotImplemented
- return (
- self.template.strings == other.template.strings
- and self.svg_context == other.svg_context
- )
+ return self.template.strings == other.template.strings
def __hash__(self) -> int:
- return hash((self.template.strings, self.svg_context))
+ return hash(self.template.strings)
From 01a77c9c006de9dcfdc4a0973ab5a3a99a3b7ea7 Mon Sep 17 00:00:00 2001
From: Ian Wilson
Date: Sat, 28 Mar 2026 23:25:21 -0700
Subject: [PATCH 05/30] Drop nodes.
---
tdom/nodes.py | 118 ----------------------
tdom/nodes_test.py | 244 ---------------------------------------------
2 files changed, 362 deletions(-)
delete mode 100644 tdom/nodes.py
delete mode 100644 tdom/nodes_test.py
diff --git a/tdom/nodes.py b/tdom/nodes.py
deleted file mode 100644
index 76086ff..0000000
--- a/tdom/nodes.py
+++ /dev/null
@@ -1,118 +0,0 @@
-from dataclasses import dataclass, field
-
-from .escaping import (
- escape_html_comment,
- escape_html_script,
- escape_html_style,
- escape_html_text,
-)
-from .htmlspec import CONTENT_ELEMENTS, VOID_ELEMENTS
-
-# FUTURE: add a pretty-printer to nodes for debugging
-# FUTURE: make nodes frozen (and have the parser work with mutable builders)
-
-
-@dataclass(slots=True)
-class Node:
- def __html__(self) -> str:
- """Return the HTML representation of the node."""
- # By default, just return the string representation
- return str(self)
-
-
-@dataclass(slots=True)
-class Text(Node):
- text: str # which may be markupsafe.Markup in practice.
-
- def __str__(self) -> str:
- # Use markupsafe's escape to handle HTML escaping
- return escape_html_text(self.text)
-
- def __eq__(self, other: object) -> bool:
- # This is primarily of use for testing purposes. We only consider
- # two Text nodes equal if their string representations match.
- return isinstance(other, Text) and str(self) == str(other)
-
-
-@dataclass(slots=True)
-class Fragment(Node):
- children: list[Node] = field(default_factory=list)
-
- def __str__(self) -> str:
- return "".join(str(child) for child in self.children)
-
-
-@dataclass(slots=True)
-class Comment(Node):
- text: str
-
- def __str__(self) -> str:
- return f""
-
-
-@dataclass(slots=True)
-class DocumentType(Node):
- text: str = "html"
-
- def __str__(self) -> str:
- return f""
-
-
-@dataclass(slots=True)
-class Element(Node):
- tag: str
- attrs: dict[str, str | None] = field(default_factory=dict)
- children: list[Node] = field(default_factory=list)
-
- def __post_init__(self):
- """Ensure all preconditions are met."""
- if not self.tag:
- raise ValueError("Element tag cannot be empty.")
-
- # Void elements cannot have children
- if self.is_void and self.children:
- raise ValueError(f"Void element <{self.tag}> cannot have children.")
-
- @property
- def is_void(self) -> bool:
- return self.tag in VOID_ELEMENTS
-
- @property
- def is_content(self) -> bool:
- return self.tag in CONTENT_ELEMENTS
-
- def _children_to_str(self):
- if not self.children:
- return ""
- if self.tag in ("script", "style"):
- chunks = []
- for child in self.children:
- if isinstance(child, Text):
- chunks.append(child.text)
- else:
- raise TypeError(
- "Cannot serialize non-text content inside a script tag."
- )
- raw_children_str = "".join(chunks)
- if self.tag == "script":
- return escape_html_script(raw_children_str)
- elif self.tag == "style":
- return escape_html_style(raw_children_str)
- else:
- raise ValueError("Unsupported tag for single-level bulk escaping.")
- else:
- return "".join(str(child) for child in self.children)
-
- def __str__(self) -> str:
- # We use markupsafe's escape to handle HTML escaping of attribute values
- # which means it's possible to mark them as safe if needed.
- attrs_str = "".join(
- f" {key}" if value is None else f' {key}="{escape_html_text(value)}"'
- for key, value in self.attrs.items()
- )
- if self.is_void:
- return f"<{self.tag}{attrs_str} />"
- if not self.children:
- return f"<{self.tag}{attrs_str}>{self.tag}>"
- children_str = self._children_to_str()
- return f"<{self.tag}{attrs_str}>{children_str}{self.tag}>"
diff --git a/tdom/nodes_test.py b/tdom/nodes_test.py
deleted file mode 100644
index ab57c9e..0000000
--- a/tdom/nodes_test.py
+++ /dev/null
@@ -1,244 +0,0 @@
-import pytest
-from markupsafe import Markup
-
-from .nodes import Comment, DocumentType, Element, Fragment, Text
-
-
-def test_comment():
- comment = Comment("This is a comment")
- assert str(comment) == ""
-
-
-def test_comment_empty():
- comment = Comment("")
- assert str(comment) == ""
-
-
-def test_comment_special_chars():
- comment = Comment("Special chars: <>&\"'")
- assert str(comment) == ""
-
-
-def test_doctype_default():
- doctype = DocumentType()
- assert str(doctype) == ""
-
-
-def test_doctype_custom():
- doctype = DocumentType("xml")
- assert str(doctype) == ""
-
-
-def test_text():
- text = Text("Hello, world!")
- assert str(text) == "Hello, world!"
-
-
-def test_text_escaping():
- text = Text("")
- assert str(text) == "<script>alert('XSS')</script>"
-
-
-def test_text_safe():
- class CustomHTML(str):
- def __html__(self) -> str:
- return "Bold Text "
-
- text = Text(CustomHTML())
- assert str(text) == "Bold Text "
-
-
-def test_text_equality():
- text1 = Text("")
- text2 = Text(Markup("<Hello>"))
- text3 = Text(Markup(""))
- assert text1 == text2
- assert text1 != text3
-
-
-def test_fragment_empty():
- fragment = Fragment()
- assert str(fragment) == ""
-
-
-def test_fragment_with_text():
- fragment = Fragment(children=[Text("test")])
- assert str(fragment) == "test"
-
-
-def test_fragment_with_multiple_texts():
- fragment = Fragment(children=[Text("Hello"), Text(" "), Text("World")])
- assert str(fragment) == "Hello World"
-
-
-def test_element_no_children():
- div = Element("div")
- assert not div.is_void
- assert str(div) == "
"
-
-
-def test_void_element_no_children():
- br = Element("br")
- assert br.is_void
- assert str(br) == " "
-
-
-def test_element_invalid_empty_tag():
- with pytest.raises(ValueError):
- _ = Element("")
-
-
-def test_element_is_content():
- assert Element("script").is_content
- assert Element("title").is_content
- assert not Element("div").is_content
- assert not Element("br").is_content # Void element
-
-
-def test_void_element_with_attributes():
- br = Element("br", attrs={"class": "line-break", "hidden": None})
- assert str(br) == ' '
-
-
-def test_void_element_with_children():
- with pytest.raises(ValueError):
- _ = Element("br", children=[Text("should not be here")])
-
-
-def test_standard_element_with_attributes():
- div = Element(
- "div",
- attrs={"id": "main", "data-role": "container", "hidden": None},
- )
- assert str(div) == '
'
-
-
-def test_standard_element_with_text_child():
- div = Element("div", children=[Text("Hello, world!")])
- assert str(div) == "Hello, world!
"
-
-
-def test_standard_element_with_element_children():
- div = Element(
- "div",
- children=[
- Element("h1", children=[Text("Title")]),
- Element("p", children=[Text("This is a paragraph.")]),
- ],
- )
- assert str(div) == "Title This is a paragraph.
"
-
-
-def test_element_with_fragment_with_children():
- div = Element(
- "div",
- children=[
- Fragment(
- children=[
- Element("div", children=[Text("wow")]),
- Text("inside fragment"),
- ]
- )
- ],
- )
- assert str(div) == ""
-
-
-def test_standard_element_with_mixed_children():
- div = Element(
- "div",
- children=[
- Text("Intro text."),
- Element("h1", children=[Text("Title")]),
- Text("Some more text."),
- Element("hr"),
- Element("p", children=[Text("This is a paragraph.")]),
- ],
- )
- assert str(div) == (
- "Intro text.
Title Some more text.
This is a paragraph.
"
- )
-
-
-def test_complex_tree():
- html = Fragment(
- children=[
- DocumentType(),
- Element(
- "html",
- children=[
- Element(
- "head",
- children=[
- Element("title", children=[Text("Test Page")]),
- Element("meta", attrs={"charset": "UTF-8"}),
- ],
- ),
- Element(
- "body",
- attrs={"class": "main-body"},
- children=[
- Element("h1", children=[Text("Welcome to the Test Page")]),
- Element(
- "p",
- children=[
- Text("This is a sample paragraph with "),
- Element("strong", children=[Text("bold text")]),
- Text(" and "),
- Element("em", children=[Text("italic text")]),
- Text("."),
- ],
- ),
- Element("br"),
- Element(
- "ul",
- children=[
- Element("li", children=[Text("Item 1")]),
- Element("li", children=[Text("Item 2")]),
- Element("li", children=[Text("Item 3")]),
- ],
- ),
- ],
- ),
- ],
- ),
- ]
- )
- assert str(html) == (
- "Test Page "
- ' '
- "Welcome to the Test Page "
- "This is a sample paragraph with bold text and "
- "italic text .
"
- )
-
-
-def test_dunder_html_method():
- div = Element("div", children=[Text("Hello")])
- assert div.__html__() == str(div)
-
-
-def test_escaping_of_text_content():
- div = Element("div", children=[Text("")])
- assert str(div) == "<script>alert('XSS')</script>
"
-
-
-def test_escaping_of_attribute_values():
- div = Element("div", attrs={"class": '">XSS<'})
- assert str(div) == '
'
-
-
-def test_svg_rendering():
- svg = Element(
- "svg",
- attrs={"viewBox": "0 0 10 10"},
- children=[
- Element("clipPath", attrs={"id": "clip"}),
- Element("rect", attrs={"x": "0", "y": "0", "width": "10", "height": "10"}),
- ],
- )
- assert (
- str(svg)
- == ' '
- )
From 4af611fe0ac6f417d8e68fc67494e43912f0bf64 Mon Sep 17 00:00:00 2001
From: Ian Wilson
Date: Sun, 22 Feb 2026 22:09:35 -0800
Subject: [PATCH 06/30] Experimental system context implementation.
---
tdom/processor.py | 20 ++++++++++++++++----
tdom/processor_test.py | 29 +++++++++++++++++++++++++++++
2 files changed, 45 insertions(+), 4 deletions(-)
diff --git a/tdom/processor.py b/tdom/processor.py
index 79f7d87..4630c20 100644
--- a/tdom/processor.py
+++ b/tdom/processor.py
@@ -1,6 +1,6 @@
import typing as t
from collections.abc import Callable, Iterable, Sequence
-from dataclasses import dataclass
+from dataclasses import dataclass, field
from functools import lru_cache
from string.templatelib import Interpolation, Template
@@ -436,8 +436,12 @@ def _fix_svg_attrs(html_attrs: Iterable[HTMLAttribute]) -> Iterable[HTMLAttribut
yield SVG_ATTR_FIX.get(k, k), v
-def make_ctx(parent_tag: str | None = None, ns: str | None = "html"):
- return ProcessContext(parent_tag=parent_tag, ns=ns)
+def make_ctx(
+ parent_tag: str | None = None, ns: str | None = "html", system: dict | None = None
+):
+ if system is None:
+ system = {}
+ return ProcessContext(parent_tag=parent_tag, ns=ns, system=system)
@dataclass(frozen=True, slots=True)
@@ -447,10 +451,13 @@ class ProcessContext:
# None means unknown not just a missing value.
ns: str | None = None
+ system: dict = field(default_factory=dict)
+
def copy(
self,
ns: NotSet | str | None = NOT_SET,
parent_tag: NotSet | str | None = NOT_SET,
+ system: NotSet | dict = NOT_SET,
):
if isinstance(ns, NotSet):
resolved_ns = self.ns
@@ -460,9 +467,14 @@ def copy(
resolved_parent_tag = self.parent_tag
else:
resolved_parent_tag = parent_tag
+ if isinstance(system, NotSet):
+ resolved_system = self.system
+ else:
+ resolved_system = system
return make_ctx(
parent_tag=resolved_parent_tag,
ns=resolved_ns,
+ system=resolved_system,
)
@@ -700,7 +712,7 @@ def _process_component(
kwargs = _prep_component_kwargs(
get_callable_info(component_callable),
_resolve_t_attrs(attrs, template.interpolations),
- system_kwargs={"children": children_template},
+ system_kwargs={**last_ctx.system, "children": children_template},
)
result_t = component_callable(**kwargs)
diff --git a/tdom/processor_test.py b/tdom/processor_test.py
index bfd9359..364353f 100644
--- a/tdom/processor_test.py
+++ b/tdom/processor_test.py
@@ -1960,3 +1960,32 @@ def test_svg_self_closing_empty_elements():
SVG
"""
)
+
+
+def test_framework_system_kwargs():
+ from .processor import ProcessContext
+
+ # Usually a framework would provide some "request context" mechanism,
+ # but for this test we just use a global dictionary.
+ g = {"theme": "default-theme"}
+
+ def BodyComp(children: Template) -> Template:
+ return t"<{ContentComp}>{children}{ContentComp}>"
+
+ def ContentComp(system_barge: dict, children: Template) -> Template:
+ theme = system_barge["theme"]
+ return t"{children}
"
+
+ content = "Test Content"
+ page_t = (
+ t"<{BodyComp}>{content} {BodyComp}>"
+ )
+ assert (
+ html(
+ page_t,
+ assume_ctx=ProcessContext(
+ parent_tag=None, ns="html", system={"system_barge": g}
+ ),
+ )
+ == 'Test Content
'
+ )
From 88892ab0189c2ba16e6a533b055403ee4651f7a9 Mon Sep 17 00:00:00 2001
From: Ian Wilson
Date: Sun, 29 Mar 2026 15:06:14 -0700
Subject: [PATCH 07/30] Add math and foreignobject support.
---
tdom/processor.py | 5 +++++
tdom/svg_test.py | 32 ++++++++++++++++++++++++++++++++
2 files changed, 37 insertions(+)
diff --git a/tdom/processor.py b/tdom/processor.py
index 4630c20..4eeb760 100644
--- a/tdom/processor.py
+++ b/tdom/processor.py
@@ -599,6 +599,8 @@ def walk_from_tnode(
case TElement(tag, attrs, children):
if tag == "svg":
our_ctx = last_ctx.copy(parent_tag=tag, ns="svg")
+ elif tag == "math":
+ our_ctx = last_ctx.copy(parent_tag=tag, ns="math")
else:
our_ctx = last_ctx.copy(parent_tag=tag)
if our_ctx.ns == "svg":
@@ -616,6 +618,9 @@ def walk_from_tnode(
bf.append(">")
if tag not in VOID_ELEMENTS:
q.append((last_ctx, EndTag(f"{endtag}>")))
+ # We were still in SVG but now we default back into HTML
+ if tag == "foreignobject":
+ our_ctx = our_ctx.copy(ns="html")
q.extend([(our_ctx, child) for child in reversed(children)])
case TText(ref):
if last_ctx.parent_tag is None:
diff --git a/tdom/svg_test.py b/tdom/svg_test.py
index 40924ee..4b61d04 100644
--- a/tdom/svg_test.py
+++ b/tdom/svg_test.py
@@ -120,3 +120,35 @@ def icon(attrs: dict[str, str]) -> Template:
assert (
result == '
'
)
+
+
+def test_svg_nesting():
+ svg_doc = t"""
+
+
+
+
+
+
+
+
+ text,fo,svg,html
+
+
+
+ text,div,fo,svg,html
+
+
+
+
+ text,span,fo,svg,div,fo,svg,html
+
+
+ π
+
+
+
+
+"""
+ res = html(svg_doc)
+ assert res.count("
Date: Fri, 3 Apr 2026 11:00:36 -0700
Subject: [PATCH 08/30] Remove old svg tests. Unskip mathml test.
---
tdom/processor_test.py | 43 ------------------------------------------
1 file changed, 43 deletions(-)
diff --git a/tdom/processor_test.py b/tdom/processor_test.py
index 364353f..82685f7 100644
--- a/tdom/processor_test.py
+++ b/tdom/processor_test.py
@@ -1890,9 +1890,6 @@ def Footer(pager, paginate_url, footer_classes=("footer",)) -> Template:
)
-@pytest.mark.skip(
- "SVG+MATHML: This needs ns context for case correcting tags and attributes."
-)
def test_mathml():
num = 1
denom = 3
@@ -1922,46 +1919,6 @@ def test_mathml():
)
-@pytest.mark.skip(
- "SVG+MATHML: This needs ns context for case correcting tags and attributes."
-)
-def test_svg():
- cx, cy, r, fill = 150, 100, 80, "green"
- svg_t = t"""
-
-
- SVG
- """
- res = html(svg_t)
- assert (
- res
- == """
-
-
- SVG
- """
- )
-
-
-@pytest.mark.skip("SVG+MATHML: This needs ns context for closing empty tags.")
-def test_svg_self_closing_empty_elements():
- cx, cy, r, fill = 150, 100, 80, "green"
- svg_t = t"""
-
-
- SVG
- """
- res = html(svg_t)
- assert (
- res
- == """
-
-
- SVG
- """
- )
-
-
def test_framework_system_kwargs():
from .processor import ProcessContext
From 3ef9773d360e34e1914404453fa4e962cb59740b Mon Sep 17 00:00:00 2001
From: Ian Wilson
Date: Fri, 3 Apr 2026 11:06:46 -0700
Subject: [PATCH 09/30] Unfactor base classes after removing Node processor.
---
tdom/processor.py | 5 +----
1 file changed, 1 insertion(+), 4 deletions(-)
diff --git a/tdom/processor.py b/tdom/processor.py
index 4eeb760..4f796d2 100644
--- a/tdom/processor.py
+++ b/tdom/processor.py
@@ -513,7 +513,7 @@ def to_tnode(self, template: Template):
@dataclass(frozen=True)
-class BaseProcessorService:
+class ProcessorService:
parser_api: ParserService
escape_html_text: Callable = default_escape_html_text
@@ -524,9 +524,6 @@ class BaseProcessorService:
escape_html_style: Callable = default_escape_html_style
-
-@dataclass(frozen=True)
-class ProcessorService(BaseProcessorService):
slash_void: bool = False # Apply a xhtml-style slash to void html elements.
uppercase_doctype: bool = False # DOCTYPE vs doctype
From 42f902eb7cc6ce249a34e5a910efe4974619d7d2 Mon Sep 17 00:00:00 2001
From: Ian Wilson
Date: Mon, 6 Apr 2026 22:15:19 -0700
Subject: [PATCH 10/30] Remove optimization.
---
tdom/processor.py | 7 +------
1 file changed, 1 insertion(+), 6 deletions(-)
diff --git a/tdom/processor.py b/tdom/processor.py
index 4f796d2..00b2f2e 100644
--- a/tdom/processor.py
+++ b/tdom/processor.py
@@ -548,17 +548,12 @@ def process_template_chunks(
]
while q:
it = q.pop()
- if bf:
- yield "".join(bf)
- bf.clear()
for new_it in it:
if new_it is not None:
q.append(it)
q.append(new_it)
break
- if bf:
- yield "".join(bf)
- bf.clear() # Remove later maybe.
+ yield "".join(bf)
def walk_from_tnode(
self, bf: list[str], template: Template, assume_ctx: ProcessContext, root: TNode
From 452fc24face158975625c8b333896cb104fdd68f Mon Sep 17 00:00:00 2001
From: Ian Wilson
Date: Mon, 6 Apr 2026 22:16:22 -0700
Subject: [PATCH 11/30] Restrict internal apis to only using list.append to
produce the output string.
---
tdom/processor.py | 85 ++++++++++++++++++++++++++---------------------
1 file changed, 47 insertions(+), 38 deletions(-)
diff --git a/tdom/processor.py b/tdom/processor.py
index 00b2f2e..63a3e94 100644
--- a/tdom/processor.py
+++ b/tdom/processor.py
@@ -512,6 +512,9 @@ def to_tnode(self, template: Template):
return self._to_tnode(CachableTemplate(template))
+type ProcessorWriter = Callable[[str], None]
+
+
@dataclass(frozen=True)
class ProcessorService:
parser_api: ParserService
@@ -544,7 +547,7 @@ def process_template_chunks(
bf: list[str] = []
q: list[WalkerProto] = [
- self.walk_from_tnode(bf, root_template, assume_ctx, root)
+ self.walk_from_tnode(bf.append, root_template, assume_ctx, root)
]
while q:
it = q.pop()
@@ -556,7 +559,11 @@ def process_template_chunks(
yield "".join(bf)
def walk_from_tnode(
- self, bf: list[str], template: Template, assume_ctx: ProcessContext, root: TNode
+ self,
+ write: ProcessorWriter,
+ template: Template,
+ assume_ctx: ProcessContext,
+ root: TNode,
) -> Iterable[WalkerProto]:
"""
Walk around tree and try not to get lost.
@@ -567,7 +574,7 @@ def walk_from_tnode(
last_ctx, tnode = q.pop()
match tnode:
case EndTag(end_tag):
- bf.append(end_tag)
+ write(end_tag)
case TDocumentType(text):
if last_ctx.ns != "html":
# Nit
@@ -575,16 +582,16 @@ def walk_from_tnode(
"Cannot process document type in subtree of a foreign element."
)
if self.uppercase_doctype:
- bf.append(f"")
+ write(f"")
else:
- bf.append(f"")
+ write(f"")
case TComment(ref):
- self._process_comment(bf, template, last_ctx, ref)
+ self._process_comment(write, template, last_ctx, ref)
case TFragment(children):
q.extend([(last_ctx, child) for child in reversed(children)])
case TComponent(start_i_index, end_i_index, attrs, children):
res = self._process_component(
- bf, template, last_ctx, attrs, start_i_index, end_i_index
+ write, template, last_ctx, attrs, start_i_index, end_i_index
)
if res is not None:
yield res
@@ -599,15 +606,15 @@ def walk_from_tnode(
starttag = endtag = SVG_TAG_FIX.get(tag, tag)
else:
starttag = endtag = tag
- bf.append(f"<{starttag}")
+ write(f"<{starttag}")
if attrs:
- self._process_attrs(bf, template, our_ctx, attrs)
+ self._process_attrs(write, template, our_ctx, attrs)
# @TODO: How can we tell if we write out children or not in
# order to self-close in non-html contexts, ie. SVG?
if self.slash_void and tag in VOID_ELEMENTS:
- bf.append(" />")
+ write(" />")
else:
- bf.append(">")
+ write(">")
if tag not in VOID_ELEMENTS:
q.append((last_ctx, EndTag(f"{endtag}>")))
# We were still in SVG but now we default back into HTML
@@ -621,17 +628,19 @@ def walk_from_tnode(
)
elif last_ctx.parent_tag in CDATA_CONTENT_ELEMENTS:
# Must be handled all at once.
- self._process_raw_texts(bf, template, last_ctx, ref)
+ self._process_raw_texts(write, template, last_ctx, ref)
elif last_ctx.parent_tag in RCDATA_CONTENT_ELEMENTS:
# We can handle all at once because there are no non-text children and everything must be string-ified.
- self._process_escapable_raw_texts(bf, template, last_ctx, ref)
+ self._process_escapable_raw_texts(
+ write, template, last_ctx, ref
+ )
else:
for part in ref:
if isinstance(part, str):
- bf.append(self.escape_html_text(part))
+ write(self.escape_html_text(part))
else:
res = self._process_normal_text(
- bf, template, last_ctx, part
+ write, template, last_ctx, part
)
if res is not None:
yield res
@@ -640,27 +649,27 @@ def walk_from_tnode(
def _process_comment(
self,
- bf: list[str],
+ write: ProcessorWriter,
template: Template,
last_ctx: ProcessContext,
content_ref: TemplateRef,
) -> None:
content = resolve_text_without_recursion(template, "")
+ write("-->")
def _process_attrs(
self,
- bf: list[str],
+ write: ProcessorWriter,
template: Template,
last_ctx: ProcessContext,
attrs: tuple[TAttribute, ...],
@@ -673,11 +682,11 @@ def _process_attrs(
else:
attrs_str = serialize_html_attrs(_resolve_html_attrs(resolved_attrs))
if attrs_str:
- bf.append(attrs_str)
+ write(attrs_str)
def _process_component(
self,
- bf: list[str],
+ write: ProcessorWriter,
template: Template,
last_ctx: ProcessContext,
attrs: tuple[TAttribute, ...],
@@ -728,13 +737,13 @@ def _process_component(
# DO NOTHING
return
result_root = self.parser_api.to_tnode(result_t)
- return self.walk_from_tnode(bf, result_t, last_ctx, result_root)
+ return self.walk_from_tnode(write, result_t, last_ctx, result_root)
else:
raise TypeError(f"Unknown component return value: {type(result_t)}")
def _process_raw_texts(
self,
- bf: list[str],
+ write: ProcessorWriter,
template: Template,
last_ctx: ProcessContext,
content_ref: TemplateRef,
@@ -746,14 +755,14 @@ def _process_raw_texts(
if content is None or content == "":
return
elif last_ctx.parent_tag == "script":
- bf.append(
+ write(
self.escape_html_script(
content,
allow_markup=True,
)
)
elif last_ctx.parent_tag == "style":
- bf.append(
+ write(
self.escape_html_style(
content,
allow_markup=True,
@@ -766,7 +775,7 @@ def _process_raw_texts(
def _process_escapable_raw_texts(
self,
- bf: list[str],
+ write: ProcessorWriter,
template: Template,
last_ctx: ProcessContext,
content_ref: TemplateRef,
@@ -778,7 +787,7 @@ def _process_escapable_raw_texts(
if content is None or content == "":
return
else:
- bf.append(
+ write(
self.escape_html_text(
content,
)
@@ -786,20 +795,20 @@ def _process_escapable_raw_texts(
def _process_normal_text(
self,
- bf: list[str],
+ write: ProcessorWriter,
template: Template,
last_ctx: ProcessContext,
values_index: int,
) -> WalkerProto | None:
value = format_interpolation(template.interpolations[values_index])
if isinstance(value, str):
- bf.append(self.escape_html_text(value))
+ write(self.escape_html_text(value))
elif isinstance(value, Template):
value_root = self.parser_api.to_tnode(value)
- return self.walk_from_tnode(bf, value, last_ctx, value_root)
+ return self.walk_from_tnode(write, value, last_ctx, value_root)
elif isinstance(value, Iterable):
return iter(
- self._process_normal_text_from_value(bf, template, last_ctx, v)
+ self._process_normal_text_from_value(write, template, last_ctx, v)
for v in value
)
elif value is None:
@@ -808,23 +817,23 @@ def _process_normal_text(
else:
# @DESIGN: Everything that isn't an object we recognize is
# coerced to a str() and emitted.
- bf.append(self.escape_html_text(value))
+ write(self.escape_html_text(value))
def _process_normal_text_from_value(
self,
- bf: list[str],
+ write: ProcessorWriter,
template: Template,
last_ctx: ProcessContext,
value: NormalTextInterpolationValue,
) -> WalkerProto | None:
if isinstance(value, str):
- bf.append(self.escape_html_text(value))
+ write(self.escape_html_text(value))
elif isinstance(value, Template):
value_root = self.parser_api.to_tnode(value)
- return self.walk_from_tnode(bf, value, last_ctx, value_root)
+ return self.walk_from_tnode(write, value, last_ctx, value_root)
elif isinstance(value, Iterable):
return iter(
- self._process_normal_text_from_value(bf, template, last_ctx, v)
+ self._process_normal_text_from_value(write, template, last_ctx, v)
for v in value
)
elif value is None:
@@ -833,7 +842,7 @@ def _process_normal_text_from_value(
else:
# @DESIGN: Everything that isn't an object we recognize is
# coerced to a str() and emitted.
- bf.append(self.escape_html_text(value))
+ write(self.escape_html_text(value))
def resolve_text_without_recursion(
From c420158f37259900f5879546c52f04543e3a4298 Mon Sep 17 00:00:00 2001
From: Ian Wilson
Date: Mon, 6 Apr 2026 22:18:33 -0700
Subject: [PATCH 12/30] Remove chunking optimization completely.
---
tdom/processor.py | 199 ++++++++++++++++++++--------------------------
1 file changed, 85 insertions(+), 114 deletions(-)
diff --git a/tdom/processor.py b/tdom/processor.py
index 63a3e94..d3bd05b 100644
--- a/tdom/processor.py
+++ b/tdom/processor.py
@@ -534,11 +534,6 @@ class ProcessorService:
def process_template(
self, root_template: Template, assume_ctx: ProcessContext | None = None
) -> str:
- return "".join(self.process_template_chunks(root_template, assume_ctx))
-
- def process_template_chunks(
- self, root_template: Template, assume_ctx: ProcessContext | None = None
- ) -> Iterable[str]:
if assume_ctx is None:
# @DESIGN: What do we want to do here? Should we assume we are in
# a tag with normal text?
@@ -546,106 +541,85 @@ def process_template_chunks(
root = self.parser_api.to_tnode(root_template)
bf: list[str] = []
- q: list[WalkerProto] = [
- self.walk_from_tnode(bf.append, root_template, assume_ctx, root)
- ]
- while q:
- it = q.pop()
- for new_it in it:
- if new_it is not None:
- q.append(it)
- q.append(new_it)
- break
- yield "".join(bf)
-
- def walk_from_tnode(
+ self._process_tnode(bf.append, root_template, assume_ctx, root)
+ return "".join(bf)
+
+ def _process_tnode(
self,
write: ProcessorWriter,
template: Template,
- assume_ctx: ProcessContext,
- root: TNode,
- ) -> Iterable[WalkerProto]:
+ last_ctx: ProcessContext,
+ tnode: TNode,
+ ) -> None:
"""
- Walk around tree and try not to get lost.
+ Walk tnode tree, write out strings until we are done or recurse into another level of processing.
"""
-
- q: list[tuple[ProcessContext, TNode | EndTag]] = [(assume_ctx, root)]
- while q:
- last_ctx, tnode = q.pop()
- match tnode:
- case EndTag(end_tag):
- write(end_tag)
- case TDocumentType(text):
- if last_ctx.ns != "html":
- # Nit
- raise ValueError(
- "Cannot process document type in subtree of a foreign element."
- )
- if self.uppercase_doctype:
- write(f"")
- else:
- write(f"")
- case TComment(ref):
- self._process_comment(write, template, last_ctx, ref)
- case TFragment(children):
- q.extend([(last_ctx, child) for child in reversed(children)])
- case TComponent(start_i_index, end_i_index, attrs, children):
- res = self._process_component(
- write, template, last_ctx, attrs, start_i_index, end_i_index
+ match tnode:
+ case TDocumentType(text):
+ if last_ctx.ns != "html":
+ # Nit
+ raise ValueError(
+ "Cannot process document type in subtree of a foreign element."
)
- if res is not None:
- yield res
- case TElement(tag, attrs, children):
- if tag == "svg":
- our_ctx = last_ctx.copy(parent_tag=tag, ns="svg")
- elif tag == "math":
- our_ctx = last_ctx.copy(parent_tag=tag, ns="math")
- else:
- our_ctx = last_ctx.copy(parent_tag=tag)
- if our_ctx.ns == "svg":
- starttag = endtag = SVG_TAG_FIX.get(tag, tag)
- else:
- starttag = endtag = tag
- write(f"<{starttag}")
- if attrs:
- self._process_attrs(write, template, our_ctx, attrs)
- # @TODO: How can we tell if we write out children or not in
- # order to self-close in non-html contexts, ie. SVG?
- if self.slash_void and tag in VOID_ELEMENTS:
- write(" />")
- else:
- write(">")
- if tag not in VOID_ELEMENTS:
- q.append((last_ctx, EndTag(f"{endtag}>")))
- # We were still in SVG but now we default back into HTML
- if tag == "foreignobject":
- our_ctx = our_ctx.copy(ns="html")
- q.extend([(our_ctx, child) for child in reversed(children)])
- case TText(ref):
- if last_ctx.parent_tag is None:
- raise NotImplementedError(
- "We cannot interpolate texts without knowing what tag they are contained in."
- )
- elif last_ctx.parent_tag in CDATA_CONTENT_ELEMENTS:
- # Must be handled all at once.
- self._process_raw_texts(write, template, last_ctx, ref)
- elif last_ctx.parent_tag in RCDATA_CONTENT_ELEMENTS:
- # We can handle all at once because there are no non-text children and everything must be string-ified.
- self._process_escapable_raw_texts(
- write, template, last_ctx, ref
- )
- else:
- for part in ref:
- if isinstance(part, str):
- write(self.escape_html_text(part))
- else:
- res = self._process_normal_text(
- write, template, last_ctx, part
- )
- if res is not None:
- yield res
- case _:
- raise ValueError(f"Unrecognized tnode: {tnode}")
+ if self.uppercase_doctype:
+ write(f"")
+ else:
+ write(f"")
+ case TComment(ref):
+ self._process_comment(write, template, last_ctx, ref)
+ case TFragment(children):
+ for child in children:
+ self._process_tnode(write, template, last_ctx, child)
+ case TComponent(start_i_index, end_i_index, attrs, children):
+ self._process_component(
+ write, template, last_ctx, attrs, start_i_index, end_i_index
+ )
+ case TElement(tag, attrs, children):
+ if tag == "svg":
+ our_ctx = last_ctx.copy(parent_tag=tag, ns="svg")
+ elif tag == "math":
+ our_ctx = last_ctx.copy(parent_tag=tag, ns="math")
+ else:
+ our_ctx = last_ctx.copy(parent_tag=tag)
+ if our_ctx.ns == "svg":
+ starttag = endtag = SVG_TAG_FIX.get(tag, tag)
+ else:
+ starttag = endtag = tag
+ write(f"<{starttag}")
+ if attrs:
+ self._process_attrs(write, template, our_ctx, attrs)
+ # @TODO: How can we tell if we write out children or not in
+ # order to self-close in non-html contexts, ie. SVG?
+ if self.slash_void and tag in VOID_ELEMENTS:
+ write(" />")
+ else:
+ write(">")
+ if tag not in VOID_ELEMENTS:
+ # We were still in SVG but now we default back into HTML
+ if tag == "foreignobject":
+ our_ctx = our_ctx.copy(ns="html")
+ for child in children:
+ self._process_tnode(write, template, our_ctx, child)
+ write(f"{endtag}>")
+ case TText(ref):
+ if last_ctx.parent_tag is None:
+ raise NotImplementedError(
+ "We cannot interpolate texts without knowing what tag they are contained in."
+ )
+ elif last_ctx.parent_tag in CDATA_CONTENT_ELEMENTS:
+ # Must be handled all at once.
+ self._process_raw_texts(write, template, last_ctx, ref)
+ elif last_ctx.parent_tag in RCDATA_CONTENT_ELEMENTS:
+ # We can handle all at once because there are no non-text children and everything must be string-ified.
+ self._process_escapable_raw_texts(write, template, last_ctx, ref)
+ else:
+ for part in ref:
+ if isinstance(part, str):
+ write(self.escape_html_text(part))
+ else:
+ self._process_normal_text(write, template, last_ctx, part)
+ case _:
+ raise ValueError(f"Unrecognized tnode: {tnode}")
def _process_comment(
self,
@@ -735,9 +709,10 @@ def _process_component(
if isinstance(result_t, Template):
if result_t.strings == ("",):
# DO NOTHING
- return
- result_root = self.parser_api.to_tnode(result_t)
- return self.walk_from_tnode(write, result_t, last_ctx, result_root)
+ pass
+ else:
+ result_root = self.parser_api.to_tnode(result_t)
+ self._process_tnode(write, result_t, last_ctx, result_root)
else:
raise TypeError(f"Unknown component return value: {type(result_t)}")
@@ -799,21 +774,19 @@ def _process_normal_text(
template: Template,
last_ctx: ProcessContext,
values_index: int,
- ) -> WalkerProto | None:
+ ) -> None:
value = format_interpolation(template.interpolations[values_index])
if isinstance(value, str):
write(self.escape_html_text(value))
elif isinstance(value, Template):
value_root = self.parser_api.to_tnode(value)
- return self.walk_from_tnode(write, value, last_ctx, value_root)
+ self._process_tnode(write, value, last_ctx, value_root)
elif isinstance(value, Iterable):
- return iter(
+ for v in value:
self._process_normal_text_from_value(write, template, last_ctx, v)
- for v in value
- )
elif value is None:
# @DESIGN: Ignore None.
- return
+ pass
else:
# @DESIGN: Everything that isn't an object we recognize is
# coerced to a str() and emitted.
@@ -825,20 +798,18 @@ def _process_normal_text_from_value(
template: Template,
last_ctx: ProcessContext,
value: NormalTextInterpolationValue,
- ) -> WalkerProto | None:
+ ) -> None:
if isinstance(value, str):
write(self.escape_html_text(value))
elif isinstance(value, Template):
value_root = self.parser_api.to_tnode(value)
- return self.walk_from_tnode(write, value, last_ctx, value_root)
+ self._process_tnode(write, value, last_ctx, value_root)
elif isinstance(value, Iterable):
- return iter(
+ for v in value:
self._process_normal_text_from_value(write, template, last_ctx, v)
- for v in value
- )
elif value is None:
# @DESIGN: Ignore None.
- return
+ pass
else:
# @DESIGN: Everything that isn't an object we recognize is
# coerced to a str() and emitted.
From 05e0182e6468d726194a7bfe2844c9221910431b Mon Sep 17 00:00:00 2001
From: Ian Wilson
Date: Mon, 6 Apr 2026 23:09:00 -0700
Subject: [PATCH 13/30] Explicitly return str.
---
tdom/processor.py | 160 +++++++++++++++++++++-------------------------
1 file changed, 72 insertions(+), 88 deletions(-)
diff --git a/tdom/processor.py b/tdom/processor.py
index d3bd05b..80fed63 100644
--- a/tdom/processor.py
+++ b/tdom/processor.py
@@ -512,9 +512,6 @@ def to_tnode(self, template: Template):
return self._to_tnode(CachableTemplate(template))
-type ProcessorWriter = Callable[[str], None]
-
-
@dataclass(frozen=True)
class ProcessorService:
parser_api: ParserService
@@ -539,18 +536,11 @@ def process_template(
# a tag with normal text?
assume_ctx = make_ctx(parent_tag=DEFAULT_NORMAL_TEXT_ELEMENT, ns="html")
root = self.parser_api.to_tnode(root_template)
-
- bf: list[str] = []
- self._process_tnode(bf.append, root_template, assume_ctx, root)
- return "".join(bf)
+ return self._process_tnode(root_template, assume_ctx, root)
def _process_tnode(
- self,
- write: ProcessorWriter,
- template: Template,
- last_ctx: ProcessContext,
- tnode: TNode,
- ) -> None:
+ self, template: Template, last_ctx: ProcessContext, tnode: TNode
+ ) -> str:
"""
Walk tnode tree, write out strings until we are done or recurse into another level of processing.
"""
@@ -562,19 +552,21 @@ def _process_tnode(
"Cannot process document type in subtree of a foreign element."
)
if self.uppercase_doctype:
- write(f"")
+ return f""
else:
- write(f"")
+ return f""
case TComment(ref):
- self._process_comment(write, template, last_ctx, ref)
+ return self._process_comment(template, last_ctx, ref)
case TFragment(children):
- for child in children:
- self._process_tnode(write, template, last_ctx, child)
+ return "".join(
+ self._process_tnode(template, last_ctx, child) for child in children
+ )
case TComponent(start_i_index, end_i_index, attrs, children):
- self._process_component(
- write, template, last_ctx, attrs, start_i_index, end_i_index
+ return self._process_component(
+ template, last_ctx, attrs, start_i_index, end_i_index
)
case TElement(tag, attrs, children):
+ out: list[str] = []
if tag == "svg":
our_ctx = last_ctx.copy(parent_tag=tag, ns="svg")
elif tag == "math":
@@ -585,22 +577,25 @@ def _process_tnode(
starttag = endtag = SVG_TAG_FIX.get(tag, tag)
else:
starttag = endtag = tag
- write(f"<{starttag}")
+ out.append(f"<{starttag}")
if attrs:
- self._process_attrs(write, template, our_ctx, attrs)
+ out.append(self._process_attrs(template, our_ctx, attrs))
# @TODO: How can we tell if we write out children or not in
# order to self-close in non-html contexts, ie. SVG?
if self.slash_void and tag in VOID_ELEMENTS:
- write(" />")
+ out.append(" />")
else:
- write(">")
+ out.append(">")
if tag not in VOID_ELEMENTS:
# We were still in SVG but now we default back into HTML
if tag == "foreignobject":
our_ctx = our_ctx.copy(ns="html")
- for child in children:
- self._process_tnode(write, template, our_ctx, child)
- write(f"{endtag}>")
+ out.extend(
+ self._process_tnode(template, our_ctx, child)
+ for child in children
+ )
+ out.append(f"{endtag}>")
+ return "".join(out)
case TText(ref):
if last_ctx.parent_tag is None:
raise NotImplementedError(
@@ -608,46 +603,41 @@ def _process_tnode(
)
elif last_ctx.parent_tag in CDATA_CONTENT_ELEMENTS:
# Must be handled all at once.
- self._process_raw_texts(write, template, last_ctx, ref)
+ return self._process_raw_texts(template, last_ctx, ref)
elif last_ctx.parent_tag in RCDATA_CONTENT_ELEMENTS:
# We can handle all at once because there are no non-text children and everything must be string-ified.
- self._process_escapable_raw_texts(write, template, last_ctx, ref)
+ return self._process_escapable_raw_texts(template, last_ctx, ref)
else:
- for part in ref:
- if isinstance(part, str):
- write(self.escape_html_text(part))
- else:
- self._process_normal_text(write, template, last_ctx, part)
+ return "".join(
+ (
+ self.escape_html_text(part)
+ if isinstance(part, str)
+ else self._process_normal_text(template, last_ctx, part)
+ )
+ for part in ref
+ )
case _:
raise ValueError(f"Unrecognized tnode: {tnode}")
def _process_comment(
self,
- write: ProcessorWriter,
template: Template,
last_ctx: ProcessContext,
content_ref: TemplateRef,
- ) -> None:
+ ) -> str:
content = resolve_text_without_recursion(template, "")
+ comment_str = self.escape_html_comment(content, allow_markup=True)
+ return f""
def _process_attrs(
self,
- write: ProcessorWriter,
template: Template,
last_ctx: ProcessContext,
attrs: tuple[TAttribute, ...],
- ) -> None:
+ ) -> str:
resolved_attrs = _resolve_t_attrs(attrs, template.interpolations)
if last_ctx.ns == "svg":
attrs_str = serialize_html_attrs(
@@ -656,17 +646,17 @@ def _process_attrs(
else:
attrs_str = serialize_html_attrs(_resolve_html_attrs(resolved_attrs))
if attrs_str:
- write(attrs_str)
+ return attrs_str
+ return ""
def _process_component(
self,
- write: ProcessorWriter,
template: Template,
last_ctx: ProcessContext,
attrs: tuple[TAttribute, ...],
start_i_index: int,
end_i_index: int | None,
- ) -> None | WalkerProto:
+ ) -> str:
body_start_s_index = (
start_i_index
+ 1
@@ -709,39 +699,34 @@ def _process_component(
if isinstance(result_t, Template):
if result_t.strings == ("",):
# DO NOTHING
- pass
+ return ""
else:
result_root = self.parser_api.to_tnode(result_t)
- self._process_tnode(write, result_t, last_ctx, result_root)
+ return self._process_tnode(result_t, last_ctx, result_root)
else:
raise TypeError(f"Unknown component return value: {type(result_t)}")
def _process_raw_texts(
self,
- write: ProcessorWriter,
template: Template,
last_ctx: ProcessContext,
content_ref: TemplateRef,
- ) -> None:
+ ) -> str:
assert last_ctx.parent_tag in CDATA_CONTENT_ELEMENTS
content = resolve_text_without_recursion(
template, last_ctx.parent_tag, content_ref
)
if content is None or content == "":
- return
+ return ""
elif last_ctx.parent_tag == "script":
- write(
- self.escape_html_script(
- content,
- allow_markup=True,
- )
+ return self.escape_html_script(
+ content,
+ allow_markup=True,
)
elif last_ctx.parent_tag == "style":
- write(
- self.escape_html_style(
- content,
- allow_markup=True,
- )
+ return self.escape_html_style(
+ content,
+ allow_markup=True,
)
else:
raise NotImplementedError(
@@ -750,70 +735,69 @@ def _process_raw_texts(
def _process_escapable_raw_texts(
self,
- write: ProcessorWriter,
template: Template,
last_ctx: ProcessContext,
content_ref: TemplateRef,
- ) -> None:
+ ) -> str:
assert last_ctx.parent_tag in RCDATA_CONTENT_ELEMENTS
content = resolve_text_without_recursion(
template, last_ctx.parent_tag, content_ref
)
if content is None or content == "":
- return
+ return ""
else:
- write(
- self.escape_html_text(
- content,
- )
+ return self.escape_html_text(
+ content,
)
def _process_normal_text(
self,
- write: ProcessorWriter,
template: Template,
last_ctx: ProcessContext,
values_index: int,
- ) -> None:
+ ) -> str:
value = format_interpolation(template.interpolations[values_index])
if isinstance(value, str):
- write(self.escape_html_text(value))
+ return self.escape_html_text(value)
elif isinstance(value, Template):
value_root = self.parser_api.to_tnode(value)
- self._process_tnode(write, value, last_ctx, value_root)
+ return self._process_tnode(value, last_ctx, value_root)
elif isinstance(value, Iterable):
- for v in value:
- self._process_normal_text_from_value(write, template, last_ctx, v)
+ return "".join(
+ self._process_normal_text_from_value(template, last_ctx, v)
+ for v in value
+ )
elif value is None:
# @DESIGN: Ignore None.
- pass
+ return ""
else:
# @DESIGN: Everything that isn't an object we recognize is
# coerced to a str() and emitted.
- write(self.escape_html_text(value))
+ return self.escape_html_text(value)
def _process_normal_text_from_value(
self,
- write: ProcessorWriter,
template: Template,
last_ctx: ProcessContext,
value: NormalTextInterpolationValue,
- ) -> None:
+ ) -> str:
if isinstance(value, str):
- write(self.escape_html_text(value))
+ return self.escape_html_text(value)
elif isinstance(value, Template):
value_root = self.parser_api.to_tnode(value)
- self._process_tnode(write, value, last_ctx, value_root)
+ return self._process_tnode(value, last_ctx, value_root)
elif isinstance(value, Iterable):
- for v in value:
- self._process_normal_text_from_value(write, template, last_ctx, v)
+ return "".join(
+ self._process_normal_text_from_value(template, last_ctx, v)
+ for v in value
+ )
elif value is None:
# @DESIGN: Ignore None.
- pass
+ return ""
else:
# @DESIGN: Everything that isn't an object we recognize is
# coerced to a str() and emitted.
- write(self.escape_html_text(value))
+ return self.escape_html_text(value)
def resolve_text_without_recursion(
From 204aded2841991fb5bd6d9bdb74f97f985719c06 Mon Sep 17 00:00:00 2001
From: Ian Wilson
Date: Mon, 6 Apr 2026 23:29:47 -0700
Subject: [PATCH 14/30] Start trying to fix doc strings.
---
tdom/processor.py | 31 ++++++++++++++++++++++++++++++-
1 file changed, 30 insertions(+), 1 deletion(-)
diff --git a/tdom/processor.py b/tdom/processor.py
index 80fed63..9102701 100644
--- a/tdom/processor.py
+++ b/tdom/processor.py
@@ -531,6 +531,9 @@ class ProcessorService:
def process_template(
self, root_template: Template, assume_ctx: ProcessContext | None = None
) -> str:
+ """
+ Process a TDOM compatible template into a string.
+ """
if assume_ctx is None:
# @DESIGN: What do we want to do here? Should we assume we are in
# a tag with normal text?
@@ -542,7 +545,7 @@ def _process_tnode(
self, template: Template, last_ctx: ProcessContext, tnode: TNode
) -> str:
"""
- Walk tnode tree, write out strings until we are done or recurse into another level of processing.
+ Process a tnode from a template's "t-tree" into a string.
"""
match tnode:
case TDocumentType(text):
@@ -625,6 +628,9 @@ def _process_comment(
last_ctx: ProcessContext,
content_ref: TemplateRef,
) -> str:
+ """
+ Process a comment into a string.
+ """
content = resolve_text_without_recursion(template, ""
+ def _process_element(
+ self,
+ template: Template,
+ last_ctx: ProcessContext,
+ tag: str,
+ attrs: tuple[TAttribute, ...],
+ children: tuple[TNode, ...],
+ ) -> str:
+ out: list[str] = []
+ if tag == "svg":
+ our_ctx = last_ctx.copy(parent_tag=tag, ns="svg")
+ elif tag == "math":
+ our_ctx = last_ctx.copy(parent_tag=tag, ns="math")
+ else:
+ our_ctx = last_ctx.copy(parent_tag=tag)
+ if our_ctx.ns == "svg":
+ starttag = endtag = SVG_TAG_FIX.get(tag, tag)
+ else:
+ starttag = endtag = tag
+ out.append(f"<{starttag}")
+ if attrs:
+ out.append(self._process_attrs(template, our_ctx, attrs))
+ # @TODO: How can we tell if we write out children or not in
+ # order to self-close in non-html contexts, ie. SVG?
+ if self.slash_void and tag in VOID_ELEMENTS:
+ out.append(" />")
+ else:
+ out.append(">")
+ if tag not in VOID_ELEMENTS:
+ # We were still in SVG but now we default back into HTML
+ if tag == "foreignobject":
+ our_ctx = our_ctx.copy(ns="html")
+ out.extend(
+ self._process_tnode(template, our_ctx, child) for child in children
+ )
+ out.append(f"{endtag}>")
+ return "".join(out)
+
def _process_attrs(
self,
template: Template,
From ce6927678bd6dc41733e12abf8355dcfcddc7101 Mon Sep 17 00:00:00 2001
From: Ian Wilson
Date: Mon, 6 Apr 2026 23:52:31 -0700
Subject: [PATCH 16/30] Use dedicated variable for clarity.
---
tdom/processor.py | 6 ++++--
1 file changed, 4 insertions(+), 2 deletions(-)
diff --git a/tdom/processor.py b/tdom/processor.py
index bb61a52..9f9bb82 100644
--- a/tdom/processor.py
+++ b/tdom/processor.py
@@ -640,9 +640,11 @@ def _process_element(
if tag not in VOID_ELEMENTS:
# We were still in SVG but now we default back into HTML
if tag == "foreignobject":
- our_ctx = our_ctx.copy(ns="html")
+ child_ctx = our_ctx.copy(ns="html")
+ else:
+ child_ctx = our_ctx
out.extend(
- self._process_tnode(template, our_ctx, child) for child in children
+ self._process_tnode(template, child_ctx, child) for child in children
)
out.append(f"{endtag}>")
return "".join(out)
From 5bd687877e6dbdd24461d50b2836aeb4cac7c2a7 Mon Sep 17 00:00:00 2001
From: Ian Wilson
Date: Tue, 7 Apr 2026 23:10:31 -0700
Subject: [PATCH 17/30] Remove unused type.
---
tdom/processor.py | 3 ---
1 file changed, 3 deletions(-)
diff --git a/tdom/processor.py b/tdom/processor.py
index 9f9bb82..2f7c8a9 100644
--- a/tdom/processor.py
+++ b/tdom/processor.py
@@ -484,9 +484,6 @@ def copy(
type ComponentObjectProto = Callable[[], Template]
-type WalkerProto = Iterable[WalkerProto | None]
-
-
type NormalTextInterpolationValue = (
None | str | Template | Iterable[NormalTextInterpolationValue] | object
)
From 85b9c76640ddfaccfb52a9db3e0016140f65fc3b Mon Sep 17 00:00:00 2001
From: Ian Wilson
Date: Wed, 8 Apr 2026 10:48:37 -0700
Subject: [PATCH 18/30] Push normal text processing around to matchup with raw
and escapable raw.
---
tdom/processor.py | 41 ++++++++++++++++-------------------------
1 file changed, 16 insertions(+), 25 deletions(-)
diff --git a/tdom/processor.py b/tdom/processor.py
index 2f7c8a9..52468d5 100644
--- a/tdom/processor.py
+++ b/tdom/processor.py
@@ -579,14 +579,7 @@ def _process_tnode(
# We can handle all at once because there are no non-text children and everything must be string-ified.
return self._process_escapable_raw_texts(template, last_ctx, ref)
else:
- return "".join(
- (
- self.escape_html_text(part)
- if isinstance(part, str)
- else self._process_normal_text(template, last_ctx, part)
- )
- for part in ref
- )
+ return self._process_normal_texts(template, last_ctx, ref)
case _:
raise ValueError(f"Unrecognized tnode: {tnode}")
@@ -776,6 +769,19 @@ def _process_escapable_raw_texts(
content,
)
+ def _process_normal_texts(self, template: Template, last_ctx: ProcessContext, content_ref: TemplateRef):
+ """
+ Process the given context into a string as "normal text".
+ """
+ return "".join(
+ (
+ self.escape_html_text(part)
+ if isinstance(part, str)
+ else self._process_normal_text(template, last_ctx, t.cast(int, part))
+ )
+ for part in content_ref
+ )
+
def _process_normal_text(
self,
template: Template,
@@ -788,23 +794,8 @@ def _process_normal_text(
@NOTE: This is an interpolation that must be formatted to get the value.
"""
value = format_interpolation(template.interpolations[values_index])
- if isinstance(value, str):
- return self.escape_html_text(value)
- elif isinstance(value, Template):
- value_root = self.parser_api.to_tnode(value)
- return self._process_tnode(value, last_ctx, value_root)
- elif isinstance(value, Iterable):
- return "".join(
- self._process_normal_text_from_value(template, last_ctx, v)
- for v in value
- )
- elif value is None:
- # @DESIGN: Ignore None.
- return ""
- else:
- # @DESIGN: Everything that isn't an object we recognize is
- # coerced to a str() and emitted.
- return self.escape_html_text(value)
+ value = t.cast(NormalTextInterpolationValue, value) # ty: ignore[redundant-cast]
+ return self._process_normal_text_from_value(template, last_ctx, value)
def _process_normal_text_from_value(
self,
From 6f9cb4c8a9eecf49494a549efca4f3ce76122f15 Mon Sep 17 00:00:00 2001
From: Ian Wilson
Date: Wed, 8 Apr 2026 21:24:13 -0700
Subject: [PATCH 19/30] Remove suffix from types.
---
tdom/processor.py | 12 ++++++------
1 file changed, 6 insertions(+), 6 deletions(-)
diff --git a/tdom/processor.py b/tdom/processor.py
index 52468d5..37b1b82 100644
--- a/tdom/processor.py
+++ b/tdom/processor.py
@@ -478,10 +478,10 @@ def copy(
)
-type FunctionComponentProto = Callable[..., Template]
-type FactoryComponentProto = Callable[..., ComponentObjectProto]
-type ComponentCallableProto = FunctionComponentProto | FactoryComponentProto
-type ComponentObjectProto = Callable[[], Template]
+type FunctionComponent = Callable[..., Template]
+type FactoryComponent = Callable[..., ComponentObject]
+type ComponentCallable = FunctionComponent | FactoryComponent
+type ComponentObject = Callable[[], Template]
type NormalTextInterpolationValue = (
@@ -676,7 +676,7 @@ def _process_component(
+ len([1 for attr in attrs if not isinstance(attr, TLiteralAttribute)])
)
start_i = template.interpolations[start_i_index]
- component_callable = t.cast(ComponentCallableProto, start_i.value)
+ component_callable = t.cast(ComponentCallable, start_i.value)
if start_i_index != end_i_index and end_i_index is not None:
# @TODO: We should do this during parsing.
children_template = extract_embedded_template(
@@ -704,7 +704,7 @@ def _process_component(
and not isinstance(result_t, Template)
and callable(result_t)
):
- component_obj = t.cast(ComponentObjectProto, result_t) # ty: ignore[redundant-cast]
+ component_obj = t.cast(ComponentObject, result_t) # ty: ignore[redundant-cast]
result_t = component_obj()
else:
component_obj = None
From dacd029bf4fc7f3939dcf1a4b2b2a39e1f74f88f Mon Sep 17 00:00:00 2001
From: Ian Wilson
Date: Wed, 8 Apr 2026 21:24:32 -0700
Subject: [PATCH 20/30] Formatting.
---
tdom/processor.py | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/tdom/processor.py b/tdom/processor.py
index 37b1b82..db899ab 100644
--- a/tdom/processor.py
+++ b/tdom/processor.py
@@ -769,7 +769,9 @@ def _process_escapable_raw_texts(
content,
)
- def _process_normal_texts(self, template: Template, last_ctx: ProcessContext, content_ref: TemplateRef):
+ def _process_normal_texts(
+ self, template: Template, last_ctx: ProcessContext, content_ref: TemplateRef
+ ):
"""
Process the given context into a string as "normal text".
"""
From 7e5187758a1554cff48d22ddb5083bc58c5fec65 Mon Sep 17 00:00:00 2001
From: Ian Wilson
Date: Thu, 9 Apr 2026 23:51:30 -0700
Subject: [PATCH 21/30] Be more explicit about HasHTMLDunder since it might not
be Markup.
---
tdom/processor.py | 10 +++++++++-
1 file changed, 9 insertions(+), 1 deletion(-)
diff --git a/tdom/processor.py b/tdom/processor.py
index db899ab..2b0d78d 100644
--- a/tdom/processor.py
+++ b/tdom/processor.py
@@ -485,7 +485,7 @@ def copy(
type NormalTextInterpolationValue = (
- None | str | Template | Iterable[NormalTextInterpolationValue] | object
+ None | str | HasHTMLDunder | Template | Iterable[NormalTextInterpolationValue] | object
)
# Applies to both escapable raw text and raw text.
type RawTextExactInterpolationValue = None | str | HasHTMLDunder | object
@@ -812,6 +812,8 @@ def _process_normal_text_from_value(
used when processing an iterable of values as normal text.
"""
if isinstance(value, str):
+ # @NOTE: This would apply to Markup() but not to a custom object
+ # implementing HasHTMLDunder.
return self.escape_html_text(value)
elif isinstance(value, Template):
value_root = self.parser_api.to_tnode(value)
@@ -824,6 +826,12 @@ def _process_normal_text_from_value(
elif value is None:
# @DESIGN: Ignore None.
return ""
+ elif isinstance(value, HasHTMLDunder):
+ # @NOTE: markupsafe's escape does this for us but we put this in
+ # here for completeness.
+ # @NOTE: An actual Markup() would actually pass as a str() but a
+ # custom object with __html__ might not.
+ return Markup(value.__html__())
else:
# @DESIGN: Everything that isn't an object we recognize is
# coerced to a str() and emitted.
From e68e05ac0ffb9a7331e06f4848e488a38331ec4c Mon Sep 17 00:00:00 2001
From: Ian Wilson
Date: Thu, 9 Apr 2026 23:52:28 -0700
Subject: [PATCH 22/30] Make sure Markup and a custom HasHTMLDunder
implementation are consistent.
---
tdom/processor_test.py | 143 ++++++++++++++++++++++++++++++++++-------
1 file changed, 119 insertions(+), 24 deletions(-)
diff --git a/tdom/processor_test.py b/tdom/processor_test.py
index 82685f7..b06f0a9 100644
--- a/tdom/processor_test.py
+++ b/tdom/processor_test.py
@@ -20,6 +20,8 @@
from .processor import (
_prep_component_kwargs as prep_component_kwargs,
)
+from .protocols import HasHTMLDunder
+
processor_api = processor_service_factory(slash_void=True, uppercase_doctype=True)
@@ -100,6 +102,14 @@ def __html__(self):
return self.text
+def test_literal_html_has_html_dunder():
+ assert isinstance(LiteralHTML, HasHTMLDunder)
+
+
+def test_markup_has_html_dunder():
+ assert isinstance(Markup, HasHTMLDunder)
+
+
class TestComment:
def test_literal(self):
assert html(t"") == ""
@@ -117,8 +127,15 @@ def test_singleton_object(self):
def test_singleton_none(self):
assert html(t"") == ""
- def test_singleton_has_dunder_html(self):
- content = LiteralHTML("-->")
+ @pytest.mark.parametrize(
+ "html_dunder_cls",
+ (
+ LiteralHTML,
+ Markup,
+ ),
+ )
+ def test_singleton_has_html_dunder(self, html_dunder_cls):
+ content = html_dunder_cls("-->")
assert html(t"") == "-->", (
"DO NOT DO THIS! This is just an advanced escape hatch."
)
@@ -140,9 +157,16 @@ def test_templated_object(self):
def test_templated_none(self):
assert html(t"") == ""
- def test_templated_has_dunder_html_error(self):
+ @pytest.mark.parametrize(
+ "html_dunder_cls",
+ (
+ LiteralHTML,
+ Markup,
+ ),
+ )
+ def test_templated_has_html_dunder_error(self, html_dunder_cls):
"""Objects with __html__ are not processed with literal text or other interpolations."""
- text = LiteralHTML("in a comment")
+ text = html_dunder_cls("in a comment")
with pytest.raises(ValueError, match="not supported"):
_ = html(t"")
with pytest.raises(ValueError, match="not supported"):
@@ -229,8 +253,15 @@ def test_singleton_str(self):
def test_singleton_object(self):
assert html(t"{0}
") == "0
"
- def test_singleton_has_dunder_html(self):
- content = LiteralHTML("Alright! ")
+ @pytest.mark.parametrize(
+ "html_dunder_cls",
+ (
+ LiteralHTML,
+ Markup,
+ ),
+ )
+ def test_singleton_has_html_dunder(self, html_dunder_cls):
+ content = html_dunder_cls("Alright! ")
assert html(t"{content}
") == "Alright!
"
def test_singleton_simple_template(self):
@@ -256,8 +287,16 @@ def test_templated_str(self):
def test_templated_object(self):
assert html(t"Response: {0}.
") == "Response: 0.
"
- def test_templated_has_dunder_html(self):
- text = LiteralHTML("Alright! ")
+
+ @pytest.mark.parametrize(
+ "html_dunder_cls",
+ (
+ LiteralHTML,
+ Markup,
+ ),
+ )
+ def test_templated_has_html_dunder(self, html_dunder_cls):
+ text = html_dunder_cls("Alright! ")
assert (
html(t"Response: {text}.
") == "Response: Alright! .
"
)
@@ -388,11 +427,18 @@ def test_singleton_object(self):
content = 0
assert html(t"") == ""
- def test_singleton_has_dunder_html_pitfall(self):
+ @pytest.mark.parametrize(
+ "html_dunder_cls",
+ (
+ LiteralHTML,
+ Markup,
+ ),
+ )
+ def test_singleton_has_html_dunder_pitfall(self, html_dunder_cls):
# @TODO: We should probably put some double override to prevent this by accident.
# Or just disable this and if people want to do this then put the
# content in a SCRIPT and inject the whole thing with a __html__?
- content = LiteralHTML("")
+ content = html_dunder_cls("")
assert html(t"") == "", (
"DO NOT DO THIS! This is just an advanced escape hatch! Use a data attribute and parseJSON!"
)
@@ -424,8 +470,15 @@ def test_templated_object(self):
== ""
)
- def test_templated_has_dunder_html(self):
- content = LiteralHTML("anything")
+ @pytest.mark.parametrize(
+ "html_dunder_cls",
+ (
+ LiteralHTML,
+ Markup,
+ ),
+ )
+ def test_templated_has_html_dunder(self, html_dunder_cls):
+ content = html_dunder_cls("anything")
with pytest.raises(ValueError, match="not supported"):
_ = html(t"")
@@ -468,11 +521,18 @@ def test_singleton_object(self):
content = 0
assert html(t"") == ""
- def test_singleton_has_dunder_html_pitfall(self):
+ @pytest.mark.parametrize(
+ "html_dunder_cls",
+ (
+ LiteralHTML,
+ Markup,
+ ),
+ )
+ def test_singleton_has_html_dunder_pitfall(self, html_dunder_cls):
# @TODO: We should probably put some double override to prevent this by accident.
# Or just disable this and if people want to do this then put the
# content in a STYLE and inject the whole thing with a __html__?
- content = LiteralHTML("")
+ content = html_dunder_cls("")
assert html(t"") == "", (
"DO NOT DO THIS! This is just an advanced escape hatch!"
)
@@ -504,8 +564,15 @@ def test_templated_object(self):
== ""
)
- def test_templated_has_dunder_html(self):
- content = LiteralHTML("anything")
+ @pytest.mark.parametrize(
+ "html_dunder_cls",
+ (
+ LiteralHTML,
+ Markup,
+ ),
+ )
+ def test_templated_has_html_dunder(self, html_dunder_cls):
+ content = html_dunder_cls("anything")
with pytest.raises(ValueError, match="not supported"):
_ = html(t"")
@@ -549,9 +616,16 @@ def test_singleton_object(self):
content = 0
assert html(t"{content} ") == "0 "
- def test_singleton_has_dunder_html_pitfall(self):
+ @pytest.mark.parametrize(
+ "html_dunder_cls",
+ (
+ LiteralHTML,
+ Markup,
+ ),
+ )
+ def test_singleton_has_html_dunder_pitfall(self, html_dunder_cls):
# @TODO: We should probably put some double override to prevent this by accident.
- content = LiteralHTML("")
+ content = html_dunder_cls("")
assert html(t"{content} ") == " ", (
"DO NOT DO THIS! This is just an advanced escape hatch!"
)
@@ -580,8 +654,15 @@ def test_templated_object(self):
== "A great number: 0 "
)
- def test_templated_has_dunder_html(self):
- content = LiteralHTML("No")
+ @pytest.mark.parametrize(
+ "html_dunder_cls",
+ (
+ LiteralHTML,
+ Markup,
+ ),
+ )
+ def test_templated_has_html_dunder(self, html_dunder_cls):
+ content = html_dunder_cls("No")
with pytest.raises(ValueError, match="not supported"):
_ = html(t"Literal html?: {content} ")
@@ -624,9 +705,16 @@ def test_singleton_object(self):
content = 0
assert html(t"") == ""
- def test_singleton_has_dunder_html_pitfall(self):
+ @pytest.mark.parametrize(
+ "html_dunder_cls",
+ (
+ LiteralHTML,
+ Markup,
+ ),
+ )
+ def test_singleton_has_html_dunder_pitfall(self, html_dunder_cls):
# @TODO: We should probably put some double override to prevent this by accident.
- content = LiteralHTML("")
+ content = html_dunder_cls("")
assert (
html(t"")
== ""
@@ -659,8 +747,15 @@ def test_templated_object(self):
== ""
)
- def test_templated_has_dunder_html(self):
- content = LiteralHTML("No")
+ @pytest.mark.parametrize(
+ "html_dunder_cls",
+ (
+ LiteralHTML,
+ Markup,
+ ),
+ )
+ def test_templated_has_html_dunder(self, html_dunder_cls):
+ content = html_dunder_cls("No")
with pytest.raises(ValueError, match="not supported"):
_ = html(t"")
From d759c173449b92628480a410549bf7948662156f Mon Sep 17 00:00:00 2001
From: Ian Wilson
Date: Fri, 10 Apr 2026 00:50:14 -0700
Subject: [PATCH 23/30] Formatting...
---
tdom/processor.py | 7 ++++++-
tdom/processor_test.py | 1 -
2 files changed, 6 insertions(+), 2 deletions(-)
diff --git a/tdom/processor.py b/tdom/processor.py
index 2b0d78d..569d36e 100644
--- a/tdom/processor.py
+++ b/tdom/processor.py
@@ -485,7 +485,12 @@ def copy(
type NormalTextInterpolationValue = (
- None | str | HasHTMLDunder | Template | Iterable[NormalTextInterpolationValue] | object
+ None
+ | str
+ | HasHTMLDunder
+ | Template
+ | Iterable[NormalTextInterpolationValue]
+ | object
)
# Applies to both escapable raw text and raw text.
type RawTextExactInterpolationValue = None | str | HasHTMLDunder | object
diff --git a/tdom/processor_test.py b/tdom/processor_test.py
index b06f0a9..12a3ce8 100644
--- a/tdom/processor_test.py
+++ b/tdom/processor_test.py
@@ -287,7 +287,6 @@ def test_templated_str(self):
def test_templated_object(self):
assert html(t"Response: {0}.
") == "Response: 0.
"
-
@pytest.mark.parametrize(
"html_dunder_cls",
(
From 461ba3c22a74335e93902d7e5cbbe01bb91c9248 Mon Sep 17 00:00:00 2001
From: Ian Wilson
Date: Fri, 10 Apr 2026 08:30:28 -0700
Subject: [PATCH 24/30] Commit process_tnode to dispatching.
---
tdom/processor.py | 71 ++++++++++++++++++++++++++++--------------
tdom/processor_test.py | 1 -
2 files changed, 47 insertions(+), 25 deletions(-)
diff --git a/tdom/processor.py b/tdom/processor.py
index 569d36e..b81ffb2 100644
--- a/tdom/processor.py
+++ b/tdom/processor.py
@@ -551,21 +551,11 @@ def _process_tnode(
"""
match tnode:
case TDocumentType(text):
- if last_ctx.ns != "html":
- # Nit
- raise ValueError(
- "Cannot process document type in subtree of a foreign element."
- )
- if self.uppercase_doctype:
- return f""
- else:
- return f""
+ return self._process_document_type(last_ctx, text)
case TComment(ref):
return self._process_comment(template, last_ctx, ref)
case TFragment(children):
- return "".join(
- self._process_tnode(template, last_ctx, child) for child in children
- )
+ return self._process_fragment(template, last_ctx, children)
case TComponent(start_i_index, end_i_index, attrs, children):
return self._process_component(
template, last_ctx, attrs, start_i_index, end_i_index
@@ -573,21 +563,54 @@ def _process_tnode(
case TElement(tag, attrs, children):
return self._process_element(template, last_ctx, tag, attrs, children)
case TText(ref):
- if last_ctx.parent_tag is None:
- raise NotImplementedError(
- "We cannot interpolate texts without knowing what tag they are contained in."
- )
- elif last_ctx.parent_tag in CDATA_CONTENT_ELEMENTS:
- # Must be handled all at once.
- return self._process_raw_texts(template, last_ctx, ref)
- elif last_ctx.parent_tag in RCDATA_CONTENT_ELEMENTS:
- # We can handle all at once because there are no non-text children and everything must be string-ified.
- return self._process_escapable_raw_texts(template, last_ctx, ref)
- else:
- return self._process_normal_texts(template, last_ctx, ref)
+ return self._process_texts(template, last_ctx, ref)
case _:
raise ValueError(f"Unrecognized tnode: {tnode}")
+ def _process_document_type(
+ self,
+ last_ctx: ProcessContext,
+ text: str,
+ ) -> str:
+ if last_ctx.ns != "html":
+ # Nit
+ raise ValueError(
+ "Cannot process document type in subtree of a foreign element."
+ )
+ if self.uppercase_doctype:
+ return f""
+ else:
+ return f""
+
+ def _process_fragment(
+ self,
+ template: Template,
+ last_ctx: ProcessContext,
+ children: Iterable[TNode],
+ ) -> str:
+ return "".join(
+ self._process_tnode(template, last_ctx, child) for child in children
+ )
+
+ def _process_texts(
+ self,
+ template: Template,
+ last_ctx: ProcessContext,
+ ref: TemplateRef,
+ ) -> str:
+ if last_ctx.parent_tag is None:
+ raise NotImplementedError(
+ "We cannot interpolate texts without knowing what tag they are contained in."
+ )
+ elif last_ctx.parent_tag in CDATA_CONTENT_ELEMENTS:
+ # Must be handled all at once.
+ return self._process_raw_texts(template, last_ctx, ref)
+ elif last_ctx.parent_tag in RCDATA_CONTENT_ELEMENTS:
+ # We can handle all at once because there are no non-text children and everything must be string-ified.
+ return self._process_escapable_raw_texts(template, last_ctx, ref)
+ else:
+ return self._process_normal_texts(template, last_ctx, ref)
+
def _process_comment(
self,
template: Template,
diff --git a/tdom/processor_test.py b/tdom/processor_test.py
index 12a3ce8..8d3ddbb 100644
--- a/tdom/processor_test.py
+++ b/tdom/processor_test.py
@@ -22,7 +22,6 @@
)
from .protocols import HasHTMLDunder
-
processor_api = processor_service_factory(slash_void=True, uppercase_doctype=True)
From 889c0b73bcaa19ef64a0a7690d479d94211c1a32 Mon Sep 17 00:00:00 2001
From: Ian Wilson
Date: Fri, 10 Apr 2026 08:49:04 -0700
Subject: [PATCH 25/30] Breakup conditional and add explanation.
---
tdom/escaping.py | 9 ++++++---
1 file changed, 6 insertions(+), 3 deletions(-)
diff --git a/tdom/escaping.py b/tdom/escaping.py
index de82228..6b6c1ff 100644
--- a/tdom/escaping.py
+++ b/tdom/escaping.py
@@ -15,10 +15,13 @@ def escape_html_comment(text: str, allow_markup: bool = False) -> str:
"""Escape text injected into an HTML comment."""
if not text:
return text
- elif allow_markup and isinstance(text, HasHTMLDunder):
+ if allow_markup and isinstance(text, HasHTMLDunder):
return text.__html__()
- elif not allow_markup and type(text) is not str:
- # text manipulation triggers regular html escapes on Markup
+
+ if not allow_markup and type(text) is not str:
+ # String manipulation triggers regular html escapes on Markup
+ # so we coerce the subclass of `str` into a true `str` before
+ # we start string manipulating.
text = str(text)
# - text must not start with the string ">"
From 582d64bf5e66b23ca5b33eedc7e97cc055d88e80 Mon Sep 17 00:00:00 2001
From: Ian Wilson
Date: Fri, 10 Apr 2026 10:06:25 -0700
Subject: [PATCH 26/30] Add back bool support and remove some unecessary
optimizations to streamline.
---
tdom/processor.py | 56 +++++++++++++++++++++++------------------------
1 file changed, 27 insertions(+), 29 deletions(-)
diff --git a/tdom/processor.py b/tdom/processor.py
index b81ffb2..49027d0 100644
--- a/tdom/processor.py
+++ b/tdom/processor.py
@@ -486,6 +486,7 @@ def copy(
type NormalTextInterpolationValue = (
None
+ | bool # to support `showValue and value` idiom
| str
| HasHTMLDunder
| Template
@@ -493,9 +494,20 @@ def copy(
| object
)
# Applies to both escapable raw text and raw text.
-type RawTextExactInterpolationValue = None | str | HasHTMLDunder | object
+type RawTextExactInterpolationValue = (
+ None
+ | bool # to support `showValue and value` idiom
+ | str
+ | HasHTMLDunder
+ | object
+)
# Applies to both escapable raw text and raw text.
-type RawTextInexactInterpolationValue = None | str | object
+type RawTextInexactInterpolationValue = (
+ None
+ | bool # to support `showValue and value` idiom
+ | str
+ | object
+)
@dataclass(frozen=True)
@@ -620,12 +632,9 @@ def _process_comment(
"""
Process a comment into a string.
"""
- content = resolve_text_without_recursion(template, ""
+ content_str = resolve_text_without_recursion(template, ""
def _process_element(
self,
@@ -760,9 +769,7 @@ def _process_raw_texts(
content = resolve_text_without_recursion(
template, last_ctx.parent_tag, content_ref
)
- if content is None or content == "":
- return ""
- elif last_ctx.parent_tag == "script":
+ if last_ctx.parent_tag == "script":
return self.escape_html_script(
content,
allow_markup=True,
@@ -790,12 +797,7 @@ def _process_escapable_raw_texts(
content = resolve_text_without_recursion(
template, last_ctx.parent_tag, content_ref
)
- if content is None or content == "":
- return ""
- else:
- return self.escape_html_text(
- content,
- )
+ return self.escape_html_text(content)
def _process_normal_texts(
self, template: Template, last_ctx: ProcessContext, content_ref: TemplateRef
@@ -839,7 +841,9 @@ def _process_normal_text_from_value(
@NOTE: This is an actual value and NOT an interpolation. This is meant to be
used when processing an iterable of values as normal text.
"""
- if isinstance(value, str):
+ if value is None or isinstance(value, bool):
+ return ""
+ elif isinstance(value, str):
# @NOTE: This would apply to Markup() but not to a custom object
# implementing HasHTMLDunder.
return self.escape_html_text(value)
@@ -851,9 +855,6 @@ def _process_normal_text_from_value(
self._process_normal_text_from_value(template, last_ctx, v)
for v in value
)
- elif value is None:
- # @DESIGN: Ignore None.
- return ""
elif isinstance(value, HasHTMLDunder):
# @NOTE: markupsafe's escape does this for us but we put this in
# here for completeness.
@@ -868,7 +869,7 @@ def _process_normal_text_from_value(
def resolve_text_without_recursion(
template: Template, parent_tag: str, content_ref: TemplateRef
-) -> str | None:
+) -> str:
"""
Resolve the text in the given template without recursing into more structured text.
@@ -880,8 +881,8 @@ def resolve_text_without_recursion(
if content_ref.is_singleton:
value = format_interpolation(template.interpolations[content_ref.i_indexes[0]])
value = t.cast(RawTextExactInterpolationValue, value) # ty: ignore[redundant-cast]
- if value is None:
- return None
+ if value is None or isinstance(value, bool):
+ return ""
elif isinstance(value, str):
return value
elif isinstance(value, HasHTMLDunder):
@@ -903,7 +904,7 @@ def resolve_text_without_recursion(
continue
value = format_interpolation(template.interpolations[part])
value = t.cast(RawTextInexactInterpolationValue, value) # ty: ignore[redundant-cast]
- if value is None:
+ if value is None or isinstance(value, bool):
continue
elif (
type(value) is str
@@ -922,10 +923,7 @@ def resolve_text_without_recursion(
value_str = str(value)
if value_str:
text.append(value_str)
- if text:
- return "".join(text)
- else:
- return None
+ return "".join(text)
def extract_embedded_template(
From eacff4ff5a8360018c0daa4744017ef426f43923 Mon Sep 17 00:00:00 2001
From: Ian Wilson
Date: Fri, 10 Apr 2026 10:06:45 -0700
Subject: [PATCH 27/30] Formatting.
---
tdom/processor.py | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/tdom/processor.py b/tdom/processor.py
index 49027d0..6277b23 100644
--- a/tdom/processor.py
+++ b/tdom/processor.py
@@ -486,7 +486,7 @@ def copy(
type NormalTextInterpolationValue = (
None
- | bool # to support `showValue and value` idiom
+ | bool # to support `showValue and value` idiom
| str
| HasHTMLDunder
| Template
@@ -496,7 +496,7 @@ def copy(
# Applies to both escapable raw text and raw text.
type RawTextExactInterpolationValue = (
None
- | bool # to support `showValue and value` idiom
+ | bool # to support `showValue and value` idiom
| str
| HasHTMLDunder
| object
@@ -504,7 +504,7 @@ def copy(
# Applies to both escapable raw text and raw text.
type RawTextInexactInterpolationValue = (
None
- | bool # to support `showValue and value` idiom
+ | bool # to support `showValue and value` idiom
| str
| object
)
From f92d5cace7ba2b84804b49b7b0583ca7ac0d0be6 Mon Sep 17 00:00:00 2001
From: Ian Wilson
Date: Fri, 10 Apr 2026 10:29:37 -0700
Subject: [PATCH 28/30] Drop system support, extend children kwarg coverage.
---
tdom/processor.py | 27 ++++------------
tdom/processor_test.py | 72 ++++++++++++++++++++++--------------------
2 files changed, 45 insertions(+), 54 deletions(-)
diff --git a/tdom/processor.py b/tdom/processor.py
index 6277b23..ef53230 100644
--- a/tdom/processor.py
+++ b/tdom/processor.py
@@ -1,6 +1,6 @@
import typing as t
from collections.abc import Callable, Iterable, Sequence
-from dataclasses import dataclass, field
+from dataclasses import dataclass
from functools import lru_cache
from string.templatelib import Interpolation, Template
@@ -386,7 +386,7 @@ def _kebab_to_snake(name: str) -> str:
def _prep_component_kwargs(
callable_info: CallableInfo,
attrs: AttributesDict,
- system_kwargs: AttributesDict,
+ children: Template,
) -> AttributesDict:
if callable_info.requires_positional:
raise TypeError(
@@ -401,9 +401,8 @@ def _prep_component_kwargs(
if snake_name in callable_info.named_params or callable_info.kwargs:
kwargs[snake_name] = attr_value
- for attr_name, attr_value in system_kwargs.items():
- if attr_name in callable_info.named_params or callable_info.kwargs:
- kwargs[attr_name] = attr_value
+ if "children" in callable_info.named_params or callable_info.kwargs:
+ kwargs["children"] = children
# Check to make sure we've fully satisfied the callable's requirements
missing = callable_info.required_named_params - kwargs.keys()
@@ -436,12 +435,8 @@ def _fix_svg_attrs(html_attrs: Iterable[HTMLAttribute]) -> Iterable[HTMLAttribut
yield SVG_ATTR_FIX.get(k, k), v
-def make_ctx(
- parent_tag: str | None = None, ns: str | None = "html", system: dict | None = None
-):
- if system is None:
- system = {}
- return ProcessContext(parent_tag=parent_tag, ns=ns, system=system)
+def make_ctx(parent_tag: str | None = None, ns: str | None = "html"):
+ return ProcessContext(parent_tag=parent_tag, ns=ns)
@dataclass(frozen=True, slots=True)
@@ -451,13 +446,10 @@ class ProcessContext:
# None means unknown not just a missing value.
ns: str | None = None
- system: dict = field(default_factory=dict)
-
def copy(
self,
ns: NotSet | str | None = NOT_SET,
parent_tag: NotSet | str | None = NOT_SET,
- system: NotSet | dict = NOT_SET,
):
if isinstance(ns, NotSet):
resolved_ns = self.ns
@@ -467,14 +459,9 @@ def copy(
resolved_parent_tag = self.parent_tag
else:
resolved_parent_tag = parent_tag
- if isinstance(system, NotSet):
- resolved_system = self.system
- else:
- resolved_system = system
return make_ctx(
parent_tag=resolved_parent_tag,
ns=resolved_ns,
- system=resolved_system,
)
@@ -732,7 +719,7 @@ def _process_component(
kwargs = _prep_component_kwargs(
get_callable_info(component_callable),
_resolve_t_attrs(attrs, template.interpolations),
- system_kwargs={**last_ctx.system, "children": children_template},
+ children=children_template,
)
result_t = component_callable(**kwargs)
diff --git a/tdom/processor_test.py b/tdom/processor_test.py
index 8d3ddbb..15dbafd 100644
--- a/tdom/processor_test.py
+++ b/tdom/processor_test.py
@@ -1365,13 +1365,13 @@ def InputElement(size=10, type="text"):
pass
callable_info = get_callable_info(InputElement)
- assert prep_component_kwargs(callable_info, {"size": 20}, system_kwargs={}) == {
+ assert prep_component_kwargs(callable_info, {"size": 20}, children=t"") == {
"size": 20
}
assert prep_component_kwargs(
- callable_info, {"type": "email"}, system_kwargs={}
+ callable_info, {"type": "email"}, children=t""
) == {"type": "email"}
- assert prep_component_kwargs(callable_info, {}, system_kwargs={}) == {}
+ assert prep_component_kwargs(callable_info, {}, children=t"") == {}
@pytest.mark.skip("Should we just ignore unused user-specified kwargs?")
def test_unused_kwargs(self):
@@ -1381,10 +1381,43 @@ def InputElement(size=10, type="text"):
callable_info = get_callable_info(InputElement)
with pytest.raises(ValueError):
assert (
- prep_component_kwargs(callable_info, {"type2": 15}, system_kwargs={})
- == {}
+ prep_component_kwargs(callable_info, {"type2": 15}, children=t"") == {}
)
+ def test_accepts_children(self):
+ def DivWrapper(
+ children: Template, add_classes: list[str] | None = None
+ ) -> Template:
+ return t"{children}
"
+
+ callable_info = get_callable_info(DivWrapper)
+ kwargs = prep_component_kwargs(callable_info, {}, children=t"")
+ assert tuple(kwargs.keys()) == ("children",)
+ assert isinstance(kwargs["children"], Template) and kwargs[
+ "children"
+ ].strings == ("",)
+
+ add_classes = ["red"]
+ kwargs = prep_component_kwargs(
+ callable_info, {"add_classes": add_classes}, children=t" "
+ )
+ assert set(kwargs.keys()) == {"children", "add_classes"}
+ assert isinstance(kwargs["children"], Template) and kwargs[
+ "children"
+ ].strings == (" ",)
+ assert kwargs["add_classes"] == add_classes
+
+ def test_no_children(self):
+ def SpanMaker(content_text: str) -> Template:
+ return t"{content_text} "
+
+ callable_info = get_callable_info(SpanMaker)
+ content_text = "inner"
+ kwargs = prep_component_kwargs(
+ callable_info, {"content_text": content_text}, children=t"
"
+ )
+ assert kwargs == {"content_text": content_text} # no children
+
class TestFunctionComponent:
@staticmethod
@@ -2010,32 +2043,3 @@ def test_mathml():
is not a decimal number.
"""
)
-
-
-def test_framework_system_kwargs():
- from .processor import ProcessContext
-
- # Usually a framework would provide some "request context" mechanism,
- # but for this test we just use a global dictionary.
- g = {"theme": "default-theme"}
-
- def BodyComp(children: Template) -> Template:
- return t"<{ContentComp}>{children}{ContentComp}>"
-
- def ContentComp(system_barge: dict, children: Template) -> Template:
- theme = system_barge["theme"]
- return t"{children}
"
-
- content = "Test Content"
- page_t = (
- t"<{BodyComp}>{content} {BodyComp}>"
- )
- assert (
- html(
- page_t,
- assume_ctx=ProcessContext(
- parent_tag=None, ns="html", system={"system_barge": g}
- ),
- )
- == 'Test Content
'
- )
From b0b5e1875b4cd4d7069c260abc4293e5f38a63fc Mon Sep 17 00:00:00 2001
From: Ian Wilson
Date: Fri, 10 Apr 2026 19:45:08 -0700
Subject: [PATCH 29/30] Add bool tests.
---
tdom/processor_test.py | 60 ++++++++++++++++++++++++++++++++++++++++++
1 file changed, 60 insertions(+)
diff --git a/tdom/processor_test.py b/tdom/processor_test.py
index 15dbafd..f4fc5fa 100644
--- a/tdom/processor_test.py
+++ b/tdom/processor_test.py
@@ -126,6 +126,10 @@ def test_singleton_object(self):
def test_singleton_none(self):
assert html(t"") == ""
+ @pytest.mark.parametrize("bool_value", (True, False))
+ def test_singleton_bool(self, bool_value):
+ assert html(t"") == ""
+
@pytest.mark.parametrize(
"html_dunder_cls",
(
@@ -156,6 +160,10 @@ def test_templated_object(self):
def test_templated_none(self):
assert html(t"") == ""
+ @pytest.mark.parametrize("bool_value", (True, False))
+ def test_templated_bool(self, bool_value):
+ assert html(t"") == ""
+
@pytest.mark.parametrize(
"html_dunder_cls",
(
@@ -249,6 +257,10 @@ def test_singleton_str(self):
name = "Alice"
assert html(t"{name}
") == "Alice
"
+ @pytest.mark.parametrize("bool_value", (True, False))
+ def test_singleton_bool(self, bool_value):
+ assert html(t"{bool_value}
") == "
"
+
def test_singleton_object(self):
assert html(t"{0}
") == "0
"
@@ -283,6 +295,10 @@ def test_templated_str(self):
name = "Alice"
assert html(t"Response: {name}.
") == "Response: Alice.
"
+ @pytest.mark.parametrize("bool_value", (True, False))
+ def test_templated_bool(self, bool_value):
+ assert html(t"Response: {bool_value}
") == "Response:
"
+
def test_templated_object(self):
assert html(t"Response: {0}.
") == "Response: 0.
"
@@ -421,6 +437,10 @@ def test_singleton_str(self):
content = "var x = 1;"
assert html(t"") == ""
+ @pytest.mark.parametrize("bool_value", (True, False))
+ def test_singleton_bool(self, bool_value):
+ assert html(t"") == ""
+
def test_singleton_object(self):
content = 0
assert html(t"") == ""
@@ -461,6 +481,13 @@ def test_templated_str(self):
== ""
)
+ @pytest.mark.parametrize("bool_value", (True, False))
+ def test_templated_bool(self, bool_value):
+ assert (
+ html(t"")
+ == ""
+ )
+
def test_templated_object(self):
content = 0
assert (
@@ -515,6 +542,10 @@ def test_singleton_str(self):
== ""
)
+ @pytest.mark.parametrize("bool_value", (True, False))
+ def test_singleton_bool(self, bool_value):
+ assert html(t"") == ""
+
def test_singleton_object(self):
content = 0
assert html(t"") == ""
@@ -555,6 +586,13 @@ def test_templated_str(self):
== ""
)
+ @pytest.mark.parametrize("bool_value", (True, False))
+ def test_templated_bool(self, bool_value):
+ assert (
+ html(t"")
+ == ""
+ )
+
def test_templated_object(self):
padding_right = 0
assert (
@@ -610,6 +648,10 @@ def test_singleton_str(self):
content = "Welcome To TDOM"
assert html(t"{content} ") == "Welcome To TDOM "
+ @pytest.mark.parametrize("bool_value", (True, False))
+ def test_singleton_bool(self, bool_value):
+ assert html(t"{bool_value} ") == " "
+
def test_singleton_object(self):
content = 0
assert html(t"{content} ") == "0 "
@@ -645,6 +687,13 @@ def test_templated_str(self):
== "A great story about: TDOM "
)
+ @pytest.mark.parametrize("bool_value", (True, False))
+ def test_templated_bool(self, bool_value):
+ assert (
+ html(t"A great story; {bool_value} ")
+ == "A great story; "
+ )
+
def test_templated_object(self):
content = 0
assert (
@@ -699,6 +748,10 @@ def test_singleton_str(self):
== ""
)
+ @pytest.mark.parametrize("bool_value", (True, False))
+ def test_singleton_bool(self, bool_value):
+ assert html(t"") == ""
+
def test_singleton_object(self):
content = 0
assert html(t"") == ""
@@ -738,6 +791,13 @@ def test_templated_str(self):
== ""
)
+ @pytest.mark.parametrize("bool_value", (True, False))
+ def test_templated_bool(self, bool_value):
+ assert (
+ html(t"")
+ == ""
+ )
+
def test_templated_object(self):
content = 0
assert (
From b753510cc03b2b00f07518d778406456ae65c5b9 Mon Sep 17 00:00:00 2001
From: Ian Wilson
Date: Fri, 10 Apr 2026 21:01:43 -0700
Subject: [PATCH 30/30] Remove now unused class.
---
tdom/processor.py | 5 -----
1 file changed, 5 deletions(-)
diff --git a/tdom/processor.py b/tdom/processor.py
index ef53230..59aab19 100644
--- a/tdom/processor.py
+++ b/tdom/processor.py
@@ -414,11 +414,6 @@ def _prep_component_kwargs(
return kwargs
-@dataclass
-class EndTag:
- end_tag: str
-
-
def serialize_html_attrs(
html_attrs: Iterable[HTMLAttribute], escape: Callable = default_escape_html_text
) -> str: