From ab742260564ee3c7bfdd6a4f5e86ae216088c410 Mon Sep 17 00:00:00 2001 From: Ian Wilson Date: Mon, 9 Feb 2026 22:23:01 -0800 Subject: [PATCH 01/59] Add helper method to parallel Template.__iter__. --- tdom/template_utils.py | 11 +++++++++++ tdom/template_utils_test.py | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/tdom/template_utils.py b/tdom/template_utils.py index 39dc8b3..cbdcde6 100644 --- a/tdom/template_utils.py +++ b/tdom/template_utils.py @@ -74,3 +74,14 @@ def __post_init__(self): raise ValueError( "TemplateRef must have one more string than interpolation indexes." ) + + def __iter__(self): + index = 0 + last_s_index = len(self.strings) - 1 + while index <= last_s_index: + s = self.strings[index] + if s: + yield s + if index < last_s_index: + yield self.i_indexes[index] + index += 1 diff --git a/tdom/template_utils_test.py b/tdom/template_utils_test.py index 971b1ee..2eca85d 100644 --- a/tdom/template_utils_test.py +++ b/tdom/template_utils_test.py @@ -55,3 +55,39 @@ def test_combine_template_refs(): assert combine_template_refs(*template_refs) == TemplateRef.from_naive_template( t"abc{0}def{1}{2}ghi" ) + + +def test_template_ref_iter_singleton(): + assert list(TemplateRef.from_naive_template(t"{1}")) == [1] + + +def test_template_ref_iter_empty(): + assert list(TemplateRef.from_naive_template(t"")) == [] + + +def test_template_ref_iter_empty_prefix(): + assert list(TemplateRef.from_naive_template(t"{1}def")) == [1, "def"] + + +def test_template_ref_iter_empty_suffix(): + assert list(TemplateRef.from_naive_template(t"abc{1}")) == ["abc", 1] + + +def test_template_ref_iter_literal(): + assert list(TemplateRef.from_naive_template(t"abc")) == ["abc"] + + +def test_template_ref_iter_only_interpolations(): + assert list(TemplateRef.from_naive_template(t"{1}{3}{5}")) == [1, 3, 5] + + +def test_template_ref_iter_complete(): + assert list(TemplateRef.from_naive_template(t"abc{1}def{3}ghi{5}jkl")) == [ + "abc", + 1, + "def", + 3, + "ghi", + 5, + "jkl", + ] From 0c76fc7b61c62976d1d4439b81bddcdacc4a74b6 Mon Sep 17 00:00:00 2001 From: Ian Wilson Date: Mon, 9 Feb 2026 22:33:29 -0800 Subject: [PATCH 02/59] Move html dunder protocol into module and add tests. --- tdom/processor.py | 6 +----- tdom/protocols.py | 6 ++++++ tdom/protocols_test.py | 24 ++++++++++++++++++++++++ 3 files changed, 31 insertions(+), 5 deletions(-) create mode 100644 tdom/protocols.py create mode 100644 tdom/protocols_test.py diff --git a/tdom/processor.py b/tdom/processor.py index 1e2528d..3c4cd0c 100644 --- a/tdom/processor.py +++ b/tdom/processor.py @@ -31,11 +31,7 @@ from .placeholders import TemplateRef from .template_utils import template_from_parts from .utils import CachableTemplate, LastUpdatedOrderedDict - - -@t.runtime_checkable -class HasHTMLDunder(t.Protocol): - def __html__(self) -> str: ... # pragma: no cover +from .protocols import HasHTMLDunder # TODO: in Ian's original PR, this caching was tethered to the diff --git a/tdom/protocols.py b/tdom/protocols.py new file mode 100644 index 0000000..bcb8147 --- /dev/null +++ b/tdom/protocols.py @@ -0,0 +1,6 @@ +import typing as t + + +@t.runtime_checkable +class HasHTMLDunder(t.Protocol): + def __html__(self) -> str: ... # pragma: no cover diff --git a/tdom/protocols_test.py b/tdom/protocols_test.py new file mode 100644 index 0000000..9f7b663 --- /dev/null +++ b/tdom/protocols_test.py @@ -0,0 +1,24 @@ +from markupsafe import Markup, escape + + +from .protocols import HasHTMLDunder + + +class LTEntity: + def __html__(self): + return "<" + + +def test_custom_html_dunder_isinstance_has_html_dunder(): + lt = LTEntity() + assert isinstance(lt, HasHTMLDunder) + + +def test_markup_isinstance_has_html_dunder(): + wrapped_html = Markup(escape("
")) + assert isinstance(wrapped_html, HasHTMLDunder) + + +def test_str_not_isinstance_has_html_dunder(): + html_str = "
" + assert not isinstance(html_str, HasHTMLDunder) From da0337cc22f0ad6f47dbcb8487ba305f8218022f Mon Sep 17 00:00:00 2001 From: Ian Wilson Date: Mon, 9 Feb 2026 23:03:41 -0800 Subject: [PATCH 03/59] Split out component kwargs resolution for testing/reusability. --- tdom/processor.py | 60 +++++++++++++++++++++++++----------------- tdom/processor_test.py | 30 ++++++++++++++++++++- 2 files changed, 65 insertions(+), 25 deletions(-) diff --git a/tdom/processor.py b/tdom/processor.py index 3c4cd0c..bdc36c4 100644 --- a/tdom/processor.py +++ b/tdom/processor.py @@ -7,7 +7,7 @@ from markupsafe import Markup -from .callables import get_callable_info +from .callables import get_callable_info, CallableInfo from .format import format_interpolation as base_format_interpolation from .format import format_template from .nodes import Comment, DocumentType, Element, Fragment, Node, Text @@ -433,6 +433,38 @@ def _kebab_to_snake(name: str) -> str: return name.replace("-", "_").lower() +def _prep_component_kwargs( + callable_info: CallableInfo, + attrs: AttributesDict, + system_kwargs: dict[str, object], +): + if callable_info.requires_positional: + raise TypeError( + "Component callables cannot have required positional arguments." + ) + + kwargs: AttributesDict = {} + + # Add all supported attributes + for attr_name, attr_value in attrs.items(): + snake_name = _kebab_to_snake(attr_name) + 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 + + # Check to make sure we've fully satisfied the callable's requirements + missing = callable_info.required_named_params - kwargs.keys() + if missing: + raise TypeError( + f"Missing required parameters for component: {', '.join(missing)}" + ) + + return kwargs + + def _invoke_component( attrs: AttributesDict, children: list[Node], # TODO: why not TNode, though? @@ -473,29 +505,9 @@ def _invoke_component( ) callable_info = get_callable_info(value) - if callable_info.requires_positional: - raise TypeError( - "Component callables cannot have required positional arguments." - ) - - kwargs: AttributesDict = {} - - # Add all supported attributes - for attr_name, attr_value in attrs.items(): - snake_name = _kebab_to_snake(attr_name) - if snake_name in callable_info.named_params or callable_info.kwargs: - kwargs[snake_name] = attr_value - - # Add children if appropriate - if "children" in callable_info.named_params or callable_info.kwargs: - kwargs["children"] = tuple(children) - - # Check to make sure we've fully satisfied the callable's requirements - missing = callable_info.required_named_params - kwargs.keys() - if missing: - raise TypeError( - f"Missing required parameters for component: {', '.join(missing)}" - ) + kwargs = _prep_component_kwargs( + callable_info, attrs, system_kwargs={"children": tuple(children)} + ) result = value(**kwargs) return _node_from_value(result) diff --git a/tdom/processor_test.py b/tdom/processor_test.py index 11b1c4d..61ed873 100644 --- a/tdom/processor_test.py +++ b/tdom/processor_test.py @@ -9,7 +9,9 @@ from .nodes import Comment, DocumentType, Element, Fragment, Node, Text from .placeholders import make_placeholder_config -from .processor import html +from .processor import html, _prep_component_kwargs +from .callables import get_callable_info + # -------------------------------------------------------------------------- # Basic HTML parsing tests @@ -1103,6 +1105,32 @@ def test_invalid_component_invocation(): _ = html(t"<{FunctionComponent}>Missing props") +def test_prep_component_kwargs_named(): + 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_prep_component_kwargs_unused_kwargs(): + 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 FunctionComponentNoChildren(first: str, second: int, third_arg: str) -> Template: # Ensure type correctness of props at runtime for testing purposes assert isinstance(first, str) From a66140442e47ed3bd4ed2ceec6487db7b27bdd66 Mon Sep 17 00:00:00 2001 From: Ian Wilson Date: Mon, 9 Feb 2026 23:37:41 -0800 Subject: [PATCH 04/59] Rename html to to_node and introduce to_html for string serialization. --- tdom/__init__.py | 4 +- tdom/processor.py | 15 ++- tdom/processor_test.py | 253 +++++++++++++++++++++-------------------- 3 files changed, 145 insertions(+), 127 deletions(-) diff --git a/tdom/__init__.py b/tdom/__init__.py index 4503582..44ff7fa 100644 --- a/tdom/__init__.py +++ b/tdom/__init__.py @@ -1,7 +1,7 @@ from markupsafe import Markup, escape from .nodes import Comment, DocumentType, Element, Fragment, Node, Text -from .processor import html +from .processor import to_html, to_node, html # We consider `Markup` and `escape` to be part of this module's public API @@ -11,6 +11,8 @@ "Element", "escape", "Fragment", + "to_html", + "to_node", "html", "Markup", "Node", diff --git a/tdom/processor.py b/tdom/processor.py index bdc36c4..3ebea74 100644 --- a/tdom/processor.py +++ b/tdom/processor.py @@ -409,7 +409,7 @@ def _node_from_value(value: object) -> Node: case Node(): return value case Template(): - return html(value) + return to_node(value) # Consider: falsey values, not just False and None? case False | None: return Fragment(children=[]) @@ -598,8 +598,19 @@ def _resolve_t_node(t_node: TNode, interpolations: tuple[Interpolation, ...]) -> # -------------------------------------------------------------------------- -def html(template: Template) -> Node: +def to_html(template: Template) -> str: + """Parse an HTML t-string, substitue values, and return a string of HTML.""" + cachable = CachableTemplate(template) + t_node = _parse_and_cache(cachable) + return str(_resolve_t_node(t_node, template.interpolations)) + + +def to_node(template: Template) -> Node: """Parse an HTML t-string, substitue values, and return a tree of Nodes.""" cachable = CachableTemplate(template) t_node = _parse_and_cache(cachable) return _resolve_t_node(t_node, template.interpolations) + + +# BWC: SHIM +html = to_node diff --git a/tdom/processor_test.py b/tdom/processor_test.py index 61ed873..155f68a 100644 --- a/tdom/processor_test.py +++ b/tdom/processor_test.py @@ -9,10 +9,15 @@ from .nodes import Comment, DocumentType, Element, Fragment, Node, Text from .placeholders import make_placeholder_config -from .processor import html, _prep_component_kwargs +from .processor import to_node, to_html, _prep_component_kwargs from .callables import get_callable_info +def test_to_node_and_to_html(): + div_t = t"
" + assert str(to_node(div_t)) == to_html(div_t) + + # -------------------------------------------------------------------------- # Basic HTML parsing tests # -------------------------------------------------------------------------- @@ -22,34 +27,34 @@ # Text # def test_empty(): - node = html(t"") + node = to_node(t"") assert node == Fragment(children=[]) assert str(node) == "" def test_text_literal(): - node = html(t"Hello, world!") + node = to_node(t"Hello, world!") assert node == Text("Hello, world!") assert str(node) == "Hello, world!" def test_text_singleton(): greeting = "Hello, Alice!" - node = html(t"{greeting}") + node = to_node(t"{greeting}") assert node == Text("Hello, Alice!") assert str(node) == "Hello, Alice!" def test_text_template(): name = "Alice" - node = html(t"Hello, {name}!") + node = to_node(t"Hello, {name}!") assert node == Fragment(children=[Text("Hello, "), Text("Alice"), Text("!")]) assert str(node) == "Hello, Alice!" def test_text_template_escaping(): name = "Alice & Bob" - node = html(t"Hello, {name}!") + node = to_node(t"Hello, {name}!") assert node == Fragment(children=[Text("Hello, "), Text("Alice & Bob"), Text("!")]) assert str(node) == "Hello, Alice & Bob!" @@ -58,21 +63,21 @@ def test_text_template_escaping(): # Comments. # def test_comment(): - node = html(t"") + node = to_node(t"") assert node == Comment("This is a comment") assert str(node) == "" def test_comment_template(): text = "comment" - node = html(t"") + node = to_node(t"") assert node == Comment("This is a comment") assert str(node) == "" def test_comment_template_escaping(): text = "-->comment" - node = html(t"") + node = to_node(t"") assert node == Comment("This is a -->comment") assert str(node) == "" @@ -81,7 +86,7 @@ def test_comment_template_escaping(): # Document types. # def test_parse_document_type(): - node = html(t"") + node = to_node(t"") assert node == DocumentType("html") assert str(node) == "" @@ -90,20 +95,20 @@ def test_parse_document_type(): # Elements # def test_parse_void_element(): - node = html(t"
") + node = to_node(t"
") assert node == Element("br") assert str(node) == "
" def test_parse_void_element_self_closed(): - node = html(t"
") + node = to_node(t"
") assert node == Element("br") assert str(node) == "
" def test_parse_chain_of_void_elements(): # Make sure our handling of CPython issue #69445 is reasonable. - node = html(t"



") + node = to_node(t"



") assert node == Fragment( children=[ Element("br"), @@ -117,7 +122,7 @@ def test_parse_chain_of_void_elements(): def test_parse_element_with_text(): - node = html(t"

Hello, world!

") + node = to_node(t"

Hello, world!

") assert node == Element( "p", children=[ @@ -128,7 +133,7 @@ def test_parse_element_with_text(): def test_parse_nested_elements(): - node = html(t"

Hello

World

") + node = to_node(t"

Hello

World

") assert node == Element( "div", children=[ @@ -140,7 +145,7 @@ def test_parse_nested_elements(): def test_parse_entities_are_escaped(): - node = html(t"

</p>

") + node = to_node(t"

</p>

") assert node == Element( "p", children=[Text("

")], @@ -155,14 +160,14 @@ def test_parse_entities_are_escaped(): def test_interpolated_text_content(): name = "Alice" - node = html(t"

Hello, {name}!

") + node = to_node(t"

Hello, {name}!

") assert node == Element("p", children=[Text("Hello, "), Text("Alice"), Text("!")]) assert str(node) == "

Hello, Alice!

" def test_escaping_of_interpolated_text_content(): name = "" - node = html(t"

Hello, {name}!

") + node = to_node(t"

Hello, {name}!

") assert node == Element( "p", children=[Text("Hello, "), Text(""), Text("!")] ) @@ -181,7 +186,7 @@ def test_conversions(): c = Convertible() assert f"{c!s}" == "string" assert f"{c!r}" == "repr" - node = html(t"
  • {c!s}
  • {c!r}
  • {'😊'!a}
  • ") + node = to_node(t"
  • {c!s}
  • {c!r}
  • {'😊'!a}
  • ") assert node == Fragment( children=[ Element("li", children=[Text("string")]), @@ -194,7 +199,7 @@ def test_conversions(): def test_interpolated_in_content_node(): # https://github.com/t-strings/tdom/issues/68 evil = "") + node = to_node(t"") assert node == Element( "style", children=[ @@ -211,7 +216,7 @@ def test_interpolated_in_content_node(): def test_interpolated_trusted_in_content_node(): # https://github.com/t-strings/tdom/issues/68 - node = html(t"") + node = to_node(t"") assert node == Element( "script", children=[Text("if (a < b && c > d) { alert('wow'); }")], @@ -223,7 +228,7 @@ def test_script_elements_error(): nested_template = t"
    " # Putting non-text content inside a script is not allowed. with pytest.raises(ValueError): - node = html(t"") + node = to_node(t"") _ = str(node) @@ -233,13 +238,13 @@ def test_script_elements_error(): def test_interpolated_false_content(): - node = html(t"
    {False}
    ") + node = to_node(t"
    {False}
    ") assert node == Element("div") assert str(node) == "
    " def test_interpolated_none_content(): - node = html(t"
    {None}
    ") + node = to_node(t"
    {None}
    ") assert node == Element("div", children=[]) assert str(node) == "
    " @@ -248,7 +253,7 @@ def test_interpolated_zero_arg_function(): def get_value(): return "dynamic" - node = html(t"

    The value is {get_value}.

    ") + node = to_node(t"

    The value is {get_value}.

    ") assert node == Element( "p", children=[Text("The value is "), Text("dynamic"), Text(".")] ) @@ -259,7 +264,7 @@ def add(a, b): # pragma: no cover return a + b with pytest.raises(TypeError): - _ = html(t"

    The sum is {add}.

    ") + _ = to_node(t"

    The sum is {add}.

    ") # -------------------------------------------------------------------------- @@ -269,7 +274,7 @@ def add(a, b): # pragma: no cover def test_raw_html_injection_with_markupsafe(): raw_content = Markup("I am bold") - node = html(t"
    {raw_content}
    ") + node = to_node(t"
    {raw_content}
    ") assert node == Element("div", children=[Text(text=raw_content)]) assert str(node) == "
    I am bold
    " @@ -284,7 +289,7 @@ def __html__(self): return f"{self._text}" content = SafeContent("emphasized") - node = html(t"

    Here is some {content}.

    ") + node = to_node(t"

    Here is some {content}.

    ") assert node == Element( "p", children=[ @@ -298,7 +303,7 @@ def __html__(self): def test_raw_html_injection_with_format_spec(): raw_content = "underlined" - node = html(t"

    This is {raw_content:safe} text.

    ") + node = to_node(t"

    This is {raw_content:safe} text.

    ") assert node == Element( "p", children=[ @@ -312,7 +317,7 @@ def test_raw_html_injection_with_format_spec(): def test_raw_html_injection_with_markupsafe_unsafe_format_spec(): supposedly_safe = Markup("italic") - node = html(t"

    This is {supposedly_safe:unsafe} text.

    ") + node = to_node(t"

    This is {supposedly_safe:unsafe} text.

    ") assert node == Element( "p", children=[ @@ -333,7 +338,7 @@ 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}
    ") + node = to_node(t"
    {user_profile if is_logged_in else login_prompt}
    ") assert node == Element( "div", children=[Element("span", children=[Text("Welcome, User!")])] @@ -341,14 +346,14 @@ def test_conditional_rendering_with_if_else(): assert str(node) == "
    Welcome, User!
    " is_logged_in = False - node = html(t"
    {user_profile if is_logged_in else login_prompt}
    ") + node = to_node(t"
    {user_profile if is_logged_in else login_prompt}
    ") assert str(node) == '' def test_conditional_rendering_with_and(): show_warning = True warning_message = t'
    Warning!
    ' - node = html(t"
    {show_warning and warning_message}
    ") + node = to_node(t"
    {show_warning and warning_message}
    ") assert node == Element( "main", @@ -359,7 +364,7 @@ def test_conditional_rendering_with_and(): assert str(node) == '
    Warning!
    ' show_warning = False - node = html(t"
    {show_warning and warning_message}
    ") + node = to_node(t"
    {show_warning and warning_message}
    ") # Assuming False renders nothing assert str(node) == "
    " @@ -371,21 +376,21 @@ def test_conditional_rendering_with_and(): def test_interpolated_template_content(): child = t"Child" - node = html(t"
    {child}
    ") - assert node == Element("div", children=[html(child)]) + node = to_node(t"
    {child}
    ") + assert node == Element("div", children=[to_node(child)]) assert str(node) == "
    Child
    " def test_interpolated_element_content(): - child = html(t"Child") - node = html(t"
    {child}
    ") + child = to_node(t"Child") + node = to_node(t"
    {child}
    ") assert node == Element("div", children=[child]) assert str(node) == "
    Child
    " def test_interpolated_nonstring_content(): number = 42 - node = html(t"

    The answer is {number}.

    ") + node = to_node(t"

    The answer is {number}.

    ") assert node == Element( "p", children=[Text("The answer is "), Text("42"), Text(".")] ) @@ -394,7 +399,7 @@ def test_interpolated_nonstring_content(): def test_list_items(): items = ["Apple", "Banana", "Cherry"] - node = html(t"
      {[t'
    • {item}
    • ' for item in items]}
    ") + node = to_node(t"
      {[t'
    • {item}
    • ' for item in items]}
    ") assert node == Element( "ul", children=[ @@ -412,7 +417,7 @@ def test_nested_list_items(): inner = ["apple", "banana", "cherry"] inner_items = [t"
  • {item}
  • " for item in inner] outer_items = [t"
  • {category}
      {inner_items}
  • " for category in outer] - node = html(t"
      {outer_items}
    ") + node = to_node(t"
      {outer_items}
    ") assert node == Element( "ul", children=[ @@ -458,7 +463,7 @@ def test_nested_list_items(): def test_literal_attrs(): - node = html( + node = to_node( ( t"') + node = to_node(t'') assert node == Element( "a", attrs={"title": "<"}, @@ -495,14 +500,14 @@ def test_literal_attr_escaped(): def test_interpolated_attr(): url = "https://example.com/" - node = html(t'') + node = to_node(t'') assert node == Element("a", attrs={"href": "https://example.com/"}) assert str(node) == '' def test_interpolated_attr_escaped(): url = 'https://example.com/?q="test"&lang=en' - node = html(t'') + node = to_node(t'') assert node == Element( "a", attrs={"href": 'https://example.com/?q="test"&lang=en'}, @@ -514,34 +519,34 @@ def test_interpolated_attr_escaped(): def test_interpolated_attr_unquoted(): id = "roquefort" - node = html(t"
    ") + node = to_node(t"
    ") assert node == Element("div", attrs={"id": "roquefort"}) assert str(node) == '
    ' def test_interpolated_attr_true(): disabled = True - node = html(t"") + node = to_node(t"") assert node == Element("button", attrs={"disabled": None}) assert str(node) == "" def test_interpolated_attr_false(): disabled = False - node = html(t"") + node = to_node(t"") assert node == Element("button") assert str(node) == "" def test_interpolated_attr_none(): disabled = None - node = html(t"") + node = to_node(t"") assert node == Element("button") assert str(node) == "" def test_interpolate_attr_empty_string(): - node = html(t'
    ') + node = to_node(t'
    ') assert node == Element( "div", attrs={"title": ""}, @@ -551,7 +556,7 @@ def test_interpolate_attr_empty_string(): def test_spread_attr(): attrs = {"href": "https://example.com/", "target": "_blank"} - node = html(t"") + node = to_node(t"") assert node == Element( "a", attrs={"href": "https://example.com/", "target": "_blank"}, @@ -561,7 +566,7 @@ def test_spread_attr(): def test_spread_attr_none(): attrs = None - node = html(t"") + node = to_node(t"") assert node == Element("a") assert str(node) == "" @@ -569,7 +574,7 @@ def test_spread_attr_none(): def test_spread_attr_type_errors(): for attrs in (0, [], (), False, True): with pytest.raises(TypeError): - _ = html(t"") + _ = to_node(t"") def test_templated_attr_mixed_interpolations_start_end_and_nest(): @@ -583,7 +588,7 @@ def test_templated_attr_mixed_interpolations_start_end_and_nest(): (t"{right}", Template(str(right))), ): test_t = prefix + left_part + t"-" + middle_part + t"-" + right_part + suffix - node = html(test_t) + node = to_node(test_t) assert node == Element( "div", attrs={"data-range": "1-3-5"}, @@ -594,7 +599,7 @@ def test_templated_attr_mixed_interpolations_start_end_and_nest(): def test_templated_attr_no_quotes(): start = 1 end = 5 - node = html(t"
    ") + node = to_node(t"
    ") assert node == Element( "div", attrs={"data-range": "1-5"}, @@ -605,7 +610,7 @@ def test_templated_attr_no_quotes(): def test_attr_merge_disjoint_interpolated_attr_spread_attr(): attrs = {"href": "https://example.com/", "id": "link1"} target = "_blank" - node = html(t"") + node = to_node(t"") assert node == Element( "a", attrs={"href": "https://example.com/", "id": "link1", "target": "_blank"}, @@ -616,7 +621,7 @@ def test_attr_merge_disjoint_interpolated_attr_spread_attr(): def test_attr_merge_overlapping_spread_attrs(): attrs1 = {"href": "https://example.com/", "id": "overwrtten"} attrs2 = {"target": "_blank", "id": "link1"} - node = html(t"") + node = to_node(t"") assert node == Element( "a", attrs={"href": "https://example.com/", "target": "_blank", "id": "link1"}, @@ -625,37 +630,37 @@ def test_attr_merge_overlapping_spread_attrs(): def test_attr_merge_replace_literal_attr_str_str(): - node = html(t'
    ') + node = to_node(t'
    ') assert node == Element("div", {"title": "fresh"}) assert str(node) == '
    ' def test_attr_merge_replace_literal_attr_str_true(): - node = html(t'
    ') + node = to_node(t'
    ') assert node == Element("div", {"title": None}) assert str(node) == "
    " def test_attr_merge_replace_literal_attr_true_str(): - node = html(t"
    ") + node = to_node(t"
    ") assert node == Element("div", {"title": "fresh"}) assert str(node) == '
    ' def test_attr_merge_remove_literal_attr_str_none(): - node = html(t'
    ') + node = to_node(t'
    ') assert node == Element("div") assert str(node) == "
    " def test_attr_merge_remove_literal_attr_true_none(): - node = html(t"
    ") + node = to_node(t"
    ") assert node == Element("div") assert str(node) == "
    " def test_attr_merge_other_literal_attr_intact(): - node = html(t'') + node = to_node(t'') assert node == Element("img", {"title": "default", "alt": "fresh"}) assert str(node) == 'fresh' @@ -670,7 +675,7 @@ def test_placeholder_collision_avoidance(): Interpolation(tricky, "tricky", None, ""), f'{config.suffix}">
    ', ) - node = html(template) + node = to_node(template) assert node == Element( "div", attrs={"data-tricky": config.prefix + tricky + config.suffix}, @@ -686,7 +691,7 @@ def test_placeholder_collision_avoidance(): # def test_interpolated_data_attributes(): data = {"user-id": 123, "role": "admin", "wild": True, "false": False, "none": None} - node = html(t"
    User Info
    ") + node = to_node(t"
    User Info
    ") assert node == Element( "div", attrs={"data-user-id": "123", "data-role": "admin", "data-wild": None}, @@ -700,21 +705,21 @@ def test_interpolated_data_attributes(): def test_data_attr_toggle_to_str(): for node in [ - html(t"
    "), - html(t'
    '), + to_node(t"
    "), + to_node(t'
    '), ]: assert node == Element("div", {"data-selected": "yes"}) assert str(node) == '
    ' def test_data_attr_toggle_to_true(): - node = html(t'
    ') + node = to_node(t'
    ') assert node == Element("div", {"data-selected": None}) assert str(node) == "
    " def test_data_attr_unrelated_unaffected(): - node = html(t"
    ") + node = to_node(t"
    ") assert node == Element("div", {"data-selected": None, "data-active": None}) assert str(node) == "
    " @@ -723,13 +728,13 @@ def test_data_attr_templated_error(): data1 = {"user-id": "user-123"} data2 = {"role": "admin"} with pytest.raises(TypeError): - node = html(t'
    ') + node = to_node(t'
    ') print(str(node)) def test_data_attr_none(): button_data = None - node = html(t"") + node = to_node(t"") assert node == Element("button", children=[Text("X")]) assert str(node) == "" @@ -737,12 +742,12 @@ def test_data_attr_none(): def test_data_attr_errors(): for v in [False, [], (), 0, "data?"]: with pytest.raises(TypeError): - _ = html(t"") + _ = to_node(t"") def test_data_literal_attr_bypass(): # Trigger overall attribute resolution with an unrelated interpolated attr. - node = html(t'

    ') + node = to_node(t'

    ') assert node == Element( "p", attrs={"data": "passthru", "id": "resolved"}, @@ -756,13 +761,13 @@ def test_aria_templated_attr_error(): aria1 = {"label": "close"} aria2 = {"hidden": "true"} with pytest.raises(TypeError): - node = html(t'
    ') + node = to_node(t'
    ') print(str(node)) def test_aria_interpolated_attr_dict(): aria = {"label": "Close", "hidden": True, "another": False, "more": None} - node = html(t"") + node = to_node(t"") assert node == Element( "button", attrs={"aria-label": "Close", "aria-hidden": "true", "aria-another": "false"}, @@ -776,7 +781,7 @@ def test_aria_interpolated_attr_dict(): def test_aria_interpolate_attr_none(): button_aria = None - node = html(t"") + node = to_node(t"") assert node == Element("button", children=[Text("X")]) assert str(node) == "" @@ -784,12 +789,12 @@ def test_aria_interpolate_attr_none(): def test_aria_attr_errors(): for v in [False, [], (), 0, "aria?"]: with pytest.raises(TypeError): - _ = html(t"") + _ = to_node(t"") def test_aria_literal_attr_bypass(): # Trigger overall attribute resolution with an unrelated interpolated attr. - node = html(t'

    ') + node = to_node(t'

    ') assert node == Element( "p", attrs={"aria": "passthru", "id": "resolved"}, @@ -815,7 +820,7 @@ def test_interpolated_class_attribute(): t" class={class_str} class={class_space_sep_str}" t" >Click me" ) - node = html(button_t) + node = to_node(button_t) assert node == Element( "button", attrs={"class": "red btn btn-primary one two active blue green yellow"}, @@ -830,7 +835,7 @@ def test_interpolated_class_attribute(): def test_interpolated_class_attribute_with_multiple_placeholders(): classes1 = ["btn", "btn-primary"] classes2 = [False and "disabled", None, {"active": True}] - node = html(t'') + node = to_node(t'') # 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( @@ -842,7 +847,7 @@ def test_interpolated_class_attribute_with_multiple_placeholders(): def test_interpolated_attribute_spread_with_class_attribute(): attrs = {"id": "button1", "class": ["btn", "btn-primary"]} - node = html(t"") + node = to_node(t"") assert node == Element( "button", attrs={"id": "button1", "class": "btn btn-primary"}, @@ -853,7 +858,7 @@ def test_interpolated_attribute_spread_with_class_attribute(): def test_class_literal_attr_bypass(): # Trigger overall attribute resolution with an unrelated interpolated attr. - node = html(t'

    ') + node = to_node(t'

    ') assert node == Element( "p", attrs={"class": "red red", "id": "veryred"}, @@ -862,29 +867,29 @@ def test_class_literal_attr_bypass(): def test_class_none_ignored(): class_item = None - node = html(t"

    ") + node = to_node(t"

    ") assert node == Element("p") # Also ignored inside a sequence. - node = html(t"

    ") + node = to_node(t"

    ") assert node == Element("p") def test_class_type_errors(): for class_item in (False, True, 0): with pytest.raises(TypeError): - _ = html(t"

    ") + _ = to_node(t"

    ") with pytest.raises(TypeError): - _ = html(t"

    ") + _ = to_node(t"

    ") def test_class_merge_literals(): - node = html(t'

    ') + node = to_node(t'

    ') assert node == Element("p", {"class": "red blue"}) def test_class_merge_literal_then_interpolation(): class_item = "blue" - node = html(t'

    ') + node = to_node(t'

    ') assert node == Element("p", {"class": "red blue"}) @@ -893,7 +898,7 @@ def test_class_merge_literal_then_interpolation(): # def test_style_literal_attr_passthru(): p_id = "para1" # non-literal attribute to cause attr resolution - node = html(t'

    Warning!

    ') + node = to_node(t'

    Warning!

    ') assert node == Element( "p", attrs={"style": "color: red", "id": "para1"}, @@ -904,7 +909,7 @@ def test_style_literal_attr_passthru(): def test_style_in_interpolated_attr(): styles = {"color": "red", "font-weight": "bold", "font-size": "16px"} - node = html(t"

    Warning!

    ") + node = to_node(t"

    Warning!

    ") assert node == Element( "p", attrs={"style": "color: red; font-weight: bold; font-size: 16px"}, @@ -918,7 +923,7 @@ def test_style_in_interpolated_attr(): def test_style_in_templated_attr(): color = "red" - node = html(t'

    Warning!

    ') + node = to_node(t'

    Warning!

    ') assert node == Element( "p", attrs={"style": "color: red"}, @@ -929,7 +934,7 @@ def test_style_in_templated_attr(): def test_style_in_spread_attr(): attrs = {"style": {"color": "red"}} - node = html(t"

    Warning!

    ") + node = to_node(t"

    Warning!

    ") assert node == Element( "p", attrs={"style": "color: red"}, @@ -942,7 +947,7 @@ def test_style_merged_from_all_attrs(): attrs = dict(style="font-size: 15px") style = {"font-weight": "bold"} color = "red" - node = html( + node = to_node( t'

    ' ) assert node == Element( @@ -966,7 +971,7 @@ def test_style_override_left_to_right(): for index in range(len(parts)): expected_style = parts[index][1] t = sum([part[0] for part in parts[: index + 1]], t"") + suffix - node = html(t) + node = to_node(t) assert node == Element("p", {"style": expected_style}) assert str(node) == f'

    ' @@ -978,13 +983,13 @@ def test_interpolated_style_attribute_multiple_placeholders(): # 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!

    ") + _ = to_node(t"

    Warning!

    ") def test_interpolated_style_attribute_merged(): styles1 = {"color": "red"} styles2 = {"font-weight": "bold"} - node = html(t"

    Warning!

    ") + node = to_node(t"

    Warning!

    ") assert node == Element( "p", attrs={"style": "color: red; font-weight: bold"}, @@ -996,7 +1001,7 @@ def test_interpolated_style_attribute_merged(): def test_interpolated_style_attribute_merged_override(): styles1 = {"color": "red", "font-weight": "normal"} styles2 = {"font-weight": "bold"} - node = html(t"

    Warning!

    ") + node = to_node(t"

    Warning!

    ") assert node == Element( "p", attrs={"style": "color: red; font-weight: bold"}, @@ -1007,7 +1012,7 @@ def test_interpolated_style_attribute_merged_override(): def test_style_attribute_str(): styles = "color: red; font-weight: bold;" - node = html(t"

    Warning!

    ") + node = to_node(t"

    Warning!

    ") assert node == Element( "p", attrs={"style": "color: red; font-weight: bold"}, @@ -1019,12 +1024,12 @@ def test_style_attribute_str(): def test_style_attribute_non_str_non_dict(): with pytest.raises(TypeError): styles = [1, 2] - _ = html(t"

    Warning!

    ") + _ = to_node(t"

    Warning!

    ") def test_style_literal_attr_bypass(): # Trigger overall attribute resolution with an unrelated interpolated attr. - node = html(t'

    ') + node = to_node(t'

    ') assert node == Element( "p", attrs={"style": "invalid;invalid:", "id": "resolved"}, @@ -1033,7 +1038,7 @@ def test_style_literal_attr_bypass(): def test_style_none(): styles = None - node = html(t"

    ") + node = to_node(t"

    ") assert node == Element("p") @@ -1058,7 +1063,7 @@ def FunctionComponent( def test_interpolated_template_component(): - node = html( + node = to_node( t'<{FunctionComponent} first=1 second={99} third-arg="comp1" class="my-comp">Hello, Component!' ) assert node == Element( @@ -1079,7 +1084,7 @@ def test_interpolated_template_component(): def test_interpolated_template_component_no_children_provided(): """Same test, but the caller didn't provide any children.""" - node = html( + node = to_node( t'<{FunctionComponent} first=1 second={99} third-arg="comp1" class="my-comp" />' ) assert node == Element( @@ -1102,7 +1107,7 @@ def test_interpolated_template_component_no_children_provided(): def test_invalid_component_invocation(): with pytest.raises(TypeError): - _ = html(t"<{FunctionComponent}>Missing props") + _ = to_node(t"<{FunctionComponent}>Missing props") def test_prep_component_kwargs_named(): @@ -1144,7 +1149,7 @@ def FunctionComponentNoChildren(first: str, second: int, third_arg: str) -> Temp def test_interpolated_template_component_ignore_children(): - node = html( + node = to_node( t'<{FunctionComponentNoChildren} first=1 second={99} third-arg="comp1">Hello, Component!' ) assert node == Element( @@ -1172,7 +1177,7 @@ def FunctionComponentKeywordArgs(first: str, **attrs: t.Any) -> Template: def test_children_always_passed_via_kwargs(): - node = html( + node = to_node( t'<{FunctionComponentKeywordArgs} first="value" extra="info">Child content' ) assert node == Element( @@ -1189,7 +1194,7 @@ def test_children_always_passed_via_kwargs(): def test_children_always_passed_via_kwargs_even_when_empty(): - node = html(t'<{FunctionComponentKeywordArgs} first="value" extra="info" />') + node = to_node(t'<{FunctionComponentKeywordArgs} first="value" extra="info" />') assert node == Element( "div", attrs={ @@ -1210,7 +1215,7 @@ def ColumnsComponent() -> Template: 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"<{ColumnsComponent} />
    ") + node = to_node(t"<{ColumnsComponent} />
    ") assert node == Element( "table", children=[ @@ -1232,7 +1237,7 @@ def Wrapper( ) -> Template: return t"<{sub_component} {attrs}>{children}" - node = html( + node = to_node( t'<{Wrapper} sub-component={FunctionComponent} class="wrapped" first=1 second={99} third-arg="comp1">

    Inside wrapper

    ' ) assert node == Element( @@ -1254,9 +1259,9 @@ def Wrapper( def test_nested_component_gh23(): # See https://github.com/t-strings/tdom/issues/23 for context def Header(): - return html(t"{'Hello World'}") + return to_node(t"{'Hello World'}") - node = html(t"<{Header} />") + node = to_node(t"<{Header} />") assert node == Text("Hello World") assert str(node) == "Hello World" @@ -1265,9 +1270,9 @@ def test_component_returning_iterable(): def Items() -> t.Iterable: for i in range(2): yield t"
  • Item {i + 1}
  • " - yield html(t"
  • Item {3}
  • ") + yield to_node(t"
  • Item {3}
  • ") - node = html(t"
      <{Items} />
    ") + node = to_node(t"
      <{Items} />
    ") assert node == Element( "ul", children=[ @@ -1281,9 +1286,9 @@ def Items() -> t.Iterable: def test_component_returning_fragment(): def Items() -> Node: - return html(t"
  • Item {1}
  • Item {2}
  • Item {3}
  • ") + return to_node(t"
  • Item {1}
  • Item {2}
  • Item {3}
  • ") - node = html(t"
      <{Items} />
    ") + node = to_node(t"
      <{Items} />
    ") assert node == Element( "ul", children=[ @@ -1305,7 +1310,7 @@ class ClassComponent: children: t.Iterable[Node] = field(default_factory=list) def __call__(self) -> Node: - return html( + return to_node( t"
    " t"" t"{f" @@ -1317,7 +1322,7 @@ def __call__(self) -> Node: def test_class_component_implicit_invocation_with_children(): - node = html( + node = to_node( t"<{ClassComponent} user-name='Alice' image-url='https://example.com/alice.png'>Fun times!" ) assert node == Element( @@ -1353,7 +1358,7 @@ def test_class_component_direct_invocation(): image_url="https://example.com/alice.png", homepage="https://example.com/users/alice", ) - node = html(t"<{avatar} />") + node = to_node(t"<{avatar} />") assert node == Element( "div", attrs={"class": "avatar"}, @@ -1389,7 +1394,7 @@ class ClassComponentNoChildren: homepage: str = "#" def __call__(self) -> Node: - return html( + return to_node( t"
    " t"" t"{f" @@ -1401,7 +1406,7 @@ def __call__(self) -> Node: def test_class_component_implicit_invocation_ignore_children(): - node = html( + node = to_node( t"<{ClassComponentNoChildren} user-name='Alice' image-url='https://example.com/alice.png'>Fun times!" ) assert node == Element( @@ -1485,7 +1490,7 @@ def test_attribute_type_component(): "spread_dict": dict(), "spread_list": ["eggs", "milk"], } - node = html( + node = to_node( 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}/>" @@ -1496,7 +1501,7 @@ def test_attribute_type_component(): def test_component_non_callable_fails(): with pytest.raises(TypeError): - _ = html(t"<{'not a function'} />") + _ = to_node(t"<{'not a function'} />") def RequiresPositional(whoops: int, /) -> Template: # pragma: no cover @@ -1505,11 +1510,11 @@ def RequiresPositional(whoops: int, /) -> Template: # pragma: no cover def test_component_requiring_positional_arg_fails(): with pytest.raises(TypeError): - _ = html(t"<{RequiresPositional} />") + _ = to_node(t"<{RequiresPositional} />") def test_mismatched_component_closing_tag_fails(): with pytest.raises(TypeError): - _ = html( + _ = to_node( t"<{FunctionComponent} first=1 second={99} third-arg='comp1'>Hello" ) From a412e6ff6d67bd8195aaac9e43eb0ee50b1c16c4 Mon Sep 17 00:00:00 2001 From: Ian Wilson Date: Tue, 10 Feb 2026 11:44:06 -0800 Subject: [PATCH 05/59] Conditionally allow Markup for tags with non-normal text. --- tdom/escaping.py | 19 ++++++++++++++++--- tdom/escaping_test.py | 40 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 55 insertions(+), 4 deletions(-) diff --git a/tdom/escaping.py b/tdom/escaping.py index 2caebba..726f00c 100644 --- a/tdom/escaping.py +++ b/tdom/escaping.py @@ -2,6 +2,9 @@ from markupsafe import escape as markup_escape +from .protocols import HasHTMLDunder + + escape_html_text = markup_escape # unify api for test of project @@ -9,10 +12,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:] @@ -39,8 +48,10 @@ def escape_html_comment(text: str) -> str: STYLE_RES = ((re.compile("style)>", re.I | re.A), LT + r"/\g>"),) -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 @@ -62,7 +73,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. @@ -75,6 +86,8 @@ def escape_html_script(text: str) -> str: - " None: + assert escape_html_text("
    ") == "<div>" def test_escape_html_comment_empty() -> None: @@ -27,6 +38,15 @@ def test_escape_html_comment_ends_with_lt_dash() -> None: assert escape_html_comment("This is a comment None: + input_text = "-->" + escaped_text = "-->" + out = escape_html_comment(Markup(input_text), allow_markup=False) + assert out != input_text and out == escaped_text + out = escape_html_comment(Markup(input_text), allow_markup=True) + assert out == input_text and out != escaped_text + + def test_escape_html_style() -> None: input_text = "body { color: red; } p { font-SIZE: 12px; }" expected_output = ( @@ -35,6 +55,15 @@ def test_escape_html_style() -> None: assert escape_html_style(input_text) == expected_output +def test_escape_html_style_markup() -> None: + input_text = "" + escaped_text = "</STYLE>" + out = escape_html_style(Markup(input_text), allow_markup=False) + assert out != input_text and out == escaped_text + out = escape_html_style(Markup(input_text), allow_markup=True) + assert out == input_text and out != escaped_text + + def test_escape_html_script() -> None: input_text = "" + case TFragment(children): + q.extend([(last_parent_tag, child) for child in reversed(children)]) + case TComponent(start_i_index, end_i_index, attrs, children): + yield self._stream_component_interpolation( + last_parent_tag, attrs, start_i_index, end_i_index + ) + case TElement(tag, attrs, children): + yield f"<{tag}" + if self.has_dynamic_attrs(attrs): + yield self._stream_attrs_interpolation(tag, attrs) + else: + yield serialize_html_attrs( + _resolve_html_attrs( + _resolve_t_attrs(attrs, interpolations=()) + ) + ) + # @DESIGN: This is just a want to have. + if self.slash_void and tag in VOID_ELEMENTS: + yield " />" + else: + yield ">" + if tag not in VOID_ELEMENTS: + q.append((last_parent_tag, EndTag(f""))) + q.extend([(tag, child) for child in reversed(children)]) + case TText(ref): + text_t = Template( + *[ + part + if isinstance(part, str) + else Interpolation(part, "", None, "") + for part in iter(ref) + ] + ) + if ref.is_literal: + yield ref.strings[0] # Trust literals. + elif last_parent_tag is None: + # We can't know how to handle this right now, so wait until write time and if + # we still cannot know then probably fail. + yield self._stream_dynamic_texts_interpolation( + last_parent_tag, text_t + ) + elif last_parent_tag in CDATA_CONTENT_ELEMENTS: + # Must be handled all at once. + yield self._stream_raw_texts_interpolation( + last_parent_tag, text_t + ) + elif last_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. + yield self._stream_escapable_raw_texts_interpolation( + last_parent_tag, text_t + ) + else: + # Flatten the template back out into the stream because each interpolation can + # be escaped as is and structured content can be injected between text anyways. + for part in text_t: + if isinstance(part, str): + yield part + else: + yield self._stream_normal_text_interpolation( + last_parent_tag, part.value + ) + case _: + raise ValueError(f"Unrecognized tnode: {tnode}") + + def has_dynamic_attrs(self, attrs: Sequence[TAttribute]) -> bool: + """ + Determine if any attributes with interpolations are in attrs sequence. + + This is mainly used to tell if we can pre-emptively serialize an + element's attributes (or not). + """ + for attr in attrs: + if not isinstance(attr, TLiteralAttribute): + return True + return False + + +def resolve_text_without_recursion( + template: Template, parent_tag: str, content_t: Template +) -> str | None: + """ + Resolve the text in the given template without recursing into more structured text. + + This can be bypassed by interpolating an exact match with an object with `__html__()`. + + A non-exact match is not allowed because we cannot process escaping + across the boundary between other content and the pass-through content. + """ + # @TODO: We should use formatting but not in a way that + # auto-interpolates structured values. + if len(content_t.interpolations) == 1 and content_t.strings == ("", ""): + i_index = cast(int, content_t.interpolations[0].value) + value = template.interpolations[i_index].value + if value is None: + return None + elif isinstance(value, str): + # @DESIGN: Markup() must be used explicitly if you want __html__ supported. + return value + elif isinstance(value, (Template, Iterable)): + raise ValueError( + f"Recursive includes are not supported within {parent_tag}" + ) + else: + return str(value) + else: + text = [] + for part in content_t: + if isinstance(part, str): + if part: + text.append(part) + continue + value = template.interpolations[part.value].value + if value is None: + continue + elif ( + type(value) is str + ): # type() check to avoid subclasses, probably something smarter here + if value: + text.append(value) + elif not isinstance(value, str) and isinstance(value, (Template, Iterable)): + raise ValueError( + f"Recursive includes are not supported within {parent_tag}" + ) + elif hasattr(value, "__html__"): + raise ValueError( + f"Non-exact trusted interpolations are not supported within {parent_tag}" + ) + else: + value_str = str(value) + if value_str: + text.append(value_str) + if text: + return "".join(text) + else: + return None + + +def determine_body_start_s_index(tcomp): + """ + Calculate the strings index when the embedded template starts after a component start tag. + + This doesn't actually know or care if the component has a body it just + counts past the dynamic (non-literal) attributes and returns the first strings index + offset by interpolation index for the component callable itself. + """ + return ( + tcomp.start_i_index + + 1 + + len([1 for attr in tcomp.attrs if not isinstance(attr, TLiteralAttribute)]) + ) + + +def extract_embedded_template( + template: Template, body_start_s_index: int, end_i_index: int +) -> Template: + """ + Extract the template parts exclusively from start tag to end tag. + + Note that interpolations INSIDE the start tag make this more complex + than just "the `s_index` after the component callable's `i_index`". + + Example: + ```python + template = ( + t'<{comp} attr={attr}>' + t'
    {content} {footer}
    ' + t'' + ) + assert extract_children_template(template, 2, 4) == ( + t'
    {content} {footer}
    ' + ) + starttag = t'<{comp} attr={attr}>' + endtag = t'' + assert template == starttag + extract_children_template(template, 2, 4) + endtag + ``` + @DESIGN: "There must be a better way." + """ + # Copy the parts out of the containing template. + index = body_start_s_index + last_s_index = end_i_index + parts = [] + while index <= last_s_index: + parts.append(template.strings[index]) + if index != last_s_index: + parts.append(template.interpolations[index]) + index += 1 + # Now trim the first part to the end of the opening tag. + parts[0] = parts[0][parts[0].find(">") + 1 :] + # Now trim the last part (could also be the first) to the start of the closing tag. + parts[-1] = parts[-1][: parts[-1].rfind("<")] + return Template(*parts) + + +@dataclass(frozen=True) +class ProcessService: + transform_api: TransformService + + escape_html_text: Callable = default_escape_html_text + + escape_html_comment: Callable = default_escape_html_comment + + escape_html_script: Callable = default_escape_html_script + + escape_html_style: Callable = default_escape_html_style + + def get_system(self, **kwargs: object): + return {**kwargs} + + def make_process_queue_item( + self, + last_parent_tag: str | None, + it: Iterable[tuple[InterpolatorProto, Template, InterpolateInfo]], + ) -> ProcessQueueItem: + """ + Coerce args into standard structure. + + This is almost only here for tracking and readability. + """ + return (last_parent_tag, it) + + def process_template( + self, template: Template, last_parent_tag: str | None = None + ) -> str: + """ + Process an HTML Template into a str and return it. + + The `last_parent_tag` is used for an HTML Template that contains + interpolations without a definitive parent tag. This creates a + situation where interpolations cannot be resolved correctly. + """ + return "".join( + res for res in self.process_template_chunks(template, last_parent_tag) + ) + + def process_template_chunks( + self, template: Template, last_parent_tag: str | None = None + ) -> Generator[str]: + """ + Process an HTML Template and yield intermittent str chunks until complete. + + SEE: process_template() for more information. + """ + bf: list[str] = [] + q: list[ProcessQueueItem] = [] + q.append( + ( + last_parent_tag, + self.walk_template( + bf, template, self.transform_api.transform_template(template) + ), + ) + ) + while q: + if bf: + # Yield the buffer contents everytime we switch iterators, + # either from exhaustion or traversal. + yield "".join(bf) + bf.clear() + last_parent_tag, it = q.pop() + for interpolator, template, ip_info in it: + process_queue_item = interpolator( + self, bf, last_parent_tag, template, ip_info + ) + if process_queue_item is not None: + # + # Pause the current iterator and push a new iterator on top of it. + # + q.append(self.make_process_queue_item(last_parent_tag, it)) + q.append(process_queue_item) + break + if bf: + # Final yield in case we fell out of the `while q:`. + yield "".join(bf) + bf.clear() + + def resolve_attrs( + self, attrs: Sequence[TAttribute], template: Template + ) -> AttributesDict: + return _resolve_t_attrs(attrs, template.interpolations) + + def walk_template( + self, bf: list[str], original_t: Template, transformed_t: Template + ) -> Iterable[tuple[InterpolatorProto, Template, InterpolateInfo]]: + for part in transformed_t: + if isinstance(part, str): + bf.append(part) + else: + yield (part.value[0], original_t, part.value[1]) + + def walk_dynamic_template( + self, + bf: list[str], + original_t: Template, + transformed_t: Template, + parent_tag: str, + ) -> Iterable[tuple[InterpolatorProto, Template, InterpolateInfo]]: + """ + Walk a `Text()` template that we determined was OK during processing. + + This happens when the parent tag isn't resolvable at parse time and we + have to discover it during processing. + """ + for part in transformed_t: + if isinstance(part, str): + bf.append(part) + else: + yield ( + interpolate_normal_text_from_interpolation, + original_t, + (parent_tag, part.value), + ) + + +def process_service_factory(transform_api_kwargs=None): + return ProcessService( + transform_api=TransformService(**(transform_api_kwargs or {})) + ) + + +def cached_process_service_factory(transform_api_kwargs=None): + return ProcessService( + transform_api=CachedTransformService(**(transform_api_kwargs or {})) + ) + + +# +# SHIM: This is here until we can find a way to make a configurable cache. +# +@dataclass(frozen=True) +class CachedTransformService(TransformService): + @lru_cache(512) + def _transform_template(self, cached_template: CachableTemplate) -> Template: + return super().transform_template(cached_template.template) + + def transform_template(self, template: Template) -> Template: + ct = CachableTemplate(template) + return self._transform_template(ct) + + +_default_process_api = cached_process_service_factory( + transform_api_kwargs=dict(slash_void=True, uppercase_doctype=True) +) + + # -------------------------------------------------------------------------- # Public API # -------------------------------------------------------------------------- -def to_html(template: Template) -> str: +def to_html(template: Template, last_parent_tag: str | None = None) -> str: """Parse an HTML t-string, substitue values, and return a string of HTML.""" - cachable = CachableTemplate(template) - t_node = _parse_and_cache(cachable) - return str(_resolve_t_node(t_node, template.interpolations)) + return _default_process_api.process_template(template, last_parent_tag) def to_node(template: Template) -> Node: diff --git a/tdom/processor_to_html_test.py b/tdom/processor_to_html_test.py new file mode 100644 index 0000000..afa3020 --- /dev/null +++ b/tdom/processor_to_html_test.py @@ -0,0 +1,524 @@ +from string.templatelib import Template +from markupsafe import Markup, escape as markupsafe_escape +import typing as t +import pytest +from dataclasses import dataclass +from collections.abc import Callable +from itertools import chain + +from .processor import ( + process_service_factory, + cached_process_service_factory, + CachedTransformService, + ProcessService, + TransformService, + to_html, +) + + +def test_process_template_smoketest(): + 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 +{markup_content} + +""" + smoke_str = """ + + + +literal + +text is not literal +text is not literal +
    comp body
    +
    safe
    + +""" + process_api = process_service_factory() + assert process_api.process_template(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.""" + sample_t = t"""
    {"content"}
    """ + sample_diff_t = t"""
    {"diffcontent"}
    """ + alt_t = t"""{"content"}""" + process_api = process_service_factory() + cached_process_api = cached_process_service_factory() + # Technically this could be the superclass which doesn't have cached method. + assert isinstance(cached_process_api.transform_api, CachedTransformService) + # 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 determin the result, + # this is not great and hopefully we can find a better solution. + start_ci = cached_process_api.transform_api._transform_template.cache_info() + tf1 = process_api.transform_api.transform_template(sample_t) + tf2 = process_api.transform_api.transform_template(sample_t) + cached_tf1 = cached_process_api.transform_api.transform_template(sample_t) + cached_tf2 = cached_process_api.transform_api.transform_template(sample_t) + cached_tf3 = cached_process_api.transform_api.transform_template(sample_diff_t) + # Check that the uncached and cached services are actually + # returning non-identical results. + assert tf1 is not cached_tf1 + assert tf1 is not cached_tf2 + assert tf1 is not cached_tf3 + # Check that the uncached service returns a brand new result everytime. + assert tf1 is not tf2 + # Check that the cached service is returning the exact same, identical, result. + assert cached_tf1 is cached_tf2 + # Even if the input templates are not identical (but are still equivalent). + assert cached_tf1 is cached_tf3 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 struct_repr(tf1) == struct_repr(cached_tf1) + assert struct_repr(tf2) == struct_repr(cached_tf1) + # Now that we are setup we check that the cache is internally + # working as we intended. + ci = cached_process_api.transform_api._transform_template.cache_info() + # cached_tf2 and cached_tf3 are hits after cached_tf1 + 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_tf4 = cached_process_api.transform_api.transform_template(alt_t) + # A different template produces a brand new tf. + assert cached_tf1 is not cached_tf4 + # The template is new AND has a different structure so it also + # produces an unequivalent tf. + assert struct_repr(cached_tf1) != struct_repr(cached_tf4) + + +def test_process_template_repeated(): + """Crude check for any unintended state being kept between calls.""" + + def get_sample_t(idx, spread_attrs, button_text): + return t"""
    """ + + process_apis = (process_service_factory(), cached_process_service_factory()) + for process_api in process_apis: + for idx in range(3): + spread_attrs = {"data-enabled": True} + button_text = "PROCESS" + sample_t = get_sample_t(idx, spread_attrs, button_text) + assert ( + process_api.process_template(sample_t) + == f'
    ' + ) + + +def get_select_t_with_list(options, selected_values): + return t"""""" + + +def get_select_t_with_generator(options, selected_values): + return t"""""" + + +def get_select_t_with_concat(options, selected_values): + parts = [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): + process_api = process_service_factory() + + def get_color_select_t(selected_values: set, provider: t.Callable) -> Template: + PRIMARY_COLORS = [("R", "Red"), ("Y", "Yellow"), ("B", "Blue")] + assert set(selected_values).issubset(set([opt[0] for opt in PRIMARY_COLORS])) + return provider(PRIMARY_COLORS, selected_values) + + no_selection_t = get_color_select_t(set(), provider) + assert ( + process_api.process_template(no_selection_t) + == '' + ) + selected_yellow_t = get_color_select_t({"Y"}, provider) + assert ( + process_api.process_template(selected_yellow_t) + == '' + ) + + +def test_process_template_components_smoketest(): + """Broadly test that common template component usage works.""" + + def PageComponent(children, root_attrs=None): + return t"""
    {children}
    """ + + def FooterComponent(classes=("footer-default",)): + return t'
    ' + + def LayoutComponent(children, body_classes=None): + return t""" + + + + + + + + {children} + <{FooterComponent} /> + + +""" + + process_api = process_service_factory() + content = "HTML never goes out of style." + content_str = process_api.process_template( + t"<{LayoutComponent} body_classes={['theme-default']}><{PageComponent}>{content}" + ) + assert ( + content_str + == """ + + + + + + + +
    HTML never goes out of style.
    + + + +""" + ) + + +def test_process_template_functions_smoketest(): + """Broadly test that common template function usage works.""" + + def make_page_t(content, root_attrs=None) -> Template: + return t"""
    {content}
    """ + + def make_footer_t(classes=("footer-default",)) -> Template: + return t'' + + def make_layout_t(body_t, body_classes=None) -> Template: + footer_t = make_footer_t() + return t""" + + + + + + + + {body_t} + {footer_t} + + +""" + + process_api = process_service_factory() + content = "HTML never goes out of style." + layout_t = make_layout_t(make_page_t(content), "theme-default") + content_str = process_api.process_template(layout_t) + assert ( + content_str + == """ + + + + + + + +
    HTML never goes out of style.
    + + + +""" + ) + + +def test_text_interpolation_with_dynamic_parent(): + process_api = process_service_factory() + with pytest.raises( + ValueError, match="Recursive includes are not supported within script" + ): + content = '' + content_t = t"{content}" + _ = process_api.process_template(t"") + + +@pytest.mark.skip("Can we allow this?") +def test_escape_escapable_raw_text_with_dynamic_parent(): + content = '' + content_t = t"{content}" + process_api = process_service_factory() + LT, GT, DQ = map(markupsafe_escape, ["<", ">", '"']) + assert ( + process_api.process_template(t"") + == f"" + ) + + +def test_escape_structured_text_with_dynamic_parent(): + content = '' + content_t = t"{content}" + process_api = process_service_factory() + LT, GT, DQ = map(markupsafe_escape, ["<", ">", '"']) + assert ( + process_api.process_template(t"
    {content_t}
    ") + == f"
    {LT}script{GT}console.log({DQ}123!{DQ});{LT}/script{GT}
    " + ) + + +def test_escape_structured_text(): + content = '' + content_t = t"
    {content}
    " + process_api = process_service_factory() + LT, GT, DQ = map(markupsafe_escape, ["<", ">", '"']) + assert ( + process_api.process_template(content_t) + == f"
    {LT}script{GT}console.log({DQ}123!{DQ});{LT}/script{GT}
    " + ) + + +@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: 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_class_component(): + def paginate_url(page: int) -> str: + return f"/pages?page={page}" + + def Footer(pager, paginate_url, footer_classes=("footer",)) -> Template: + return t"
    <{PagerDisplay} pager={pager} paginate_url={paginate_url} />
    " + + pager = 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} />" + process_api = process_service_factory() + res = process_api.process_template(content_t) + print(res) + assert ( + res + == '' + ) + + +def test_mathml(): + num = 1 + denom = 3 + mathml_t = t"""

    + The fraction + + + {num} + {denom} + + + is not a decimal number. +

    """ + process_api = process_service_factory() + res = process_api.process_template(mathml_t) + assert ( + str(res) + == """

    + The fraction + + + 1 + 3 + + + is not a decimal number. +

    """ + ) + + +def test_svg(): + cx, cy, r, fill = 150, 100, 80, "green" + svg_t = t""" + + + SVG +""" + process_api = process_service_factory() + res = process_api.process_template(svg_t) + assert ( + str(res) + == """ + + + SVG +""" + ) + + +@pytest.mark.skip("""Need foreign element mode. Could work like last parent.""") +def test_svg_self_closing_empty_elements(): + cx, cy, r, fill = 150, 100, 80, "green" + svg_t = t""" + + + SVG +""" + process_api = process_service_factory() + res = process_api.process_template(svg_t) + assert ( + str(res) + == """ + + + SVG +""" + ) + + +@dataclass +class FakeUser: + name: str + id: int + + +@dataclass +class FakeRequest: + user: FakeUser | None = None + + +@dataclass(frozen=True) +class RequestProcessService(ProcessService): + request: FakeRequest | None = None + + def get_system(self, **kwargs): + return {**kwargs, "request": self.request} + + +class UserProto(t.Protocol): + name: str + + +class RequestProto(t.Protocol): + user: UserProto | None + + +def test_system_context(): + """Test providing context to components horizontally via *extra* system provided kwargs.""" + + def request_process_api(request): + return RequestProcessService(request=request, transform_api=TransformService()) + + def UserStatus(request: RequestProto, children: Template | None = None) -> Template: + user = request.user + if user: + classes = ("account-online",) + status_t = t"Logged in as {user.name}" + else: + classes = ("account-offline",) + status_t = t"Not logged in" + return t"" + + page_t = t"""
    <{UserStatus}>
    """ + process_api = request_process_api(FakeRequest(user=FakeUser(name="Guido", id=1000))) + res = process_api.process_template(page_t) + assert ( + res + == """
    """ + ) + process_api = request_process_api(FakeRequest(user=None)) + res = process_api.process_template(page_t) + assert ( + res + == """
    """ + ) + + process_api = ProcessService(transform_api=TransformService()) + with pytest.raises(TypeError) as excinfo: + res = process_api.process_template(page_t) + assert "Missing required parameters" in str(excinfo.value) + + +def test_to_html(): + assert to_html(t"") == "" + assert to_html(t"") == "" + assert to_html(t"
    {'content'}
    ") == "
    content
    " From 4f9fd5caefc2e1ce376412dde2aa4e3855c6f2f1 Mon Sep 17 00:00:00 2001 From: Ian Wilson Date: Sat, 14 Feb 2026 15:56:39 -0800 Subject: [PATCH 07/59] Move html spec info into utility module. --- tdom/htmlspec.py | 24 ++++++++++++++++++++++++ tdom/nodes.py | 30 +----------------------------- tdom/parser.py | 2 +- tdom/processor.py | 2 ++ 4 files changed, 28 insertions(+), 30 deletions(-) create mode 100644 tdom/htmlspec.py diff --git a/tdom/htmlspec.py b/tdom/htmlspec.py new file mode 100644 index 0000000..5eca1c4 --- /dev/null +++ b/tdom/htmlspec.py @@ -0,0 +1,24 @@ +# See https://developer.mozilla.org/en-US/docs/Glossary/Void_element +VOID_ELEMENTS = frozenset( + [ + "area", + "base", + "br", + "col", + "embed", + "hr", + "img", + "input", + "link", + "meta", + "param", + "source", + "track", + "wbr", + ] +) + + +CDATA_CONTENT_ELEMENTS = frozenset(["script", "style"]) +RCDATA_CONTENT_ELEMENTS = frozenset(["textarea", "title"]) +CONTENT_ELEMENTS = CDATA_CONTENT_ELEMENTS | RCDATA_CONTENT_ELEMENTS diff --git a/tdom/nodes.py b/tdom/nodes.py index 346f97f..6eed2bb 100644 --- a/tdom/nodes.py +++ b/tdom/nodes.py @@ -6,35 +6,7 @@ escape_html_style, escape_html_text, ) - -# See https://developer.mozilla.org/en-US/docs/Glossary/Void_element -VOID_ELEMENTS = frozenset( - [ - "area", - "base", - "br", - "col", - "embed", - "hr", - "img", - "input", - "link", - "meta", - "param", - "source", - "track", - "wbr", - ] -) - - -CDATA_CONTENT_ELEMENTS = frozenset(["script", "style"]) -RCDATA_CONTENT_ELEMENTS = frozenset(["textarea", "title"]) -CONTENT_ELEMENTS = CDATA_CONTENT_ELEMENTS | RCDATA_CONTENT_ELEMENTS - - -# FUTURE: add a pretty-printer to nodes for debugging -# FUTURE: make nodes frozen (and have the parser work with mutable builders) +from .htmlspec import VOID_ELEMENTS, CONTENT_ELEMENTS @dataclass(slots=True) diff --git a/tdom/parser.py b/tdom/parser.py index 82de290..d8c529e 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 .nodes import VOID_ELEMENTS +from .htmlspec import VOID_ELEMENTS from .placeholders import PlaceholderState from .tnodes import ( TAttribute, diff --git a/tdom/processor.py b/tdom/processor.py index e753165..8f2b29e 100644 --- a/tdom/processor.py +++ b/tdom/processor.py @@ -17,6 +17,8 @@ Fragment, Node, Text, +) +from .htmlspec import ( VOID_ELEMENTS, CDATA_CONTENT_ELEMENTS, RCDATA_CONTENT_ELEMENTS, From 9fcd2b7981157ba9aeb780e5fe1de67a935fe31b Mon Sep 17 00:00:00 2001 From: Ian Wilson Date: Sat, 14 Feb 2026 16:23:19 -0800 Subject: [PATCH 08/59] Add shorthand for pulling interpolations by index back into a template. --- tdom/template_utils.py | 5 +++++ tdom/template_utils_test.py | 10 ++++++++++ 2 files changed, 15 insertions(+) diff --git a/tdom/template_utils.py b/tdom/template_utils.py index cbdcde6..c6e39e1 100644 --- a/tdom/template_utils.py +++ b/tdom/template_utils.py @@ -85,3 +85,8 @@ def __iter__(self): if index < last_s_index: yield self.i_indexes[index] index += 1 + + def resolve(self, interpolations: tuple[Interpolation, ...]) -> Template: + """Use the given interpolations to resolve this reference template into a Template.""" + resolved = [interpolations[i_index] for i_index in self.i_indexes] + return template_from_parts(self.strings, resolved) diff --git a/tdom/template_utils_test.py b/tdom/template_utils_test.py index 2eca85d..1602487 100644 --- a/tdom/template_utils_test.py +++ b/tdom/template_utils_test.py @@ -91,3 +91,13 @@ def test_template_ref_iter_complete(): 5, "jkl", ] + + +def test_template_ref_resolve(): + src_t = t"{'a'}b{'c'}d{'e'}f" + src_ref = TemplateRef( + strings=src_t.strings, i_indexes=tuple(range(len(src_t.interpolations))) + ) + resolved_t = src_ref.resolve(src_t.interpolations) + assert resolved_t.values == ("a", "c", "e") + assert resolved_t.strings == ("", "b", "d", "f") From a5a5d061de3137cd14b625afcf5b8ba96d018187 Mon Sep 17 00:00:00 2001 From: Ian Wilson Date: Sat, 14 Feb 2026 16:27:07 -0800 Subject: [PATCH 09/59] Move node builder into nodes. --- tdom/__init__.py | 7 +- tdom/nodes.py | 226 +++++++++++++++++++++++++++++++++++++++++ tdom/processor.py | 226 +---------------------------------------- tdom/processor_test.py | 12 +-- 4 files changed, 240 insertions(+), 231 deletions(-) diff --git a/tdom/__init__.py b/tdom/__init__.py index 44ff7fa..f3c42e9 100644 --- a/tdom/__init__.py +++ b/tdom/__init__.py @@ -1,7 +1,10 @@ from markupsafe import Markup, escape -from .nodes import Comment, DocumentType, Element, Fragment, Node, Text -from .processor import to_html, to_node, html +from .nodes import Comment, DocumentType, Element, Fragment, Node, Text, to_node +from .processor import to_html + +# @BWC: Temporary shim. +html = to_node # We consider `Markup` and `escape` to be part of this module's public API diff --git a/tdom/nodes.py b/tdom/nodes.py index 6eed2bb..c5343b5 100644 --- a/tdom/nodes.py +++ b/tdom/nodes.py @@ -1,4 +1,9 @@ +import sys from dataclasses import dataclass, field +from functools import lru_cache +from string.templatelib import Template, Interpolation +from markupsafe import Markup +from collections.abc import Iterable, Sequence from .escaping import ( escape_html_comment, @@ -7,6 +12,35 @@ escape_html_text, ) from .htmlspec import VOID_ELEMENTS, CONTENT_ELEMENTS +from .utils import CachableTemplate +from .processor import ( + format_interpolation, + prep_component_kwargs, + _resolve_html_attrs, + _resolve_t_attrs, + AttributesDict, +) +from .parser import ( + HTMLAttributesDict, + TAttribute, + TComment, + TComponent, + TDocumentType, + TElement, + TemplateParser, + TFragment, + TNode, + TText, +) +from .protocols import HasHTMLDunder +from .format import format_template +from .callables import get_callable_info +from .template_utils import TemplateRef + + +@lru_cache(maxsize=0 if "pytest" in sys.modules else 512) +def _parse_and_cache(cachable: CachableTemplate) -> TNode: + return TemplateParser.parse(cachable.template) @dataclass(slots=True) @@ -113,3 +147,195 @@ def __str__(self) -> str: return f"<{self.tag}{attrs_str}>" children_str = self._children_to_str() return f"<{self.tag}{attrs_str}>{children_str}" + + +def _resolve_attrs( + attrs: Sequence[TAttribute], interpolations: tuple[Interpolation, ...] +) -> HTMLAttributesDict: + """ + Substitute placeholders in attributes for HTML elements. + + This is the full pipeline: interpolation + HTML processing. + """ + interpolated_attrs = _resolve_t_attrs(attrs, interpolations) + return _resolve_html_attrs(interpolated_attrs) + + +def _flatten_nodes(nodes: Iterable[Node]) -> list[Node]: + """Flatten a list of Nodes, expanding any Fragments.""" + flat: list[Node] = [] + for node in nodes: + if isinstance(node, Fragment): + flat.extend(node.children) + else: + flat.append(node) + return flat + + +def _substitute_and_flatten_children( + children: Iterable[TNode], interpolations: tuple[Interpolation, ...] +) -> list[Node]: + """Substitute placeholders in a list of children and flatten any fragments.""" + resolved = [_resolve_t_node(child, interpolations) for child in children] + flat = _flatten_nodes(resolved) + return flat + + +def _node_from_value(value: object) -> Node: + """ + Convert an arbitrary value to a Node. + + This is the primary action performed when replacing interpolations in child + content positions. + """ + match value: + case str(): + return Text(value) + case Node(): + return value + case Template(): + return to_node(value) + # Consider: falsey values, not just False and None? + case False | None: + return Fragment(children=[]) + case Iterable(): + children = [_node_from_value(v) for v in value] + return Fragment(children=children) + case HasHTMLDunder(): + # CONSIDER: should we do this lazily? + return Text(Markup(value.__html__())) + case c if callable(c): + # Treat all callable values in child content positions as if + # they are zero-arg functions that return a value to be processed. + return _node_from_value(c()) + case _: + # CONSIDER: should we do this lazily? + return Text(str(value)) + + +def _invoke_component( + attrs: AttributesDict, + children: list[Node], # TODO: why not TNode, though? + interpolation: Interpolation, +) -> Node: + """ + Invoke a component callable with the provided attributes and children. + + Components are any callable that meets the required calling signature. + Typically, that's a function, but it could also be the constructor or + __call__() method for a class; dataclass constructors match our expected + invocation style. + + We validate the callable's signature and invoke it with keyword-only + arguments, then convert the result to a Node. + + Component invocation rules: + + 1. All arguments are passed as keywords only. Components cannot require + positional arguments. + + 2. Children are passed via a "children" parameter when: + + - Child content exists in the template AND + - The callable accepts "children" OR has **kwargs + + If no children exist but the callable accepts "children", we pass an + empty tuple. + + 3. All other attributes are converted from kebab-case to snake_case + and passed as keyword arguments if the callable accepts them (or has + **kwargs). Attributes that don't match parameters are silently ignored. + """ + value = format_interpolation(interpolation) + if not callable(value): + raise TypeError( + f"Expected a callable for component invocation, got {type(value).__name__}" + ) + callable_info = get_callable_info(value) + + kwargs = prep_component_kwargs( + callable_info, attrs, system_kwargs={"children": tuple(children)} + ) + + result = value(**kwargs) + return _node_from_value(result) + + +def _resolve_t_text_ref( + ref: TemplateRef, interpolations: tuple[Interpolation, ...] +) -> Text | Fragment: + """Resolve a TText ref into Text or Fragment by processing interpolations.""" + if ref.is_literal: + return Text(ref.strings[0]) + + parts = [ + Text(part) + if isinstance(part, str) + else _node_from_value(format_interpolation(part)) + for part in ref.resolve(interpolations) + ] + flat = _flatten_nodes(parts) + + if len(flat) == 1 and isinstance(flat[0], Text): + return flat[0] + + return Fragment(children=flat) + + +def _resolve_t_node(t_node: TNode, interpolations: tuple[Interpolation, ...]) -> Node: + """Resolve a TNode tree into a Node tree by processing interpolations.""" + match t_node: + case TText(ref=ref): + return _resolve_t_text_ref(ref, interpolations) + case TComment(ref=ref): + comment_t = ref.resolve(interpolations) + comment = format_template(comment_t) + return Comment(comment) + case TDocumentType(text=text): + return DocumentType(text) + case TFragment(children=children): + resolved_children = _substitute_and_flatten_children( + children, interpolations + ) + return Fragment(children=resolved_children) + case TElement(tag=tag, attrs=attrs, children=children): + resolved_attrs = _resolve_attrs(attrs, interpolations) + resolved_children = _substitute_and_flatten_children( + children, interpolations + ) + return Element(tag=tag, attrs=resolved_attrs, children=resolved_children) + case TComponent( + start_i_index=start_i_index, + end_i_index=end_i_index, + attrs=t_attrs, + children=children, + ): + start_interpolation = interpolations[start_i_index] + end_interpolation = ( + None if end_i_index is None else interpolations[end_i_index] + ) + resolved_attrs = _resolve_t_attrs(t_attrs, interpolations) + resolved_children = _substitute_and_flatten_children( + children, interpolations + ) + # HERE ALSO BE DRAGONS: validate matching start/end callables, since + # the underlying TemplateParser cannot do that for us. + if ( + end_interpolation is not None + and end_interpolation.value != start_interpolation.value + ): + raise TypeError("Mismatched component start and end callables.") + return _invoke_component( + attrs=resolved_attrs, + children=resolved_children, + interpolation=start_interpolation, + ) + case _: + raise ValueError(f"Unknown TNode type: {type(t_node).__name__}") + + +def to_node(template: Template) -> Node: + """Parse an HTML t-string, substitue values, and return a tree of Nodes.""" + cachable = CachableTemplate(template) + t_node = _parse_and_cache(cachable) + return _resolve_t_node(t_node, template.interpolations) diff --git a/tdom/processor.py b/tdom/processor.py index 8f2b29e..fdc4d83 100644 --- a/tdom/processor.py +++ b/tdom/processor.py @@ -1,4 +1,3 @@ -import sys from typing import cast, Protocol from collections.abc import Iterable, Sequence, Callable, Generator from functools import lru_cache @@ -10,14 +9,6 @@ from .callables import get_callable_info, CallableInfo from .format import format_interpolation as base_format_interpolation from .format import format_template -from .nodes import ( - Comment, - DocumentType, - Element, - Fragment, - Node, - Text, -) from .htmlspec import ( VOID_ELEMENTS, CDATA_CONTENT_ELEMENTS, @@ -40,10 +31,7 @@ TTemplatedAttribute, TText, ) -from .placeholders import TemplateRef -from .template_utils import template_from_parts from .utils import CachableTemplate, LastUpdatedOrderedDict -from .protocols import HasHTMLDunder from .escaping import ( escape_html_script as default_escape_html_script, escape_html_style as default_escape_html_style, @@ -52,11 +40,6 @@ ) -@lru_cache(maxsize=0 if "pytest" in sys.modules else 512) -def _parse_and_cache(cachable: CachableTemplate) -> TNode: - return TemplateParser.parse(cachable.template) - - type Attribute = tuple[str, object] type AttributesDict = dict[str, object] @@ -329,7 +312,7 @@ def _resolve_t_attrs( else: new_attrs[name] = attr_value case TTemplatedAttribute(name=name, value_ref=ref): - attr_t = _resolve_ref(ref, interpolations) + attr_t = ref.resolve(interpolations) attr_value = format_template(attr_t) if name in ATTR_ACCUMULATOR_MAKERS: if name not in attr_accs: @@ -377,76 +360,12 @@ def _resolve_html_attrs(attrs: AttributesDict) -> HTMLAttributesDict: return html_attrs -def _resolve_attrs( - attrs: Sequence[TAttribute], interpolations: tuple[Interpolation, ...] -) -> HTMLAttributesDict: - """ - Substitute placeholders in attributes for HTML elements. - - This is the full pipeline: interpolation + HTML processing. - """ - interpolated_attrs = _resolve_t_attrs(attrs, interpolations) - return _resolve_html_attrs(interpolated_attrs) - - -def _flatten_nodes(nodes: Iterable[Node]) -> list[Node]: - """Flatten a list of Nodes, expanding any Fragments.""" - flat: list[Node] = [] - for node in nodes: - if isinstance(node, Fragment): - flat.extend(node.children) - else: - flat.append(node) - return flat - - -def _substitute_and_flatten_children( - children: Iterable[TNode], interpolations: tuple[Interpolation, ...] -) -> list[Node]: - """Substitute placeholders in a list of children and flatten any fragments.""" - resolved = [_resolve_t_node(child, interpolations) for child in children] - flat = _flatten_nodes(resolved) - return flat - - -def _node_from_value(value: object) -> Node: - """ - Convert an arbitrary value to a Node. - - This is the primary action performed when replacing interpolations in child - content positions. - """ - match value: - case str(): - return Text(value) - case Node(): - return value - case Template(): - return to_node(value) - # Consider: falsey values, not just False and None? - case False | None: - return Fragment(children=[]) - case Iterable(): - children = [_node_from_value(v) for v in value] - return Fragment(children=children) - case HasHTMLDunder(): - # CONSIDER: should we do this lazily? - return Text(Markup(value.__html__())) - case c if callable(c): - # Treat all callable values in child content positions as if - # they are zero-arg functions that return a value to be processed. - return _node_from_value(c()) - case _: - # CONSIDER: should we do this lazily? - return Text(str(value)) - - def _kebab_to_snake(name: str) -> str: """Convert a kebab-case name to snake_case.""" return name.replace("-", "_").lower() -def _prep_component_kwargs( +def prep_component_kwargs( callable_info: CallableInfo, attrs: AttributesDict, system_kwargs: dict[str, object], @@ -478,134 +397,6 @@ def _prep_component_kwargs( return kwargs -def _invoke_component( - attrs: AttributesDict, - children: list[Node], # TODO: why not TNode, though? - interpolation: Interpolation, -) -> Node: - """ - Invoke a component callable with the provided attributes and children. - - Components are any callable that meets the required calling signature. - Typically, that's a function, but it could also be the constructor or - __call__() method for a class; dataclass constructors match our expected - invocation style. - - We validate the callable's signature and invoke it with keyword-only - arguments, then convert the result to a Node. - - Component invocation rules: - - 1. All arguments are passed as keywords only. Components cannot require - positional arguments. - - 2. Children are passed via a "children" parameter when: - - - Child content exists in the template AND - - The callable accepts "children" OR has **kwargs - - If no children exist but the callable accepts "children", we pass an - empty tuple. - - 3. All other attributes are converted from kebab-case to snake_case - and passed as keyword arguments if the callable accepts them (or has - **kwargs). Attributes that don't match parameters are silently ignored. - """ - value = format_interpolation(interpolation) - if not callable(value): - raise TypeError( - f"Expected a callable for component invocation, got {type(value).__name__}" - ) - callable_info = get_callable_info(value) - - kwargs = _prep_component_kwargs( - callable_info, attrs, system_kwargs={"children": tuple(children)} - ) - - result = value(**kwargs) - return _node_from_value(result) - - -def _resolve_ref( - ref: TemplateRef, interpolations: tuple[Interpolation, ...] -) -> Template: - resolved = [interpolations[i_index] for i_index in ref.i_indexes] - return template_from_parts(ref.strings, resolved) - - -def _resolve_t_text_ref( - ref: TemplateRef, interpolations: tuple[Interpolation, ...] -) -> Text | Fragment: - """Resolve a TText ref into Text or Fragment by processing interpolations.""" - if ref.is_literal: - return Text(ref.strings[0]) - - parts = [ - Text(part) - if isinstance(part, str) - else _node_from_value(format_interpolation(part)) - for part in _resolve_ref(ref, interpolations) - ] - flat = _flatten_nodes(parts) - - if len(flat) == 1 and isinstance(flat[0], Text): - return flat[0] - - return Fragment(children=flat) - - -def _resolve_t_node(t_node: TNode, interpolations: tuple[Interpolation, ...]) -> Node: - """Resolve a TNode tree into a Node tree by processing interpolations.""" - match t_node: - case TText(ref=ref): - return _resolve_t_text_ref(ref, interpolations) - case TComment(ref=ref): - comment_t = _resolve_ref(ref, interpolations) - comment = format_template(comment_t) - return Comment(comment) - case TDocumentType(text=text): - return DocumentType(text) - case TFragment(children=children): - resolved_children = _substitute_and_flatten_children( - children, interpolations - ) - return Fragment(children=resolved_children) - case TElement(tag=tag, attrs=attrs, children=children): - resolved_attrs = _resolve_attrs(attrs, interpolations) - resolved_children = _substitute_and_flatten_children( - children, interpolations - ) - return Element(tag=tag, attrs=resolved_attrs, children=resolved_children) - case TComponent( - start_i_index=start_i_index, - end_i_index=end_i_index, - attrs=t_attrs, - children=children, - ): - start_interpolation = interpolations[start_i_index] - end_interpolation = ( - None if end_i_index is None else interpolations[end_i_index] - ) - resolved_attrs = _resolve_t_attrs(t_attrs, interpolations) - resolved_children = _substitute_and_flatten_children( - children, interpolations - ) - # HERE ALSO BE DRAGONS: validate matching start/end callables, since - # the underlying TemplateParser cannot do that for us. - if ( - end_interpolation is not None - and end_interpolation.value != start_interpolation.value - ): - raise TypeError("Mismatched component start and end callables.") - return _invoke_component( - attrs=resolved_attrs, - children=resolved_children, - interpolation=start_interpolation, - ) - case _: - raise ValueError(f"Unknown TNode type: {type(t_node).__name__}") - - @dataclass class EndTag: end_tag: str @@ -731,7 +522,7 @@ def interpolate_component( if not callable(component_callable): raise TypeError("Component callable must be callable.") - kwargs = _prep_component_kwargs( + kwargs = prep_component_kwargs( get_callable_info(component_callable), _resolve_t_attrs(attrs, template.interpolations), system_kwargs=system_kwargs, @@ -1412,14 +1203,3 @@ def transform_template(self, template: Template) -> Template: def to_html(template: Template, last_parent_tag: str | None = None) -> str: """Parse an HTML t-string, substitue values, and return a string of HTML.""" return _default_process_api.process_template(template, last_parent_tag) - - -def to_node(template: Template) -> Node: - """Parse an HTML t-string, substitue values, and return a tree of Nodes.""" - cachable = CachableTemplate(template) - t_node = _parse_and_cache(cachable) - return _resolve_t_node(t_node, template.interpolations) - - -# BWC: SHIM -html = to_node diff --git a/tdom/processor_test.py b/tdom/processor_test.py index 155f68a..d69fb50 100644 --- a/tdom/processor_test.py +++ b/tdom/processor_test.py @@ -7,9 +7,9 @@ import pytest from markupsafe import Markup -from .nodes import Comment, DocumentType, Element, Fragment, Node, Text +from .nodes import Comment, DocumentType, Element, Fragment, Node, Text, to_node from .placeholders import make_placeholder_config -from .processor import to_node, to_html, _prep_component_kwargs +from .processor import to_html, prep_component_kwargs from .callables import get_callable_info @@ -1115,13 +1115,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}, system_kwargs={}) == { "size": 20 } - assert _prep_component_kwargs( + assert prep_component_kwargs( callable_info, {"type": "email"}, system_kwargs={} ) == {"type": "email"} - assert _prep_component_kwargs(callable_info, {}, system_kwargs={}) == {} + assert prep_component_kwargs(callable_info, {}, system_kwargs={}) == {} @pytest.mark.skip("Should we just ignore unused user-specified kwargs?") @@ -1132,7 +1132,7 @@ 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}, system_kwargs={}) == {} ) From fd18de6a33d0dcd17a6cae3675ff4c15806e96ee Mon Sep 17 00:00:00 2001 From: Ian Wilson Date: Sun, 15 Feb 2026 01:14:41 -0800 Subject: [PATCH 10/59] Remove extra arg to escaping. --- tdom/processor.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tdom/processor.py b/tdom/processor.py index fdc4d83..618d21c 100644 --- a/tdom/processor.py +++ b/tdom/processor.py @@ -574,7 +574,6 @@ def interpolate_raw_texts_from_template( if parent_tag == "script": bf.append( process_api.escape_html_script( - parent_tag, content, allow_markup=True, ) @@ -582,7 +581,6 @@ def interpolate_raw_texts_from_template( elif parent_tag == "style": bf.append( process_api.escape_html_style( - parent_tag, content, allow_markup=True, ) From 678755b5008a24e7427d394af68c05f29ee9b6d5 Mon Sep 17 00:00:00 2001 From: Ian Wilson Date: Sun, 15 Feb 2026 01:18:51 -0800 Subject: [PATCH 11/59] Unescaped entities need to be re-escaped. --- tdom/processor.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/tdom/processor.py b/tdom/processor.py index 618d21c..7618631 100644 --- a/tdom/processor.py +++ b/tdom/processor.py @@ -883,7 +883,15 @@ def streamer( ] ) if ref.is_literal: - yield ref.strings[0] # Trust literals. + if last_parent_tag == 'script': + yield default_escape_html_script(ref.strings[0]) + elif last_parent_tag == 'style': + yield default_escape_html_style(ref.strings[0]) + else: + # Fallback to escape everything. + # This works because you cannot interpolate a + # template into a script/style. + yield default_escape_html_text(ref.strings[0]) elif last_parent_tag is None: # We can't know how to handle this right now, so wait until write time and if # we still cannot know then probably fail. From c2335817ef571098a700f5bfeb08978052f40f8c Mon Sep 17 00:00:00 2001 From: Ian Wilson Date: Sun, 15 Feb 2026 01:20:12 -0800 Subject: [PATCH 12/59] Coerce tests into two processor strategies. --- tdom/nodes_test.py | 1534 +++++++++++++++++++++++++++++++++++++++- tdom/processor_test.py | 983 ++++++++----------------- 2 files changed, 1823 insertions(+), 694 deletions(-) diff --git a/tdom/nodes_test.py b/tdom/nodes_test.py index 184d387..b6fc6a4 100644 --- a/tdom/nodes_test.py +++ b/tdom/nodes_test.py @@ -1,22 +1,15 @@ import pytest from markupsafe import Markup +from dataclasses import dataclass, field +import datetime +import typing as t +from string.templatelib import Interpolation, Template +from itertools import product -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) == "" +from .placeholders import make_placeholder_config +from .processor import prep_component_kwargs +from .callables import get_callable_info +from .nodes import Comment, DocumentType, Element, Fragment, Text, to_node, Node def test_doctype_default(): @@ -227,3 +220,1512 @@ def test_escaping_of_text_content(): def test_escaping_of_attribute_values(): div = Element("div", attrs={"class": '">XSS<'}) assert str(div) == '
    ' + + +# +# to_node() +# +def test_empty(): + node = to_node(t"") + assert node == Fragment(children=[]) + assert str(node) == "" + + +def test_text_literal(): + node = to_node(t"Hello, world!") + assert node == Text("Hello, world!") + assert str(node) == "Hello, world!" + + +def test_text_singleton(): + greeting = "Hello, Alice!" + node = to_node(t"{greeting}") + assert node == Text("Hello, Alice!") + assert str(node) == "Hello, Alice!" + + +def test_text_template(): + name = "Alice" + node = to_node(t"Hello, {name}!") + assert node == Fragment(children=[Text("Hello, "), Text("Alice"), Text("!")]) + assert str(node) == "Hello, Alice!" + + +def test_text_template_escaping(): + name = "Alice & Bob" + node = to_node(t"Hello, {name}!") + assert node == Fragment(children=[Text("Hello, "), Text("Alice & Bob"), Text("!")]) + assert str(node) == "Hello, Alice & Bob!" + + +# +# Comments. +# +def test_comment(): + node = to_node(t"") + assert node == Comment("This is a comment") + assert str(node) == "" + + +def test_comment_empty(): + node = to_node(t"") + assert node == Comment("") + assert str(node) == "" + + +def test_comment_template(): + text = "comment" + node = to_node(t"") + assert node == Comment("This is a comment") + assert str(node) == "" + + +def test_comment_template_escaping(): + text = "-->comment" + node = to_node(t"") + assert node == Comment("This is a -->comment") + assert str(node) == "" + + +def test_comment_special_chars(): + node = to_node(t"") + assert node == Comment("Special chars: <>&\"'") + assert str(node) == "" + + +# +# Document types. +# +def test_parse_document_type(): + node = to_node(t"") + assert node == DocumentType("html") + assert str(node) == "" + + +# +# Elements +# +def test_parse_void_element(): + node = to_node(t"
    ") + assert node == Element("br") + assert str(node) == "
    " + + +def test_parse_void_element_self_closed(): + node = to_node(t"
    ") + assert node == Element("br") + assert str(node) == "
    " + + +def test_parse_chain_of_void_elements(): + # Make sure our handling of CPython issue #69445 is reasonable. + node = to_node(t"



    ") + assert node == Fragment( + children=[ + Element("br"), + Element("hr"), + Element("img", attrs={"src": "image.png"}), + Element("br"), + Element("hr"), + ], + ) + assert str(node) == '



    ' + + +def test_parse_element_with_text(): + node = to_node(t"

    Hello, world!

    ") + assert node == Element( + "p", + children=[ + Text("Hello, world!"), + ], + ) + assert str(node) == "

    Hello, world!

    " + + +def test_parse_nested_elements(): + node = to_node(t"

    Hello

    World

    ") + assert node == Element( + "div", + children=[ + Element("p", children=[Text("Hello")]), + Element("p", children=[Text("World")]), + ], + ) + assert str(node) == "

    Hello

    World

    " + + +def test_parse_entities_are_escaped(): + node = to_node(t"

    </p>

    ") + assert node == Element( + "p", + children=[Text("

    ")], + ) + assert str(node) == "

    </p>

    " + + +# -------------------------------------------------------------------------- +# Interpolated text content +# -------------------------------------------------------------------------- + + +def test_interpolated_text_content(): + name = "Alice" + node = to_node(t"

    Hello, {name}!

    ") + assert node == Element("p", children=[Text("Hello, "), Text("Alice"), Text("!")]) + assert str(node) == "

    Hello, Alice!

    " + + +def test_escaping_of_interpolated_text_content(): + name = "" + node = to_node(t"

    Hello, {name}!

    ") + assert node == Element( + "p", children=[Text("Hello, "), Text(""), Text("!")] + ) + assert str(node) == "

    Hello, <Alice & Bob>!

    " + + +class Convertible: + def __str__(self): + return "string" + + def __repr__(self): + return "repr" + + +def test_conversions(): + c = Convertible() + assert f"{c!s}" == "string" + assert f"{c!r}" == "repr" + node = to_node(t"
  • {c!s}
  • {c!r}
  • {'😊'!a}
  • ") + assert node == Fragment( + children=[ + Element("li", children=[Text("string")]), + Element("li", children=[Text("repr")]), + Element("li", children=[Text("'\\U0001f60a'")]), + ], + ) + + +def test_interpolated_in_content_node(): + # https://github.com/t-strings/tdom/issues/68 + evil = "") + assert node == Element( + "style", + children=[ + Text("" + ) + + +def test_interpolated_trusted_in_content_node(): + # https://github.com/t-strings/tdom/issues/68 + node = to_node(t"") + assert node == Element( + "script", + children=[Text("if (a < b && c > d) { alert('wow'); }")], + ) + assert str(node) == ("") + + +def test_script_elements_error(): + nested_template = t"
    " + # Putting non-text content inside a script is not allowed. + with pytest.raises(ValueError): + node = to_node(t"") + _ = str(node) + + +# -------------------------------------------------------------------------- +# Interpolated non-text content +# -------------------------------------------------------------------------- + + +def test_interpolated_false_content(): + node = to_node(t"
    {False}
    ") + assert node == Element("div") + assert str(node) == "
    " + + +def test_interpolated_none_content(): + node = to_node(t"
    {None}
    ") + assert node == Element("div", children=[]) + assert str(node) == "
    " + + +def test_interpolated_zero_arg_function(): + def get_value(): + return "dynamic" + + node = to_node(t"

    The value is {get_value}.

    ") + assert node == Element( + "p", children=[Text("The value is "), Text("dynamic"), Text(".")] + ) + + +def test_interpolated_multi_arg_function_fails(): + def add(a, b): # pragma: no cover + return a + b + + with pytest.raises(TypeError): + _ = to_node(t"

    The sum is {add}.

    ") + + +# -------------------------------------------------------------------------- +# Raw HTML injection tests +# -------------------------------------------------------------------------- + + +def test_raw_html_injection_with_markupsafe(): + raw_content = Markup("I am bold") + node = to_node(t"
    {raw_content}
    ") + assert node == Element("div", children=[Text(text=raw_content)]) + assert str(node) == "
    I am bold
    " + + +def test_raw_html_injection_with_dunder_html_protocol(): + class SafeContent: + def __init__(self, text): + self._text = text + + def __html__(self): + # In a real app, this would come from a sanitizer or trusted source + return f"{self._text}" + + content = SafeContent("emphasized") + node = to_node(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 = to_node(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 = to_node(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.

    " + + +# -------------------------------------------------------------------------- +# Conditional rendering and control flow +# -------------------------------------------------------------------------- + + +def test_conditional_rendering_with_if_else(): + is_logged_in = True + user_profile = t"Welcome, User!" + login_prompt = t"Please log in" + node = to_node(t"
    {user_profile if is_logged_in else login_prompt}
    ") + + assert node == Element( + "div", children=[Element("span", children=[Text("Welcome, User!")])] + ) + assert str(node) == "
    Welcome, User!
    " + + is_logged_in = False + node = to_node(t"
    {user_profile if is_logged_in else login_prompt}
    ") + assert str(node) == '' + + +def test_conditional_rendering_with_and(): + show_warning = True + warning_message = t'
    Warning!
    ' + node = to_node(t"
    {show_warning and warning_message}
    ") + + assert node == Element( + "main", + children=[ + Element("div", attrs={"class": "warning"}, children=[Text("Warning!")]), + ], + ) + assert str(node) == '
    Warning!
    ' + + show_warning = False + node = to_node(t"
    {show_warning and warning_message}
    ") + # Assuming False renders nothing + assert str(node) == "
    " + + +# -------------------------------------------------------------------------- +# Interpolated nesting of templates and elements +# -------------------------------------------------------------------------- + + +def test_interpolated_template_content(): + child = t"Child" + node = to_node(t"
    {child}
    ") + assert node == Element("div", children=[to_node(child)]) + assert str(node) == "
    Child
    " + + +def test_interpolated_element_content(): + child = to_node(t"Child") + node = to_node(t"
    {child}
    ") + assert node == Element("div", children=[child]) + assert str(node) == "
    Child
    " + + +def test_interpolated_nonstring_content(): + number = 42 + node = to_node(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 = to_node(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) == "
    • Apple
    • Banana
    • Cherry
    " + + +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}
      {inner_items}
  • " for category in outer] + node = to_node(t"
      {outer_items}
    ") + 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) + == "
    • fruit
      • apple
      • banana
      • cherry
    • more fruit
      • apple
      • banana
      • cherry
    " + ) + + +# -------------------------------------------------------------------------- +# Attributes +# -------------------------------------------------------------------------- + + +def test_literal_attrs(): + node = to_node( + ( + t"" + ) + ) + assert node == Element( + "a", + attrs={ + "id": "example_link", + "autofocus": None, + "title": "", + "href": "https://example.com", + "target": "_blank", + }, + ) + assert ( + str(node) + == '' + ) + + +def test_literal_attr_escaped(): + node = to_node(t'') + assert node == Element( + "a", + attrs={"title": "<"}, + ) + assert str(node) == '' + + +def test_interpolated_attr(): + url = "https://example.com/" + node = to_node(t'') + assert node == Element("a", attrs={"href": "https://example.com/"}) + assert str(node) == '' + + +def test_interpolated_attr_escaped(): + url = 'https://example.com/?q="test"&lang=en' + node = to_node(t'') + assert node == Element( + "a", + attrs={"href": 'https://example.com/?q="test"&lang=en'}, + ) + assert ( + str(node) == '' + ) + + +def test_interpolated_attr_unquoted(): + id = "roquefort" + node = to_node(t"
    ") + assert node == Element("div", attrs={"id": "roquefort"}) + assert str(node) == '
    ' + + +def test_interpolated_attr_true(): + disabled = True + node = to_node(t"") + assert node == Element("button", attrs={"disabled": None}) + assert str(node) == "" + + +def test_interpolated_attr_false(): + disabled = False + node = to_node(t"") + assert node == Element("button") + assert str(node) == "" + + +def test_interpolated_attr_none(): + disabled = None + node = to_node(t"") + assert node == Element("button") + assert str(node) == "" + + +def test_interpolate_attr_empty_string(): + node = to_node(t'
    ') + assert node == Element( + "div", + attrs={"title": ""}, + ) + assert str(node) == '
    ' + + +def test_spread_attr(): + attrs = {"href": "https://example.com/", "target": "_blank"} + node = to_node(t"") + assert node == Element( + "a", + attrs={"href": "https://example.com/", "target": "_blank"}, + ) + assert str(node) == '' + + +def test_spread_attr_none(): + attrs = None + node = to_node(t"") + assert node == Element("a") + assert str(node) == "" + + +def test_spread_attr_type_errors(): + for attrs in (0, [], (), False, True): + with pytest.raises(TypeError): + _ = to_node(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 = to_node(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 = to_node(t"
    ") + assert node == Element( + "div", + attrs={"data-range": "1-5"}, + ) + assert str(node) == '
    ' + + +def test_attr_merge_disjoint_interpolated_attr_spread_attr(): + attrs = {"href": "https://example.com/", "id": "link1"} + target = "_blank" + node = to_node(t"") + assert node == Element( + "a", + attrs={"href": "https://example.com/", "id": "link1", "target": "_blank"}, + ) + assert str(node) == '' + + +def test_attr_merge_overlapping_spread_attrs(): + attrs1 = {"href": "https://example.com/", "id": "overwrtten"} + attrs2 = {"target": "_blank", "id": "link1"} + node = to_node(t"") + assert node == Element( + "a", + attrs={"href": "https://example.com/", "target": "_blank", "id": "link1"}, + ) + assert str(node) == '' + + +def test_attr_merge_replace_literal_attr_str_str(): + node = to_node(t'
    ') + assert node == Element("div", {"title": "fresh"}) + assert str(node) == '
    ' + + +def test_attr_merge_replace_literal_attr_str_true(): + node = to_node(t'
    ') + assert node == Element("div", {"title": None}) + assert str(node) == "
    " + + +def test_attr_merge_replace_literal_attr_true_str(): + node = to_node(t"
    ") + assert node == Element("div", {"title": "fresh"}) + assert str(node) == '
    ' + + +def test_attr_merge_remove_literal_attr_str_none(): + node = to_node(t'
    ') + assert node == Element("div") + assert str(node) == "
    " + + +def test_attr_merge_remove_literal_attr_true_none(): + node = to_node(t"
    ") + assert node == Element("div") + assert str(node) == "
    " + + +def test_attr_merge_other_literal_attr_intact(): + node = to_node(t'') + assert node == Element("img", {"title": "default", "alt": "fresh"}) + assert str(node) == 'fresh' + + +def test_placeholder_collision_avoidance(): + config = make_placeholder_config() + # This test is to ensure that our placeholder detection avoids collisions + # even with content that might look like a placeholder. + tricky = "0" + template = Template( + f'
    ', + ) + node = to_node(template) + assert node == Element( + "div", + attrs={"data-tricky": config.prefix + tricky + config.suffix}, + children=[], + ) + assert ( + str(node) == f'
    ' + ) + + +# +# Special data attribute handling. +# +def test_interpolated_data_attributes(): + data = {"user-id": 123, "role": "admin", "wild": True, "false": False, "none": None} + node = to_node(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
    ' + ) + + +def test_data_attr_toggle_to_str(): + for node in [ + to_node(t"
    "), + to_node(t'
    '), + ]: + assert node == Element("div", {"data-selected": "yes"}) + assert str(node) == '
    ' + + +def test_data_attr_toggle_to_true(): + node = to_node(t'
    ') + assert node == Element("div", {"data-selected": None}) + assert str(node) == "
    " + + +def test_data_attr_unrelated_unaffected(): + node = to_node(t"
    ") + assert node == Element("div", {"data-selected": None, "data-active": None}) + assert str(node) == "
    " + + +def test_data_attr_templated_error(): + data1 = {"user-id": "user-123"} + data2 = {"role": "admin"} + with pytest.raises(TypeError): + node = to_node(t'
    ') + print(str(node)) + + +def test_data_attr_none(): + button_data = None + node = to_node(t"") + assert node == Element("button", children=[Text("X")]) + assert str(node) == "" + + +def test_data_attr_errors(): + for v in [False, [], (), 0, "data?"]: + with pytest.raises(TypeError): + _ = to_node(t"") + + +def test_data_literal_attr_bypass(): + # Trigger overall attribute resolution with an unrelated interpolated attr. + node = to_node(t'

    ') + assert node == Element( + "p", + attrs={"data": "passthru", "id": "resolved"}, + ), "A single literal attribute should not trigger data expansion." + + +# +# Special aria attribute handling. +# +def test_aria_templated_attr_error(): + aria1 = {"label": "close"} + aria2 = {"hidden": "true"} + with pytest.raises(TypeError): + node = to_node(t'
    ') + print(str(node)) + + +def test_aria_interpolated_attr_dict(): + aria = {"label": "Close", "hidden": True, "another": False, "more": None} + node = to_node(t"") + assert node == Element( + "button", + attrs={"aria-label": "Close", "aria-hidden": "true", "aria-another": "false"}, + children=[Text("X")], + ) + assert ( + str(node) + == '' + ) + + +def test_aria_interpolate_attr_none(): + button_aria = None + node = to_node(t"") + assert node == Element("button", children=[Text("X")]) + assert str(node) == "" + + +def test_aria_attr_errors(): + for v in [False, [], (), 0, "aria?"]: + with pytest.raises(TypeError): + _ = to_node(t"") + + +def test_aria_literal_attr_bypass(): + # Trigger overall attribute resolution with an unrelated interpolated attr. + node = to_node(t'

    ') + assert node == Element( + "p", + attrs={"aria": "passthru", "id": "resolved"}, + ), "A single literal attribute should not trigger aria expansion." + + +# +# 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"" + ) + node = to_node(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) + == '' + ) + + +def test_interpolated_class_attribute_with_multiple_placeholders(): + classes1 = ["btn", "btn-primary"] + classes2 = [False and "disabled", None, {"active": True}] + node = to_node(t'') + # 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")], + ) + + +def test_interpolated_attribute_spread_with_class_attribute(): + attrs = {"id": "button1", "class": ["btn", "btn-primary"]} + node = to_node(t"") + assert node == Element( + "button", + attrs={"id": "button1", "class": "btn btn-primary"}, + children=[Text("Click me")], + ) + assert str(node) == '' + + +def test_class_literal_attr_bypass(): + # Trigger overall attribute resolution with an unrelated interpolated attr. + node = to_node(t'

    ') + assert node == Element( + "p", + attrs={"class": "red red", "id": "veryred"}, + ), "A single literal attribute should not trigger class accumulator." + + +def test_class_none_ignored(): + class_item = None + node = to_node(t"

    ") + assert node == Element("p") + # Also ignored inside a sequence. + node = to_node(t"

    ") + assert node == Element("p") + + +def test_class_type_errors(): + for class_item in (False, True, 0): + with pytest.raises(TypeError): + _ = to_node(t"

    ") + with pytest.raises(TypeError): + _ = to_node(t"

    ") + + +def test_class_merge_literals(): + node = to_node(t'

    ') + assert node == Element("p", {"class": "red blue"}) + + +def test_class_merge_literal_then_interpolation(): + class_item = "blue" + node = to_node(t'

    ') + assert node == Element("p", {"class": "red blue"}) + + +# +# Special style attribute handling. +# +def test_style_literal_attr_passthru(): + p_id = "para1" # non-literal attribute to cause attr resolution + node = to_node(t'

    Warning!

    ') + assert node == Element( + "p", + attrs={"style": "color: red", "id": "para1"}, + children=[Text("Warning!")], + ) + assert str(node) == '

    Warning!

    ' + + +def test_style_in_interpolated_attr(): + styles = {"color": "red", "font-weight": "bold", "font-size": "16px"} + node = to_node(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_style_in_templated_attr(): + color = "red" + node = to_node(t'

    Warning!

    ') + assert node == Element( + "p", + attrs={"style": "color: red"}, + children=[Text("Warning!")], + ) + assert str(node) == '

    Warning!

    ' + + +def test_style_in_spread_attr(): + attrs = {"style": {"color": "red"}} + node = to_node(t"

    Warning!

    ") + assert node == Element( + "p", + attrs={"style": "color: red"}, + children=[Text("Warning!")], + ) + assert str(node) == '

    Warning!

    ' + + +def test_style_merged_from_all_attrs(): + attrs = dict(style="font-size: 15px") + style = {"font-weight": "bold"} + color = "red" + node = to_node( + t'

    ' + ) + assert node == Element( + "p", + {"style": "font-family: serif; color: red; font-weight: bold; font-size: 15px"}, + ) + assert ( + str(node) + == '

    ' + ) + + +def test_style_override_left_to_right(): + suffix = t">

    " + 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): + _ = to_node(t"

    Warning!

    ") + + +def test_interpolated_style_attribute_merged(): + styles1 = {"color": "red"} + styles2 = {"font-weight": "bold"} + node = to_node(t"

    Warning!

    ") + assert node == Element( + "p", + attrs={"style": "color: red; font-weight: bold"}, + children=[Text("Warning!")], + ) + assert str(node) == '

    Warning!

    ' + + +def test_interpolated_style_attribute_merged_override(): + styles1 = {"color": "red", "font-weight": "normal"} + styles2 = {"font-weight": "bold"} + node = to_node(t"

    Warning!

    ") + assert node == Element( + "p", + attrs={"style": "color: red; font-weight: bold"}, + children=[Text("Warning!")], + ) + assert str(node) == '

    Warning!

    ' + + +def test_style_attribute_str(): + styles = "color: red; font-weight: bold;" + node = to_node(t"

    Warning!

    ") + assert node == Element( + "p", + attrs={"style": "color: red; font-weight: bold"}, + children=[Text("Warning!")], + ) + assert str(node) == '

    Warning!

    ' + + +def test_style_attribute_non_str_non_dict(): + with pytest.raises(TypeError): + styles = [1, 2] + _ = to_node(t"

    Warning!

    ") + + +def test_style_literal_attr_bypass(): + # Trigger overall attribute resolution with an unrelated interpolated attr. + node = to_node(t'

    ') + assert node == Element( + "p", + attrs={"style": "invalid;invalid:", "id": "resolved"}, + ), "A single literal attribute should bypass style accumulator." + + +def test_style_none(): + styles = None + node = to_node(t"

    ") + assert node == Element("p") + + +# -------------------------------------------------------------------------- +# Function component interpolation tests +# -------------------------------------------------------------------------- + + +def FunctionComponent( + children: t.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_interpolated_template_component(): + node = to_node( + t'<{FunctionComponent} first=1 second={99} third-arg="comp1" class="my-comp">Hello, Component!' + ) + 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!
    ' + ) + + +def test_interpolated_template_component_no_children_provided(): + """Same test, but the caller didn't provide any children.""" + node = to_node( + 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_invalid_component_invocation(): + with pytest.raises(TypeError): + _ = to_node(t"<{FunctionComponent}>Missing props") + + +def test_prep_component_kwargs_named(): + 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_prep_component_kwargs_unused_kwargs(): + 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 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(): + node = to_node( + t'<{FunctionComponentNoChildren} first=1 second={99} third-arg="comp1">Hello, Component!' + ) + 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
    ' + ) + + +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_children_always_passed_via_kwargs(): + node = to_node( + t'<{FunctionComponentKeywordArgs} first="value" extra="info">Child content' + ) + assert node == Element( + "div", + attrs={ + "data-first": "value", + "extra": "info", + }, + children=[Text("Component with kwargs")], + ) + assert ( + str(node) == '
    Component with kwargs
    ' + ) + + +def test_children_always_passed_via_kwargs_even_when_empty(): + node = to_node(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 ColumnsComponent() -> Template: + return t"""Column 1Column 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 = to_node(t"<{ColumnsComponent} />
    ") + assert node == Element( + "table", + children=[ + Element( + "tr", + children=[ + Element("td", children=[Text("Column 1")]), + Element("td", children=[Text("Column 2")]), + ], + ), + ], + ) + assert str(node) == "
    Column 1Column 2
    " + + +def test_component_passed_as_attr_value(): + def Wrapper( + children: t.Iterable[Node], sub_component: t.Callable, **attrs: t.Any + ) -> Template: + return t"<{sub_component} {attrs}>{children}" + + node = to_node( + t'<{Wrapper} sub-component={FunctionComponent} class="wrapped" first=1 second={99} third-arg="comp1">

    Inside 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 to_node(t"{'Hello World'}") + + node = to_node(t"<{Header} />") + assert node == Text("Hello World") + assert str(node) == "Hello World" + + +def test_component_returning_iterable(): + def Items() -> t.Iterable: + for i in range(2): + yield t"
  • Item {i + 1}
  • " + yield to_node(t"
  • Item {3}
  • ") + + node = to_node(t"
      <{Items} />
    ") + 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) == "
    • Item 1
    • Item 2
    • Item 3
    " + + +def test_component_returning_fragment(): + def Items() -> Node: + return to_node(t"
  • Item {1}
  • Item {2}
  • Item {3}
  • ") + + node = to_node(t"
      <{Items} />
    ") + 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) == "
    • Item 1
    • Item 2
    • Item 3
    " + + +@dataclass +class ClassComponent: + """Example class-based component.""" + + user_name: str + image_url: str + homepage: str = "#" + children: t.Iterable[Node] = field(default_factory=list) + + def __call__(self) -> Node: + return to_node( + t"
    " + t"" + t"{f" + t"" + t"{self.user_name}" + t"{self.children}" + t"
    ", + ) + + +def test_class_component_implicit_invocation_with_children(): + node = to_node( + t"<{ClassComponent} user-name='Alice' image-url='https://example.com/alice.png'>Fun times!" + ) + 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) + == '
    Avatar of AliceAliceFun 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 = to_node(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) + == '
    Avatar of AliceAlice
    ' + ) + + +@dataclass +class ClassComponentNoChildren: + """Example class-based component that does not ask for children.""" + + user_name: str + image_url: str + homepage: str = "#" + + def __call__(self) -> Node: + return to_node( + t"
    " + t"" + t"{f" + t"" + t"{self.user_name}" + t"ignore children" + t"
    ", + ) + + +def test_class_component_implicit_invocation_ignore_children(): + node = to_node( + t"<{ClassComponentNoChildren} user-name='Alice' image-url='https://example.com/alice.png'>Fun times!" + ) + 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) + == '
    Avatar of AliceAliceignore children
    ' + ) + + +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!" + + +def test_attribute_type_component(): + an_int: int = 42 + a_true: bool = True + a_false: bool = False + a_none: None = None + a_float: float = 3.14 + a_dt: datetime.datetime = datetime.datetime(2024, 1, 1, 12, 0, 0) + spread_attrs: dict[str, object | None] = { + "spread_true": True, + "spread_false": False, + "spread_none": None, + "spread_int": 0, + "spread_float": 0.0, + "spread_dt": datetime.datetime(2024, 1, 1, 12, 0, 1), + "spread_dict": dict(), + "spread_list": ["eggs", "milk"], + } + node = to_node( + 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!" + + +def test_component_non_callable_fails(): + with pytest.raises(TypeError): + _ = to_node(t"<{'not a function'} />") + + +def RequiresPositional(whoops: int, /) -> Template: # pragma: no cover + return t"

    Positional arg: {whoops}

    " + + +def test_component_requiring_positional_arg_fails(): + with pytest.raises(TypeError): + _ = to_node(t"<{RequiresPositional} />") + + +def test_mismatched_component_closing_tag_fails(): + with pytest.raises(TypeError): + _ = to_node( + t"<{FunctionComponent} first=1 second={99} third-arg='comp1'>Hello" + ) diff --git a/tdom/processor_test.py b/tdom/processor_test.py index d69fb50..8bfd0e9 100644 --- a/tdom/processor_test.py +++ b/tdom/processor_test.py @@ -1,22 +1,16 @@ import datetime import typing as t -from dataclasses import dataclass, field +from dataclasses import dataclass from string.templatelib import Interpolation, Template from itertools import product import pytest from markupsafe import Markup -from .nodes import Comment, DocumentType, Element, Fragment, Node, Text, to_node from .placeholders import make_placeholder_config from .processor import to_html, prep_component_kwargs from .callables import get_callable_info - - -def test_to_node_and_to_html(): - div_t = t"
    " - assert str(to_node(div_t)) == to_html(div_t) - +from .escaping import escape_html_text # -------------------------------------------------------------------------- # Basic HTML parsing tests @@ -27,131 +21,123 @@ def test_to_node_and_to_html(): # Text # def test_empty(): - node = to_node(t"") - assert node == Fragment(children=[]) - assert str(node) == "" + assert to_html(t"") == "" def test_text_literal(): - node = to_node(t"Hello, world!") - assert node == Text("Hello, world!") - assert str(node) == "Hello, world!" + assert to_html(t"Hello, world!") == "Hello, world!" def test_text_singleton(): greeting = "Hello, Alice!" - node = to_node(t"{greeting}") - assert node == Text("Hello, Alice!") - assert str(node) == "Hello, Alice!" + assert to_html(t"{greeting}", last_parent_tag="div") == "Hello, Alice!" + assert to_html(t"{greeting}", last_parent_tag="script") == "Hello, Alice!" + assert to_html(t"{greeting}", last_parent_tag="style") == "Hello, Alice!" + assert to_html(t"{greeting}", last_parent_tag="textarea") == "Hello, Alice!" + assert to_html(t"{greeting}", last_parent_tag="title") == "Hello, Alice!" + + +def test_text_singleton_without_parent(): + greeting = "" + with pytest.raises(NotImplementedError): + _ = to_html(t"{greeting}", last_parent_tag=None) + + +def test_text_singleton_explicit_parent_script(): + greeting = "" + res = to_html(t"{greeting}", last_parent_tag="script") + assert res == "\\x3c/script>" + assert res != "" + + +def test_text_singleton_explicit_parent_div(): + greeting = "
    " + res = to_html(t"{greeting}", last_parent_tag="div") + assert res == "</div>" + assert res != "
    " def test_text_template(): name = "Alice" - node = to_node(t"Hello, {name}!") - assert node == Fragment(children=[Text("Hello, "), Text("Alice"), Text("!")]) - assert str(node) == "Hello, Alice!" + assert to_html(t"Hello, {name}!", last_parent_tag="div") == "Hello, Alice!" def test_text_template_escaping(): name = "Alice & Bob" - node = to_node(t"Hello, {name}!") - assert node == Fragment(children=[Text("Hello, "), Text("Alice & Bob"), Text("!")]) - assert str(node) == "Hello, Alice & Bob!" + assert ( + to_html(t"Hello, {name}!", last_parent_tag="div") == "Hello, Alice & Bob!" + ) # # Comments. # def test_comment(): - node = to_node(t"") - assert node == Comment("This is a comment") - assert str(node) == "" + assert to_html(t"") == "" def test_comment_template(): text = "comment" - node = to_node(t"") - assert node == Comment("This is a comment") - assert str(node) == "" + assert to_html(t"") == "" def test_comment_template_escaping(): text = "-->comment" - node = to_node(t"") - assert node == Comment("This is a -->comment") - assert str(node) == "" + assert to_html(t"") == "" # # Document types. # def test_parse_document_type(): - node = to_node(t"") - assert node == DocumentType("html") - assert str(node) == "" + assert to_html(t"") == "" # # Elements # def test_parse_void_element(): - node = to_node(t"
    ") - assert node == Element("br") - assert str(node) == "
    " + assert to_html(t"
    ") == "
    " def test_parse_void_element_self_closed(): - node = to_node(t"
    ") - assert node == Element("br") - assert str(node) == "
    " + assert to_html(t"
    ") == "
    " def test_parse_chain_of_void_elements(): # Make sure our handling of CPython issue #69445 is reasonable. - node = to_node(t"



    ") - assert node == Fragment( - children=[ - Element("br"), - Element("hr"), - Element("img", attrs={"src": "image.png"}), - Element("br"), - Element("hr"), - ], + assert ( + to_html(t"



    ") + == '



    ' ) - assert str(node) == '



    ' def test_parse_element_with_text(): - node = to_node(t"

    Hello, world!

    ") - assert node == Element( - "p", - children=[ - Text("Hello, world!"), - ], - ) - assert str(node) == "

    Hello, world!

    " + assert to_html(t"

    Hello, world!

    ") == "

    Hello, world!

    " def test_parse_nested_elements(): - node = to_node(t"

    Hello

    World

    ") - assert node == Element( - "div", - children=[ - Element("p", children=[Text("Hello")]), - Element("p", children=[Text("World")]), - ], + assert ( + to_html(t"

    Hello

    World

    ") + == "

    Hello

    World

    " ) - assert str(node) == "

    Hello

    World

    " def test_parse_entities_are_escaped(): - node = to_node(t"

    </p>

    ") - assert node == Element( - "p", - children=[Text("

    ")], - ) - assert str(node) == "

    </p>

    " + res = to_html(t"

    </p>

    ") + assert res == "

    </p>

    ", res + +def test_parse_entities_are_escaped_no_parent_tag(): + with pytest.raises(NotImplementedError): + _ = to_html(t"</p>") + + +""" +def test_parse_entities_are_escaped_parent_tag_div(): + res = to_html(t"</p>", last_parent_tag='div') + assert res == "</p>", res +""" # -------------------------------------------------------------------------- # Interpolated text content @@ -160,18 +146,12 @@ def test_parse_entities_are_escaped(): def test_interpolated_text_content(): name = "Alice" - node = to_node(t"

    Hello, {name}!

    ") - assert node == Element("p", children=[Text("Hello, "), Text("Alice"), Text("!")]) - assert str(node) == "

    Hello, Alice!

    " + assert to_html(t"

    Hello, {name}!

    ") == "

    Hello, Alice!

    " def test_escaping_of_interpolated_text_content(): name = "" - node = to_node(t"

    Hello, {name}!

    ") - assert node == Element( - "p", children=[Text("Hello, "), Text(""), Text("!")] - ) - assert str(node) == "

    Hello, <Alice & Bob>!

    " + assert to_html(t"

    Hello, {name}!

    ") == "

    Hello, <Alice & Bob>!

    " class Convertible: @@ -186,50 +166,35 @@ def test_conversions(): c = Convertible() assert f"{c!s}" == "string" assert f"{c!r}" == "repr" - node = to_node(t"
  • {c!s}
  • {c!r}
  • {'😊'!a}
  • ") - assert node == Fragment( - children=[ - Element("li", children=[Text("string")]), - Element("li", children=[Text("repr")]), - Element("li", children=[Text("'\\U0001f60a'")]), - ], + assert ( + to_html(t"
  • {c!s}
  • {c!r}
  • {'😊'!a}
  • ") + == "
  • {c!s}
  • {c!r}
  • '\\U0001f60a'
  • " ) def test_interpolated_in_content_node(): # https://github.com/t-strings/tdom/issues/68 evil = "") - assert node == Element( - "style", - children=[ - Text("") == f"" ) def test_interpolated_trusted_in_content_node(): # https://github.com/t-strings/tdom/issues/68 - node = to_node(t"") - assert node == Element( - "script", - children=[Text("if (a < b && c > d) { alert('wow'); }")], + assert ( + to_html(t"") + == "" ) - assert str(node) == ("") def test_script_elements_error(): nested_template = t"
    " # Putting non-text content inside a script is not allowed. with pytest.raises(ValueError): - node = to_node(t"") - _ = str(node) + _ = to_html(t"") # -------------------------------------------------------------------------- @@ -238,24 +203,19 @@ def test_script_elements_error(): def test_interpolated_false_content(): - node = to_node(t"
    {False}
    ") - assert node == Element("div") - assert str(node) == "
    " + assert to_html(t"
    {False}
    ") == "
    False
    " def test_interpolated_none_content(): - node = to_node(t"
    {None}
    ") - assert node == Element("div", children=[]) - assert str(node) == "
    " + assert to_html(t"
    {None}
    ") == "
    " def test_interpolated_zero_arg_function(): def get_value(): return "dynamic" - node = to_node(t"

    The value is {get_value}.

    ") - assert node == Element( - "p", children=[Text("The value is "), Text("dynamic"), Text(".")] + assert ( + to_html(t"

    The value is {get_value}.

    ") == "

    The value is dynamic.

    " ) @@ -264,7 +224,7 @@ def add(a, b): # pragma: no cover return a + b with pytest.raises(TypeError): - _ = to_node(t"

    The sum is {add}.

    ") + _ = to_html(t"

    The sum is {add}.

    ") # -------------------------------------------------------------------------- @@ -274,9 +234,9 @@ def add(a, b): # pragma: no cover def test_raw_html_injection_with_markupsafe(): raw_content = Markup("I am bold") - node = to_node(t"
    {raw_content}
    ") - assert node == Element("div", children=[Text(text=raw_content)]) - assert str(node) == "
    I am bold
    " + assert ( + to_html(t"
    {raw_content}
    ") == "
    I am bold
    " + ) def test_raw_html_injection_with_dunder_html_protocol(): @@ -289,44 +249,26 @@ def __html__(self): return f"{self._text}" content = SafeContent("emphasized") - node = to_node(t"

    Here is some {content}.

    ") - assert node == Element( - "p", - children=[ - Text("Here is some "), - Text(Markup("emphasized")), - Text("."), - ], + assert ( + to_html(t"

    Here is some {content:safe}.

    ") + == "

    Here is some emphasized.

    " ) - assert str(node) == "

    Here is some emphasized.

    " def test_raw_html_injection_with_format_spec(): raw_content = "underlined" - node = to_node(t"

    This is {raw_content:safe} text.

    ") - assert node == Element( - "p", - children=[ - Text("This is "), - Text(Markup(raw_content)), - Text(" text."), - ], + assert ( + to_html(t"

    This is {raw_content:safe} text.

    ") + == "

    This is underlined text.

    " ) - assert str(node) == "

    This is underlined text.

    " def test_raw_html_injection_with_markupsafe_unsafe_format_spec(): supposedly_safe = Markup("italic") - node = to_node(t"

    This is {supposedly_safe:unsafe} text.

    ") - assert node == Element( - "p", - children=[ - Text("This is "), - Text(str(supposedly_safe)), - Text(" text."), - ], + assert ( + to_html(t"

    This is {supposedly_safe:unsafe} text.

    ") + == "

    This is <i>italic</i> text.

    " ) - assert str(node) == "

    This is <i>italic</i> text.

    " # -------------------------------------------------------------------------- @@ -338,35 +280,33 @@ def test_conditional_rendering_with_if_else(): is_logged_in = True user_profile = t"Welcome, User!" login_prompt = t"Please log in" - node = to_node(t"
    {user_profile if is_logged_in else login_prompt}
    ") - - assert node == Element( - "div", children=[Element("span", children=[Text("Welcome, User!")])] + assert ( + to_html(t"
    {user_profile if is_logged_in else login_prompt}
    ") + == "
    Welcome, User!
    " ) - assert str(node) == "
    Welcome, User!
    " is_logged_in = False - node = to_node(t"
    {user_profile if is_logged_in else login_prompt}
    ") - assert str(node) == '' + assert ( + to_html(t"
    {user_profile if is_logged_in else login_prompt}
    ") + == '' + ) +@pytest.mark.skip( + "This is another design situation where we try to do everything and it complicates the API. If we take False then we probably take True which also does what False does?" +) def test_conditional_rendering_with_and(): show_warning = True warning_message = t'
    Warning!
    ' - node = to_node(t"
    {show_warning and warning_message}
    ") - - assert node == Element( - "main", - children=[ - Element("div", attrs={"class": "warning"}, children=[Text("Warning!")]), - ], + assert ( + to_html(t"
    {show_warning and warning_message}
    ") + == '
    Warning!
    ' ) - assert str(node) == '
    Warning!
    ' show_warning = False - node = to_node(t"
    {show_warning and warning_message}
    ") - # Assuming False renders nothing - assert str(node) == "
    " + assert ( + to_html(t"
    {show_warning and warning_message}
    ") == "
    " + ) # -------------------------------------------------------------------------- @@ -376,39 +316,27 @@ def test_conditional_rendering_with_and(): def test_interpolated_template_content(): child = t"Child" - node = to_node(t"
    {child}
    ") - assert node == Element("div", children=[to_node(child)]) - assert str(node) == "
    Child
    " + assert to_html(t"
    {child}
    ") == "
    Child
    " +@pytest.mark.skip("Allow node injection?") def test_interpolated_element_content(): - child = to_node(t"Child") - node = to_node(t"
    {child}
    ") - assert node == Element("div", children=[child]) - assert str(node) == "
    Child
    " + # child = Element("span", children=[Text("Child")]) + # assert to_html(t"
    {child}
    ") == "
    Child
    " + pass def test_interpolated_nonstring_content(): number = 42 - node = to_node(t"

    The answer is {number}.

    ") - assert node == Element( - "p", children=[Text("The answer is "), Text("42"), Text(".")] - ) - assert str(node) == "

    The answer is 42.

    " + assert to_html(t"

    The answer is {number}.

    ") == "

    The answer is 42.

    " def test_list_items(): items = ["Apple", "Banana", "Cherry"] - node = to_node(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 ( + to_html(t"
      {[t'
    • {item}
    • ' for item in items]}
    ") + == "
    • Apple
    • Banana
    • Cherry
    " ) - assert str(node) == "
    • Apple
    • Banana
    • Cherry
    " def test_nested_list_items(): @@ -417,42 +345,8 @@ def test_nested_list_items(): inner = ["apple", "banana", "cherry"] inner_items = [t"
  • {item}
  • " for item in inner] outer_items = [t"
  • {category}
      {inner_items}
  • " for category in outer] - node = to_node(t"
      {outer_items}
    ") - 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) + to_html(t"
      {outer_items}
    ") == "
    • fruit
      • apple
      • banana
      • cherry
    • more fruit
      • apple
      • banana
      • cherry
    " ) @@ -463,118 +357,82 @@ def test_nested_list_items(): def test_literal_attrs(): - node = to_node( - ( - t"" - ) - ) - assert node == Element( - "a", - attrs={ - "id": "example_link", - "autofocus": None, - "title": "", - "href": "https://example.com", - "target": "_blank", - }, - ) assert ( - str(node) + to_html( + ( + t"" + ) + ) == '' ) def test_literal_attr_escaped(): - node = to_node(t'') - assert node == Element( - "a", - attrs={"title": "<"}, - ) - assert str(node) == '' + assert to_html(t'') == '' def test_interpolated_attr(): url = "https://example.com/" - node = to_node(t'') - assert node == Element("a", attrs={"href": "https://example.com/"}) - assert str(node) == '' + assert to_html(t'') == '' def test_interpolated_attr_escaped(): url = 'https://example.com/?q="test"&lang=en' - node = to_node(t'') - assert node == Element( - "a", - attrs={"href": 'https://example.com/?q="test"&lang=en'}, - ) assert ( - str(node) == '' + to_html(t'') + == '' ) def test_interpolated_attr_unquoted(): id = "roquefort" - node = to_node(t"
    ") - assert node == Element("div", attrs={"id": "roquefort"}) - assert str(node) == '
    ' + assert to_html(t"
    ") == '
    ' def test_interpolated_attr_true(): disabled = True - node = to_node(t"") - assert node == Element("button", attrs={"disabled": None}) - assert str(node) == "" + assert ( + to_html(t"") + == "" + ) def test_interpolated_attr_false(): disabled = False - node = to_node(t"") - assert node == Element("button") - assert str(node) == "" + assert to_html(t"") == "" def test_interpolated_attr_none(): disabled = None - node = to_node(t"") - assert node == Element("button") - assert str(node) == "" + assert to_html(t"") == "" def test_interpolate_attr_empty_string(): - node = to_node(t'
    ') - assert node == Element( - "div", - attrs={"title": ""}, - ) - assert str(node) == '
    ' + assert to_html(t'
    ') == '
    ' def test_spread_attr(): attrs = {"href": "https://example.com/", "target": "_blank"} - node = to_node(t"") - assert node == Element( - "a", - attrs={"href": "https://example.com/", "target": "_blank"}, + assert ( + to_html(t"") + == '' ) - assert str(node) == '' def test_spread_attr_none(): attrs = None - node = to_node(t"") - assert node == Element("a") - assert str(node) == "" + assert to_html(t"") == "" def test_spread_attr_type_errors(): for attrs in (0, [], (), False, True): with pytest.raises(TypeError): - _ = to_node(t"") + _ = to_html(t"") def test_templated_attr_mixed_interpolations_start_end_and_nest(): @@ -588,81 +446,70 @@ def test_templated_attr_mixed_interpolations_start_end_and_nest(): (t"{right}", Template(str(right))), ): test_t = prefix + left_part + t"-" + middle_part + t"-" + right_part + suffix - node = to_node(test_t) - assert node == Element( - "div", - attrs={"data-range": "1-3-5"}, - ) - assert str(node) == '
    ' + assert to_html(test_t) == '
    ' def test_templated_attr_no_quotes(): start = 1 end = 5 - node = to_node(t"
    ") - assert node == Element( - "div", - attrs={"data-range": "1-5"}, + assert ( + to_html(t"
    ") + == '
    ' ) - assert str(node) == '
    ' def test_attr_merge_disjoint_interpolated_attr_spread_attr(): attrs = {"href": "https://example.com/", "id": "link1"} target = "_blank" - node = to_node(t"") - assert node == Element( - "a", - attrs={"href": "https://example.com/", "id": "link1", "target": "_blank"}, + assert ( + to_html(t"") + == '' ) - assert str(node) == '' def test_attr_merge_overlapping_spread_attrs(): attrs1 = {"href": "https://example.com/", "id": "overwrtten"} attrs2 = {"target": "_blank", "id": "link1"} - node = to_node(t"") - assert node == Element( - "a", - attrs={"href": "https://example.com/", "target": "_blank", "id": "link1"}, + assert ( + to_html(t"") + == '' ) - assert str(node) == '' def test_attr_merge_replace_literal_attr_str_str(): - node = to_node(t'
    ') - assert node == Element("div", {"title": "fresh"}) - assert str(node) == '
    ' + assert ( + to_html(t'
    ') + == '
    ' + ) def test_attr_merge_replace_literal_attr_str_true(): - node = to_node(t'
    ') - assert node == Element("div", {"title": None}) - assert str(node) == "
    " + assert ( + to_html(t'
    ') + == "
    " + ) def test_attr_merge_replace_literal_attr_true_str(): - node = to_node(t"
    ") - assert node == Element("div", {"title": "fresh"}) - assert str(node) == '
    ' + assert ( + to_html(t"
    ") + == '
    ' + ) def test_attr_merge_remove_literal_attr_str_none(): - node = to_node(t'
    ') - assert node == Element("div") - assert str(node) == "
    " + assert to_html(t'
    ') == "
    " def test_attr_merge_remove_literal_attr_true_none(): - node = to_node(t"
    ") - assert node == Element("div") - assert str(node) == "
    " + assert to_html(t"
    ") == "
    " def test_attr_merge_other_literal_attr_intact(): - node = to_node(t'') - assert node == Element("img", {"title": "default", "alt": "fresh"}) - assert str(node) == 'fresh' + assert ( + to_html(t'') + == 'fresh' + ) def test_placeholder_collision_avoidance(): @@ -675,14 +522,9 @@ def test_placeholder_collision_avoidance(): Interpolation(tricky, "tricky", None, ""), f'{config.suffix}">
    ', ) - node = to_node(template) - assert node == Element( - "div", - attrs={"data-tricky": config.prefix + tricky + config.suffix}, - children=[], - ) assert ( - str(node) == f'
    ' + to_html(template) + == f'
    ' ) @@ -691,67 +533,55 @@ def test_placeholder_collision_avoidance(): # def test_interpolated_data_attributes(): data = {"user-id": 123, "role": "admin", "wild": True, "false": False, "none": None} - node = to_node(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) + to_html(t"
    User Info
    ") == '
    User Info
    ' ) def test_data_attr_toggle_to_str(): - for node in [ - to_node(t"
    "), - to_node(t'
    '), + for res in [ + to_html(t"
    "), + to_html(t'
    '), ]: - assert node == Element("div", {"data-selected": "yes"}) - assert str(node) == '
    ' + assert res == '
    ' def test_data_attr_toggle_to_true(): - node = to_node(t'
    ') - assert node == Element("div", {"data-selected": None}) - assert str(node) == "
    " + res = to_html(t'
    ') + assert res == "
    " def test_data_attr_unrelated_unaffected(): - node = to_node(t"
    ") - assert node == Element("div", {"data-selected": None, "data-active": None}) - assert str(node) == "
    " + res = to_html(t"
    ") + assert res == "
    " def test_data_attr_templated_error(): data1 = {"user-id": "user-123"} data2 = {"role": "admin"} with pytest.raises(TypeError): - node = to_node(t'
    ') - print(str(node)) + _ = to_html(t'
    ') def test_data_attr_none(): button_data = None - node = to_node(t"") - assert node == Element("button", children=[Text("X")]) - assert str(node) == "" + res = to_html(t"") + assert res == "" def test_data_attr_errors(): for v in [False, [], (), 0, "data?"]: with pytest.raises(TypeError): - _ = to_node(t"") + _ = to_html(t"") def test_data_literal_attr_bypass(): # Trigger overall attribute resolution with an unrelated interpolated attr. - node = to_node(t'

    ') - assert node == Element( - "p", - attrs={"data": "passthru", "id": "resolved"}, - ), "A single literal attribute should not trigger data expansion." + res = to_html(t'

    ') + assert res == '

    ', ( + "A single literal attribute should not trigger data expansion." + ) # @@ -761,44 +591,36 @@ def test_aria_templated_attr_error(): aria1 = {"label": "close"} aria2 = {"hidden": "true"} with pytest.raises(TypeError): - node = to_node(t'
    ') - print(str(node)) + _ = to_html(t'
    ') def test_aria_interpolated_attr_dict(): aria = {"label": "Close", "hidden": True, "another": False, "more": None} - node = to_node(t"") - assert node == Element( - "button", - attrs={"aria-label": "Close", "aria-hidden": "true", "aria-another": "false"}, - children=[Text("X")], - ) + res = to_html(t"") assert ( - str(node) + res == '' ) def test_aria_interpolate_attr_none(): button_aria = None - node = to_node(t"") - assert node == Element("button", children=[Text("X")]) - assert str(node) == "" + res = to_html(t"") + assert res == "" def test_aria_attr_errors(): for v in [False, [], (), 0, "aria?"]: with pytest.raises(TypeError): - _ = to_node(t"") + _ = to_html(t"") def test_aria_literal_attr_bypass(): # Trigger overall attribute resolution with an unrelated interpolated attr. - node = to_node(t'

    ') - assert node == Element( - "p", - attrs={"aria": "passthru", "id": "resolved"}, - ), "A single literal attribute should not trigger aria expansion." + res = to_html(t'

    ') + assert res == '

    ', ( + "A single literal attribute should not trigger aria expansion." + ) # @@ -820,14 +642,9 @@ def test_interpolated_class_attribute(): t" class={class_str} class={class_space_sep_str}" t" >Click me" ) - node = to_node(button_t) - assert node == Element( - "button", - attrs={"class": "red btn btn-primary one two active blue green yellow"}, - children=[Text("Click me")], - ) + res = to_html(button_t) assert ( - str(node) + res == '' ) @@ -835,62 +652,57 @@ def test_interpolated_class_attribute(): def test_interpolated_class_attribute_with_multiple_placeholders(): classes1 = ["btn", "btn-primary"] classes2 = [False and "disabled", None, {"active": True}] - node = to_node(t'') + res = to_html(t'') # 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")], + assert ( + res + == f'' + ), ( + "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(): attrs = {"id": "button1", "class": ["btn", "btn-primary"]} - node = to_node(t"") - assert node == Element( - "button", - attrs={"id": "button1", "class": "btn btn-primary"}, - children=[Text("Click me")], - ) - assert str(node) == '' + res = to_html(t"") + assert res == '' def test_class_literal_attr_bypass(): # Trigger overall attribute resolution with an unrelated interpolated attr. - node = to_node(t'

    ') - assert node == Element( - "p", - attrs={"class": "red red", "id": "veryred"}, - ), "A single literal attribute should not trigger class accumulator." + res = to_html(t'

    ') + assert res == '

    ', ( + "A single literal attribute should not trigger class accumulator." + ) def test_class_none_ignored(): class_item = None - node = to_node(t"

    ") - assert node == Element("p") + res = to_html(t"

    ") + assert res == "

    " # Also ignored inside a sequence. - node = to_node(t"

    ") - assert node == Element("p") + res = to_html(t"

    ") + assert res == "

    " def test_class_type_errors(): for class_item in (False, True, 0): with pytest.raises(TypeError): - _ = to_node(t"

    ") + _ = to_html(t"

    ") with pytest.raises(TypeError): - _ = to_node(t"

    ") + _ = to_html(t"

    ") def test_class_merge_literals(): - node = to_node(t'

    ') - assert node == Element("p", {"class": "red blue"}) + res = to_html(t'

    ') + assert res == '

    ' def test_class_merge_literal_then_interpolation(): class_item = "blue" - node = to_node(t'

    ') - assert node == Element("p", {"class": "red blue"}) + res = to_html(t'

    ') + assert res == '

    ' # @@ -898,64 +710,39 @@ def test_class_merge_literal_then_interpolation(): # def test_style_literal_attr_passthru(): p_id = "para1" # non-literal attribute to cause attr resolution - node = to_node(t'

    Warning!

    ') - assert node == Element( - "p", - attrs={"style": "color: red", "id": "para1"}, - children=[Text("Warning!")], - ) - assert str(node) == '

    Warning!

    ' + res = to_html(t'

    Warning!

    ') + assert res == '

    Warning!

    ' def test_style_in_interpolated_attr(): styles = {"color": "red", "font-weight": "bold", "font-size": "16px"} - node = to_node(t"

    Warning!

    ") - assert node == Element( - "p", - attrs={"style": "color: red; font-weight: bold; font-size: 16px"}, - children=[Text("Warning!")], - ) + res = to_html(t"

    Warning!

    ") assert ( - str(node) - == '

    Warning!

    ' + res == '

    Warning!

    ' ) def test_style_in_templated_attr(): color = "red" - node = to_node(t'

    Warning!

    ') - assert node == Element( - "p", - attrs={"style": "color: red"}, - children=[Text("Warning!")], - ) - assert str(node) == '

    Warning!

    ' + res = to_html(t'

    Warning!

    ') + assert res == '

    Warning!

    ' def test_style_in_spread_attr(): attrs = {"style": {"color": "red"}} - node = to_node(t"

    Warning!

    ") - assert node == Element( - "p", - attrs={"style": "color: red"}, - children=[Text("Warning!")], - ) - assert str(node) == '

    Warning!

    ' + res = to_html(t"

    Warning!

    ") + assert res == '

    Warning!

    ' def test_style_merged_from_all_attrs(): attrs = dict(style="font-size: 15px") style = {"font-weight": "bold"} color = "red" - node = to_node( + res = to_html( t'

    ' ) - assert node == Element( - "p", - {"style": "font-family: serif; color: red; font-weight: bold; font-size: 15px"}, - ) assert ( - str(node) + res == '

    ' ) @@ -971,9 +758,8 @@ def test_style_override_left_to_right(): for index in range(len(parts)): expected_style = parts[index][1] t = sum([part[0] for part in parts[: index + 1]], t"") + suffix - node = to_node(t) - assert node == Element("p", {"style": expected_style}) - assert str(node) == f'

    ' + res = to_html(t) + assert res == f'

    ' def test_interpolated_style_attribute_multiple_placeholders(): @@ -983,63 +769,47 @@ def test_interpolated_style_attribute_multiple_placeholders(): # placeholders in a single attribute, we treat it as a string attribute # which produces an invalid style attribute. with pytest.raises(ValueError): - _ = to_node(t"

    Warning!

    ") + _ = to_html(t"

    Warning!

    ") def test_interpolated_style_attribute_merged(): styles1 = {"color": "red"} styles2 = {"font-weight": "bold"} - node = to_node(t"

    Warning!

    ") - assert node == Element( - "p", - attrs={"style": "color: red; font-weight: bold"}, - children=[Text("Warning!")], - ) - assert str(node) == '

    Warning!

    ' + res = to_html(t"

    Warning!

    ") + assert res == '

    Warning!

    ' def test_interpolated_style_attribute_merged_override(): styles1 = {"color": "red", "font-weight": "normal"} styles2 = {"font-weight": "bold"} - node = to_node(t"

    Warning!

    ") - assert node == Element( - "p", - attrs={"style": "color: red; font-weight: bold"}, - children=[Text("Warning!")], - ) - assert str(node) == '

    Warning!

    ' + res = to_html(t"

    Warning!

    ") + assert res == '

    Warning!

    ' def test_style_attribute_str(): styles = "color: red; font-weight: bold;" - node = to_node(t"

    Warning!

    ") - assert node == Element( - "p", - attrs={"style": "color: red; font-weight: bold"}, - children=[Text("Warning!")], - ) - assert str(node) == '

    Warning!

    ' + res = to_html(t"

    Warning!

    ") + assert res == '

    Warning!

    ' def test_style_attribute_non_str_non_dict(): with pytest.raises(TypeError): styles = [1, 2] - _ = to_node(t"

    Warning!

    ") + _ = to_html(t"

    Warning!

    ") def test_style_literal_attr_bypass(): # Trigger overall attribute resolution with an unrelated interpolated attr. - node = to_node(t'

    ') - assert node == Element( - "p", - attrs={"style": "invalid;invalid:", "id": "resolved"}, - ), "A single literal attribute should bypass style accumulator." + res = to_html(t'

    ') + assert res == '

    ', ( + "A single literal attribute should bypass style accumulator." + ) def test_style_none(): styles = None - node = to_node(t"

    ") - assert node == Element("p") + res = to_html(t"

    ") + assert res == "

    " # -------------------------------------------------------------------------- @@ -1048,7 +818,7 @@ def test_style_none(): def FunctionComponent( - children: t.Iterable[Node], first: str, second: int, third_arg: str, **attrs: t.Any + children: Template, 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) @@ -1063,51 +833,29 @@ def FunctionComponent( def test_interpolated_template_component(): - node = to_node( + res = to_html( t'<{FunctionComponent} first=1 second={99} third-arg="comp1" class="my-comp">Hello, Component!' ) - 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) + res == '
    Component: Hello, Component!
    ' ) def test_interpolated_template_component_no_children_provided(): """Same test, but the caller didn't provide any children.""" - node = to_node( + res = to_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) + res == '
    Component:
    ' ) def test_invalid_component_invocation(): with pytest.raises(TypeError): - _ = to_node(t"<{FunctionComponent}>Missing props") + _ = to_html(t"<{FunctionComponent}>Missing props") def test_prep_component_kwargs_named(): @@ -1149,20 +897,11 @@ def FunctionComponentNoChildren(first: str, second: int, third_arg: str) -> Temp def test_interpolated_template_component_ignore_children(): - node = to_node( + res = to_html( t'<{FunctionComponentNoChildren} first=1 second={99} third-arg="comp1">Hello, Component!' ) - assert node == Element( - "div", - attrs={ - "id": "comp1", - "data-first": "1", - "data-second": "99", - }, - children=[Text(text="Component: ignore children")], - ) assert ( - str(node) + res == '
    Component: ignore children
    ' ) @@ -1177,35 +916,15 @@ def FunctionComponentKeywordArgs(first: str, **attrs: t.Any) -> Template: def test_children_always_passed_via_kwargs(): - node = to_node( + res = to_html( t'<{FunctionComponentKeywordArgs} first="value" extra="info">Child content' ) - assert node == Element( - "div", - attrs={ - "data-first": "value", - "extra": "info", - }, - children=[Text("Component with kwargs")], - ) - assert ( - str(node) == '
    Component with kwargs
    ' - ) + assert res == '
    Component with kwargs
    ' def test_children_always_passed_via_kwargs_even_when_empty(): - node = to_node(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
    ' - ) + res = to_html(t'<{FunctionComponentKeywordArgs} first="value" extra="info" />') + assert res == '
    Component with kwargs
    ' def ColumnsComponent() -> Template: @@ -1215,89 +934,60 @@ def ColumnsComponent() -> Template: 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 = to_node(t"<{ColumnsComponent} />
    ") - assert node == Element( - "table", - children=[ - Element( - "tr", - children=[ - Element("td", children=[Text("Column 1")]), - Element("td", children=[Text("Column 2")]), - ], - ), - ], - ) - assert str(node) == "
    Column 1Column 2
    " + res = to_html(t"<{ColumnsComponent} />
    ") + assert res == "
    Column 1Column 2
    " def test_component_passed_as_attr_value(): def Wrapper( - children: t.Iterable[Node], sub_component: t.Callable, **attrs: t.Any + children: Template, sub_component: t.Callable, **attrs: t.Any ) -> Template: return t"<{sub_component} {attrs}>{children}" - node = to_node( + res = to_html( t'<{Wrapper} sub-component={FunctionComponent} class="wrapped" first=1 second={99} third-arg="comp1">

    Inside 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) + res == '
    Component:

    Inside wrapper

    ' ) def test_nested_component_gh23(): + # @DESIGN: Do we need this? Should we recommend an alternative? # See https://github.com/t-strings/tdom/issues/23 for context - def Header(): - return to_node(t"{'Hello World'}") + def Header() -> Template: + return t"{'Hello World'}" - node = to_node(t"<{Header} />") - assert node == Text("Hello World") - assert str(node) == "Hello World" + assert False, "The last parent tag does not seem to pass to components" + res = to_html(t"<{Header} />", last_parent_tag="div") + assert res == "Hello World" +# @DESIGN: This seems to complicate component definitions. +# Worst case we can just return an iterable in a template interpolation. +@pytest.mark.skip( + "This seems to make component definitions more confusing. Marking skip to then rewrite or remove." +) def test_component_returning_iterable(): - def Items() -> t.Iterable: + def generate_lis() -> t.Iterable: for i in range(2): yield t"
  • Item {i + 1}
  • " - yield to_node(t"
  • Item {3}
  • ") - - node = to_node(t"
      <{Items} />
    ") - 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) == "
    • Item 1
    • Item 2
    • Item 3
    " + yield t"
  • Item {3}
  • " + + def Items() -> Template: + return t"{generate_lis()}" + + res = to_html(t"
      <{Items} />
    ") + assert res == "
    • Item 1
    • Item 2
    • Item 3
    " def test_component_returning_fragment(): - def Items() -> Node: - return to_node(t"
  • Item {1}
  • Item {2}
  • Item {3}
  • ") - - node = to_node(t"
      <{Items} />
    ") - 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) == "
    • Item 1
    • Item 2
    • Item 3
    " + def Items() -> Template: + return t"
  • Item {1}
  • Item {2}
  • Item {3}
  • " + + res = to_html(t"
      <{Items} />
    ") + assert str(res) == "
    • Item 1
    • Item 2
    • Item 3
    " @dataclass @@ -1307,47 +997,26 @@ class ClassComponent: user_name: str image_url: str homepage: str = "#" - children: t.Iterable[Node] = field(default_factory=list) + children: Template | None = None - def __call__(self) -> Node: - return to_node( + def __call__(self) -> Template: + return ( t"
    " t"" t"{f" t"" t"{self.user_name}" t"{self.children}" - t"
    ", + t"
    " ) def test_class_component_implicit_invocation_with_children(): - node = to_node( + res = to_html( t"<{ClassComponent} user-name='Alice' image-url='https://example.com/alice.png'>Fun times!" ) - 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) + res == '
    Avatar of AliceAliceFun times!
    ' ) @@ -1358,29 +1027,9 @@ def test_class_component_direct_invocation(): image_url="https://example.com/alice.png", homepage="https://example.com/users/alice", ) - node = to_node(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")]), - ], - ) + res = to_html(t"<{avatar} />") assert ( - str(node) + res == '
    Avatar of AliceAlice
    ' ) @@ -1393,45 +1042,24 @@ class ClassComponentNoChildren: image_url: str homepage: str = "#" - def __call__(self) -> Node: - return to_node( + def __call__(self) -> Template: + return ( t"
    " t"" t"{f" t"" t"{self.user_name}" t"ignore children" - t"
    ", + t"" ) def test_class_component_implicit_invocation_ignore_children(): - node = to_node( + res = to_html( t"<{ClassComponentNoChildren} user-name='Alice' image-url='https://example.com/alice.png'>Fun times!" ) - 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) + res == '
    Avatar of AliceAliceignore children
    ' ) @@ -1490,18 +1118,17 @@ def test_attribute_type_component(): "spread_dict": dict(), "spread_list": ["eggs", "milk"], } - node = to_node( + res = to_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!" def test_component_non_callable_fails(): with pytest.raises(TypeError): - _ = to_node(t"<{'not a function'} />") + _ = to_html(t"<{'not a function'} />") def RequiresPositional(whoops: int, /) -> Template: # pragma: no cover @@ -1510,11 +1137,11 @@ def RequiresPositional(whoops: int, /) -> Template: # pragma: no cover def test_component_requiring_positional_arg_fails(): with pytest.raises(TypeError): - _ = to_node(t"<{RequiresPositional} />") + _ = to_html(t"<{RequiresPositional} />") def test_mismatched_component_closing_tag_fails(): with pytest.raises(TypeError): - _ = to_node( + _ = to_html( t"<{FunctionComponent} first=1 second={99} third-arg='comp1'>Hello" ) From 2c7f71813f265408791da76426035b26ad8572c1 Mon Sep 17 00:00:00 2001 From: Ian Wilson Date: Sun, 15 Feb 2026 01:20:59 -0800 Subject: [PATCH 13/59] Parsed entities should be re-escaped by default --- tdom/processor_test.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tdom/processor_test.py b/tdom/processor_test.py index 8bfd0e9..19892fe 100644 --- a/tdom/processor_test.py +++ b/tdom/processor_test.py @@ -129,8 +129,8 @@ def test_parse_entities_are_escaped(): def test_parse_entities_are_escaped_no_parent_tag(): - with pytest.raises(NotImplementedError): - _ = to_html(t"</p>") + res = to_html(t"</p>") + assert res == "</p>", "Default to standard escaping." """ From df2854f0c0d5ef32d4a3424fe355affbc16616df Mon Sep 17 00:00:00 2001 From: Ian Wilson Date: Sun, 15 Feb 2026 01:23:04 -0800 Subject: [PATCH 14/59] Formatting. --- tdom/processor.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tdom/processor.py b/tdom/processor.py index 7618631..9c0e936 100644 --- a/tdom/processor.py +++ b/tdom/processor.py @@ -883,9 +883,9 @@ def streamer( ] ) if ref.is_literal: - if last_parent_tag == 'script': + if last_parent_tag == "script": yield default_escape_html_script(ref.strings[0]) - elif last_parent_tag == 'style': + elif last_parent_tag == "style": yield default_escape_html_style(ref.strings[0]) else: # Fallback to escape everything. From 60366975967b5e41ec90fca15dd6387724b01e57 Mon Sep 17 00:00:00 2001 From: Ian Wilson Date: Sun, 15 Feb 2026 14:22:54 -0800 Subject: [PATCH 15/59] Use runtime parent tag when component has no predefined parent tag. --- tdom/processor.py | 2 ++ tdom/processor_test.py | 1 - 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/tdom/processor.py b/tdom/processor.py index 9c0e936..993a42a 100644 --- a/tdom/processor.py +++ b/tdom/processor.py @@ -503,6 +503,8 @@ def interpolate_component( (parent_tag, attrs, start_i_index, end_i_index, body_start_s_index) = cast( InterpolateComponentInfo, ip_info ) + if parent_tag is None: + parent_tag = last_parent_tag start_i = template.interpolations[start_i_index] component_callable = start_i.value if start_i_index != end_i_index and end_i_index is not None: diff --git a/tdom/processor_test.py b/tdom/processor_test.py index 19892fe..d7d975c 100644 --- a/tdom/processor_test.py +++ b/tdom/processor_test.py @@ -959,7 +959,6 @@ def test_nested_component_gh23(): def Header() -> Template: return t"{'Hello World'}" - assert False, "The last parent tag does not seem to pass to components" res = to_html(t"<{Header} />", last_parent_tag="div") assert res == "Hello World" From 96f465488b327911041814182cca8fd5ad68b9fe Mon Sep 17 00:00:00 2001 From: Ian Wilson Date: Sun, 15 Feb 2026 21:56:46 -0800 Subject: [PATCH 16/59] Split assertions and correct the expected output. --- tdom/processor_test.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tdom/processor_test.py b/tdom/processor_test.py index d7d975c..805471c 100644 --- a/tdom/processor_test.py +++ b/tdom/processor_test.py @@ -166,9 +166,10 @@ def test_conversions(): c = Convertible() assert f"{c!s}" == "string" assert f"{c!r}" == "repr" + assert to_html(t"
    {c!s}
    ") == "
    string
    " + assert to_html(t"
    {c!r}
    ") == "
    repr
    " assert ( - to_html(t"
  • {c!s}
  • {c!r}
  • {'😊'!a}
  • ") - == "
  • {c!s}
  • {c!r}
  • '\\U0001f60a'
  • " + to_html(t"
    {'😊'!a}
    ") == f"
    {escape_html_text(ascii('😊'))}
    " ) From 027ddd558fa28c91e743f8e1d0e0091e9b4cf772 Mon Sep 17 00:00:00 2001 From: Ian Wilson Date: Sun, 15 Feb 2026 22:24:33 -0800 Subject: [PATCH 17/59] Add callback as explicit opt-in formatter. --- tdom/format.py | 6 +++++- tdom/processor.py | 12 +++++++++++- tdom/processor_test.py | 5 +++-- 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/tdom/format.py b/tdom/format.py index fba5539..6082481 100644 --- a/tdom/format.py +++ b/tdom/format.py @@ -29,7 +29,11 @@ def convert[T](value: T, conversion: t.Literal["a", "r", "s"] | None) -> T | str type FormatMatcher = t.Callable[[str], bool] """A predicate function that returns True if a given format specifier matches its criteria.""" -type CustomFormatter = t.Callable[[object, str], str] + +V = t.TypeVar("V", bound=object) + + +type CustomFormatter[V] = t.Callable[[V, str], object] """A function that takes a value and a format specifier and returns a formatted string.""" type MatcherAndFormatter = tuple[str | FormatMatcher, CustomFormatter] diff --git a/tdom/processor.py b/tdom/processor.py index 993a42a..f735c16 100644 --- a/tdom/processor.py +++ b/tdom/processor.py @@ -61,7 +61,17 @@ def _format_unsafe(value: object, format_spec: str) -> str: return str(value) -CUSTOM_FORMATTERS = (("safe", _format_safe), ("unsafe", _format_unsafe)) +def _format_callback(value: Callable[..., object], format_spec: str) -> object: + """Execute a callback and return the value.""" + assert format_spec == "callback" + return value() + + +CUSTOM_FORMATTERS = ( + ("safe", _format_safe), + ("unsafe", _format_unsafe), + ("callback", _format_callback), +) def format_interpolation(interpolation: Interpolation) -> object: diff --git a/tdom/processor_test.py b/tdom/processor_test.py index 805471c..415cd1d 100644 --- a/tdom/processor_test.py +++ b/tdom/processor_test.py @@ -216,7 +216,8 @@ def get_value(): return "dynamic" assert ( - to_html(t"

    The value is {get_value}.

    ") == "

    The value is dynamic.

    " + to_html(t"

    The value is {get_value:callback}.

    ") + == "

    The value is dynamic.

    " ) @@ -225,7 +226,7 @@ def add(a, b): # pragma: no cover return a + b with pytest.raises(TypeError): - _ = to_html(t"

    The sum is {add}.

    ") + _ = to_html(t"

    The sum is {add:callback}.

    ") # -------------------------------------------------------------------------- From bcd5b74ab6249f14cb5966aaab8305e6bfd4d9f6 Mon Sep 17 00:00:00 2001 From: Ian Wilson Date: Sun, 15 Feb 2026 23:20:12 -0800 Subject: [PATCH 18/59] Merge extra transform processor tests into main processor tests file. --- tdom/processor_test.py | 517 +++++++++++++++++++++++++++++++- tdom/processor_to_html_test.py | 524 --------------------------------- 2 files changed, 514 insertions(+), 527 deletions(-) delete mode 100644 tdom/processor_to_html_test.py diff --git a/tdom/processor_test.py b/tdom/processor_test.py index 415cd1d..24656a7 100644 --- a/tdom/processor_test.py +++ b/tdom/processor_test.py @@ -2,13 +2,22 @@ import typing as t from dataclasses import dataclass from string.templatelib import Interpolation, Template -from itertools import product +from itertools import product, chain +from collections.abc import Callable import pytest -from markupsafe import Markup +from markupsafe import Markup, escape as markupsafe_escape from .placeholders import make_placeholder_config -from .processor import to_html, prep_component_kwargs +from .processor import ( + to_html, + prep_component_kwargs, + TransformService, + ProcessService, + process_service_factory, + cached_process_service_factory, + CachedTransformService, +) from .callables import get_callable_info from .escaping import escape_html_text @@ -1146,3 +1155,505 @@ def test_mismatched_component_closing_tag_fails(): _ = to_html( t"<{FunctionComponent} first=1 second={99} third-arg='comp1'>Hello" ) + + +def test_process_template_smoketest(): + 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 +{markup_content} + +""" + smoke_str = """ + + + +literal + +text is not literal +text is not literal +
    comp body
    +
    safe
    + +""" + process_api = process_service_factory() + assert process_api.process_template(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.""" + sample_t = t"""
    {"content"}
    """ + sample_diff_t = t"""
    {"diffcontent"}
    """ + alt_t = t"""{"content"}""" + process_api = process_service_factory() + cached_process_api = cached_process_service_factory() + # Technically this could be the superclass which doesn't have cached method. + assert isinstance(cached_process_api.transform_api, CachedTransformService) + # 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 determin the result, + # this is not great and hopefully we can find a better solution. + start_ci = cached_process_api.transform_api._transform_template.cache_info() + tf1 = process_api.transform_api.transform_template(sample_t) + tf2 = process_api.transform_api.transform_template(sample_t) + cached_tf1 = cached_process_api.transform_api.transform_template(sample_t) + cached_tf2 = cached_process_api.transform_api.transform_template(sample_t) + cached_tf3 = cached_process_api.transform_api.transform_template(sample_diff_t) + # Check that the uncached and cached services are actually + # returning non-identical results. + assert tf1 is not cached_tf1 + assert tf1 is not cached_tf2 + assert tf1 is not cached_tf3 + # Check that the uncached service returns a brand new result everytime. + assert tf1 is not tf2 + # Check that the cached service is returning the exact same, identical, result. + assert cached_tf1 is cached_tf2 + # Even if the input templates are not identical (but are still equivalent). + assert cached_tf1 is cached_tf3 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 struct_repr(tf1) == struct_repr(cached_tf1) + assert struct_repr(tf2) == struct_repr(cached_tf1) + # Now that we are setup we check that the cache is internally + # working as we intended. + ci = cached_process_api.transform_api._transform_template.cache_info() + # cached_tf2 and cached_tf3 are hits after cached_tf1 + 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_tf4 = cached_process_api.transform_api.transform_template(alt_t) + # A different template produces a brand new tf. + assert cached_tf1 is not cached_tf4 + # The template is new AND has a different structure so it also + # produces an unequivalent tf. + assert struct_repr(cached_tf1) != struct_repr(cached_tf4) + + +def test_process_template_repeated(): + """Crude check for any unintended state being kept between calls.""" + + def get_sample_t(idx, spread_attrs, button_text): + return t"""
    """ + + process_apis = (process_service_factory(), cached_process_service_factory()) + for process_api in process_apis: + for idx in range(3): + spread_attrs = {"data-enabled": True} + button_text = "PROCESS" + sample_t = get_sample_t(idx, spread_attrs, button_text) + assert ( + process_api.process_template(sample_t) + == f'
    ' + ) + + +def get_select_t_with_list(options, selected_values): + return t"""""" + + +def get_select_t_with_generator(options, selected_values): + return t"""""" + + +def get_select_t_with_concat(options, selected_values): + parts = [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): + process_api = process_service_factory() + + def get_color_select_t(selected_values: set, provider: t.Callable) -> Template: + PRIMARY_COLORS = [("R", "Red"), ("Y", "Yellow"), ("B", "Blue")] + assert set(selected_values).issubset(set([opt[0] for opt in PRIMARY_COLORS])) + return provider(PRIMARY_COLORS, selected_values) + + no_selection_t = get_color_select_t(set(), provider) + assert ( + process_api.process_template(no_selection_t) + == '' + ) + selected_yellow_t = get_color_select_t({"Y"}, provider) + assert ( + process_api.process_template(selected_yellow_t) + == '' + ) + + +def test_process_template_components_smoketest(): + """Broadly test that common template component usage works.""" + + def PageComponent(children, root_attrs=None): + return t"""
    {children}
    """ + + def FooterComponent(classes=("footer-default",)): + return t'' + + def LayoutComponent(children, body_classes=None): + return t""" + + + + + + + + {children} + <{FooterComponent} /> + + +""" + + process_api = process_service_factory() + content = "HTML never goes out of style." + content_str = process_api.process_template( + t"<{LayoutComponent} body_classes={['theme-default']}><{PageComponent}>{content}" + ) + assert ( + content_str + == """ + + + + + + + +
    HTML never goes out of style.
    + + + +""" + ) + + +def test_process_template_functions_smoketest(): + """Broadly test that common template function usage works.""" + + def make_page_t(content, root_attrs=None) -> Template: + return t"""
    {content}
    """ + + def make_footer_t(classes=("footer-default",)) -> Template: + return t'' + + def make_layout_t(body_t, body_classes=None) -> Template: + footer_t = make_footer_t() + return t""" + + + + + + + + {body_t} + {footer_t} + + +""" + + process_api = process_service_factory() + content = "HTML never goes out of style." + layout_t = make_layout_t(make_page_t(content), "theme-default") + content_str = process_api.process_template(layout_t) + assert ( + content_str + == """ + + + + + + + +
    HTML never goes out of style.
    + + + +""" + ) + + +def test_text_interpolation_with_dynamic_parent(): + process_api = process_service_factory() + with pytest.raises( + ValueError, match="Recursive includes are not supported within script" + ): + content = '' + content_t = t"{content}" + _ = process_api.process_template(t"") + + +@pytest.mark.skip("Can we allow this?") +def test_escape_escapable_raw_text_with_dynamic_parent(): + content = '' + content_t = t"{content}" + process_api = process_service_factory() + LT, GT, DQ = map(markupsafe_escape, ["<", ">", '"']) + assert ( + process_api.process_template(t"") + == f"" + ) + + +def test_escape_structured_text_with_dynamic_parent(): + content = '' + content_t = t"{content}" + process_api = process_service_factory() + LT, GT, DQ = map(markupsafe_escape, ["<", ">", '"']) + assert ( + process_api.process_template(t"
    {content_t}
    ") + == f"
    {LT}script{GT}console.log({DQ}123!{DQ});{LT}/script{GT}
    " + ) + + +def test_escape_structured_text(): + content = '' + content_t = t"
    {content}
    " + process_api = process_service_factory() + LT, GT, DQ = map(markupsafe_escape, ["<", ">", '"']) + assert ( + process_api.process_template(content_t) + == f"
    {LT}script{GT}console.log({DQ}123!{DQ});{LT}/script{GT}
    " + ) + + +@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: 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_class_component(): + def paginate_url(page: int) -> str: + return f"/pages?page={page}" + + def Footer(pager, paginate_url, footer_classes=("footer",)) -> Template: + return t"
    <{PagerDisplay} pager={pager} paginate_url={paginate_url} />
    " + + pager = 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} />" + process_api = process_service_factory() + res = process_api.process_template(content_t) + print(res) + assert ( + res + == '' + ) + + +def test_mathml(): + num = 1 + denom = 3 + mathml_t = t"""

    + The fraction + + + {num} + {denom} + + + is not a decimal number. +

    """ + process_api = process_service_factory() + res = process_api.process_template(mathml_t) + assert ( + str(res) + == """

    + The fraction + + + 1 + 3 + + + is not a decimal number. +

    """ + ) + + +def test_svg(): + cx, cy, r, fill = 150, 100, 80, "green" + svg_t = t""" + + + SVG +""" + process_api = process_service_factory() + res = process_api.process_template(svg_t) + assert ( + str(res) + == """ + + + SVG +""" + ) + + +@pytest.mark.skip("""Need foreign element mode. Could work like last parent.""") +def test_svg_self_closing_empty_elements(): + cx, cy, r, fill = 150, 100, 80, "green" + svg_t = t""" + + + SVG +""" + process_api = process_service_factory() + res = process_api.process_template(svg_t) + assert ( + str(res) + == """ + + + SVG +""" + ) + + +@dataclass +class FakeUser: + name: str + id: int + + +@dataclass +class FakeRequest: + user: FakeUser | None = None + + +@dataclass(frozen=True) +class RequestProcessService(ProcessService): + request: FakeRequest | None = None + + def get_system(self, **kwargs): + return {**kwargs, "request": self.request} + + +class UserProto(t.Protocol): + name: str + + +class RequestProto(t.Protocol): + user: UserProto | None + + +def test_system_context(): + """Test providing context to components horizontally via *extra* system provided kwargs.""" + + def request_process_api(request): + return RequestProcessService(request=request, transform_api=TransformService()) + + def UserStatus(request: RequestProto, children: Template | None = None) -> Template: + user = request.user + if user: + classes = ("account-online",) + status_t = t"Logged in as {user.name}" + else: + classes = ("account-offline",) + status_t = t"Not logged in" + return t"" + + page_t = t"""
    <{UserStatus}>
    """ + process_api = request_process_api(FakeRequest(user=FakeUser(name="Guido", id=1000))) + res = process_api.process_template(page_t) + assert ( + res + == """
    """ + ) + process_api = request_process_api(FakeRequest(user=None)) + res = process_api.process_template(page_t) + assert ( + res + == """
    """ + ) + + process_api = ProcessService(transform_api=TransformService()) + with pytest.raises(TypeError) as excinfo: + res = process_api.process_template(page_t) + assert "Missing required parameters" in str(excinfo.value) diff --git a/tdom/processor_to_html_test.py b/tdom/processor_to_html_test.py deleted file mode 100644 index afa3020..0000000 --- a/tdom/processor_to_html_test.py +++ /dev/null @@ -1,524 +0,0 @@ -from string.templatelib import Template -from markupsafe import Markup, escape as markupsafe_escape -import typing as t -import pytest -from dataclasses import dataclass -from collections.abc import Callable -from itertools import chain - -from .processor import ( - process_service_factory, - cached_process_service_factory, - CachedTransformService, - ProcessService, - TransformService, - to_html, -) - - -def test_process_template_smoketest(): - 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 -{markup_content} - -""" - smoke_str = """ - - - -literal - -text is not literal -text is not literal -
    comp body
    -
    safe
    - -""" - process_api = process_service_factory() - assert process_api.process_template(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.""" - sample_t = t"""
    {"content"}
    """ - sample_diff_t = t"""
    {"diffcontent"}
    """ - alt_t = t"""{"content"}""" - process_api = process_service_factory() - cached_process_api = cached_process_service_factory() - # Technically this could be the superclass which doesn't have cached method. - assert isinstance(cached_process_api.transform_api, CachedTransformService) - # 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 determin the result, - # this is not great and hopefully we can find a better solution. - start_ci = cached_process_api.transform_api._transform_template.cache_info() - tf1 = process_api.transform_api.transform_template(sample_t) - tf2 = process_api.transform_api.transform_template(sample_t) - cached_tf1 = cached_process_api.transform_api.transform_template(sample_t) - cached_tf2 = cached_process_api.transform_api.transform_template(sample_t) - cached_tf3 = cached_process_api.transform_api.transform_template(sample_diff_t) - # Check that the uncached and cached services are actually - # returning non-identical results. - assert tf1 is not cached_tf1 - assert tf1 is not cached_tf2 - assert tf1 is not cached_tf3 - # Check that the uncached service returns a brand new result everytime. - assert tf1 is not tf2 - # Check that the cached service is returning the exact same, identical, result. - assert cached_tf1 is cached_tf2 - # Even if the input templates are not identical (but are still equivalent). - assert cached_tf1 is cached_tf3 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 struct_repr(tf1) == struct_repr(cached_tf1) - assert struct_repr(tf2) == struct_repr(cached_tf1) - # Now that we are setup we check that the cache is internally - # working as we intended. - ci = cached_process_api.transform_api._transform_template.cache_info() - # cached_tf2 and cached_tf3 are hits after cached_tf1 - 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_tf4 = cached_process_api.transform_api.transform_template(alt_t) - # A different template produces a brand new tf. - assert cached_tf1 is not cached_tf4 - # The template is new AND has a different structure so it also - # produces an unequivalent tf. - assert struct_repr(cached_tf1) != struct_repr(cached_tf4) - - -def test_process_template_repeated(): - """Crude check for any unintended state being kept between calls.""" - - def get_sample_t(idx, spread_attrs, button_text): - return t"""
    """ - - process_apis = (process_service_factory(), cached_process_service_factory()) - for process_api in process_apis: - for idx in range(3): - spread_attrs = {"data-enabled": True} - button_text = "PROCESS" - sample_t = get_sample_t(idx, spread_attrs, button_text) - assert ( - process_api.process_template(sample_t) - == f'
    ' - ) - - -def get_select_t_with_list(options, selected_values): - return t"""""" - - -def get_select_t_with_generator(options, selected_values): - return t"""""" - - -def get_select_t_with_concat(options, selected_values): - parts = [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): - process_api = process_service_factory() - - def get_color_select_t(selected_values: set, provider: t.Callable) -> Template: - PRIMARY_COLORS = [("R", "Red"), ("Y", "Yellow"), ("B", "Blue")] - assert set(selected_values).issubset(set([opt[0] for opt in PRIMARY_COLORS])) - return provider(PRIMARY_COLORS, selected_values) - - no_selection_t = get_color_select_t(set(), provider) - assert ( - process_api.process_template(no_selection_t) - == '' - ) - selected_yellow_t = get_color_select_t({"Y"}, provider) - assert ( - process_api.process_template(selected_yellow_t) - == '' - ) - - -def test_process_template_components_smoketest(): - """Broadly test that common template component usage works.""" - - def PageComponent(children, root_attrs=None): - return t"""
    {children}
    """ - - def FooterComponent(classes=("footer-default",)): - return t'' - - def LayoutComponent(children, body_classes=None): - return t""" - - - - - - - - {children} - <{FooterComponent} /> - - -""" - - process_api = process_service_factory() - content = "HTML never goes out of style." - content_str = process_api.process_template( - t"<{LayoutComponent} body_classes={['theme-default']}><{PageComponent}>{content}" - ) - assert ( - content_str - == """ - - - - - - - -
    HTML never goes out of style.
    - - - -""" - ) - - -def test_process_template_functions_smoketest(): - """Broadly test that common template function usage works.""" - - def make_page_t(content, root_attrs=None) -> Template: - return t"""
    {content}
    """ - - def make_footer_t(classes=("footer-default",)) -> Template: - return t'' - - def make_layout_t(body_t, body_classes=None) -> Template: - footer_t = make_footer_t() - return t""" - - - - - - - - {body_t} - {footer_t} - - -""" - - process_api = process_service_factory() - content = "HTML never goes out of style." - layout_t = make_layout_t(make_page_t(content), "theme-default") - content_str = process_api.process_template(layout_t) - assert ( - content_str - == """ - - - - - - - -
    HTML never goes out of style.
    - - - -""" - ) - - -def test_text_interpolation_with_dynamic_parent(): - process_api = process_service_factory() - with pytest.raises( - ValueError, match="Recursive includes are not supported within script" - ): - content = '' - content_t = t"{content}" - _ = process_api.process_template(t"") - - -@pytest.mark.skip("Can we allow this?") -def test_escape_escapable_raw_text_with_dynamic_parent(): - content = '' - content_t = t"{content}" - process_api = process_service_factory() - LT, GT, DQ = map(markupsafe_escape, ["<", ">", '"']) - assert ( - process_api.process_template(t"") - == f"" - ) - - -def test_escape_structured_text_with_dynamic_parent(): - content = '' - content_t = t"{content}" - process_api = process_service_factory() - LT, GT, DQ = map(markupsafe_escape, ["<", ">", '"']) - assert ( - process_api.process_template(t"
    {content_t}
    ") - == f"
    {LT}script{GT}console.log({DQ}123!{DQ});{LT}/script{GT}
    " - ) - - -def test_escape_structured_text(): - content = '' - content_t = t"
    {content}
    " - process_api = process_service_factory() - LT, GT, DQ = map(markupsafe_escape, ["<", ">", '"']) - assert ( - process_api.process_template(content_t) - == f"
    {LT}script{GT}console.log({DQ}123!{DQ});{LT}/script{GT}
    " - ) - - -@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: 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_class_component(): - def paginate_url(page: int) -> str: - return f"/pages?page={page}" - - def Footer(pager, paginate_url, footer_classes=("footer",)) -> Template: - return t"
    <{PagerDisplay} pager={pager} paginate_url={paginate_url} />
    " - - pager = 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} />" - process_api = process_service_factory() - res = process_api.process_template(content_t) - print(res) - assert ( - res - == '' - ) - - -def test_mathml(): - num = 1 - denom = 3 - mathml_t = t"""

    - The fraction - - - {num} - {denom} - - - is not a decimal number. -

    """ - process_api = process_service_factory() - res = process_api.process_template(mathml_t) - assert ( - str(res) - == """

    - The fraction - - - 1 - 3 - - - is not a decimal number. -

    """ - ) - - -def test_svg(): - cx, cy, r, fill = 150, 100, 80, "green" - svg_t = t""" - - - SVG -""" - process_api = process_service_factory() - res = process_api.process_template(svg_t) - assert ( - str(res) - == """ - - - SVG -""" - ) - - -@pytest.mark.skip("""Need foreign element mode. Could work like last parent.""") -def test_svg_self_closing_empty_elements(): - cx, cy, r, fill = 150, 100, 80, "green" - svg_t = t""" - - - SVG -""" - process_api = process_service_factory() - res = process_api.process_template(svg_t) - assert ( - str(res) - == """ - - - SVG -""" - ) - - -@dataclass -class FakeUser: - name: str - id: int - - -@dataclass -class FakeRequest: - user: FakeUser | None = None - - -@dataclass(frozen=True) -class RequestProcessService(ProcessService): - request: FakeRequest | None = None - - def get_system(self, **kwargs): - return {**kwargs, "request": self.request} - - -class UserProto(t.Protocol): - name: str - - -class RequestProto(t.Protocol): - user: UserProto | None - - -def test_system_context(): - """Test providing context to components horizontally via *extra* system provided kwargs.""" - - def request_process_api(request): - return RequestProcessService(request=request, transform_api=TransformService()) - - def UserStatus(request: RequestProto, children: Template | None = None) -> Template: - user = request.user - if user: - classes = ("account-online",) - status_t = t"Logged in as {user.name}" - else: - classes = ("account-offline",) - status_t = t"Not logged in" - return t"" - - page_t = t"""
    <{UserStatus}>
    """ - process_api = request_process_api(FakeRequest(user=FakeUser(name="Guido", id=1000))) - res = process_api.process_template(page_t) - assert ( - res - == """
    """ - ) - process_api = request_process_api(FakeRequest(user=None)) - res = process_api.process_template(page_t) - assert ( - res - == """
    """ - ) - - process_api = ProcessService(transform_api=TransformService()) - with pytest.raises(TypeError) as excinfo: - res = process_api.process_template(page_t) - assert "Missing required parameters" in str(excinfo.value) - - -def test_to_html(): - assert to_html(t"") == "" - assert to_html(t"") == "" - assert to_html(t"
    {'content'}
    ") == "
    content
    " From 55af1939d814774f50f50e3cb7d34931c163bb22 Mon Sep 17 00:00:00 2001 From: Ian Wilson Date: Mon, 16 Feb 2026 15:06:34 -0800 Subject: [PATCH 19/59] Remove tests that no longer apply. --- tdom/processor_test.py | 42 ------------------------------------------ 1 file changed, 42 deletions(-) diff --git a/tdom/processor_test.py b/tdom/processor_test.py index 24656a7..0c58898 100644 --- a/tdom/processor_test.py +++ b/tdom/processor_test.py @@ -303,23 +303,6 @@ def test_conditional_rendering_with_if_else(): ) -@pytest.mark.skip( - "This is another design situation where we try to do everything and it complicates the API. If we take False then we probably take True which also does what False does?" -) -def test_conditional_rendering_with_and(): - show_warning = True - warning_message = t'
    Warning!
    ' - assert ( - to_html(t"
    {show_warning and warning_message}
    ") - == '
    Warning!
    ' - ) - - show_warning = False - assert ( - to_html(t"
    {show_warning and warning_message}
    ") == "
    " - ) - - # -------------------------------------------------------------------------- # Interpolated nesting of templates and elements # -------------------------------------------------------------------------- @@ -330,13 +313,6 @@ def test_interpolated_template_content(): assert to_html(t"
    {child}
    ") == "
    Child
    " -@pytest.mark.skip("Allow node injection?") -def test_interpolated_element_content(): - # child = Element("span", children=[Text("Child")]) - # assert to_html(t"
    {child}
    ") == "
    Child
    " - pass - - def test_interpolated_nonstring_content(): number = 42 assert to_html(t"

    The answer is {number}.

    ") == "

    The answer is 42.

    " @@ -974,24 +950,6 @@ def Header() -> Template: assert res == "Hello World" -# @DESIGN: This seems to complicate component definitions. -# Worst case we can just return an iterable in a template interpolation. -@pytest.mark.skip( - "This seems to make component definitions more confusing. Marking skip to then rewrite or remove." -) -def test_component_returning_iterable(): - def generate_lis() -> t.Iterable: - for i in range(2): - yield t"
  • Item {i + 1}
  • " - yield t"
  • Item {3}
  • " - - def Items() -> Template: - return t"{generate_lis()}" - - res = to_html(t"
      <{Items} />
    ") - assert res == "
    • Item 1
    • Item 2
    • Item 3
    " - - def test_component_returning_fragment(): def Items() -> Template: return t"
  • Item {1}
  • Item {2}
  • Item {3}
  • " From 79955e50ac409022e3ea031f9cd0876c402df809 Mon Sep 17 00:00:00 2001 From: Ian Wilson Date: Mon, 16 Feb 2026 15:09:17 -0800 Subject: [PATCH 20/59] Group very special case tests together with class. --- tdom/processor_test.py | 77 ++++++++++++++++++++---------------------- 1 file changed, 37 insertions(+), 40 deletions(-) diff --git a/tdom/processor_test.py b/tdom/processor_test.py index 0c58898..d262cc5 100644 --- a/tdom/processor_test.py +++ b/tdom/processor_test.py @@ -1382,48 +1382,45 @@ def make_layout_t(body_t, body_classes=None) -> Template: ) -def test_text_interpolation_with_dynamic_parent(): - process_api = process_service_factory() - with pytest.raises( - ValueError, match="Recursive includes are not supported within script" - ): +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}" - _ = process_api.process_template(t"") - - -@pytest.mark.skip("Can we allow this?") -def test_escape_escapable_raw_text_with_dynamic_parent(): - content = '' - content_t = t"{content}" - process_api = process_service_factory() - LT, GT, DQ = map(markupsafe_escape, ["<", ">", '"']) - assert ( - process_api.process_template(t"") - == f"" - ) - - -def test_escape_structured_text_with_dynamic_parent(): - content = '' - content_t = t"{content}" - process_api = process_service_factory() - LT, GT, DQ = map(markupsafe_escape, ["<", ">", '"']) - assert ( - process_api.process_template(t"
    {content_t}
    ") - == f"
    {LT}script{GT}console.log({DQ}123!{DQ});{LT}/script{GT}
    " - ) - - -def test_escape_structured_text(): - content = '' - content_t = t"
    {content}
    " - process_api = process_service_factory() - LT, GT, DQ = map(markupsafe_escape, ["<", ">", '"']) - assert ( - process_api.process_template(content_t) - == f"
    {LT}script{GT}console.log({DQ}123!{DQ});{LT}/script{GT}
    " - ) + process_api = process_service_factory() + with pytest.raises( + ValueError, match="Recursive includes are not supported within script" + ): + content_t = t'''''' + _ = process_api.process_template(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}" + process_api = process_service_factory() + with pytest.raises( + ValueError, match="Recursive includes are not supported within textarea" + ): + _ = process_api.process_template(t"") + + def test_dynamic_normal_text(self): + """Escaping should be applied when normal text type is goes into effect.""" + content = '' + content_t = t"{content}" + process_api = process_service_factory() + LT, GT, DQ = map(markupsafe_escape, ["<", ">", '"']) + assert ( + process_api.process_template(t"
    {content_t}
    ") + == f"
    {LT}script{GT}console.log({DQ}123!{DQ});{LT}/script{GT}
    " + ) @dataclass From 32fd5ca122e30157782e8b51a1e32ffdcb13d96e Mon Sep 17 00:00:00 2001 From: Ian Wilson Date: Mon, 16 Feb 2026 16:04:02 -0800 Subject: [PATCH 21/59] Do not call str() prematurely to allow __html__ through. --- tdom/processor.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tdom/processor.py b/tdom/processor.py index f735c16..7468a15 100644 --- a/tdom/processor.py +++ b/tdom/processor.py @@ -667,7 +667,6 @@ def interpolate_normal_text_from_value( parent_tag = last_parent_tag if isinstance(value, str): - # @DESIGN: Objects with `__html__` must be wrapped with markupsafe.Markup. bf.append(process_api.escape_html_text(value)) elif isinstance(value, Template): return process_api.make_process_queue_item( @@ -696,7 +695,7 @@ def interpolate_normal_text_from_value( else: # @DESIGN: Everything that isn't an object we recognize is # coerced to a str() and emitted. - bf.append(process_api.escape_html_text(str(value))) + bf.append(process_api.escape_html_text(value)) type InterpolateDynamicTextsFromTemplateInfo = tuple[None, Template] From 2f9f88fa936352b485e8430d2aa5d961265439d2 Mon Sep 17 00:00:00 2001 From: Ian Wilson Date: Mon, 16 Feb 2026 16:04:50 -0800 Subject: [PATCH 22/59] Explicitly allow __html__ from exact interpolations in non-normal texts. --- tdom/processor.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tdom/processor.py b/tdom/processor.py index 7468a15..50ce9d1 100644 --- a/tdom/processor.py +++ b/tdom/processor.py @@ -38,6 +38,7 @@ escape_html_text as default_escape_html_text, escape_html_comment as default_escape_html_comment, ) +from .protocols import HasHTMLDunder type Attribute = tuple[str, object] @@ -964,8 +965,11 @@ def resolve_text_without_recursion( if value is None: return None elif isinstance(value, str): - # @DESIGN: Markup() must be used explicitly if you want __html__ supported. return value + elif isinstance(value, HasHTMLDunder): + # @DESIGN: We could also force callers to use `:safe` to trigger + # the interpolation in this special case. + return Markup(value.__html__()) elif isinstance(value, (Template, Iterable)): raise ValueError( f"Recursive includes are not supported within {parent_tag}" From e8be5cf0aa5f388e9a2dc2043b5b847fbc3177b7 Mon Sep 17 00:00:00 2001 From: Ian Wilson Date: Mon, 16 Feb 2026 16:06:03 -0800 Subject: [PATCH 23/59] Safe is not needed in test anymore. --- tdom/processor_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tdom/processor_test.py b/tdom/processor_test.py index d262cc5..791a962 100644 --- a/tdom/processor_test.py +++ b/tdom/processor_test.py @@ -261,7 +261,7 @@ def __html__(self): content = SafeContent("emphasized") assert ( - to_html(t"

    Here is some {content:safe}.

    ") + to_html(t"

    Here is some {content}.

    ") == "

    Here is some emphasized.

    " ) From 4fcfb0b051225d7689b334c34ab63df00bfc4dfd Mon Sep 17 00:00:00 2001 From: Ian Wilson Date: Mon, 16 Feb 2026 21:31:24 -0800 Subject: [PATCH 24/59] Use protocol --- tdom/processor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tdom/processor.py b/tdom/processor.py index 50ce9d1..921c522 100644 --- a/tdom/processor.py +++ b/tdom/processor.py @@ -995,7 +995,7 @@ def resolve_text_without_recursion( raise ValueError( f"Recursive includes are not supported within {parent_tag}" ) - elif hasattr(value, "__html__"): + elif isinstance(value, HasHTMLDunder): raise ValueError( f"Non-exact trusted interpolations are not supported within {parent_tag}" ) From 280c43221a59c49465c1b4b1e83cf654aa16886d Mon Sep 17 00:00:00 2001 From: Ian Wilson Date: Tue, 17 Feb 2026 14:10:35 -0800 Subject: [PATCH 25/59] Draft direct str processor. --- docs/usage/looping.md | 4 +- tdom/__init__.py | 8 +- tdom/htmlspec.py | 4 + tdom/processor.py | 845 +++++++++++++---------------------------- tdom/processor_test.py | 79 ++-- 5 files changed, 307 insertions(+), 633 deletions(-) diff --git a/docs/usage/looping.md b/docs/usage/looping.md index 5b9b80b..9af7304 100644 --- a/docs/usage/looping.md +++ b/docs/usage/looping.md @@ -40,7 +40,7 @@ then use that `Node` 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"
      {items}
    ") -assert str(result) == '
    • World
    • Universe
    ' +assert result == '
    • World
    • Universe
    ' ``` diff --git a/tdom/__init__.py b/tdom/__init__.py index f3c42e9..9aaec5d 100644 --- a/tdom/__init__.py +++ b/tdom/__init__.py @@ -3,8 +3,9 @@ from .nodes import Comment, DocumentType, Element, Fragment, Node, Text, to_node from .processor import to_html -# @BWC: Temporary shim. -html = to_node + +html = to_html + # We consider `Markup` and `escape` to be part of this module's public API @@ -14,8 +15,9 @@ "Element", "escape", "Fragment", - "to_html", + "html", "to_node", + "to_html", "html", "Markup", "Node", diff --git a/tdom/htmlspec.py b/tdom/htmlspec.py index 5eca1c4..d1a6567 100644 --- a/tdom/htmlspec.py +++ b/tdom/htmlspec.py @@ -22,3 +22,7 @@ CDATA_CONTENT_ELEMENTS = frozenset(["script", "style"]) RCDATA_CONTENT_ELEMENTS = frozenset(["textarea", "title"]) CONTENT_ELEMENTS = CDATA_CONTENT_ELEMENTS | RCDATA_CONTENT_ELEMENTS + +# Used for fragments that do no have a tag +# to assume that text is inside this element. +DEFAULT_NORMAL_TEXT_ELEMENT = "div" diff --git a/tdom/processor.py b/tdom/processor.py index 921c522..ef25e2c 100644 --- a/tdom/processor.py +++ b/tdom/processor.py @@ -10,6 +10,7 @@ from .format import format_interpolation as base_format_interpolation from .format import format_template from .htmlspec import ( + DEFAULT_NORMAL_TEXT_ELEMENT, VOID_ELEMENTS, CDATA_CONTENT_ELEMENTS, RCDATA_CONTENT_ELEMENTS, @@ -424,431 +425,100 @@ def serialize_html_attrs( ) -type InterpolateInfo = tuple +class NotSet: + pass -type ProcessQueueItem = tuple[ - str | None, Iterable[tuple[InterpolatorProto, Template, InterpolateInfo]] -] +NOT_SET = NotSet() +@lru_cache(1024) +def make_ctx(parent_tag=None, ns='html'): + return ProcessContext(parent_tag=parent_tag, ns=ns) -class InterpolatorProto(Protocol): - def __call__( - self, - process_api: ProcessService, - bf: list[str], - last_parent_tag: str | None, - template: Template, - ip_info: InterpolateInfo, - ) -> ProcessQueueItem | None: - """ - Populates an interpolation or returns iterator to descend into. - - process_api - The current process api, provides various helper methods. - bf - A list-like output buffer. - last_parent_tag - The last HTML tag known for this interpolation or None if unknown. - template - The "values" template that is being used to fulfill interpolations. - ip_info - The information provided in the structured template interpolation OR from another source, - for example a value from a user provided iterator. - - Returns a process queue item when the main iteration loops needs to be paused and restarted to descend. - """ - raise NotImplementedError - - -type InterpolateCommentInfo = tuple[str, Template] - - -def interpolate_comment( - process_api: ProcessService, - bf: list[str], - last_parent_tag: str | None, - template: Template, - ip_info: InterpolateInfo, -) -> ProcessQueueItem | None: - parent_tag, comment_t = cast(InterpolateCommentInfo, ip_info) - assert parent_tag == "" + bf.append("") case TFragment(children): - q.extend([(last_parent_tag, child) for child in reversed(children)]) + q.extend([(last_ctx, child) for child in reversed(children)]) case TComponent(start_i_index, end_i_index, attrs, children): - yield self._stream_component_interpolation( - last_parent_tag, attrs, start_i_index, end_i_index + res = self._stream_component( + bf, template, last_ctx, attrs, start_i_index, end_i_index ) + if res is not None: + yield res case TElement(tag, attrs, children): - yield f"<{tag}" - if self.has_dynamic_attrs(attrs): - yield self._stream_attrs_interpolation(tag, attrs) - else: - yield serialize_html_attrs( - _resolve_html_attrs( - _resolve_t_attrs(attrs, interpolations=()) - ) - ) - # @DESIGN: This is just a want to have. + bf.append(f"<{tag}") + our_ctx = last_ctx.copy(parent_tag=tag) + res = self._stream_attrs(bf, template, our_ctx, attrs) + if res is not None: + yield res + # @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: - yield " />" + bf.append(" />") else: - yield ">" + bf.append(">") if tag not in VOID_ELEMENTS: - q.append((last_parent_tag, EndTag(f""))) - q.extend([(tag, child) for child in reversed(children)]) + q.append((last_ctx, EndTag(f""))) + q.extend([(our_ctx, child) for child in reversed(children)]) case TText(ref): text_t = Template( *[ @@ -894,56 +565,194 @@ def streamer( for part in iter(ref) ] ) - if ref.is_literal: - if last_parent_tag == "script": - yield default_escape_html_script(ref.strings[0]) - elif last_parent_tag == "style": - yield default_escape_html_style(ref.strings[0]) + if last_ctx.parent_tag is None: + raise NotImplementedError("We cannot interpolate texts without knowing what tag they are contained in.") + elif ref.is_literal: + if last_ctx.parent_tag == "script": + bf.append(self.escape_html_script(ref.strings[0])) + elif last_ctx.parent_tag == "style": + bf.append(self.escape_html_style(ref.strings[0])) else: # Fallback to escape everything. # This works because you cannot interpolate a # template into a script/style. - yield default_escape_html_text(ref.strings[0]) - elif last_parent_tag is None: - # We can't know how to handle this right now, so wait until write time and if - # we still cannot know then probably fail. - yield self._stream_dynamic_texts_interpolation( - last_parent_tag, text_t - ) - elif last_parent_tag in CDATA_CONTENT_ELEMENTS: + # @TODO: Is this correct? + bf.append(self.escape_html_text(ref.strings[0])) + elif last_ctx.parent_tag in CDATA_CONTENT_ELEMENTS: # Must be handled all at once. - yield self._stream_raw_texts_interpolation( - last_parent_tag, text_t + res = self._stream_raw_texts( + bf, template, last_ctx, text_t ) - elif last_parent_tag in RCDATA_CONTENT_ELEMENTS: + if res is not None: + yield res + 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. - yield self._stream_escapable_raw_texts_interpolation( - last_parent_tag, text_t + res = self._stream_escapable_raw_texts( + bf, template, last_ctx, text_t ) + if res is not None: + yield res else: # Flatten the template back out into the stream because each interpolation can # be escaped as is and structured content can be injected between text anyways. for part in text_t: if isinstance(part, str): - yield part + bf.append(part) else: - yield self._stream_normal_text_interpolation( - last_parent_tag, part.value + res = self._stream_normal_text( + bf, template, last_ctx, part.value ) + if res is not None: + yield res case _: raise ValueError(f"Unrecognized tnode: {tnode}") - def has_dynamic_attrs(self, attrs: Sequence[TAttribute]) -> bool: - """ - Determine if any attributes with interpolations are in attrs sequence. + def _stream_comment(self, bf: list[str], template: Template, last_ctx: ProcessContext, text_t: Template) -> None: + assert last_ctx.parent_tag == '") @@ -567,7 +586,9 @@ def walk_from_tnode(self, bf: list[str], template: Template, assume_ctx: Process ] ) if last_ctx.parent_tag is None: - raise NotImplementedError("We cannot interpolate texts without knowing what tag they are contained in.") + raise NotImplementedError( + "We cannot interpolate texts without knowing what tag they are contained in." + ) elif ref.is_literal: if last_ctx.parent_tag == "script": bf.append(self.escape_html_script(ref.strings[0])) @@ -581,9 +602,7 @@ def walk_from_tnode(self, bf: list[str], template: Template, assume_ctx: Process bf.append(self.escape_html_text(ref.strings[0])) elif last_ctx.parent_tag in CDATA_CONTENT_ELEMENTS: # Must be handled all at once. - res = self._stream_raw_texts( - bf, template, last_ctx, text_t - ) + res = self._stream_raw_texts(bf, template, last_ctx, text_t) if res is not None: yield res elif last_ctx.parent_tag in RCDATA_CONTENT_ELEMENTS: @@ -608,16 +627,28 @@ def walk_from_tnode(self, bf: list[str], template: Template, assume_ctx: Process case _: raise ValueError(f"Unrecognized tnode: {tnode}") - def _stream_comment(self, bf: list[str], template: Template, last_ctx: ProcessContext, text_t: Template) -> None: - assert last_ctx.parent_tag == ' - -```python -from typing import Iterable - -def Items() -> Iterable[Template]: - return [t"
  • first
  • ", t"
  • second
  • "] - -result = html(t"
      <{Items} />
    ") -assert str(result) == "
    • first
    • second
    " +assert result == "

    Hello, Alice!

    " ``` #### Class-based components @@ -416,18 +382,18 @@ from tdom import Node, 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

    ") #
    @@ -442,7 +408,7 @@ class, making it easier to manage complex components. As a note, `children` are optional in component signatures. If a component requests children, it will receive them if provided. If no children are -provided, the value of children is an empty tuple. If the component does _not_ +provided, the value of children is an empty Template, ie. `t""`. If the component does _not_ ask for children, but they are provided, then they are silently ignored. #### SVG Support @@ -470,12 +436,12 @@ All the same interpolation, attribute handling, and component features work with SVG elements: ```python -def Icon(*, size: int = 24, color: str = "currentColor") -> 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) @@ -497,9 +463,9 @@ 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'') + return t'' result = html(t'<{Button} text="Click me" />') assert 'color: blue' in str(result) @@ -518,41 +484,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) == "" -``` - -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..fb65cd4 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">') -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') -assert str(result) == '

    My Title

    Child
    ' +assert result == '

    My Title

    Child
    ' ``` Note how the component closes with `` 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. +children are provided, the value of children is an empty `Template`, ie. `t""`. 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') -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}>") -assert str(result) == '

    Other Heading

    ' +assert result == '

    Other Heading

    ' ``` ## Conditional Default @@ -165,7 +162,7 @@ def Body(heading: Callable | None = None) -> Template: result = html(t"<{Body} heading={OtherHeading}>") -assert str(result) == '

    Other Heading

    ' +assert result == '

    Other Heading

    ' ``` ## Generators as Components @@ -175,13 +172,11 @@ 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}
  • " - +def Todos() -> Template: + return t'
      {(t"
    • {todo}
    • " for todo in ["first", "second", "third"])}
    ' -result = html(t"
      <{Todos} />
    ") -assert str(result) == '
    • first
    • second
    • third
    ' +result = html(t"<{Todos} />") +assert result == '
    • first
    • second
    • third
    ' ``` ## Nested Components @@ -200,5 +195,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

    • first
    • second
    • third
    ' +assert result == '

    My Todos

    • first
    • second
    • third
    ' ``` diff --git a/docs/usage/static_string.md b/docs/usage/static_string.md index 4f869d3..10aa8ce 100644 --- a/docs/usage/static_string.md +++ b/docs/usage/static_string.md @@ -13,14 +13,13 @@ from tdom import html, Element, Text ```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 Python `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 @@ -71,29 +47,6 @@ 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 7d84ce309e3d37aba4b632ca813f0198f846d57a Mon Sep 17 00:00:00 2001 From: Ian Wilson Date: Tue, 17 Feb 2026 21:47:56 -0800 Subject: [PATCH 30/59] Move nodes and nodes processor into submodule. --- tdom/__init__.py | 3 +- tdom/nodes.py | 344 --------------- tdom/nodes/__init__.py | 1 + tdom/nodes/nodes.py | 123 ++++++ tdom/nodes/nodes_test.py | 219 ++++++++++ tdom/nodes/processor.py | 398 ++++++++++++++++++ .../processor_test.py} | 335 +++------------ tdom/processor.py | 44 +- 8 files changed, 809 insertions(+), 658 deletions(-) delete mode 100644 tdom/nodes.py create mode 100644 tdom/nodes/__init__.py create mode 100644 tdom/nodes/nodes.py create mode 100644 tdom/nodes/nodes_test.py create mode 100644 tdom/nodes/processor.py rename tdom/{nodes_test.py => nodes/processor_test.py} (83%) diff --git a/tdom/__init__.py b/tdom/__init__.py index 9aaec5d..4734801 100644 --- a/tdom/__init__.py +++ b/tdom/__init__.py @@ -1,6 +1,7 @@ from markupsafe import Markup, escape -from .nodes import Comment, DocumentType, Element, Fragment, Node, Text, to_node +from .nodes.nodes import Comment, DocumentType, Element, Fragment, Node, Text +from .nodes.processor import to_node from .processor import to_html diff --git a/tdom/nodes.py b/tdom/nodes.py deleted file mode 100644 index f406247..0000000 --- a/tdom/nodes.py +++ /dev/null @@ -1,344 +0,0 @@ -import sys -from dataclasses import dataclass, field -from functools import lru_cache -from string.templatelib import Template, Interpolation -from markupsafe import Markup -from collections.abc import Iterable, Sequence - -from .escaping import ( - escape_html_comment, - escape_html_script, - escape_html_style, - escape_html_text, -) -from .htmlspec import VOID_ELEMENTS, CONTENT_ELEMENTS -from .utils import CachableTemplate -from .processor import ( - format_interpolation, - prep_component_kwargs, - _resolve_html_attrs, - _resolve_t_attrs, - AttributesDict, -) -from .parser import ( - HTMLAttributesDict, - TAttribute, - TComment, - TComponent, - TDocumentType, - TElement, - TemplateParser, - TFragment, - TNode, - TText, -) -from .protocols import HasHTMLDunder -from .format import format_template -from .callables import get_callable_info -from .template_utils import TemplateRef - - -@lru_cache(maxsize=0 if "pytest" in sys.modules else 512) -def _parse_and_cache(cachable: CachableTemplate) -> TNode: - return TemplateParser.parse(cachable.template) - - -@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 ValueError( - "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}>" - children_str = self._children_to_str() - return f"<{self.tag}{attrs_str}>{children_str}" - - -def _resolve_attrs( - attrs: Sequence[TAttribute], interpolations: tuple[Interpolation, ...] -) -> HTMLAttributesDict: - """ - Substitute placeholders in attributes for HTML elements. - - This is the full pipeline: interpolation + HTML processing. - """ - interpolated_attrs = _resolve_t_attrs(attrs, interpolations) - return _resolve_html_attrs(interpolated_attrs) - - -def _flatten_nodes(nodes: Iterable[Node]) -> list[Node]: - """Flatten a list of Nodes, expanding any Fragments.""" - flat: list[Node] = [] - for node in nodes: - if isinstance(node, Fragment): - flat.extend(node.children) - else: - flat.append(node) - return flat - - -def _substitute_and_flatten_children( - children: Iterable[TNode], interpolations: tuple[Interpolation, ...] -) -> list[Node]: - """Substitute placeholders in a list of children and flatten any fragments.""" - resolved = [_resolve_t_node(child, interpolations) for child in children] - flat = _flatten_nodes(resolved) - return flat - - -def _node_from_value(value: object) -> Node: - """ - Convert an arbitrary value to a Node. - - This is the primary action performed when replacing interpolations in child - content positions. - """ - match value: - case str(): - return Text(value) - case Node(): - return value - case Template(): - return to_node(value) - # Consider: falsey values, not just False and None? - case False | None: - return Fragment(children=[]) - case Iterable(): - children = [_node_from_value(v) for v in value] - return Fragment(children=children) - case HasHTMLDunder(): - # CONSIDER: should we do this lazily? - return Text(Markup(value.__html__())) - case c if callable(c): - # Treat all callable values in child content positions as if - # they are zero-arg functions that return a value to be processed. - return _node_from_value(c()) - case _: - # CONSIDER: should we do this lazily? - return Text(str(value)) - - -def _invoke_component( - attrs: AttributesDict, - children: list[Node], # TODO: why not TNode, though? - interpolation: Interpolation, -) -> Node: - """ - Invoke a component callable with the provided attributes and children. - - Components are any callable that meets the required calling signature. - Typically, that's a function, but it could also be the constructor or - __call__() method for a class; dataclass constructors match our expected - invocation style. - - We validate the callable's signature and invoke it with keyword-only - arguments, then convert the result to a Node. - - Component invocation rules: - - 1. All arguments are passed as keywords only. Components cannot require - positional arguments. - - 2. Children are passed via a "children" parameter when: - - - Child content exists in the template AND - - The callable accepts "children" OR has **kwargs - - If no children exist but the callable accepts "children", we pass an - empty tuple. - - 3. All other attributes are converted from kebab-case to snake_case - and passed as keyword arguments if the callable accepts them (or has - **kwargs). Attributes that don't match parameters are silently ignored. - """ - value = format_interpolation(interpolation) - if not callable(value): - raise TypeError( - f"Expected a callable for component invocation, got {type(value).__name__}" - ) - callable_info = get_callable_info(value) - - kwargs = prep_component_kwargs( - callable_info, attrs, system_kwargs={"children": tuple(children)} - ) - - result = value(**kwargs) - return _node_from_value(result) - - -def _resolve_t_text_ref( - ref: TemplateRef, interpolations: tuple[Interpolation, ...] -) -> Text | Fragment: - """Resolve a TText ref into Text or Fragment by processing interpolations.""" - if ref.is_literal: - return Text(ref.strings[0]) - - parts = [ - Text(part) - if isinstance(part, str) - else _node_from_value(format_interpolation(part)) - for part in ref.resolve(interpolations) - ] - flat = _flatten_nodes(parts) - - if len(flat) == 1 and isinstance(flat[0], Text): - return flat[0] - - return Fragment(children=flat) - - -def _resolve_t_node(t_node: TNode, interpolations: tuple[Interpolation, ...]) -> Node: - """Resolve a TNode tree into a Node tree by processing interpolations.""" - match t_node: - case TText(ref=ref): - return _resolve_t_text_ref(ref, interpolations) - case TComment(ref=ref): - comment_t = ref.resolve(interpolations) - comment = format_template(comment_t) - return Comment(comment) - case TDocumentType(text=text): - return DocumentType(text) - case TFragment(children=children): - resolved_children = _substitute_and_flatten_children( - children, interpolations - ) - return Fragment(children=resolved_children) - case TElement(tag=tag, attrs=attrs, children=children): - if attrs: - resolved_attrs = _resolve_attrs(attrs, interpolations) - else: - resolved_attrs = {} - resolved_children = _substitute_and_flatten_children( - children, interpolations - ) - return Element(tag=tag, attrs=resolved_attrs, children=resolved_children) - case TComponent( - start_i_index=start_i_index, - end_i_index=end_i_index, - attrs=t_attrs, - children=children, - ): - start_interpolation = interpolations[start_i_index] - end_interpolation = ( - None if end_i_index is None else interpolations[end_i_index] - ) - resolved_attrs = _resolve_t_attrs(t_attrs, interpolations) - resolved_children = _substitute_and_flatten_children( - children, interpolations - ) - # HERE ALSO BE DRAGONS: validate matching start/end callables, since - # the underlying TemplateParser cannot do that for us. - if ( - end_interpolation is not None - and end_interpolation.value != start_interpolation.value - ): - raise TypeError("Mismatched component start and end callables.") - return _invoke_component( - attrs=resolved_attrs, - children=resolved_children, - interpolation=start_interpolation, - ) - case _: - raise ValueError(f"Unknown TNode type: {type(t_node).__name__}") - - -def to_node(template: Template) -> Node: - """Parse an HTML t-string, substitue values, and return a tree of Nodes.""" - cachable = CachableTemplate(template) - t_node = _parse_and_cache(cachable) - return _resolve_t_node(t_node, template.interpolations) diff --git a/tdom/nodes/__init__.py b/tdom/nodes/__init__.py new file mode 100644 index 0000000..f3b0d32 --- /dev/null +++ b/tdom/nodes/__init__.py @@ -0,0 +1 @@ +"""""" diff --git a/tdom/nodes/nodes.py b/tdom/nodes/nodes.py new file mode 100644 index 0000000..4cad3d1 --- /dev/null +++ b/tdom/nodes/nodes.py @@ -0,0 +1,123 @@ +from dataclasses import dataclass, field + +from ..escaping import ( + escape_html_comment, + escape_html_script, + escape_html_style, + escape_html_text, +) +from ..htmlspec import VOID_ELEMENTS, CONTENT_ELEMENTS + + +@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 ValueError( + "Cannot serialize non-text content inside a script tag." + ) + if len(chunks) == 1: + raw_children_str = chunks[0] + else: + for chunk in chunks: + assert type(chunk) is str, "Do not allow markup in mixed text." + raw_children_str = "".join(chunks) + if self.tag == "script": + return escape_html_script(raw_children_str, allow_markup=True) + elif self.tag == "style": + return escape_html_style(raw_children_str, allow_markup=True) + 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}>" + children_str = self._children_to_str() + return f"<{self.tag}{attrs_str}>{children_str}" + + +type NodeContainer = Element | Fragment diff --git a/tdom/nodes/nodes_test.py b/tdom/nodes/nodes_test.py new file mode 100644 index 0000000..70e20b8 --- /dev/null +++ b/tdom/nodes/nodes_test.py @@ -0,0 +1,219 @@ +import pytest +from markupsafe import Markup + +from .nodes import Comment, DocumentType, Element, Fragment, Text + + +def test_doctype_default(): + doctype = DocumentType() + assert str(doctype) == "" + + +def test_doctype_custom(): + doctype = DocumentType("xml") + assert str(doctype) == "" + + +def test_comment(): + comment = Comment("Added for testing.") + assert str(comment) == "" + + +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) == "
    wow
    inside fragment
    " + + +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.


    • Item 1
    • Item 2
    • " + "
    • Item 3
    " + ) + + +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) == '
    ' diff --git a/tdom/nodes/processor.py b/tdom/nodes/processor.py new file mode 100644 index 0000000..6a2d0bf --- /dev/null +++ b/tdom/nodes/processor.py @@ -0,0 +1,398 @@ +from dataclasses import dataclass +from string.templatelib import Template, Interpolation +from typing import cast +from collections.abc import Iterable +from functools import lru_cache + +from markupsafe import Markup + +from ..protocols import HasHTMLDunder +from ..processor import ( + ProcessContext, + BaseProcessorService, + _resolve_t_attrs, + _resolve_html_attrs, + prep_component_kwargs, + resolve_text_without_recursion, + make_ctx, + extract_embedded_template, + ComponentObjectProto, + format_interpolation, + WalkerProto, + NormalTextInterpolationValue, +) +from ..callables import get_callable_info +from ..htmlspec import ( + VOID_ELEMENTS, + RCDATA_CONTENT_ELEMENTS, + CDATA_CONTENT_ELEMENTS, + DEFAULT_NORMAL_TEXT_ELEMENT, +) +from .nodes import Fragment, Comment, DocumentType, Element, Text, Node, NodeContainer +from ..parser import ( + TAttribute, + TComment, + TComponent, + TDocumentType, + TElement, + TFragment, + TLiteralAttribute, + TNode, + TText, +) +from ..utils import CachableTemplate + + +@dataclass(frozen=True) +class NodeProcessorService(BaseProcessorService): + def process_template(self, root_template, assume_ctx=None) -> Node: + root_tnode = self.to_tnode(root_template) + if assume_ctx is None: + assume_ctx = make_ctx(parent_tag=DEFAULT_NORMAL_TEXT_ELEMENT, ns="html") + root_node = Fragment() + q: list[WalkerProto] = [ + self.walk_from_tnode(root_node, root_template, assume_ctx, root_tnode) + ] + while q: + it = q.pop() + for new_it in it: + if new_it is not None: + q.append(it) + q.append(new_it) + break + if len(root_node.children) == 1: + return root_node.children[0] + return root_node + + def walk_from_tnode( + self, + parent_node: NodeContainer, + template: Template, + assume_ctx: ProcessContext, + root: TNode, + ) -> Iterable[WalkerProto]: + q: list[tuple[ProcessContext, TNode, NodeContainer]] = [ + (assume_ctx, root, parent_node) + ] + while q: + last_ctx, tnode, parent_node = q.pop() + match tnode: + case TDocumentType(text): + if last_ctx.ns != "html": + # Nit + raise ValueError( + "Cannot process document type in subtree of a foreign element." + ) + parent_node.children.append(DocumentType(text)) + case TComment(ref): + text_t = Template( + *[ + part + if isinstance(part, str) + else Interpolation(part, "", None, "") + for part in iter(ref) + ] + ) + res = self._stream_comment( + parent_node, template, last_ctx.copy(parent_tag="") == "" +class LiteralHTML: + """Text is returned as is by __html__.""" + def __init__(self, text): + self.text = text -def test_comment_template(): - text = "comment" - assert to_html(t"") == "" + def __html__(self): + # In a real app, this would come from a sanitizer or trusted source + return self.text -def test_comment_template_escaping(): - text = "-->comment" - assert to_html(t"") == "" +class TestComment: + def test_literal(self): + assert to_html(t"") == "" + # + # Singleton / Exact Match + # + def test_singleton_str(self): + text = "This is a comment" + assert to_html(t"") == "" -# -# Document types. -# -def test_parse_document_type(): - assert to_html(t"") == "" + def test_singleton_object(self): + assert to_html(t"") == "" + def test_singleton_none(self): + assert to_html(t"") == "" -# -# Elements -# -def test_parse_void_element(): - assert to_html(t"
    ") == "
    " + def test_singleton_has_dunder_html(self): + content = LiteralHTML("-->") + assert to_html(t"") == "-->", ( + "DO NOT DO THIS! This is just an advanced escape hatch." + ) + def test_singleton_escaping(self): + text = "-->comment" + assert to_html(t"") == "" + + # + # Templated -- literal text mixed with interpolation(s) + # + def test_templated_str(self): + text = "comment" + assert to_html(t"") == "" + + def test_templated_object(self): + assert to_html(t"") == "" + + def test_templated_none(self): + assert to_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"): + _ = to_html(t"") + with pytest.raises(ValueError, match="not supported"): + _ = to_html(t"") + with pytest.raises(ValueError, match="not supported"): + _ = to_html(t"") + + def test_templated_multiple_interpolations(self): + text = "comment" + assert ( + to_html(t"") + == "" + ) -def test_parse_void_element_self_closed(): - assert to_html(t"
    ") == "
    " + 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 to_html(t"") == "" + def test_not_supported__recursive_template_error(self): + text_t = t"comment" + with pytest.raises(ValueError, match="not supported"): + _ = to_html(t"") -def test_parse_chain_of_void_elements(): - # Make sure our handling of CPython issue #69445 is reasonable. - assert ( - to_html(t"



    ") - == '



    ' - ) + def test_not_supported_recursive_iterable_error(self): + texts = ["This", "is", "a", "comment"] + with pytest.raises(ValueError, match="not supported"): + _ = to_html(t"") -def test_parse_element_with_text(): - assert to_html(t"

    Hello, world!

    ") == "

    Hello, world!

    " +class TestDocumentType: + def test_literal(self): + assert to_html(t"") == "" -def test_parse_nested_elements(): - assert ( - to_html(t"

    Hello

    World

    ") - == "

    Hello

    World

    " - ) +class TestVoidElementLiteral: + def test_void(self): + assert to_html(t"
    ") == "
    " + + def test_void_self_closed(self): + assert to_html(t"
    ") == "
    " + + def test_void_mixed_closing(self): + assert to_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 ( + to_html(t"



    ") + == '



    ' + ) -def test_parse_entities_are_escaped(): - res = to_html(t"

    </p>

    ") - assert res == "

    </p>

    ", res +class TestNormalTextElementLiteral: + def test_empty(self): + assert to_html(t"
    ") == "
    " + def test_with_text(self): + assert to_html(t"

    Hello, world!

    ") == "

    Hello, world!

    " + def test_nested_elements(self): + assert ( + to_html(t"

    Hello

    World

    ") + == "

    Hello

    World

    " + ) + + def test_entities_are_escaped(self): + """Literal entities interpreted by parser but escaped in output.""" + res = to_html(t"

    </p>

    ") + assert res == "

    </p>

    ", res + + +# @TODO: Move with other bare text. def test_parse_entities_are_escaped_no_parent_tag(): res = to_html(t"</p>") assert res == "</p>", "Default to standard escaping." -# -------------------------------------------------------------------------- -# Interpolated text content -# -------------------------------------------------------------------------- +class TestNormalTextElementDynamic: + def test_singleton_None(self): + assert to_html(t"

    {None}

    ") == "

    " + def test_singleton_str(self): + name = "Alice" + assert to_html(t"

    {name}

    ") == "

    Alice

    " -def test_interpolated_text_content(): - name = "Alice" - assert to_html(t"

    Hello, {name}!

    ") == "

    Hello, Alice!

    " + def test_singleton_object(self): + assert to_html(t"

    {0}

    ") == "

    0

    " + def test_singleton_has_dunder_html(self): + content = LiteralHTML("Alright!") + assert to_html(t"

    {content}

    ") == "

    Alright!

    " -def test_escaping_of_interpolated_text_content(): - name = "" - assert to_html(t"

    Hello, {name}!

    ") == "

    Hello, <Alice & Bob>!

    " + def test_singleton_simple_template(self): + name = "Alice" + text_t = t"Hi {name}" + assert to_html(t"

    {text_t}

    ") == "

    Hi Alice

    " + def test_singleton_simple_iterable(self): + strs = ["Strings", "...", "Yeah!", "Rock", "...", "Yeah!"] + assert to_html(t"

    {strs}

    ") == "

    Strings...Yeah!Rock...Yeah!

    " -class Convertible: - def __str__(self): - return "string" + def test_singleton_escaping(self): + text = '''<>&'"''' + assert to_html(t"

    {text}

    ") == "

    <>&'"

    " - def __repr__(self): - return "repr" + def test_templated_None(self): + assert to_html(t"

    Response: {None}.

    ") == "

    Response: .

    " + def test_templated_str(self): + name = "Alice" + assert to_html(t"

    Response: {name}.

    ") == "

    Response: Alice.

    " -def test_conversions(): - c = Convertible() - assert f"{c!s}" == "string" - assert f"{c!r}" == "repr" - assert to_html(t"
    {c!s}
    ") == "
    string
    " - assert to_html(t"
    {c!r}
    ") == "
    repr
    " - assert ( - to_html(t"
    {'😊'!a}
    ") == f"
    {escape_html_text(ascii('😊'))}
    " - ) + def test_templated_object(self): + assert to_html(t"

    Response: {0}.

    ") == "

    Response: 0.

    " + def test_templated_has_dunder_html(self): + text = LiteralHTML("Alright!") + assert ( + to_html(t"

    Response: {text}.

    ") + == "

    Response: Alright!.

    " + ) -def test_interpolated_in_content_node(): - # https://github.com/t-strings/tdom/issues/68 - evil = "") - == f"" - ) + def test_templated_simple_template(self): + name = "Alice" + text_t = t"Hi {name}" + assert to_html(t"

    Response: {text_t}.

    ") == "

    Response: Hi Alice.

    " + def test_templated_simple_iterable(self): + strs = ["Strings", "...", "Yeah!", "Rock", "...", "Yeah!"] + assert ( + to_html(t"

    Response: {strs}.

    ") + == "

    Response: Strings...Yeah!Rock...Yeah!.

    " + ) -def test_interpolated_trusted_in_content_node(): - # https://github.com/t-strings/tdom/issues/68 - assert ( - to_html(t"") - == "" - ) + def test_templated_escaping(self): + text = '''<>&'"''' + assert ( + to_html(t"

    Response: {text}.

    ") + == "

    Response: <>&'".

    " + ) -def test_script_elements_error(): - nested_template = t"
    " - # Putting non-text content inside a script is not allowed. - with pytest.raises(ValueError): - _ = to_html(t"") +class TestRawTextElementLiteral: + def test_script_empty(self): + assert to_html(t"") == "" + def test_style_empty(self): + assert to_html(t"") == "" -# -------------------------------------------------------------------------- -# Interpolated non-text content -# -------------------------------------------------------------------------- + def test_script_with_content(self): + assert to_html(t"") == "" + + def test_style_with_content(self): + # @NOTE: Double {{ and }} to avoid t-string interpolation. + assert ( + to_html(t"") + == "" + ) + def test_script_with_content_escaped_in_normal_text(self): + # @NOTE: Double {{ and }} to avoid t-string interpolation. + assert ( + to_html( + t"" + ) + == "" + ), "The < should not be escaped." -def test_interpolated_false_content(): - assert to_html(t"
    {False}
    ") == "
    False
    " + def test_style_with_content_escaped_in_normal_text(self): + # @NOTE: Double {{ and }} to avoid t-string interpolation. + assert ( + to_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"): + _ = to_html(t"") -def test_interpolated_none_content(): - assert to_html(t"
    {None}
    ") == "
    " + def test_not_supported_recursive_iterable_error(self): + texts = ["This", "is", "a", "comment"] + with pytest.raises(ValueError, match="not supported"): + _ = to_html(t"") -def test_interpolated_zero_arg_function(): - def get_value(): - return "dynamic" +class TestEscapableRawTextElementLiteral: + def test_title_empty(self): + assert to_html(t"") == "" - assert ( - to_html(t"

    The value is {get_value:callback}.

    ") - == "

    The value is dynamic.

    " - ) + def test_textarea_empty(self): + assert to_html(t"") == "" + def test_title_with_content(self): + assert to_html(t"Content") == "Content" -def test_interpolated_multi_arg_function_fails(): - def add(a, b): # pragma: no cover - return a + b + def test_textarea_with_content(self): + assert ( + to_html(t"") == "" + ) - with pytest.raises(TypeError): - _ = to_html(t"

    The sum is {add:callback}.

    ") + def test_title_with_escapable_content(self): + assert ( + to_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 ( + to_html(t"") + == "" + ), "The p tags can be escaped in this content type." + + +class TestRawTextScriptDynamic: + def test_singleton_none(self): + assert to_html(t"") == "" + + def test_singleton_str(self): + content = "var x = 1;" + assert to_html(t"") == "" + + def test_singleton_object(self): + content = 0 + assert to_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 to_html(t"") == "", ( + "DO NOT DO THIS! This is just an advanced escape hatch! Use a data attribute and parseJSON!" + ) -# -------------------------------------------------------------------------- -# Raw HTML injection tests -# -------------------------------------------------------------------------- + def test_singleton_escaping(self): + content = "" + script_t = t"" + bad_output = script_t.strings[0] + content + script_t.strings[1] + assert to_html(script_t) == "" + assert to_html(script_t) != bad_output, "Sanity check." + def test_templated_none(self): + assert ( + to_html(t"") + == "" + ) -def test_raw_html_injection_with_markupsafe(): - raw_content = Markup("I am bold") - assert ( - to_html(t"
    {raw_content}
    ") == "
    I am bold
    " - ) + def test_templated_str(self): + content = "var x = 1" + assert ( + to_html(t"") + == "" + ) + def test_templated_object(self): + content = 0 + assert ( + to_html(t"") + == "" + ) -def test_raw_html_injection_with_dunder_html_protocol(): - class SafeContent: - def __init__(self, text): - self._text = text + def test_templated_has_dunder_html(self): + content = LiteralHTML("anything") + with pytest.raises(ValueError, match="not supported"): + _ = to_html(t"") - def __html__(self): - # In a real app, this would come from a sanitizer or trusted source - return f"{self._text}" + def test_templated_escaping(self): + content = "" + script_t = t"" + bad_output = script_t.strings[0] + content + script_t.strings[1] + assert to_html(script_t) == "" + assert to_html(script_t) != bad_output, "Sanity check." - content = SafeContent("emphasized") - assert ( - to_html(t"

    Here is some {content}.

    ") - == "

    Here is some emphasized.

    " - ) + def test_templated_multiple_interpolations(self): + assert ( + to_html(t"") + == "" + ) + def test_not_supported_recursive_template_error(self): + text_t = t"script" + with pytest.raises(ValueError, match="not supported"): + _ = to_html(t"") -def test_raw_html_injection_with_format_spec(): - raw_content = "underlined" - assert ( - to_html(t"

    This is {raw_content:safe} text.

    ") - == "

    This is underlined text.

    " - ) + def test_not_supported_recursive_iterable_error(self): + texts = ["This", "is", "a", "script"] + with pytest.raises(ValueError, match="not supported"): + _ = to_html(t"") -def test_raw_html_injection_with_markupsafe_unsafe_format_spec(): - supposedly_safe = Markup("italic") - assert ( - to_html(t"

    This is {supposedly_safe:unsafe} text.

    ") - == "

    This is <i>italic</i> text.

    " - ) +class TestRawTextStyleDynamic: + def test_singleton_none(self): + assert to_html(t"") == "" + + def test_singleton_str(self): + content = "div { background-color: red; }" + assert ( + to_html(t"") + == "" + ) + + def test_singleton_object(self): + content = 0 + assert to_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 to_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 to_html(style_t) == "" + assert to_html(style_t) != bad_output, "Sanity check." + + def test_templated_none(self): + assert ( + to_html(t"") + == "" + ) + + def test_templated_str(self): + content = " h2 { background-color: blue; }" + assert ( + to_html(t"") + == "" + ) + + def test_templated_object(self): + padding_right = 0 + assert ( + to_html(t"") + == "" + ) + + def test_templated_has_dunder_html(self): + content = LiteralHTML("anything") + with pytest.raises(ValueError, match="not supported"): + _ = to_html(t"") + + def test_templated_escaping(self): + content = "" + style_t = t"" + bad_output = style_t.strings[0] + content + style_t.strings[1] + assert ( + to_html(style_t) + == "" + ) + assert to_html(style_t) != bad_output, "Sanity check." + + def test_templated_multiple_interpolations(self): + assert ( + to_html( + t"" + ) + == "" + ) + + def test_not_supported_recursive_template_error(self): + text_t = t"style" + with pytest.raises(ValueError, match="not supported"): + _ = to_html(t"") + + def test_not_supported_recursive_iterable_error(self): + texts = ["This", "is", "a", "style"] + with pytest.raises(ValueError, match="not supported"): + _ = to_html(t"") + + +class TestEscapableRawTextTitleDynamic: + def test_singleton_none(self): + assert to_html(t"{None}") == "" + + def test_singleton_str(self): + content = "Welcome To TDOM" + assert to_html(t"{content}") == "Welcome To TDOM" + + def test_singleton_object(self): + content = 0 + assert to_html(t"{content}") == "0" + + def test_singleton_has_dunder_html_pitfall(self): + # @TODO: We should probably put some double override to prevent this by accident. + content = LiteralHTML("") + assert to_html(t"{content}") == "", ( + "DO NOT DO THIS! This is just an advanced escape hatch!" + ) + + def test_singleton_escaping(self): + content = "" + assert to_html(t"{content}") == "</title>" + + def test_templated_none(self): + assert ( + to_html(t"A great story about: {None}") + == "A great story about: " + ) + + def test_templated_str(self): + content = "TDOM" + assert ( + to_html(t"A great story about: {content}") + == "A great story about: TDOM" + ) + + def test_templated_object(self): + content = 0 + assert ( + to_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"): + _ = to_html(t"Literal html?: {content}") + + def test_templated_escaping(self): + content = "" + assert ( + to_html(t"The end tag: {content}.") + == "The end tag: </title>." + ) + + def test_templated_multiple_interpolations(self): + assert ( + to_html(t"The number {0} is less than {1}.") + == "The number 0 is less than 1." + ) + + def test_not_supported_recursive_template_error(self): + text_t = t"title" + with pytest.raises(ValueError, match="not supported"): + _ = to_html(t"{text_t}") + + def test_not_supported_recursive_iterable_error(self): + texts = ["This", "is", "a", "title"] + with pytest.raises(ValueError, match="not supported"): + _ = to_html(t"{texts}") + + +class TestEscapableRawTextTextareaDynamic: + def test_singleton_none(self): + assert to_html(t"") == "" + + def test_singleton_str(self): + content = "Welcome To TDOM" + assert ( + to_html(t"") + == "" + ) + + def test_singleton_object(self): + content = 0 + assert to_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 ( + to_html(t"") + == "" + ), "DO NOT DO THIS! This is just an advanced escape hatch!" + + def test_singleton_escaping(self): + content = "" + assert ( + to_html(t"") + == "" + ) + + def test_templated_none(self): + assert ( + to_html(t"") + == "" + ) + + def test_templated_str(self): + content = "TDOM" + assert ( + to_html(t"") + == "" + ) + + def test_templated_object(self): + content = 0 + assert ( + to_html(t"") + == "" + ) + + def test_templated_has_dunder_html(self): + content = LiteralHTML("No") + with pytest.raises(ValueError, match="not supported"): + _ = to_html(t"") + + def test_templated_multiple_interpolations(self): + assert ( + to_html(t"") + == "" + ) + + def test_templated_escaping(self): + content = "" + assert ( + to_html(t"") + == "" + ) + + def test_not_supported_recursive_template_error(self): + text_t = t"textarea" + with pytest.raises(ValueError, match="not supported"): + _ = to_html(t"") + + def test_not_supported_recursive_iterable_error(self): + texts = ["This", "is", "a", "textarea"] + with pytest.raises(ValueError, match="not supported"): + _ = to_html(t"") + + +class Convertible: + def __str__(self): + return "string" + + def __repr__(self): + return "repr" + + +class TestInterpolationConversion: + def test_fixture(self): + """Make sure test fixture is working correctly.""" + c = Convertible() + assert f"{c!s}" == "string" + assert f"{c!r}" == "repr" + + def test_str(self): + c = Convertible() + assert to_html(t"
    {c!s}
    ") == "
    string
    " + + def test_repr(self): + c = Convertible() + assert to_html(t"
    {c!r}
    ") == "
    repr
    " + + def test_ascii(self): + assert ( + to_html(t"
    {'😊'!a}
    ") + == f"
    {escape_html_text(ascii('😊'))}
    " + ) + + +class TestInterpolationFormatSpec: + def test_safe(self): + raw_content = "underlined" + assert ( + to_html(t"

    This is {raw_content:safe} text.

    ") + == "

    This is underlined text.

    " + ) + + def test_unsafe(self): + supposedly_safe = Markup("italic") + assert ( + to_html(t"

    This is {supposedly_safe:unsafe} text.

    ") + == "

    This is <i>italic</i> text.

    " + ) + + def test_callback(self): + def get_value(): + return "dynamic" + + assert ( + to_html(t"

    The value is {get_value:callback}.

    ") + == "

    The value is dynamic.

    " + ) + + def test_callback_nonzero_callable_error(self): + def add(a, b): + return a + b + + assert add(1, 2) == 3, "Make sure fixture could work..." + + with pytest.raises(TypeError): + _ = to_html(t"

    The sum is {add:callback}.

    ") # -------------------------------------------------------------------------- From 326c6cb200e2e027041dfb656b84e6fd40e55885 Mon Sep 17 00:00:00 2001 From: Ian Wilson Date: Sat, 21 Feb 2026 11:38:23 -0800 Subject: [PATCH 39/59] Correctly handle None for resolve without recursion texts in to_node. --- tdom/nodes/processor.py | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/tdom/nodes/processor.py b/tdom/nodes/processor.py index 755d1a6..902a80a 100644 --- a/tdom/nodes/processor.py +++ b/tdom/nodes/processor.py @@ -200,7 +200,9 @@ def _stream_comment( text_t: Template, ) -> None: content = resolve_text_without_recursion(template, "") == "" # # Singleton / Exact Match # - def test_singleton_str(self): + def test_singleton_str(self, to_html): text = "This is a comment" assert to_html(t"") == "" - def test_singleton_object(self): + def test_singleton_object(self, to_html): assert to_html(t"") == "" - def test_singleton_none(self): + def test_singleton_none(self, to_html): assert to_html(t"") == "" - def test_singleton_has_dunder_html(self): + def test_singleton_has_dunder_html(self, to_html): content = LiteralHTML("-->") assert to_html(t"") == "-->", ( "DO NOT DO THIS! This is just an advanced escape hatch." ) - def test_singleton_escaping(self): + def test_singleton_escaping(self, to_html): text = "-->comment" assert to_html(t"") == "" # # Templated -- literal text mixed with interpolation(s) # - def test_templated_str(self): + def test_templated_str(self, to_html): text = "comment" assert to_html(t"") == "" - def test_templated_object(self): + def test_templated_object(self, to_html): assert to_html(t"") == "" - def test_templated_none(self): + def test_templated_none(self, to_html): assert to_html(t"") == "" - def test_templated_has_dunder_html_error(self): + def test_templated_has_dunder_html_error(self, to_html): """Objects with __html__ are not processed with literal text or other interpolations.""" text = LiteralHTML("in a comment") with pytest.raises(ValueError, match="not supported"): @@ -143,14 +163,14 @@ def test_templated_has_dunder_html_error(self): with pytest.raises(ValueError, match="not supported"): _ = to_html(t"") - def test_templated_multiple_interpolations(self): + def test_templated_multiple_interpolations(self, to_html): text = "comment" assert ( to_html(t"") == "" ) - def test_templated_escaping(self): + def test_templated_escaping(self, to_html): # @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 @@ -158,33 +178,35 @@ def test_templated_escaping(self): text = "-->comment" assert to_html(t"") == "" - def test_not_supported__recursive_template_error(self): + def test_not_supported__recursive_template_error(self, to_html): text_t = t"comment" with pytest.raises(ValueError, match="not supported"): _ = to_html(t"") - def test_not_supported_recursive_iterable_error(self): + def test_not_supported_recursive_iterable_error(self, to_html): texts = ["This", "is", "a", "comment"] with pytest.raises(ValueError, match="not supported"): _ = to_html(t"") +@pytest.mark.parametrize("to_html", PROCESSORS) class TestDocumentType: - def test_literal(self): + def test_literal(self, to_html): assert to_html(t"") == "" +@pytest.mark.parametrize("to_html", PROCESSORS) class TestVoidElementLiteral: - def test_void(self): + def test_void(self, to_html): assert to_html(t"
    ") == "
    " - def test_void_self_closed(self): + def test_void_self_closed(self, to_html): assert to_html(t"
    ") == "
    " - def test_void_mixed_closing(self): + def test_void_mixed_closing(self, to_html): assert to_html(t"
    Is this content?
    ") == "
    Is this content?
    " - def test_chain_of_void_elements(self): + def test_chain_of_void_elements(self, to_html): # Make sure our handling of CPython issue #69445 is reasonable. assert ( to_html(t"



    ") @@ -192,114 +214,130 @@ def test_chain_of_void_elements(self): ) +@pytest.mark.parametrize("to_html", PROCESSORS) class TestNormalTextElementLiteral: - def test_empty(self): + def test_empty(self, to_html): assert to_html(t"
    ") == "
    " - def test_with_text(self): + def test_with_text(self, to_html): assert to_html(t"

    Hello, world!

    ") == "

    Hello, world!

    " - def test_nested_elements(self): + def test_nested_elements(self, to_html): assert ( to_html(t"

    Hello

    World

    ") == "

    Hello

    World

    " ) - def test_entities_are_escaped(self): + def test_entities_are_escaped(self, to_html): """Literal entities interpreted by parser but escaped in output.""" res = to_html(t"

    </p>

    ") assert res == "

    </p>

    ", res -# @TODO: Move with other bare text. -def test_parse_entities_are_escaped_no_parent_tag(): - res = to_html(t"</p>") - assert res == "</p>", "Default to standard escaping." - - +@pytest.mark.parametrize("to_html", PROCESSORS) class TestNormalTextElementDynamic: - def test_singleton_None(self): + def test_singleton_None(self, to_html): assert to_html(t"

    {None}

    ") == "

    " - def test_singleton_str(self): + def test_singleton_str(self, to_html): name = "Alice" assert to_html(t"

    {name}

    ") == "

    Alice

    " - def test_singleton_object(self): + def test_singleton_object(self, to_html): assert to_html(t"

    {0}

    ") == "

    0

    " - def test_singleton_has_dunder_html(self): + def test_singleton_has_dunder_html(self, to_html): content = LiteralHTML("Alright!") assert to_html(t"

    {content}

    ") == "

    Alright!

    " - def test_singleton_simple_template(self): + def test_singleton_simple_template(self, to_html): name = "Alice" text_t = t"Hi {name}" assert to_html(t"

    {text_t}

    ") == "

    Hi Alice

    " - def test_singleton_simple_iterable(self): + def test_singleton_simple_iterable(self, to_html): strs = ["Strings", "...", "Yeah!", "Rock", "...", "Yeah!"] assert to_html(t"

    {strs}

    ") == "

    Strings...Yeah!Rock...Yeah!

    " - def test_singleton_escaping(self): + def test_singleton_escaping(self, to_html): text = '''<>&'"''' assert to_html(t"

    {text}

    ") == "

    <>&'"

    " - def test_templated_None(self): + def test_templated_None(self, to_html): assert to_html(t"

    Response: {None}.

    ") == "

    Response: .

    " - def test_templated_str(self): + def test_templated_str(self, to_html): name = "Alice" assert to_html(t"

    Response: {name}.

    ") == "

    Response: Alice.

    " - def test_templated_object(self): + def test_templated_object(self, to_html): assert to_html(t"

    Response: {0}.

    ") == "

    Response: 0.

    " - def test_templated_has_dunder_html(self): + def test_templated_has_dunder_html(self, to_html): text = LiteralHTML("Alright!") assert ( to_html(t"

    Response: {text}.

    ") == "

    Response: Alright!.

    " ) - def test_templated_simple_template(self): + def test_templated_simple_template(self, to_html): name = "Alice" text_t = t"Hi {name}" assert to_html(t"

    Response: {text_t}.

    ") == "

    Response: Hi Alice.

    " - def test_templated_simple_iterable(self): + def test_templated_simple_iterable(self, to_html): strs = ["Strings", "...", "Yeah!", "Rock", "...", "Yeah!"] assert ( to_html(t"

    Response: {strs}.

    ") == "

    Response: Strings...Yeah!Rock...Yeah!.

    " ) - def test_templated_escaping(self): + def test_templated_escaping(self, to_html): text = '''<>&'"''' assert ( to_html(t"

    Response: {text}.

    ") == "

    Response: <>&'".

    " ) + def test_iterable_of_templates(self, to_html): + items = ["Apple", "Banana", "Cherry"] + assert ( + to_html(t"
      {[t'
    • {item}
    • ' for item in items]}
    ") + == "
    • Apple
    • Banana
    • Cherry
    " + ) + + def test_iterable_of_templates_of_iterable_of_templates(self, to_html): + outer = ["fruit", "more fruit"] + inner = ["apple", "banana", "cherry"] + inner_items = [t"
  • {item}
  • " for item in inner] + outer_items = [ + t"
  • {category}
      {inner_items}
  • " for category in outer + ] + assert ( + to_html(t"
      {outer_items}
    ") + == "
    • fruit
      • apple
      • banana
      • cherry
    • more fruit
      • apple
      • banana
      • cherry
    " + ) + +@pytest.mark.parametrize("to_html", PROCESSORS) class TestRawTextElementLiteral: - def test_script_empty(self): + def test_script_empty(self, to_html): assert to_html(t"") == "" - def test_style_empty(self): + def test_style_empty(self, to_html): assert to_html(t"") == "" - def test_script_with_content(self): + def test_script_with_content(self, to_html): assert to_html(t"") == "" - def test_style_with_content(self): + def test_style_with_content(self, to_html): # @NOTE: Double {{ and }} to avoid t-string interpolation. assert ( to_html(t"") == "" ) - def test_script_with_content_escaped_in_normal_text(self): + def test_script_with_content_escaped_in_normal_text(self, to_html): # @NOTE: Double {{ and }} to avoid t-string interpolation. assert ( to_html( @@ -308,65 +346,67 @@ def test_script_with_content_escaped_in_normal_text(self): == "" ), "The < should not be escaped." - def test_style_with_content_escaped_in_normal_text(self): + def test_style_with_content_escaped_in_normal_text(self, to_html): # @NOTE: Double {{ and }} to avoid t-string interpolation. assert ( to_html(t"") == "" ), "The > should not be escaped." - def test_not_supported_recursive_template_error(self): + def test_not_supported_recursive_template_error(self, to_html): text_t = t"comment" with pytest.raises(ValueError, match="not supported"): _ = to_html(t"") - def test_not_supported_recursive_iterable_error(self): + def test_not_supported_recursive_iterable_error(self, to_html): texts = ["This", "is", "a", "comment"] with pytest.raises(ValueError, match="not supported"): _ = to_html(t"") +@pytest.mark.parametrize("to_html", PROCESSORS) class TestEscapableRawTextElementLiteral: - def test_title_empty(self): + def test_title_empty(self, to_html): assert to_html(t"") == "" - def test_textarea_empty(self): + def test_textarea_empty(self, to_html): assert to_html(t"") == "" - def test_title_with_content(self): + def test_title_with_content(self, to_html): assert to_html(t"Content") == "Content" - def test_textarea_with_content(self): + def test_textarea_with_content(self, to_html): assert ( to_html(t"") == "" ) - def test_title_with_escapable_content(self): + def test_title_with_escapable_content(self, to_html): assert ( to_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): + def test_textarea_with_escapable_content(self, to_html): assert ( to_html(t"") == "" ), "The p tags can be escaped in this content type." +@pytest.mark.parametrize("to_html", PROCESSORS) class TestRawTextScriptDynamic: - def test_singleton_none(self): + def test_singleton_none(self, to_html): assert to_html(t"") == "" - def test_singleton_str(self): + def test_singleton_str(self, to_html): content = "var x = 1;" assert to_html(t"") == "" - def test_singleton_object(self): + def test_singleton_object(self, to_html): content = 0 assert to_html(t"") == "" - def test_singleton_has_dunder_html_pitfall(self): + def test_singleton_has_dunder_html_pitfall(self, to_html): # @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__? @@ -375,78 +415,79 @@ def test_singleton_has_dunder_html_pitfall(self): "DO NOT DO THIS! This is just an advanced escape hatch! Use a data attribute and parseJSON!" ) - def test_singleton_escaping(self): + def test_singleton_escaping(self, to_html): content = "" script_t = t"" bad_output = script_t.strings[0] + content + script_t.strings[1] assert to_html(script_t) == "" assert to_html(script_t) != bad_output, "Sanity check." - def test_templated_none(self): + def test_templated_none(self, to_html): assert ( to_html(t"") == "" ) - def test_templated_str(self): + def test_templated_str(self, to_html): content = "var x = 1" assert ( to_html(t"") == "" ) - def test_templated_object(self): + def test_templated_object(self, to_html): content = 0 assert ( to_html(t"") == "" ) - def test_templated_has_dunder_html(self): + def test_templated_has_dunder_html(self, to_html): content = LiteralHTML("anything") with pytest.raises(ValueError, match="not supported"): _ = to_html(t"") - def test_templated_escaping(self): + def test_templated_escaping(self, to_html): content = "" script_t = t"" bad_output = script_t.strings[0] + content + script_t.strings[1] assert to_html(script_t) == "" assert to_html(script_t) != bad_output, "Sanity check." - def test_templated_multiple_interpolations(self): + def test_templated_multiple_interpolations(self, to_html): assert ( to_html(t"") == "" ) - def test_not_supported_recursive_template_error(self): + def test_not_supported_recursive_template_error(self, to_html): text_t = t"script" with pytest.raises(ValueError, match="not supported"): _ = to_html(t"") - def test_not_supported_recursive_iterable_error(self): + def test_not_supported_recursive_iterable_error(self, to_html): texts = ["This", "is", "a", "script"] with pytest.raises(ValueError, match="not supported"): _ = to_html(t"") +@pytest.mark.parametrize("to_html", PROCESSORS) class TestRawTextStyleDynamic: - def test_singleton_none(self): + def test_singleton_none(self, to_html): assert to_html(t"") == "" - def test_singleton_str(self): + def test_singleton_str(self, to_html): content = "div { background-color: red; }" assert ( to_html(t"") == "" ) - def test_singleton_object(self): + def test_singleton_object(self, to_html): content = 0 assert to_html(t"") == "" - def test_singleton_has_dunder_html_pitfall(self): + def test_singleton_has_dunder_html_pitfall(self, to_html): # @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__? @@ -455,39 +496,39 @@ def test_singleton_has_dunder_html_pitfall(self): "DO NOT DO THIS! This is just an advanced escape hatch!" ) - def test_singleton_escaping(self): + def test_singleton_escaping(self, to_html): content = "" style_t = t"" bad_output = style_t.strings[0] + content + style_t.strings[1] assert to_html(style_t) == "" assert to_html(style_t) != bad_output, "Sanity check." - def test_templated_none(self): + def test_templated_none(self, to_html): assert ( to_html(t"") == "" ) - def test_templated_str(self): + def test_templated_str(self, to_html): content = " h2 { background-color: blue; }" assert ( to_html(t"") == "" ) - def test_templated_object(self): + def test_templated_object(self, to_html): padding_right = 0 assert ( to_html(t"") == "" ) - def test_templated_has_dunder_html(self): + def test_templated_has_dunder_html(self, to_html): content = LiteralHTML("anything") with pytest.raises(ValueError, match="not supported"): _ = to_html(t"") - def test_templated_escaping(self): + def test_templated_escaping(self, to_html): content = "" style_t = t"" bad_output = style_t.strings[0] + content + style_t.strings[1] @@ -497,7 +538,7 @@ def test_templated_escaping(self): ) assert to_html(style_t) != bad_output, "Sanity check." - def test_templated_multiple_interpolations(self): + def test_templated_multiple_interpolations(self, to_html): assert ( to_html( t"" @@ -505,105 +546,107 @@ def test_templated_multiple_interpolations(self): == "" ) - def test_not_supported_recursive_template_error(self): + def test_not_supported_recursive_template_error(self, to_html): text_t = t"style" with pytest.raises(ValueError, match="not supported"): _ = to_html(t"") - def test_not_supported_recursive_iterable_error(self): + def test_not_supported_recursive_iterable_error(self, to_html): texts = ["This", "is", "a", "style"] with pytest.raises(ValueError, match="not supported"): _ = to_html(t"") +@pytest.mark.parametrize("to_html", PROCESSORS) class TestEscapableRawTextTitleDynamic: - def test_singleton_none(self): + def test_singleton_none(self, to_html): assert to_html(t"{None}") == "" - def test_singleton_str(self): + def test_singleton_str(self, to_html): content = "Welcome To TDOM" assert to_html(t"{content}") == "Welcome To TDOM" - def test_singleton_object(self): + def test_singleton_object(self, to_html): content = 0 assert to_html(t"{content}") == "0" - def test_singleton_has_dunder_html_pitfall(self): + def test_singleton_has_dunder_html_pitfall(self, to_html): # @TODO: We should probably put some double override to prevent this by accident. content = LiteralHTML("") assert to_html(t"{content}") == "", ( "DO NOT DO THIS! This is just an advanced escape hatch!" ) - def test_singleton_escaping(self): + def test_singleton_escaping(self, to_html): content = "" assert to_html(t"{content}") == "</title>" - def test_templated_none(self): + def test_templated_none(self, to_html): assert ( to_html(t"A great story about: {None}") == "A great story about: " ) - def test_templated_str(self): + def test_templated_str(self, to_html): content = "TDOM" assert ( to_html(t"A great story about: {content}") == "A great story about: TDOM" ) - def test_templated_object(self): + def test_templated_object(self, to_html): content = 0 assert ( to_html(t"A great number: {content}") == "A great number: 0" ) - def test_templated_has_dunder_html(self): + def test_templated_has_dunder_html(self, to_html): content = LiteralHTML("No") with pytest.raises(ValueError, match="not supported"): _ = to_html(t"Literal html?: {content}") - def test_templated_escaping(self): + def test_templated_escaping(self, to_html): content = "" assert ( to_html(t"The end tag: {content}.") == "The end tag: </title>." ) - def test_templated_multiple_interpolations(self): + def test_templated_multiple_interpolations(self, to_html): assert ( to_html(t"The number {0} is less than {1}.") == "The number 0 is less than 1." ) - def test_not_supported_recursive_template_error(self): + def test_not_supported_recursive_template_error(self, to_html): text_t = t"title" with pytest.raises(ValueError, match="not supported"): _ = to_html(t"{text_t}") - def test_not_supported_recursive_iterable_error(self): + def test_not_supported_recursive_iterable_error(self, to_html): texts = ["This", "is", "a", "title"] with pytest.raises(ValueError, match="not supported"): _ = to_html(t"{texts}") +@pytest.mark.parametrize("to_html", PROCESSORS) class TestEscapableRawTextTextareaDynamic: - def test_singleton_none(self): + def test_singleton_none(self, to_html): assert to_html(t"") == "" - def test_singleton_str(self): + def test_singleton_str(self, to_html): content = "Welcome To TDOM" assert ( to_html(t"") == "" ) - def test_singleton_object(self): + def test_singleton_object(self, to_html): content = 0 assert to_html(t"") == "" - def test_singleton_has_dunder_html_pitfall(self): + def test_singleton_has_dunder_html_pitfall(self, to_html): # @TODO: We should probably put some double override to prevent this by accident. content = LiteralHTML("") assert ( @@ -611,57 +654,57 @@ def test_singleton_has_dunder_html_pitfall(self): == "" ), "DO NOT DO THIS! This is just an advanced escape hatch!" - def test_singleton_escaping(self): + def test_singleton_escaping(self, to_html): content = "" assert ( to_html(t"") == "" ) - def test_templated_none(self): + def test_templated_none(self, to_html): assert ( to_html(t"") == "" ) - def test_templated_str(self): + def test_templated_str(self, to_html): content = "TDOM" assert ( to_html(t"") == "" ) - def test_templated_object(self): + def test_templated_object(self, to_html): content = 0 assert ( to_html(t"") == "" ) - def test_templated_has_dunder_html(self): + def test_templated_has_dunder_html(self, to_html): content = LiteralHTML("No") with pytest.raises(ValueError, match="not supported"): _ = to_html(t"") - def test_templated_multiple_interpolations(self): + def test_templated_multiple_interpolations(self, to_html): assert ( to_html(t"") == "" ) - def test_templated_escaping(self): + def test_templated_escaping(self, to_html): content = "" assert ( to_html(t"") == "" ) - def test_not_supported_recursive_template_error(self): + def test_not_supported_recursive_template_error(self, to_html): text_t = t"textarea" with pytest.raises(ValueError, match="not supported"): _ = to_html(t"") - def test_not_supported_recursive_iterable_error(self): + def test_not_supported_recursive_iterable_error(self, to_html): texts = ["This", "is", "a", "textarea"] with pytest.raises(ValueError, match="not supported"): _ = to_html(t"") @@ -675,44 +718,47 @@ def __repr__(self): return "repr" -class TestInterpolationConversion: - def test_fixture(self): - """Make sure test fixture is working correctly.""" - c = Convertible() - assert f"{c!s}" == "string" - assert f"{c!r}" == "repr" +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_str(self): +@pytest.mark.parametrize("to_html", PROCESSORS) +class TestInterpolationConversion: + def test_str(self, to_html): c = Convertible() assert to_html(t"
    {c!s}
    ") == "
    string
    " - def test_repr(self): + def test_repr(self, to_html): c = Convertible() assert to_html(t"
    {c!r}
    ") == "
    repr
    " - def test_ascii(self): + def test_ascii(self, to_html): assert ( to_html(t"
    {'😊'!a}
    ") == f"
    {escape_html_text(ascii('😊'))}
    " ) +@pytest.mark.parametrize("to_html", PROCESSORS) class TestInterpolationFormatSpec: - def test_safe(self): + def test_safe(self, to_html): raw_content = "underlined" assert ( to_html(t"

    This is {raw_content:safe} text.

    ") == "

    This is underlined text.

    " ) - def test_unsafe(self): + def test_unsafe(self, to_html): supposedly_safe = Markup("italic") assert ( to_html(t"

    This is {supposedly_safe:unsafe} text.

    ") == "

    This is <i>italic</i> text.

    " ) - def test_callback(self): + def test_callback(self, to_html): def get_value(): return "dynamic" @@ -721,7 +767,7 @@ def get_value(): == "

    The value is dynamic.

    " ) - def test_callback_nonzero_callable_error(self): + def test_callback_nonzero_callable_error(self, to_html): def add(a, b): return a + b @@ -736,789 +782,726 @@ def add(a, b): # -------------------------------------------------------------------------- -def test_conditional_rendering_with_if_else(): - is_logged_in = True - user_profile = t"Welcome, User!" - login_prompt = t"Please log in" - assert ( - to_html(t"
    {user_profile if is_logged_in else login_prompt}
    ") - == "
    Welcome, User!
    " - ) - - is_logged_in = False - assert ( - to_html(t"
    {user_profile if is_logged_in else login_prompt}
    ") - == '' - ) - - -# -------------------------------------------------------------------------- -# Interpolated nesting of templates and elements -# -------------------------------------------------------------------------- - - -def test_interpolated_template_content(): - child = t"Child" - assert to_html(t"
    {child}
    ") == "
    Child
    " - - -def test_interpolated_nonstring_content(): - number = 42 - assert to_html(t"

    The answer is {number}.

    ") == "

    The answer is 42.

    " - - -def test_list_items(): - items = ["Apple", "Banana", "Cherry"] - assert ( - to_html(t"
      {[t'
    • {item}
    • ' for item in items]}
    ") - == "
    • Apple
    • Banana
    • Cherry
    " - ) - +@pytest.mark.parametrize("to_html", PROCESSORS) +class TestUsagePatterns: + def test_conditional_rendering_with_if_else(self, to_html): + is_logged_in = True + user_profile = t"Welcome, User!" + login_prompt = t"Please log in" + assert ( + to_html(t"
    {user_profile if is_logged_in else login_prompt}
    ") + == "
    Welcome, User!
    " + ) -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}
      {inner_items}
  • " for category in outer] - assert ( - to_html(t"
      {outer_items}
    ") - == "
    • fruit
      • apple
      • banana
      • cherry
    • more fruit
      • apple
      • banana
      • cherry
    " - ) + is_logged_in = False + assert ( + to_html(t"
    {user_profile if is_logged_in else login_prompt}
    ") + == '' + ) # -------------------------------------------------------------------------- # Attributes # -------------------------------------------------------------------------- +@pytest.mark.parametrize("to_html", PROCESSORS) +class TestLiteralAttribute: + """Test literal (non-dynamic) attributes.""" - -def test_literal_attrs(): - assert ( - to_html( - ( - t"" + def test_literal_attrs(self, to_html): + assert ( + to_html( + ( + t"" + ) ) + == '' ) - == '' - ) - - -def test_literal_attr_escaped(): - assert to_html(t'') == '' - - -def test_interpolated_attr(): - url = "https://example.com/" - assert to_html(t'') == '' - - -def test_interpolated_attr_escaped(): - url = 'https://example.com/?q="test"&lang=en' - assert ( - to_html(t'') - == '' - ) - - -def test_interpolated_attr_unquoted(): - id = "roquefort" - assert to_html(t"
    ") == '
    ' - - -def test_interpolated_attr_true(): - disabled = True - assert ( - to_html(t"") - == "" - ) - - -def test_interpolated_attr_false(): - disabled = False - assert to_html(t"") == "" - - -def test_interpolated_attr_none(): - disabled = None - assert to_html(t"") == "" - - -def test_interpolate_attr_empty_string(): - assert to_html(t'
    ') == '
    ' - - -def test_spread_attr(): - attrs = {"href": "https://example.com/", "target": "_blank"} - assert ( - to_html(t"") - == '' - ) - -def test_spread_attr_none(): - attrs = None - assert to_html(t"") == "" - - -def test_spread_attr_type_errors(): - for attrs in (0, [], (), False, True): - with pytest.raises(TypeError): - _ = to_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 - assert to_html(test_t) == '
    ' - - -def test_templated_attr_no_quotes(): - start = 1 - end = 5 - assert ( - to_html(t"
    ") - == '
    ' - ) - - -def test_attr_merge_disjoint_interpolated_attr_spread_attr(): - attrs = {"href": "https://example.com/", "id": "link1"} - target = "_blank" - assert ( - to_html(t"") - == '' - ) + def test_literal_attr_escaped(self, to_html): + assert ( + to_html(t'') + == '' + ) -def test_attr_merge_overlapping_spread_attrs(): - attrs1 = {"href": "https://example.com/", "id": "overwrtten"} - attrs2 = {"target": "_blank", "id": "link1"} - assert ( - to_html(t"") - == '' - ) +@pytest.mark.parametrize("to_html", PROCESSORS) +class TestInterpolatedAttribute: + """Test interpolated attributes, entire value is an exact interpolation.""" + def test_interpolated_attr(self, to_html): + url = "https://example.com/" + assert to_html(t'') == '' -def test_attr_merge_replace_literal_attr_str_str(): - assert ( - to_html(t'
    ') - == '
    ' - ) + def test_interpolated_attr_escaped(self, to_html): + url = 'https://example.com/?q="test"&lang=en' + assert ( + to_html(t'') + == '' + ) + def test_interpolated_attr_unquoted(self, to_html): + id = "roquefort" + assert to_html(t"
    ") == '
    ' -def test_attr_merge_replace_literal_attr_str_true(): - assert ( - to_html(t'
    ') - == "
    " - ) + def test_interpolated_attr_true(self, to_html): + disabled = True + assert ( + to_html(t"") + == "" + ) + def test_interpolated_attr_false(self, to_html): + disabled = False + assert to_html(t"") == "" -def test_attr_merge_replace_literal_attr_true_str(): - assert ( - to_html(t"
    ") - == '
    ' - ) + def test_interpolated_attr_none(self, to_html): + disabled = None + assert to_html(t"") == "" + def test_interpolate_attr_empty_string(self, to_html): + assert to_html(t'
    ') == '
    ' -def test_attr_merge_remove_literal_attr_str_none(): - assert to_html(t'
    ') == "
    " +@pytest.mark.parametrize("to_html", PROCESSORS) +class TestSpreadAttribute: + """Test spread attributes.""" -def test_attr_merge_remove_literal_attr_true_none(): - assert to_html(t"
    ") == "
    " + def test_spread_attr(self, to_html): + attrs = {"href": "https://example.com/", "target": "_blank"} + assert ( + to_html(t"") + == '' + ) + def test_spread_attr_none(self, to_html): + attrs = None + assert to_html(t"") == "" + + def test_spread_attr_type_errors(self, to_html): + for attrs in (0, [], (), False, True): + with pytest.raises(TypeError): + _ = to_html(t"") + + +@pytest.mark.parametrize("to_html", PROCESSORS) +class TestTemplatedAttribute: + def test_templated_attr_mixed_interpolations_start_end_and_nest(self, to_html): + 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 to_html(test_t) == '
    ' -def test_attr_merge_other_literal_attr_intact(): - assert ( - to_html(t'') - == 'fresh' - ) + def test_templated_attr_no_quotes(self, to_html): + start = 1 + end = 5 + assert ( + to_html(t"
    ") + == '
    ' + ) -def test_placeholder_collision_avoidance(): - config = make_placeholder_config() - # This test is to ensure that our placeholder detection avoids collisions - # even with content that might look like a placeholder. - tricky = "0" - template = Template( - f'
    ', - ) - assert ( - to_html(template) - == f'
    ' - ) +@pytest.mark.parametrize("to_html", PROCESSORS) +class TestAttributeMerging: + def test_attr_merge_disjoint_interpolated_attr_spread_attr(self, to_html): + attrs = {"href": "https://example.com/", "id": "link1"} + target = "_blank" + assert ( + to_html(t"") + == '' + ) + def test_attr_merge_overlapping_spread_attrs(self, to_html): + attrs1 = {"href": "https://example.com/", "id": "overwrtten"} + attrs2 = {"target": "_blank", "id": "link1"} + assert ( + to_html(t"") + == '' + ) -# -# Special data attribute handling. -# -def test_interpolated_data_attributes(): - data = {"user-id": 123, "role": "admin", "wild": True, "false": False, "none": None} - assert ( - to_html(t"
    User Info
    ") - == '
    User Info
    ' - ) + def test_attr_merge_replace_literal_attr_str_str(self, to_html): + assert ( + to_html(t'
    ') + == '
    ' + ) + def test_attr_merge_replace_literal_attr_str_true(self, to_html): + assert ( + to_html(t'
    ') + == "
    " + ) -def test_data_attr_toggle_to_str(): - for res in [ - to_html(t"
    "), - to_html(t'
    '), - ]: - assert res == '
    ' + def test_attr_merge_replace_literal_attr_true_str(self, to_html): + assert ( + to_html(t"
    ") + == '
    ' + ) + def test_attr_merge_remove_literal_attr_str_none(self, to_html): + assert ( + to_html(t'
    ') == "
    " + ) -def test_data_attr_toggle_to_true(): - res = to_html(t'
    ') - assert res == "
    " + def test_attr_merge_remove_literal_attr_true_none(self, to_html): + assert to_html(t"
    ") == "
    " + def test_attr_merge_other_literal_attr_intact(self, to_html): + assert ( + to_html(t'') + == 'fresh' + ) -def test_data_attr_unrelated_unaffected(): - res = to_html(t"
    ") - assert res == "
    " +@pytest.mark.parametrize("to_html", PROCESSORS) +class TestSpecialDataAttribute: + """Special data attribute handling.""" -def test_data_attr_templated_error(): - data1 = {"user-id": "user-123"} - data2 = {"role": "admin"} - with pytest.raises(TypeError): - _ = to_html(t'
    ') + def test_interpolated_data_attributes(self, to_html): + data = { + "user-id": 123, + "role": "admin", + "wild": True, + "false": False, + "none": None, + } + assert ( + to_html(t"
    User Info
    ") + == '
    User Info
    ' + ) + def test_data_attr_toggle_to_str(self, to_html): + for res in [ + to_html(t"
    "), + to_html(t'
    '), + ]: + assert res == '
    ' -def test_data_attr_none(): - button_data = None - res = to_html(t"") - assert res == "" + def test_data_attr_toggle_to_true(self, to_html): + res = to_html(t'
    ') + assert res == "
    " + def test_data_attr_unrelated_unaffected(self, to_html): + res = to_html(t"
    ") + assert res == "
    " -def test_data_attr_errors(): - for v in [False, [], (), 0, "data?"]: + def test_data_attr_templated_error(self, to_html): + data1 = {"user-id": "user-123"} + data2 = {"role": "admin"} with pytest.raises(TypeError): - _ = to_html(t"") - - -def test_data_literal_attr_bypass(): - # Trigger overall attribute resolution with an unrelated interpolated attr. - res = to_html(t'

    ') - assert res == '

    ', ( - "A single literal attribute should not trigger data expansion." - ) - - -# -# Special aria attribute handling. -# -def test_aria_templated_attr_error(): - aria1 = {"label": "close"} - aria2 = {"hidden": "true"} - with pytest.raises(TypeError): - _ = to_html(t'
    ') - - -def test_aria_interpolated_attr_dict(): - aria = {"label": "Close", "hidden": True, "another": False, "more": None} - res = to_html(t"") - assert ( - res - == '' - ) - + _ = to_html(t'
    ') + + def test_data_attr_none(self, to_html): + button_data = None + res = to_html(t"") + assert res == "" + + def test_data_attr_errors(self, to_html): + for v in [False, [], (), 0, "data?"]: + with pytest.raises(TypeError): + _ = to_html(t"") + + def test_data_literal_attr_bypass(self, to_html): + # Trigger overall attribute resolution with an unrelated interpolated attr. + res = to_html(t'

    ') + assert res == '

    ', ( + "A single literal attribute should not trigger data expansion." + ) -def test_aria_interpolate_attr_none(): - button_aria = None - res = to_html(t"") - assert res == "" +@pytest.mark.parametrize("to_html", PROCESSORS) +class TestSpecialAriaAttribute: + """Special aria attribute handling.""" -def test_aria_attr_errors(): - for v in [False, [], (), 0, "aria?"]: + def test_aria_templated_attr_error(self, to_html): + aria1 = {"label": "close"} + aria2 = {"hidden": "true"} with pytest.raises(TypeError): - _ = to_html(t"") - - -def test_aria_literal_attr_bypass(): - # Trigger overall attribute resolution with an unrelated interpolated attr. - res = to_html(t'

    ') - assert res == '

    ', ( - "A single literal attribute should not trigger aria expansion." - ) - - -# -# 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"" - ) - res = to_html(button_t) - assert ( - res - == '' - ) + _ = to_html(t'
    ') + def test_aria_interpolated_attr_dict(self, to_html): + aria = {"label": "Close", "hidden": True, "another": False, "more": None} + res = to_html(t"") + assert ( + res + == '' + ) -def test_interpolated_class_attribute_with_multiple_placeholders(): - classes1 = ["btn", "btn-primary"] - classes2 = [False and "disabled", None, {"active": True}] - res = to_html(t'') - # 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'' - ), ( - "Interpolations that are not exact, or singletons, are instead interpreted as templates and therefore these dictionaries are strified." - ) + def test_aria_interpolate_attr_none(self, to_html): + button_aria = None + res = to_html(t"") + assert res == "" + + def test_aria_attr_errors(self, to_html): + for v in [False, [], (), 0, "aria?"]: + with pytest.raises(TypeError): + _ = to_html(t"") + + def test_aria_literal_attr_bypass(self, to_html): + # Trigger overall attribute resolution with an unrelated interpolated attr. + res = to_html(t'

    ') + assert res == '

    ', ( + "A single literal attribute should not trigger aria expansion." + ) -def test_interpolated_attribute_spread_with_class_attribute(): - attrs = {"id": "button1", "class": ["btn", "btn-primary"]} - res = to_html(t"") - assert res == '' +@pytest.mark.parametrize("to_html", PROCESSORS) +class TestSpecialClassAttribute: + """Special class attribute handling.""" + + def test_interpolated_class_attribute(self, to_html): + 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"" + ) + res = to_html(button_t) + assert ( + res + == '' + ) + def test_interpolated_class_attribute_with_multiple_placeholders(self, to_html): + classes1 = ["btn", "btn-primary"] + classes2 = [False and "disabled", None, {"active": True}] + res = to_html(t'') + # 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'' + ), ( + "Interpolations that are not exact, or singletons, are instead interpreted as templates and therefore these dictionaries are strified." + ) -def test_class_literal_attr_bypass(): - # Trigger overall attribute resolution with an unrelated interpolated attr. - res = to_html(t'

    ') - assert res == '

    ', ( - "A single literal attribute should not trigger class accumulator." - ) + def test_interpolated_attribute_spread_with_class_attribute(self, to_html): + attrs = {"id": "button1", "class": ["btn", "btn-primary"]} + res = to_html(t"") + assert res == '' + def test_class_literal_attr_bypass(self, to_html): + # Trigger overall attribute resolution with an unrelated interpolated attr. + res = to_html(t'

    ') + assert res == '

    ', ( + "A single literal attribute should not trigger class accumulator." + ) -def test_class_none_ignored(): - class_item = None - res = to_html(t"

    ") - assert res == "

    " - # Also ignored inside a sequence. - res = to_html(t"

    ") - assert res == "

    " + def test_class_none_ignored(self, to_html): + class_item = None + res = to_html(t"

    ") + assert res == "

    " + # Also ignored inside a sequence. + res = to_html(t"

    ") + assert res == "

    " + + def test_class_type_errors(self, to_html): + for class_item in (False, True, 0): + with pytest.raises(TypeError): + _ = to_html(t"

    ") + with pytest.raises(TypeError): + _ = to_html(t"

    ") + + def test_class_merge_literals(self, to_html): + res = to_html(t'

    ') + assert res == '

    ' + + def test_class_merge_literal_then_interpolation(self, to_html): + class_item = "blue" + res = to_html(t'

    ') + assert res == '

    ' + + +@pytest.mark.parametrize("to_html", PROCESSORS) +class TestSpecialStyleAttribute: + """Special style attribute handling.""" + + def test_style_literal_attr_passthru(self, to_html): + p_id = "para1" # non-literal attribute to cause attr resolution + res = to_html(t'

    Warning!

    ') + assert res == '

    Warning!

    ' + + def test_style_in_interpolated_attr(self, to_html): + styles = {"color": "red", "font-weight": "bold", "font-size": "16px"} + res = to_html(t"

    Warning!

    ") + assert ( + res + == '

    Warning!

    ' + ) + def test_style_in_templated_attr(self, to_html): + color = "red" + res = to_html(t'

    Warning!

    ') + assert res == '

    Warning!

    ' + + def test_style_in_spread_attr(self, to_html): + attrs = {"style": {"color": "red"}} + res = to_html(t"

    Warning!

    ") + assert res == '

    Warning!

    ' + + def test_style_merged_from_all_attrs(self, to_html): + attrs = dict(style="font-size: 15px") + style = {"font-weight": "bold"} + color = "red" + res = to_html( + t'

    ' + ) + assert ( + res + == '

    ' + ) -def test_class_type_errors(): - for class_item in (False, True, 0): - with pytest.raises(TypeError): - _ = to_html(t"

    ") + def test_style_override_left_to_right(self, to_html): + suffix = t">

    " + parts = [ + (t'

    ' + + def test_interpolated_style_attribute_multiple_placeholders(self, to_html): + 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): + _ = to_html(t"

    Warning!

    ") + + def test_interpolated_style_attribute_merged(self, to_html): + styles1 = {"color": "red"} + styles2 = {"font-weight": "bold"} + res = to_html(t"

    Warning!

    ") + assert res == '

    Warning!

    ' + + def test_interpolated_style_attribute_merged_override(self, to_html): + styles1 = {"color": "red", "font-weight": "normal"} + styles2 = {"font-weight": "bold"} + res = to_html(t"

    Warning!

    ") + assert res == '

    Warning!

    ' + + def test_style_attribute_str(self, to_html): + styles = "color: red; font-weight: bold;" + res = to_html(t"

    Warning!

    ") + assert res == '

    Warning!

    ' + + def test_style_attribute_non_str_non_dict(self, to_html): with pytest.raises(TypeError): - _ = to_html(t"

    ") - - -def test_class_merge_literals(): - res = to_html(t'

    ') - assert res == '

    ' - - -def test_class_merge_literal_then_interpolation(): - class_item = "blue" - res = to_html(t'

    ') - assert res == '

    ' - - -# -# Special style attribute handling. -# -def test_style_literal_attr_passthru(): - p_id = "para1" # non-literal attribute to cause attr resolution - res = to_html(t'

    Warning!

    ') - assert res == '

    Warning!

    ' - - -def test_style_in_interpolated_attr(): - styles = {"color": "red", "font-weight": "bold", "font-size": "16px"} - res = to_html(t"

    Warning!

    ") - assert ( - res == '

    Warning!

    ' - ) - - -def test_style_in_templated_attr(): - color = "red" - res = to_html(t'

    Warning!

    ') - assert res == '

    Warning!

    ' - - -def test_style_in_spread_attr(): - attrs = {"style": {"color": "red"}} - res = to_html(t"

    Warning!

    ") - assert res == '

    Warning!

    ' - - -def test_style_merged_from_all_attrs(): - attrs = dict(style="font-size: 15px") - style = {"font-weight": "bold"} - color = "red" - res = to_html( - t'

    ' - ) - assert ( - res - == '

    ' - ) - - -def test_style_override_left_to_right(): - suffix = t">

    " - 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): - _ = to_html(t"

    Warning!

    ") - - -def test_interpolated_style_attribute_merged(): - styles1 = {"color": "red"} - styles2 = {"font-weight": "bold"} - res = to_html(t"

    Warning!

    ") - assert res == '

    Warning!

    ' - - -def test_interpolated_style_attribute_merged_override(): - styles1 = {"color": "red", "font-weight": "normal"} - styles2 = {"font-weight": "bold"} - res = to_html(t"

    Warning!

    ") - assert res == '

    Warning!

    ' - - -def test_style_attribute_str(): - styles = "color: red; font-weight: bold;" - res = to_html(t"

    Warning!

    ") - assert res == '

    Warning!

    ' - - -def test_style_attribute_non_str_non_dict(): - with pytest.raises(TypeError): - styles = [1, 2] - _ = to_html(t"

    Warning!

    ") - - -def test_style_literal_attr_bypass(): - # Trigger overall attribute resolution with an unrelated interpolated attr. - res = to_html(t'

    ') - assert res == '

    ', ( - "A single literal attribute should bypass style accumulator." - ) - - -def test_style_none(): - styles = None - res = to_html(t"

    ") - assert res == "

    " - - -# -------------------------------------------------------------------------- -# Function component interpolation tests -# -------------------------------------------------------------------------- - - -def FunctionComponent( - children: Template, 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_interpolated_template_component(): - res = to_html( - t'<{FunctionComponent} first=1 second={99} third-arg="comp1" class="my-comp">Hello, Component!' - ) - assert ( - res - == '
    Component: Hello, Component!
    ' - ) - - -def test_interpolated_template_component_no_children_provided(): - """Same test, but the caller didn't provide any children.""" - res = to_html( - t'<{FunctionComponent} first=1 second={99} third-arg="comp1" class="my-comp" />' - ) - assert ( - res - == '
    Component:
    ' - ) - - -def test_invalid_component_invocation(): - with pytest.raises(TypeError): - _ = to_html(t"<{FunctionComponent}>Missing props") - - -def test_prep_component_kwargs_named(): - 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={}) == {} + styles = [1, 2] + _ = to_html(t"

    Warning!

    ") + + def test_style_literal_attr_bypass(self, to_html): + # Trigger overall attribute resolution with an unrelated interpolated attr. + res = to_html(t'

    ') + assert res == '

    ', ( + "A single literal attribute should bypass style accumulator." + ) + def test_style_none(self, to_html): + styles = None + res = to_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={}) + == {} + ) -@pytest.mark.skip("Should we just ignore unused user-specified kwargs?") -def test_prep_component_kwargs_unused_kwargs(): - def InputElement(size=10, type="text"): - pass - callable_info = get_callable_info(InputElement) - with pytest.raises(ValueError): +@pytest.mark.parametrize("to_html", PROCESSORS) +class TestFunctionComponent: + @staticmethod + def FunctionComponent( + children: Template, 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_with_children(self, to_html): + res = to_html( + t'<{self.FunctionComponent} first=1 second={99} third-arg="comp1" class="my-comp">Hello, Component!' + ) assert ( - prep_component_kwargs(callable_info, {"type2": 15}, system_kwargs={}) == {} + res + == '
    Component: Hello, Component!
    ' ) + def test_with_no_children(self, to_html): + """Same test, but the caller didn't provide any children.""" + res = to_html( + t'<{self.FunctionComponent} first=1 second={99} third-arg="comp1" class="my-comp" />' + ) + assert ( + res + == '
    Component:
    ' + ) -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(): - res = to_html( - t'<{FunctionComponentNoChildren} first=1 second={99} third-arg="comp1">Hello, Component!' - ) - assert ( - res - == '
    Component: ignore children
    ' - ) - - -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_children_always_passed_via_kwargs(): - res = to_html( - t'<{FunctionComponentKeywordArgs} first="value" extra="info">Child content' - ) - assert res == '
    Component with kwargs
    ' - - -def test_children_always_passed_via_kwargs_even_when_empty(): - res = to_html(t'<{FunctionComponentKeywordArgs} first="value" extra="info" />') - assert res == '
    Component with kwargs
    ' - - -def ColumnsComponent() -> Template: - return t"""Column 1Column 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. - res = to_html(t"<{ColumnsComponent} />
    ") - assert res == "
    Column 1Column 2
    " + def test_missing_props_error(self, to_html): + with pytest.raises(TypeError): + _ = to_html( + t"<{self.FunctionComponent}>Missing props" + ) -def test_component_passed_as_attr_value(): - def Wrapper( - children: Template, sub_component: t.Callable, **attrs: t.Any +@pytest.mark.parametrize("to_html", PROCESSORS) +class TestFunctionComponentNoChildren: + @staticmethod + def FunctionComponentNoChildren( + first: str, second: int, third_arg: str ) -> Template: - return t"<{sub_component} {attrs}>{children}" - - res = to_html( - t'<{Wrapper} sub-component={FunctionComponent} class="wrapped" first=1 second={99} third-arg="comp1">

    Inside wrapper

    ' - ) - assert ( - res - == '
    Component:

    Inside wrapper

    ' - ) - - -def test_nested_component_gh23(): - # @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 = to_html(t"<{Header} />", assume_ctx=make_ctx(parent_tag="div")) - assert res == "Hello World" - - -def test_component_returning_fragment(): - def Items() -> Template: - return t"
  • Item {1}
  • Item {2}
  • Item {3}
  • " - - res = to_html(t"
      <{Items} />
    ") - assert str(res) == "
    • Item 1
    • Item 2
    • Item 3
    " - - -@dataclass -class ClassComponent: - """Example class-based component.""" + # 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, to_html): + res = to_html( + t'<{self.FunctionComponentNoChildren} first=1 second={99} third-arg="comp1">Hello, Component!' + ) + assert ( + res + == '
    Component: ignore children
    ' + ) - user_name: str - image_url: str - homepage: str = "#" - children: Template | None = None - def __call__(self) -> Template: - return ( - t"
    " - t"" - t"{f" - t"" - t"{self.user_name}" - t"{self.children}" - t"
    " +@pytest.mark.parametrize("to_html", PROCESSORS) +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 test_children_always_passed_via_kwargs(self, to_html): + res = to_html( + t'<{self.FunctionComponentKeywordArgs} first="value" extra="info">Child content' + ) + assert ( + res + == '
    Component with kwargs: Child content
    ' ) + def test_children_always_passed_via_kwargs_even_when_empty(self, to_html): + res = to_html( + t'<{self.FunctionComponentKeywordArgs} first="value" extra="info" />' + ) + assert ( + res == '
    Component with kwargs:
    ' + ) -def test_class_component_implicit_invocation_with_children(): - res = to_html( - t"<{ClassComponent} user-name='Alice' image-url='https://example.com/alice.png'>Fun times!" - ) - assert ( - res - == '
    Avatar of AliceAliceFun times!
    ' - ) +@pytest.mark.parametrize("to_html", PROCESSORS) +class TestComponentSpecialUsage: + @staticmethod + def ColumnsComponent() -> Template: + return t"""Column 1Column 2""" -def test_class_component_direct_invocation(): - avatar = ClassComponent( - user_name="Alice", - image_url="https://example.com/alice.png", - homepage="https://example.com/users/alice", - ) - res = to_html(t"<{avatar} />") - assert ( - res - == '
    Avatar of AliceAlice
    ' - ) + def test_fragment_from_component(self, to_html): + # This test assumes that if a component returns a template that parses + # into multiple root elements, they are treated as a fragment. + res = to_html(t"<{self.ColumnsComponent} />
    ") + assert res == "
    Column 1Column 2
    " + def test_component_passed_as_attr_value(self, to_html): + def Wrapper( + children: Template, sub_component: t.Callable, **attrs: t.Any + ) -> Template: + return t"<{sub_component} {attrs}>{children}" -@dataclass -class ClassComponentNoChildren: - """Example class-based component that does not ask for children.""" + res = to_html( + t'<{Wrapper} sub-component={TestFunctionComponent.FunctionComponent} class="wrapped" first=1 second={99} third-arg="comp1">

    Inside wrapper

    ' + ) + assert ( + res + == '
    Component:

    Inside wrapper

    ' + ) - user_name: str - image_url: str - homepage: str = "#" + def test_nested_component_gh23(self, to_html): + # @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 = to_html(t"<{Header} />", assume_ctx=make_ctx(parent_tag="div")) + assert res == "Hello World" + + +@pytest.mark.parametrize("to_html", PROCESSORS) +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"{f" + t"" + t"{self.user_name}" + t"{self.children}" + t"
    " + ) - def __call__(self) -> Template: - return ( - t"
    " - t"" - t"{f" - t"" - t"{self.user_name}" - t"ignore children" - t"
    " + def test_class_component_implicit_invocation_with_children(self, to_html): + res = to_html( + t"<{self.ClassComponent} user-name='Alice' image-url='https://example.com/alice.png'>Fun times!" + ) + assert ( + res + == '
    Avatar of AliceAliceFun times!
    ' ) + def test_class_component_direct_invocation(self, to_html): + 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 = to_html(t"<{avatar} />") + assert ( + res + == '
    Avatar of AliceAlice
    ' + ) -def test_class_component_implicit_invocation_ignore_children(): - res = to_html( - t"<{ClassComponentNoChildren} user-name='Alice' image-url='https://example.com/alice.png'>Fun times!" - ) - assert ( - res - == '
    Avatar of AliceAliceignore children
    ' - ) + @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"{f" + t"" + t"{self.user_name}" + t"ignore children" + t"
    " + ) + def test_implicit_invocation_ignore_children(self, to_html): + res = to_html( + t"<{self.ClassComponentNoChildren} user-name='Alice' image-url='https://example.com/alice.png'>Fun times!" + ) + assert ( + res + == '
    Avatar of AliceAliceignore children
    ' + ) -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!" +@pytest.mark.parametrize("to_html", PROCESSORS) +def test_attribute_type_component(to_html): + 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!" -def test_attribute_type_component(): an_int: int = 42 a_true: bool = True a_false: bool = False @@ -1543,28 +1526,32 @@ def test_attribute_type_component(): assert res == "Looks good!" -def test_component_non_callable_fails(): - with pytest.raises(TypeError): - _ = to_html(t"<{'not a function'} />") - +@pytest.mark.parametrize("to_html", PROCESSORS) +class TestComponentErrors: + def test_component_non_callable_fails(self, to_html): + with pytest.raises(TypeError): + _ = to_html(t"<{'not a function'} />") -def RequiresPositional(whoops: int, /) -> Template: # pragma: no cover - return t"

    Positional arg: {whoops}

    " + def test_component_requiring_positional_arg_fails(self, to_html): + def RequiresPositional(whoops: int, /) -> Template: # pragma: no cover + return t"

    Positional arg: {whoops}

    " + with pytest.raises(TypeError): + _ = to_html(t"<{RequiresPositional} />") -def test_component_requiring_positional_arg_fails(): - with pytest.raises(TypeError): - _ = to_html(t"<{RequiresPositional} />") + def test_mismatched_component_closing_tag_fails(self, to_html): + def OpenTag(children: Template) -> Template: + return t"
    open
    " + def CloseTag(children: Template) -> Template: + return t"
    close
    " -def test_mismatched_component_closing_tag_fails(): - with pytest.raises(TypeError): - _ = to_html( - t"<{FunctionComponent} first=1 second={99} third-arg='comp1'>Hello" - ) + with pytest.raises(TypeError): + _ = to_html(t"<{OpenTag}>Hello") -def test_process_template_smoketest(): +@pytest.mark.parametrize("to_html", PROCESSORS) +def test_integration_basic(to_html): comment_text = "comment is not literal" interpolated_class = "red" text_in_element = "text is not literal" @@ -1587,7 +1574,7 @@ def WrapperComponent(children): {markup_content} """ - smoke_str = """ + smoke_str = """ @@ -1599,8 +1586,7 @@ def WrapperComponent(children):
    safe
    """ - process_api = processor_service_factory() - assert process_api.process_template(smoke_t) == smoke_str + assert to_html(smoke_t) == smoke_str def struct_repr(st): @@ -1613,13 +1599,14 @@ def struct_repr(st): ) +''' @pytest.mark.skip("Come back to this.") -def test_process_template_internal_cache(): - ''' +def test_process_template_internal_cache(to_html): """Test that cache and non-cache both generally work as expected.""" - sample_t = t"""
    {"content"}
    """ - sample_diff_t = t"""
    {"diffcontent"}
    """ - alt_t = t"""{"content"}""" + """ + 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() # Technically this could be the superclass which doesn't have cached method. @@ -1661,25 +1648,25 @@ def test_process_template_internal_cache(): # The template is new AND has a different structure so it also # produces an unequivalent tf. assert struct_repr(cached_tf1) != struct_repr(cached_tf4) - ''' + """ +''' -def test_process_template_repeated(): +@pytest.mark.parametrize("to_html", PROCESSORS) +def test_repeat_calls(to_html): """Crude check for any unintended state being kept between calls.""" def get_sample_t(idx, spread_attrs, button_text): return t"""
    """ - process_apis = (processor_service_factory(), cached_processor_service_factory()) - for process_api in process_apis: - for idx in range(3): - spread_attrs = {"data-enabled": True} - button_text = "PROCESS" - sample_t = get_sample_t(idx, spread_attrs, button_text) - assert ( - process_api.process_template(sample_t) - == f'
    ' - ) + for idx in range(3): + spread_attrs = {"data-enabled": True} + button_text = "PROCESS" + sample_t = get_sample_t(idx, spread_attrs, button_text) + assert ( + to_html(sample_t) + == f'
    ' + ) def get_select_t_with_list(options, selected_values): @@ -1712,6 +1699,7 @@ def get_select_t_with_concat(options, selected_values): return sum(parts, t"") +@pytest.mark.parametrize("to_html", PROCESSORS) @pytest.mark.parametrize( "provider", ( @@ -1720,9 +1708,7 @@ def get_select_t_with_concat(options, selected_values): get_select_t_with_concat, ), ) -def test_process_template_iterables(provider): - process_api = processor_service_factory() - +def test_process_template_iterables(to_html, provider): def get_color_select_t(selected_values: set, provider: t.Callable) -> Template: PRIMARY_COLORS = [("R", "Red"), ("Y", "Yellow"), ("B", "Blue")] assert set(selected_values).issubset(set([opt[0] for opt in PRIMARY_COLORS])) @@ -1730,17 +1716,18 @@ def get_color_select_t(selected_values: set, provider: t.Callable) -> Template: no_selection_t = get_color_select_t(set(), provider) assert ( - process_api.process_template(no_selection_t) + to_html(no_selection_t) == '' ) selected_yellow_t = get_color_select_t({"Y"}, provider) assert ( - process_api.process_template(selected_yellow_t) + to_html(selected_yellow_t) == '' ) -def test_process_template_components_smoketest(): +@pytest.mark.parametrize("to_html", PROCESSORS) +def test_component_integration(to_html): """Broadly test that common template component usage works.""" def PageComponent(children, root_attrs=None): @@ -1764,66 +1751,18 @@ def LayoutComponent(children, body_classes=None): """ - process_api = processor_service_factory() content = "HTML never goes out of style." - content_str = process_api.process_template( + content_str = to_html( t"<{LayoutComponent} body_classes={['theme-default']}><{PageComponent}>{content}" ) assert ( content_str - == """ + == """ - + - - - -
    HTML never goes out of style.
    - - - -""" - ) - - -def test_process_template_functions_smoketest(): - """Broadly test that common template function usage works.""" - - def make_page_t(content, root_attrs=None) -> Template: - return t"""
    {content}
    """ - - def make_footer_t(classes=("footer-default",)) -> Template: - return t'' - - def make_layout_t(body_t, body_classes=None) -> Template: - footer_t = make_footer_t() - return t""" - - - - - - - - {body_t} - {footer_t} - - -""" - - process_api = processor_service_factory() - content = "HTML never goes out of style." - layout_t = make_layout_t(make_page_t(content), "theme-default") - content_str = process_api.process_template(layout_t) - assert ( - content_str - == """ - - - - - +
    HTML never goes out of style.
    @@ -1834,6 +1773,7 @@ def make_layout_t(body_t, body_classes=None) -> Template: ) +@pytest.mark.parametrize("to_html", PROCESSORS) class TestInterpolatingHTMLInTemplateWithDynamicParentTag: """ When a template does not have a parent tag we cannot determine the type @@ -1842,99 +1782,99 @@ class TestInterpolatingHTMLInTemplateWithDynamicParentTag: interpolations in that text correctly. """ - def test_dynamic_raw_text(self): + def test_dynamic_raw_text(self, to_html): """Type raw text should fail because template is already not allowed.""" content = '' content_t = t"{content}" - process_api = processor_service_factory() with pytest.raises( ValueError, match="Recursive includes are not supported within script" ): - content_t = t'''''' - _ = process_api.process_template(t"") + content_t = t'' + _ = to_html(t"") - def test_dynamic_escapable_raw_text(self): + def test_dynamic_escapable_raw_text(self, to_html): """Type escapable raw text should fail because template is already not allowed.""" content = '' content_t = t"{content}" - process_api = processor_service_factory() with pytest.raises( ValueError, match="Recursive includes are not supported within textarea" ): - _ = process_api.process_template(t"") + _ = to_html(t"") - def test_dynamic_normal_text(self): + def test_dynamic_normal_text(self, to_html): """Escaping should be applied when normal text type is goes into effect.""" content = '' content_t = t"{content}" - process_api = processor_service_factory() LT, GT, DQ = map(markupsafe_escape, ["<", ">", '"']) assert ( - process_api.process_template(t"
    {content_t}
    ") + to_html(t"
    {content_t}
    ") == f"
    {LT}script{GT}console.log({DQ}123!{DQ});{LT}/script{GT}
    " ) -@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: 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_class_component(): - def paginate_url(page: int) -> str: - return f"/pages?page={page}" - - def Footer(pager, paginate_url, footer_classes=("footer",)) -> Template: - return t"
    <{PagerDisplay} pager={pager} paginate_url={paginate_url} />
    " - - pager = 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} />" - process_api = processor_service_factory() - res = process_api.process_template(content_t) - print(res) - assert ( - res - == '' - ) +@pytest.mark.parametrize("to_html", PROCESSORS) +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, to_html): + def paginate_url(page: int) -> str: + return f"/pages?page={page}" + + def Footer(pager, paginate_url, footer_classes=("footer",)) -> Template: + return t"
    <{self.PagerDisplay} pager={pager} paginate_url={paginate_url} />
    " + + 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 = to_html(content_t) + print(res) + assert ( + res + == '' + ) -def test_mathml(): +@pytest.mark.skip( + "SVG+MATHML: This needs ns context for case correcting tags and attributes." +) +@pytest.mark.parametrize("to_html", PROCESSORS) +def test_mathml(to_html): num = 1 denom = 3 mathml_t = t"""

    @@ -1947,8 +1887,7 @@ def test_mathml(): is not a decimal number.

    """ - process_api = processor_service_factory() - res = process_api.process_template(mathml_t) + res = to_html(mathml_t) assert ( str(res) == """

    @@ -1964,17 +1903,20 @@ def test_mathml(): ) -def test_svg(): +@pytest.mark.skip( + "SVG+MATHML: This needs ns context for case correcting tags and attributes." +) +@pytest.mark.parametrize("to_html", PROCESSORS) +def test_svg(to_html): cx, cy, r, fill = 150, 100, 80, "green" svg_t = t""" SVG """ - process_api = processor_service_factory() - res = process_api.process_template(svg_t) + res = to_html(svg_t) assert ( - str(res) + res == """ @@ -1983,18 +1925,18 @@ def test_svg(): ) -@pytest.mark.skip("""Need foreign element mode. Could work like last parent.""") -def test_svg_self_closing_empty_elements(): +@pytest.mark.skip("SVG+MATHML: This needs ns context for closing empty tags.") +@pytest.mark.parametrize("to_html", PROCESSORS) +def test_svg_self_closing_empty_elements(to_html): cx, cy, r, fill = 150, 100, 80, "green" svg_t = t""" SVG """ - process_api = processor_service_factory() - res = process_api.process_template(svg_t) + res = to_html(svg_t) assert ( - str(res) + res == """ From 808629586739546a9dc93da8cdf01015ae534e1a Mon Sep 17 00:00:00 2001 From: Ian Wilson Date: Sat, 21 Feb 2026 15:42:02 -0800 Subject: [PATCH 43/59] Replace duplicate tests with basic integration test. --- tdom/nodes/processor_test.py | 1510 +--------------------------------- 1 file changed, 38 insertions(+), 1472 deletions(-) diff --git a/tdom/nodes/processor_test.py b/tdom/nodes/processor_test.py index 69b51dd..6a2b569 100644 --- a/tdom/nodes/processor_test.py +++ b/tdom/nodes/processor_test.py @@ -1,1494 +1,60 @@ -from dataclasses import dataclass -import datetime -from string.templatelib import Interpolation, Template -from itertools import product -import typing as t -from collections.abc import Iterable, Callable - -import pytest -from markupsafe import Markup +from string.templatelib import Template from .processor import to_node -from .nodes import Element, Text, Fragment, DocumentType, Comment, Node -from ..placeholders import make_placeholder_config - - -def test_to_node(): - assert to_node(t"

    ") == Element("div") - - -def test_empty(): - node = to_node(t"") - assert node == Fragment(children=[]) - assert str(node) == "" - - -def test_text_literal(): - node = to_node(t"Hello, world!") - assert node == Text("Hello, world!") - assert str(node) == "Hello, world!" - - -def test_text_singleton(): - greeting = "Hello, Alice!" - node = to_node(t"{greeting}") - assert node == Text("Hello, Alice!") - assert str(node) == "Hello, Alice!" - - -def test_text_template(): - name = "Alice" - node = to_node(t"Hello, {name}!") - assert node == Fragment(children=[Text("Hello, "), Text("Alice"), Text("!")]) - assert str(node) == "Hello, Alice!" - - -def test_text_template_escaping(): - name = "Alice & Bob" - node = to_node(t"Hello, {name}!") - assert node == Fragment(children=[Text("Hello, "), Text("Alice & Bob"), Text("!")]) - assert str(node) == "Hello, Alice & Bob!" - +from .nodes import Element, Text, Fragment, DocumentType, Comment # -# Comments. +# @NOTE: Most of the processor tests are in the main process_test.py file. # -def test_comment(): - node = to_node(t"") - assert node == Comment("This is a comment") - assert str(node) == "" -def test_comment_empty(): - node = to_node(t"") - assert node == Comment("") - assert str(node) == "" - - -def test_comment_template(): - text = "comment" - node = to_node(t"") - assert node == Comment("This is a comment") - assert str(node) == "" - - -def test_comment_template_escaping(): - text = "-->comment" - node = to_node(t"") - assert node == Comment("This is a -->comment") - assert str(node) == "" - - -def test_comment_special_chars(): - node = to_node(t"") - assert node == Comment("Special chars: <>&\"'") - assert str(node) == "" - - -# -# Document types. -# -def test_parse_document_type(): - node = to_node(t"") - assert node == DocumentType("html") - assert str(node) == "" - - -# -# Elements -# -def test_parse_void_element(): - node = to_node(t"
    ") - assert node == Element("br") - assert str(node) == "
    " - - -def test_parse_void_element_self_closed(): - node = to_node(t"
    ") - assert node == Element("br") - assert str(node) == "
    " - - -def test_parse_chain_of_void_elements(): - # Make sure our handling of CPython issue #69445 is reasonable. - node = to_node(t"



    ") - assert node == Fragment( - children=[ - Element("br"), - Element("hr"), - Element("img", attrs={"src": "image.png"}), - Element("br"), - Element("hr"), - ], - ) - assert str(node) == '



    ' - - -def test_parse_element_with_text(): - node = to_node(t"

    Hello, world!

    ") - assert node == Element( - "p", - children=[ - Text("Hello, world!"), - ], - ) - assert str(node) == "

    Hello, world!

    " - - -def test_parse_nested_elements(): - node = to_node(t"

    Hello

    World

    ") - assert node == Element( - "div", - children=[ - Element("p", children=[Text("Hello")]), - Element("p", children=[Text("World")]), - ], - ) - assert str(node) == "

    Hello

    World

    " - - -def test_parse_entities_are_escaped(): - node = to_node(t"

    </p>

    ") - assert node == Element( - "p", - children=[Text("

    ")], - ) - assert str(node) == "

    </p>

    " - - -# -------------------------------------------------------------------------- -# Interpolated text content -# -------------------------------------------------------------------------- - - -def test_interpolated_text_content(): - name = "Alice" - node = to_node(t"

    Hello, {name}!

    ") - assert node == Element("p", children=[Text("Hello, "), Text("Alice"), Text("!")]) - assert str(node) == "

    Hello, Alice!

    " - - -def test_escaping_of_interpolated_text_content(): - name = "" - node = to_node(t"

    Hello, {name}!

    ") - assert node == Element( - "p", children=[Text("Hello, "), Text(""), Text("!")] - ) - assert str(node) == "

    Hello, <Alice & Bob>!

    " +def test_to_node_integration(): + """Test all the major elements with nodes.""" + title = "Test Title" + url_path = "about" + content = "About" + extra_kwargs = {"id": "my-link"} + def simple_comp(label: str, children: Template) -> Template: + return t"
    {label}: {children}
    Tail" -class Convertible: - def __str__(self): - return "string" - - def __repr__(self): - return "repr" - - -def test_conversions(): - c = Convertible() - assert f"{c!s}" == "string" - assert f"{c!r}" == "repr" - node = to_node(t"
  • {c!s}
  • {c!r}
  • {'😊'!a}
  • ") - assert node == Fragment( - children=[ - Element("li", children=[Text("string")]), - Element("li", children=[Text("repr")]), - Element("li", children=[Text("'\\U0001f60a'")]), - ], - ) - - -def test_interpolated_in_content_node(): - from ..escaping import escape_html_style - - # https://github.com/t-strings/tdom/issues/68 - evil = "") - assert node == Element( - "style", - children=[ - Text( - Markup( - escape_html_style( - "" - ) - - -def test_interpolated_trusted_in_content_node(): - # https://github.com/t-strings/tdom/issues/68 - from ..escaping import escape_html_script - - node = to_node(t"") - assert node == Element( - "script", - children=[ - Text(Markup(escape_html_script("if (a < b && c > d) { alert('wow'); }"))) - ], - ) - assert str(node) == ("") - - -def test_script_elements_error(): - nested_template = t"
    " - # Putting non-text content inside a script is not allowed. - with pytest.raises(ValueError): - node = to_node(t"") - _ = str(node) - - -# -------------------------------------------------------------------------- -# Interpolated non-text content -# -------------------------------------------------------------------------- - - -def test_interpolated_false_content(): - node = to_node(t"
    {False}
    ") - assert node == Element("div", children=[Text("False")]) - assert str(node) == "
    False
    " - - -def test_interpolated_none_content(): - node = to_node(t"
    {None}
    ") - assert node == Element("div", children=[]) - assert str(node) == "
    " - - -def test_interpolated_zero_arg_function(): - def get_value(): - return "dynamic" - - node = to_node(t"

    The value is {get_value:callback}.

    ") - assert node == Element( - "p", children=[Text("The value is "), Text("dynamic"), Text(".")] - ) - - -def test_interpolated_multi_arg_function_fails(): - def add(a, b): # pragma: no cover - return a + b - - with pytest.raises(TypeError): - _ = to_node(t"

    The sum is {add:callback}.

    ") - - -# -------------------------------------------------------------------------- -# Raw HTML injection tests -# -------------------------------------------------------------------------- - - -def test_raw_html_injection_with_markupsafe(): - raw_content = Markup("I am bold") - node = to_node(t"
    {raw_content}
    ") - assert node == Element("div", children=[Text(text=raw_content)]) - assert str(node) == "
    I am bold
    " - - -def test_raw_html_injection_with_dunder_html_protocol(): - class SafeContent: - def __init__(self, text): - self._text = text - - def __html__(self): - # In a real app, this would come from a sanitizer or trusted source - return f"{self._text}" - - content = SafeContent("emphasized") - node = to_node(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 = to_node(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 = to_node(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.

    " - - -# -------------------------------------------------------------------------- -# Conditional rendering and control flow -# -------------------------------------------------------------------------- - - -def test_conditional_rendering_with_if_else(): - is_logged_in = True - user_profile = t"Welcome, User!" - login_prompt = t"Please log in" - node = to_node(t"
    {user_profile if is_logged_in else login_prompt}
    ") - - assert node == Element( - "div", children=[Element("span", children=[Text("Welcome, User!")])] - ) - assert str(node) == "
    Welcome, User!
    " - - is_logged_in = False - node = to_node(t"
    {user_profile if is_logged_in else login_prompt}
    ") - assert str(node) == '' - - -# -------------------------------------------------------------------------- -# Interpolated nesting of templates and elements -# -------------------------------------------------------------------------- - - -def test_interpolated_template_content(): - child = t"Child" - node = to_node(t"
    {child}
    ") - assert node == Element("div", children=[to_node(child)]) - assert str(node) == "
    Child
    " - - -@pytest.mark.skip("We only handle nodes at the end of the pipeline.") -def test_interpolated_element_content(): - child = to_node(t"Child") - node = to_node(t"
    {child}
    ") - assert node == Element("div", children=[child]) - assert str(node) == "
    Child
    " - - -def test_interpolated_nonstring_content(): - number = 42 - node = to_node(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 = to_node(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) == "
    • Apple
    • Banana
    • Cherry
    " - - -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}
      {inner_items}
  • " for category in outer] - node = to_node(t"
      {outer_items}
    ") - 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) - == "
    • fruit
      • apple
      • banana
      • cherry
    • more fruit
      • apple
      • banana
      • cherry
    " - ) - - -# -------------------------------------------------------------------------- -# Attributes -# -------------------------------------------------------------------------- - - -def test_literal_attrs(): node = to_node( ( - t"" - ) - ) - assert node == Element( - "a", - attrs={ - "id": "example_link", - "autofocus": None, - "title": "", - "href": "https://example.com", - "target": "_blank", - }, - ) - assert ( - str(node) - == '' - ) - - -def test_literal_attr_escaped(): - node = to_node(t'') - assert node == Element( - "a", - attrs={"title": "<"}, - ) - assert str(node) == '' - - -def test_interpolated_attr(): - url = "https://example.com/" - node = to_node(t'') - assert node == Element("a", attrs={"href": "https://example.com/"}) - assert str(node) == '' - - -def test_interpolated_attr_escaped(): - url = 'https://example.com/?q="test"&lang=en' - node = to_node(t'') - assert node == Element( - "a", - attrs={"href": 'https://example.com/?q="test"&lang=en'}, - ) - assert ( - str(node) == '' - ) - - -def test_interpolated_attr_unquoted(): - id = "roquefort" - node = to_node(t"
    ") - assert node == Element("div", attrs={"id": "roquefort"}) - assert str(node) == '
    ' - - -def test_interpolated_attr_true(): - disabled = True - node = to_node(t"") - assert node == Element("button", attrs={"disabled": None}) - assert str(node) == "" - - -def test_interpolated_attr_false(): - disabled = False - node = to_node(t"") - assert node == Element("button") - assert str(node) == "" - - -def test_interpolated_attr_none(): - disabled = None - node = to_node(t"") - assert node == Element("button") - assert str(node) == "" - - -def test_interpolate_attr_empty_string(): - node = to_node(t'
    ') - assert node == Element( - "div", - attrs={"title": ""}, - ) - assert str(node) == '
    ' - - -def test_spread_attr(): - attrs = {"href": "https://example.com/", "target": "_blank"} - node = to_node(t"") - assert node == Element( - "a", - attrs={"href": "https://example.com/", "target": "_blank"}, - ) - assert str(node) == '' - - -def test_spread_attr_none(): - attrs = None - node = to_node(t"") - assert node == Element("a") - assert str(node) == "" - - -def test_spread_attr_type_errors(): - for attrs in (0, [], (), False, True): - with pytest.raises(TypeError): - _ = to_node(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 = to_node(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 = to_node(t"
    ") - assert node == Element( - "div", - attrs={"data-range": "1-5"}, - ) - assert str(node) == '
    ' - - -def test_attr_merge_disjoint_interpolated_attr_spread_attr(): - attrs = {"href": "https://example.com/", "id": "link1"} - target = "_blank" - node = to_node(t"") - assert node == Element( - "a", - attrs={"href": "https://example.com/", "id": "link1", "target": "_blank"}, - ) - assert str(node) == '' - - -def test_attr_merge_overlapping_spread_attrs(): - attrs1 = {"href": "https://example.com/", "id": "overwrtten"} - attrs2 = {"target": "_blank", "id": "link1"} - node = to_node(t"") - assert node == Element( - "a", - attrs={"href": "https://example.com/", "target": "_blank", "id": "link1"}, - ) - assert str(node) == '' - - -def test_attr_merge_replace_literal_attr_str_str(): - node = to_node(t'
    ') - assert node == Element("div", {"title": "fresh"}) - assert str(node) == '
    ' - - -def test_attr_merge_replace_literal_attr_str_true(): - node = to_node(t'
    ') - assert node == Element("div", {"title": None}) - assert str(node) == "
    " - - -def test_attr_merge_replace_literal_attr_true_str(): - node = to_node(t"
    ") - assert node == Element("div", {"title": "fresh"}) - assert str(node) == '
    ' - - -def test_attr_merge_remove_literal_attr_str_none(): - node = to_node(t'
    ') - assert node == Element("div") - assert str(node) == "
    " - - -def test_attr_merge_remove_literal_attr_true_none(): - node = to_node(t"
    ") - assert node == Element("div") - assert str(node) == "
    " - - -def test_attr_merge_other_literal_attr_intact(): - node = to_node(t'') - assert node == Element("img", {"title": "default", "alt": "fresh"}) - assert str(node) == 'fresh' - - -def test_placeholder_collision_avoidance(): - config = make_placeholder_config() - # This test is to ensure that our placeholder detection avoids collisions - # even with content that might look like a placeholder. - tricky = "0" - template = Template( - f'
    ', - ) - node = to_node(template) - assert node == Element( - "div", - attrs={"data-tricky": config.prefix + tricky + config.suffix}, - children=[], - ) - assert ( - str(node) == f'
    ' - ) - - -# -# Special data attribute handling. -# -def test_interpolated_data_attributes(): - data = {"user-id": 123, "role": "admin", "wild": True, "false": False, "none": None} - node = to_node(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
    ' - ) - - -def test_data_attr_toggle_to_str(): - for node in [ - to_node(t"
    "), - to_node(t'
    '), - ]: - assert node == Element("div", {"data-selected": "yes"}) - assert str(node) == '
    ' - - -def test_data_attr_toggle_to_true(): - node = to_node(t'
    ') - assert node == Element("div", {"data-selected": None}) - assert str(node) == "
    " - - -def test_data_attr_unrelated_unaffected(): - node = to_node(t"
    ") - assert node == Element("div", {"data-selected": None, "data-active": None}) - assert str(node) == "
    " - - -def test_data_attr_templated_error(): - data1 = {"user-id": "user-123"} - data2 = {"role": "admin"} - with pytest.raises(TypeError): - node = to_node(t'
    ') - print(str(node)) - - -def test_data_attr_none(): - button_data = None - node = to_node(t"") - assert node == Element("button", children=[Text("X")]) - assert str(node) == "" - - -def test_data_attr_errors(): - for v in [False, [], (), 0, "data?"]: - with pytest.raises(TypeError): - _ = to_node(t"") - - -def test_data_literal_attr_bypass(): - # Trigger overall attribute resolution with an unrelated interpolated attr. - node = to_node(t'

    ') - assert node == Element( - "p", - attrs={"data": "passthru", "id": "resolved"}, - ), "A single literal attribute should not trigger data expansion." - - -# -# Special aria attribute handling. -# -def test_aria_templated_attr_error(): - aria1 = {"label": "close"} - aria2 = {"hidden": "true"} - with pytest.raises(TypeError): - node = to_node(t'
    ') - print(str(node)) - - -def test_aria_interpolated_attr_dict(): - aria = {"label": "Close", "hidden": True, "another": False, "more": None} - node = to_node(t"") - assert node == Element( - "button", - attrs={"aria-label": "Close", "aria-hidden": "true", "aria-another": "false"}, - children=[Text("X")], - ) - assert ( - str(node) - == '' - ) - - -def test_aria_interpolate_attr_none(): - button_aria = None - node = to_node(t"") - assert node == Element("button", children=[Text("X")]) - assert str(node) == "" - - -def test_aria_attr_errors(): - for v in [False, [], (), 0, "aria?"]: - with pytest.raises(TypeError): - _ = to_node(t"") - - -def test_aria_literal_attr_bypass(): - # Trigger overall attribute resolution with an unrelated interpolated attr. - node = to_node(t'

    ') - assert node == Element( - "p", - attrs={"aria": "passthru", "id": "resolved"}, - ), "A single literal attribute should not trigger aria expansion." - - -# -# 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"" - ) - node = to_node(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) - == '' - ) - - -def test_interpolated_class_attribute_with_multiple_placeholders(): - classes1 = ["btn", "btn-primary"] - classes2 = [False and "disabled", None, {"active": True}] - node = to_node(t'') - # 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")], - ) - - -def test_interpolated_attribute_spread_with_class_attribute(): - attrs = {"id": "button1", "class": ["btn", "btn-primary"]} - node = to_node(t"") - assert node == Element( - "button", - attrs={"id": "button1", "class": "btn btn-primary"}, - children=[Text("Click me")], - ) - assert str(node) == '' - - -def test_class_literal_attr_bypass(): - # Trigger overall attribute resolution with an unrelated interpolated attr. - node = to_node(t'

    ') - assert node == Element( - "p", - attrs={"class": "red red", "id": "veryred"}, - ), "A single literal attribute should not trigger class accumulator." - - -def test_class_none_ignored(): - class_item = None - node = to_node(t"

    ") - assert node == Element("p") - # Also ignored inside a sequence. - node = to_node(t"

    ") - assert node == Element("p") - - -def test_class_type_errors(): - for class_item in (False, True, 0): - with pytest.raises(TypeError): - _ = to_node(t"

    ") - with pytest.raises(TypeError): - _ = to_node(t"

    ") - - -def test_class_merge_literals(): - node = to_node(t'

    ') - assert node == Element("p", {"class": "red blue"}) - - -def test_class_merge_literal_then_interpolation(): - class_item = "blue" - node = to_node(t'

    ') - assert node == Element("p", {"class": "red blue"}) - - -# -# Special style attribute handling. -# -def test_style_literal_attr_passthru(): - p_id = "para1" # non-literal attribute to cause attr resolution - node = to_node(t'

    Warning!

    ') - assert node == Element( - "p", - attrs={"style": "color: red", "id": "para1"}, - children=[Text("Warning!")], - ) - assert str(node) == '

    Warning!

    ' - - -def test_style_in_interpolated_attr(): - styles = {"color": "red", "font-weight": "bold", "font-size": "16px"} - node = to_node(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_style_in_templated_attr(): - color = "red" - node = to_node(t'

    Warning!

    ') - assert node == Element( - "p", - attrs={"style": "color: red"}, - children=[Text("Warning!")], - ) - assert str(node) == '

    Warning!

    ' - - -def test_style_in_spread_attr(): - attrs = {"style": {"color": "red"}} - node = to_node(t"

    Warning!

    ") - assert node == Element( - "p", - attrs={"style": "color: red"}, - children=[Text("Warning!")], - ) - assert str(node) == '

    Warning!

    ' - - -def test_style_merged_from_all_attrs(): - attrs = dict(style="font-size: 15px") - style = {"font-weight": "bold"} - color = "red" - node = to_node( - t'

    ' - ) - assert node == Element( - "p", - {"style": "font-family: serif; color: red; font-weight: bold; font-size: 15px"}, - ) - assert ( - str(node) - == '

    ' - ) - - -def test_style_override_left_to_right(): - suffix = t">

    " - 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): - _ = to_node(t"

    Warning!

    ") - - -def test_interpolated_style_attribute_merged(): - styles1 = {"color": "red"} - styles2 = {"font-weight": "bold"} - node = to_node(t"

    Warning!

    ") - assert node == Element( - "p", - attrs={"style": "color: red; font-weight: bold"}, - children=[Text("Warning!")], - ) - assert str(node) == '

    Warning!

    ' - - -def test_interpolated_style_attribute_merged_override(): - styles1 = {"color": "red", "font-weight": "normal"} - styles2 = {"font-weight": "bold"} - node = to_node(t"

    Warning!

    ") - assert node == Element( - "p", - attrs={"style": "color: red; font-weight: bold"}, - children=[Text("Warning!")], - ) - assert str(node) == '

    Warning!

    ' - - -def test_style_attribute_str(): - styles = "color: red; font-weight: bold;" - node = to_node(t"

    Warning!

    ") - assert node == Element( - "p", - attrs={"style": "color: red; font-weight: bold"}, - children=[Text("Warning!")], - ) - assert str(node) == '

    Warning!

    ' - - -def test_style_attribute_non_str_non_dict(): - with pytest.raises(TypeError): - styles = [1, 2] - _ = to_node(t"

    Warning!

    ") - - -def test_style_literal_attr_bypass(): - # Trigger overall attribute resolution with an unrelated interpolated attr. - node = to_node(t'

    ') - assert node == Element( - "p", - attrs={"style": "invalid;invalid:", "id": "resolved"}, - ), "A single literal attribute should bypass style accumulator." - - -def test_style_none(): - styles = None - node = to_node(t"

    ") - assert node == Element("p") - - -# -------------------------------------------------------------------------- -# Function component interpolation tests -# -------------------------------------------------------------------------- - - -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_interpolated_template_component(): - node = to_node( - t'<{FunctionComponent} first=1 second={99} third-arg="comp1" class="my-comp">Hello, Component!' - ) - 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!
    ' - ) - - -def test_interpolated_template_component_no_children_provided(): - """Same test, but the caller didn't provide any children.""" - node = to_node( - 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_invalid_component_invocation(): - with pytest.raises(TypeError): - _ = to_node(t"<{FunctionComponent}>Missing props") - - -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(): - node = to_node( - t'<{FunctionComponentNoChildren} first=1 second={99} third-arg="comp1">Hello, Component!' - ) - 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
    ' - ) - - -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_children_always_passed_via_kwargs(): - node = to_node( - t'<{FunctionComponentKeywordArgs} first="value" extra="info">Child content' - ) - assert node == Element( - "div", - attrs={ - "data-first": "value", - "extra": "info", - }, - children=[Text("Component with kwargs")], - ) - assert ( - str(node) == '
    Component with kwargs
    ' - ) - - -def test_children_always_passed_via_kwargs_even_when_empty(): - node = to_node(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 ColumnsComponent() -> Template: - return t"""Column 1Column 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 = to_node(t"<{ColumnsComponent} />
    ") - assert node == Element( - "table", - children=[ - Element( - "tr", - children=[ - Element("td", children=[Text("Column 1")]), - Element("td", children=[Text("Column 2")]), - ], - ), - ], - ) - assert str(node) == "
    Column 1Column 2
    " - - -def test_component_passed_as_attr_value(): - def Wrapper( - children: Template, sub_component: Callable, **attrs: t.Any - ) -> Template: - return t"<{sub_component} {attrs}>{children}" - - node = to_node( - t'<{Wrapper} sub-component={FunctionComponent} class="wrapped" first=1 second={99} third-arg="comp1">

    Inside 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() -> Template: - return t"{'Hello World'}" - - node = to_node(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 t"
  • Item {3}
  • " - - def ItemsComponent() -> Template: - # Wrap in Template - return t"{Items()}" - - node = to_node(t"
      <{ItemsComponent} />
    ") - 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) == "
    • Item 1
    • Item 2
    • Item 3
    " - - -def test_component_returning_fragment(): - def Items() -> Template: - return t"
  • Item {1}
  • Item {2}
  • Item {3}
  • " - - node = to_node(t"
      <{Items} />
    ") - 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) == "
    • Item 1
    • Item 2
    • Item 3
    " - - -@dataclass -class ClassComponent: - """Example class-based component.""" - - user_name: str - image_url: str - children: Template = t"" - homepage: str = "#" - - def __call__(self) -> Template: - return ( - t"
    " - t"" - t"{f" - t"" - t"{self.user_name}" - t"{self.children}" - t"
    " + t"" + t"" + t"" + t"

    >
    " + t"
    {[0, 1]}
    " + t'{content}' + t'<{simple_comp} label="The Children">Child0Child1' ) - - -def test_class_component_implicit_invocation_with_children(): - node = to_node( - t"<{ClassComponent} user-name='Alice' image-url='https://example.com/alice.png'>Fun times!" - ) - 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) - == '
    Avatar of AliceAliceFun 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 = to_node(t"<{avatar} />") - assert node == Element( - "div", - attrs={"class": "avatar"}, + assert node == Fragment( children=[ + DocumentType("html"), + Comment(" comment "), + Element("script", children=[Text("var x = 1;")]), + Element("div", children=[Element("br"), Text(">")]), + Element("div", children=[Text("0"), Text("1")]), Element( "a", - attrs={"href": "https://example.com/users/alice"}, - children=[ - Element( - "img", - attrs={ - "src": "https://example.com/alice.png", - "alt": "Avatar of Alice", - }, - ) - ], + attrs={ + "class": "red", + "title": title, + "href": f"/{url_path}", + "id": "my-link", + }, + children=[Text(content)], ), - Element("span", children=[Text("Alice")]), - ], - ) - assert ( - str(node) - == '
    Avatar of AliceAlice
    ' - ) - - -@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"{f" - t"" - t"{self.user_name}" - t"ignore children" - t"
    " - ) - - -def test_class_component_implicit_invocation_ignore_children(): - node = to_node( - t"<{ClassComponentNoChildren} user-name='Alice' image-url='https://example.com/alice.png'>Fun times!" - ) - assert node == Element( - "div", - attrs={"class": "avatar"}, - children=[ Element( - "a", - attrs={"href": "#"}, + "section", children=[ - Element( - "img", - attrs={ - "src": "https://example.com/alice.png", - "alt": "Avatar of Alice", - }, - ) + Text("The Children"), + Text(": "), + Element("span", children=[Text("Child0")]), + Element("span", children=[Text("Child1")]), ], ), - Element("span", children=[Text("Alice")]), - Text("ignore children"), - ], - ) - assert ( - str(node) - == '
    Avatar of AliceAliceignore children
    ' - ) - - -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!" - - -def test_attribute_type_component(): - an_int: int = 42 - a_true: bool = True - a_false: bool = False - a_none: None = None - a_float: float = 3.14 - a_dt: datetime.datetime = datetime.datetime(2024, 1, 1, 12, 0, 0) - spread_attrs: dict[str, object | None] = { - "spread_true": True, - "spread_false": False, - "spread_none": None, - "spread_int": 0, - "spread_float": 0.0, - "spread_dt": datetime.datetime(2024, 1, 1, 12, 0, 1), - "spread_dict": dict(), - "spread_list": ["eggs", "milk"], - } - node = to_node( - 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}/>" + Element("span", children=[Text("Tail")]), + ] ) - assert node == Text("Looks good!") - assert str(node) == "Looks good!" - - -def test_component_non_callable_fails(): - with pytest.raises(TypeError): - _ = to_node(t"<{'not a function'} />") - - -def RequiresPositional(whoops: int, /) -> Template: # pragma: no cover - return t"

    Positional arg: {whoops}

    " - - -def test_component_requiring_positional_arg_fails(): - with pytest.raises(TypeError): - _ = to_node(t"<{RequiresPositional} />") - - -def test_mismatched_component_closing_tag_fails(): - with pytest.raises(TypeError): - _ = to_node( - t"<{FunctionComponent} first=1 second={99} third-arg='comp1'>Hello" - ) From 62b60cdc0d1989cc5df49679350b0601484661e4 Mon Sep 17 00:00:00 2001 From: Ian Wilson Date: Sat, 21 Feb 2026 17:10:15 -0800 Subject: [PATCH 44/59] Drop stream naming in favor of process, fixup return types, only act on non-none returns. --- tdom/processor.py | 56 +++++++++++++++++++---------------------------- 1 file changed, 23 insertions(+), 33 deletions(-) diff --git a/tdom/processor.py b/tdom/processor.py index 68b4b75..fe59c1e 100644 --- a/tdom/processor.py +++ b/tdom/processor.py @@ -551,17 +551,13 @@ def walk_from_tnode( for part in iter(ref) ] ) - bf.append("") 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._stream_component( + res = self._process_component( bf, template, last_ctx, attrs, start_i_index, end_i_index ) if res is not None: @@ -570,9 +566,7 @@ def walk_from_tnode( bf.append(f"<{tag}") our_ctx = last_ctx.copy(parent_tag=tag) if attrs: - res = self._stream_attrs(bf, template, our_ctx, attrs) - if res is not None: - yield res + self._process_attrs(bf, 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: @@ -608,24 +602,18 @@ def walk_from_tnode( bf.append(self.escape_html_text(ref.strings[0])) elif last_ctx.parent_tag in CDATA_CONTENT_ELEMENTS: # Must be handled all at once. - res = self._stream_raw_texts(bf, template, last_ctx, text_t) - if res is not None: - yield res + self._process_raw_texts(bf, template, last_ctx, text_t) 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. - res = self._stream_escapable_raw_texts( + self._process_escapable_raw_texts( bf, template, last_ctx, text_t ) - if res is not None: - yield res else: - # Flatten the template back out into the stream because each interpolation can - # be escaped as is and structured content can be injected between text anyways. for part in text_t: if isinstance(part, str): bf.append(part) else: - res = self._stream_normal_text( + res = self._process_normal_text( bf, template, last_ctx, part.value ) if res is not None: @@ -633,17 +621,17 @@ def walk_from_tnode( case _: raise ValueError(f"Unrecognized tnode: {tnode}") - def _stream_comment( + def _process_comment( self, bf: list[str], template: Template, last_ctx: ProcessContext, text_t: Template, ) -> None: - assert last_ctx.parent_tag == "") - def _stream_attrs( + def _process_attrs( self, bf: list[str], template: Template, last_ctx: ProcessContext, attrs: tuple[TAttribute, ...], - ): + ) -> None: resolved_attrs = _resolve_t_attrs(attrs, template.interpolations) attrs_str = serialize_html_attrs(_resolve_html_attrs(resolved_attrs)) - bf.append(attrs_str) + if attrs_str: + bf.append(attrs_str) - def _stream_component( + def _process_component( self, bf: list[str], template: Template, @@ -671,7 +661,7 @@ def _stream_component( attrs: tuple[TAttribute, ...], start_i_index: int, end_i_index: int | None, - ): + ) -> None | WalkerProto: body_start_s_index = ( start_i_index + 1 @@ -723,7 +713,7 @@ def _stream_component( else: raise ValueError(f"Unknown component return value: {type(result_t)}") - def _stream_raw_texts( + def _process_raw_texts( self, bf: list[str], template: Template, @@ -753,7 +743,7 @@ def _stream_raw_texts( f"Parent tag {last_ctx.parent_tag} is not supported." ) - def _stream_escapable_raw_texts( + def _process_escapable_raw_texts( self, bf: list[str], template: Template, @@ -771,7 +761,7 @@ def _stream_escapable_raw_texts( ) ) - def _stream_normal_text( + def _process_normal_text( self, bf: list[str], template: Template, @@ -786,7 +776,7 @@ def _stream_normal_text( return self.walk_from_tnode(bf, value, last_ctx, value_root) elif isinstance(value, Iterable): return iter( - self._stream_normal_text_from_value(bf, template, last_ctx, v) + self._process_normal_text_from_value(bf, template, last_ctx, v) for v in value ) elif value is None: @@ -797,7 +787,7 @@ def _stream_normal_text( # coerced to a str() and emitted. bf.append(self.escape_html_text(value)) - def _stream_normal_text_from_value( + def _process_normal_text_from_value( self, bf: list[str], template: Template, @@ -811,7 +801,7 @@ def _stream_normal_text_from_value( return self.walk_from_tnode(bf, value, last_ctx, value_root) elif isinstance(value, Iterable): return iter( - self._stream_normal_text_from_value(bf, template, last_ctx, v) + self._process_normal_text_from_value(bf, template, last_ctx, v) for v in value ) elif value is None: From 6a309baf2e90c97b787c20ec3a85d8e1cbee52b4 Mon Sep 17 00:00:00 2001 From: Ian Wilson Date: Sat, 21 Feb 2026 21:54:33 -0800 Subject: [PATCH 45/59] Context changes inside method so don't alter it before. --- tdom/processor.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tdom/processor.py b/tdom/processor.py index fe59c1e..fec44a3 100644 --- a/tdom/processor.py +++ b/tdom/processor.py @@ -551,9 +551,7 @@ def walk_from_tnode( for part in iter(ref) ] ) - self._process_comment( - bf, template, last_ctx.copy(parent_tag="