diff --git a/README.md b/README.md index 2265d27..cfef056 100644 --- a/README.md +++ b/README.md @@ -42,9 +42,7 @@ T-strings work just like f-strings but use a `t` prefix and instead of strings. Once you have a `Template`, you can call this package's `html()` function to -convert it into a tree of `Node` objects that represent your HTML structure. -From there, you can render it to a string, manipulate it programmatically, or -compose it with other templates for maximum flexibility. +convert it into a str. ### Getting Started @@ -53,7 +51,6 @@ Import the `html` function and start creating templates: ```python from tdom import html greeting = html(t"
Important text
') -assert str(para) == 'Important text
' +assert para == 'Important text
' ``` #### The `data` and `aria` Attributes @@ -312,18 +309,6 @@ page = html(t"Card content
{Card}>") #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) == '

Hello, world!
") - assert node == Element( - "p", - children=[ - Text("Hello, world!"), - ], - ) - assert str(node) == "Hello, world!
" + # + # Singleton / Exact Match + # + def test_singleton_str(self, to_html): + text = "This is a comment" + assert to_html(t"") == "" + def test_singleton_object(self, to_html): + assert to_html(t"") == "" -def test_parse_nested_elements(): - node = html(t"Hello
World
Hello
World
</p>
") - assert node == Element( - "p", - children=[Text("")], - ) - assert str(node) == "</p>
" + 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, to_html): + text = "-->comment" + assert to_html(t"") == "" + + # + # Templated -- literal text mixed with interpolation(s) + # + def test_templated_str(self, to_html): + text = "comment" + assert to_html(t"") == "" + + def test_templated_object(self, to_html): + assert to_html(t"") == "" + + def test_templated_none(self, to_html): + assert to_html(t"") == "" + + 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"): + _ = 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, to_html): + text = "comment" + assert ( + to_html(t"") + == "" + ) -# -------------------------------------------------------------------------- -# Interpolated text content -# -------------------------------------------------------------------------- + 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 + # might be something better for this. + text = "-->comment" + assert to_html(t"") == "" + 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_interpolated_text_content(): - name = "Alice" - node = html(t"Hello, {name}!
") - assert node == Element("p", children=[Text("Hello, "), Text("Alice"), Text("!")]) - assert str(node) == "Hello, Alice!
" + 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"") -def test_escaping_of_interpolated_text_content(): - name = "Hello, {name}!
") - assert node == Element( - "p", children=[Text("Hello, "), Text("Hello, <Alice & Bob>!
" +@pytest.mark.parametrize("to_html", PROCESSORS) +class TestDocumentType: + def test_literal(self, to_html): + assert to_html(t"") == "" -class Convertible: - def __str__(self): - return "string" +@pytest.mark.parametrize("to_html", PROCESSORS) +class TestVoidElementLiteral: + def test_void(self, to_html): + assert to_html(t"

Hello, world!
") == "Hello, world!
" + def test_nested_elements(self, to_html): + assert ( + to_html(t"Hello
World
Hello
World
</p>
") + assert res == "</p>
", res -def test_interpolated_none_content(): - node = html(t"{None}
") == "" -def test_interpolated_zero_arg_function(): - def get_value(): - return "dynamic" + def test_singleton_str(self, to_html): + name = "Alice" + assert to_html(t"{name}
") == "Alice
" - node = html(t"The value is {get_value}.
") - assert node == Element( - "p", children=[Text("The value is "), Text("dynamic"), Text(".")] - ) + def test_singleton_object(self, to_html): + assert to_html(t"{0}
") == "0
" + def test_singleton_has_dunder_html(self, to_html): + content = LiteralHTML("Alright!") + assert to_html(t"{content}
") == "Alright!
" -def test_interpolated_multi_arg_function_fails(): - def add(a, b): # pragma: no cover - return a + b + def test_singleton_simple_template(self, to_html): + name = "Alice" + text_t = t"Hi {name}" + assert to_html(t"{text_t}
") == "Hi Alice
" - with pytest.raises(TypeError): - _ = html(t"The sum is {add}.
") + 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, to_html): + text = '''<>&'"''' + assert to_html(t"{text}
") == "<>&'"
" -# -------------------------------------------------------------------------- -# Raw HTML injection tests -# -------------------------------------------------------------------------- + def test_templated_None(self, to_html): + assert to_html(t"Response: {None}.
") == "Response: .
" + def test_templated_str(self, to_html): + name = "Alice" + assert to_html(t"Response: {name}.
") == "Response: Alice.
" -def test_raw_html_injection_with_markupsafe(): - raw_content = Markup("I am bold") - node = html(t"Response: {0}.
") == "Response: 0.
" + def test_templated_has_dunder_html(self, to_html): + text = LiteralHTML("Alright!") + assert ( + to_html(t"Response: {text}.
") + == "Response: Alright!.
" + ) -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 = html(t"Here is some {content}.
") - assert node == Element( - "p", - children=[ - Text("Here is some "), - Text(Markup("emphasized")), - Text("."), - ], - ) - assert str(node) == "Here is some emphasized.
" - - -def test_raw_html_injection_with_format_spec(): - raw_content = "underlined" - node = html(t"This is {raw_content:safe} text.
") - assert node == Element( - "p", - children=[ - Text("This is "), - Text(Markup(raw_content)), - Text(" text."), - ], - ) - assert str(node) == "This is underlined text.
" - - -def test_raw_html_injection_with_markupsafe_unsafe_format_spec(): - supposedly_safe = Markup("italic") - node = html(t"This is {supposedly_safe:unsafe} text.
") - assert node == Element( - "p", - children=[ - Text("This is "), - Text(str(supposedly_safe)), - Text(" text."), - ], - ) - assert str(node) == "This is <i>italic</i> text.
" - + def test_templated_simple_template(self, to_html): + name = "Alice" + text_t = t"Hi {name}" + assert to_html(t"Response: {text_t}.
") == "Response: Hi Alice.
" -# -------------------------------------------------------------------------- -# Conditional rendering and control flow -# -------------------------------------------------------------------------- + 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, to_html): + text = '''<>&'"''' + assert ( + to_html(t"Response: {text}.
") + == "Response: <>&'".
" + ) -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"The literal has < in it: {text}.
") + == "The literal has < in it: This text is fine.
" + ) - assert node == Element( - "div", children=[Element("span", children=[Text("Welcome, User!")])] - ) - assert str(node) == "The answer is {number}.
") - assert node == Element( - "p", children=[Text("The answer is "), Text("42"), Text(".")] - ) - assert str(node) == "The answer is 42.
" - - -def test_list_items(): - items = ["Apple", "Banana", "Cherry"] - node = html(t"This is {raw_content:safe} text.
") + == "This is underlined text.
" + ) -def test_data_literal_attr_bypass(): - # Trigger overall attribute resolution with an unrelated interpolated attr. - node = html(t'') - assert node == Element( - "p", - attrs={"data": "passthru", "id": "resolved"}, - ), "A single literal attribute should not trigger data expansion." + def test_raw_text_safe(self, to_html): + # @TODO: What should even happen here? + raw_content = "" + assert ( + to_html(t"") + == "" + ), "DO NOT DO THIS! This is an advanced escape hatch." + + def test_escapable_raw_text_safe(self, to_html): + raw_content = "underlined" + assert ( + to_html(t"") + == "" + ) + def test_normal_text_unsafe(self, to_html): + supposedly_safe = Markup("italic") + assert ( + to_html(t"This is {supposedly_safe:unsafe} text.
") + == "This is <i>italic</i> text.
" + ) -# -# Special aria attribute handling. -# -def test_aria_templated_attr_error(): - aria1 = {"label": "close"} - aria2 = {"hidden": "true"} - with pytest.raises(TypeError): - node = html(t'') - print(str(node)) - - -def test_aria_interpolated_attr_dict(): - aria = {"label": "Close", "hidden": True, "another": False, "more": None} - node = html(t"") - assert node == Element( - "button", - attrs={"aria-label": "Close", "aria-hidden": "true", "aria-another": "false"}, - children=[Text("X")], - ) - assert ( - str(node) - == '' - ) + def test_raw_text_unsafe(self, to_html): + # @TODO: What should even happen here? + supposedly_safe = "" + assert ( + to_html(t"") + == "" + ) + assert ( + to_html(t"") + != "" + ) # Sanity check + + def test_escapable_raw_text_unsafe(self, to_html): + supposedly_safe = Markup("italic") + assert ( + to_html(t"") + == "" + ) + def test_all_text_callback(self, to_html): + def get_value(): + return "dynamic" + + for tag in ("p", "script", "style"): + assert ( + to_html( + Template(f"<{tag}>") + + t"The value is {get_value:callback}." + + Template(f"{tag}>") + ) + == f"<{tag}>The value is dynamic.{tag}>" + ) -def test_aria_interpolate_attr_none(): - button_aria = None - node = html(t"") - assert node == Element("button", children=[Text("X")]) - assert str(node) == "" + def test_callback_nonzero_callable_error(self, to_html): + def add(a, b): + return a + b + assert add(1, 2) == 3, "Make sure fixture could work..." -def test_aria_attr_errors(): - for v in [False, [], (), 0, "aria?"]: with pytest.raises(TypeError): - _ = html(t"") - - -def test_aria_literal_attr_bypass(): - # Trigger overall attribute resolution with an unrelated interpolated attr. - node = html(t'') - assert node == Element( - "p", - attrs={"aria": "passthru", "id": "resolved"}, - ), "A single literal attribute should not trigger aria expansion." + for tag in ("p", "script", "style"): + _ = to_html( + Template(f"<{tag}>") + + t"The sum is {add:callback}." + + Template(f"{tag}>") + ) -# -# 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 = html(button_t) - assert node == Element( - "button", - attrs={"class": "red btn btn-primary one two active blue green yellow"}, - children=[Text("Click me")], - ) - assert ( - str(node) - == '' - ) - - -def test_interpolated_class_attribute_with_multiple_placeholders(): - classes1 = ["btn", "btn-primary"] - classes2 = [False and "disabled", None, {"active": True}] - node = 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")], - ) - - -def test_interpolated_attribute_spread_with_class_attribute(): - attrs = {"id": "button1", "class": ["btn", "btn-primary"]} - node = html(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 = html(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 = html(t"") - assert node == Element("p") - # Also ignored inside a sequence. - node = html(t"") - assert node == Element("p") - - -def test_class_type_errors(): - for class_item in (False, True, 0): - with pytest.raises(TypeError): - _ = html(t"") - with pytest.raises(TypeError): - _ = html(t"") - +# -------------------------------------------------------------------------- +# Conditional rendering and control flow +# -------------------------------------------------------------------------- -def test_class_merge_literals(): - node = html(t'') - assert node == Element("p", {"class": "red blue"}) +@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"Warning!
') - assert node == Element( - "p", - attrs={"style": "color: red", "id": "para1"}, - children=[Text("Warning!")], - ) - assert str(node) == 'Warning!
' +# -------------------------------------------------------------------------- +# Attributes +# -------------------------------------------------------------------------- +@pytest.mark.parametrize("to_html", PROCESSORS) +class TestLiteralAttribute: + """Test literal (non-dynamic) attributes.""" + + def test_literal_attrs(self, to_html): + assert ( + to_html( + ( + t"" + ) + ) + == '' + ) + def test_literal_attr_escaped(self, to_html): + assert ( + to_html(t'') + == '' + ) -def test_style_in_interpolated_attr(): - styles = {"color": "red", "font-weight": "bold", "font-size": "16px"} - node = html(t"Warning!
") - assert node == Element( - "p", - attrs={"style": "color: red; font-weight: bold; font-size: 16px"}, - children=[Text("Warning!")], - ) - assert ( - str(node) - == 'Warning!
' - ) +@pytest.mark.parametrize("to_html", PROCESSORS) +class TestInterpolatedAttribute: + """Test interpolated attributes, entire value is an exact interpolation.""" -def test_style_in_templated_attr(): - color = "red" - node = html(t'Warning!
') - assert node == Element( - "p", - attrs={"style": "color: red"}, - children=[Text("Warning!")], - ) - assert str(node) == 'Warning!
' + def test_interpolated_attr(self, to_html): + url = "https://example.com/" + 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_style_in_spread_attr(): - attrs = {"style": {"color": "red"}} - node = html(t"Warning!
") - assert node == Element( - "p", - attrs={"style": "color: red"}, - children=[Text("Warning!")], - ) - assert str(node) == 'Warning!
' + def test_interpolated_attr_unquoted(self, to_html): + id = "roquefort" + assert to_html(t"") == '' + def test_interpolated_attr_true(self, to_html): + disabled = True + assert ( + to_html(t"") + == "" + ) -def test_style_merged_from_all_attrs(): - attrs = dict(style="font-size: 15px") - style = {"font-weight": "bold"} - color = "red" - node = html( - t'' - ) - assert node == Element( - "p", - {"style": "font-family: serif; color: red; font-weight: bold; font-size: 15px"}, - ) - assert ( - str(node) - == '' - ) + def test_interpolated_attr_false(self, to_html): + disabled = False + assert to_html(t"") == "" + def test_interpolated_attr_none(self, to_html): + disabled = None + assert to_html(t"") == "" -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): - _ = html(t"Warning!
") - - -def test_interpolated_style_attribute_merged(): - styles1 = {"color": "red"} - styles2 = {"font-weight": "bold"} - node = html(t"Warning!
") - assert node == Element( - "p", - attrs={"style": "color: red; font-weight: bold"}, - children=[Text("Warning!")], - ) - assert str(node) == 'Warning!
' + def test_interpolate_attr_empty_string(self, to_html): + assert to_html(t'') == '' -def test_interpolated_style_attribute_merged_override(): - styles1 = {"color": "red", "font-weight": "normal"} - styles2 = {"font-weight": "bold"} - node = html(t"Warning!
") - assert node == Element( - "p", - attrs={"style": "color: red; font-weight: bold"}, - children=[Text("Warning!")], - ) - assert str(node) == 'Warning!
' +@pytest.mark.parametrize("to_html", PROCESSORS) +class TestSpreadAttribute: + """Test spread attributes.""" + def test_spread_attr(self, to_html): + attrs = {"href": "https://example.com/", "target": "_blank"} + assert ( + to_html(t"") + == '' + ) -def test_style_attribute_str(): - styles = "color: red; font-weight: bold;" - node = html(t"Warning!
") - assert node == Element( - "p", - attrs={"style": "color: red; font-weight: bold"}, - children=[Text("Warning!")], - ) - assert str(node) == 'Warning!
' + def test_spread_attr_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_templated_attr_no_quotes(self, to_html): + start = 1 + end = 5 + assert ( + to_html(t"") + == '' + ) -def test_style_attribute_non_str_non_dict(): - with pytest.raises(TypeError): - styles = [1, 2] - _ = html(t"Warning!
") +@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"") + == '' + ) -def test_style_literal_attr_bypass(): - # Trigger overall attribute resolution with an unrelated interpolated attr. - node = html(t'') - assert node == Element( - "p", - attrs={"style": "invalid;invalid:", "id": "resolved"}, - ), "A single literal attribute should bypass style accumulator." + def test_attr_merge_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_style_none(): - styles = None - node = html(t"") - assert node == Element("p") + 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'') == "" + ) -# -------------------------------------------------------------------------- -# Function component interpolation tests -# -------------------------------------------------------------------------- + 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'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_children_always_passed_via_kwargs_even_when_empty(): - node = html(t'<{FunctionComponentKeywordArgs} first="value" extra="info" />') - assert node == Element( - "div", - attrs={ - "data-first": "value", - "extra": "info", - }, - children=[Text("Component with kwargs")], - ) - assert ( - str(node) == '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_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): + 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 ColumnsComponent() -> Template: - return t"""| Column 1 | Column 2 |
Inside wrapper
{Wrapper}>' - ) - assert node == Element( - "div", - attrs={ - "id": "comp1", - "data-first": "1", - "data-second": "99", - "class": "wrapped", - }, - children=[Text("Component: "), Element("p", children=[Text("Inside wrapper")])], - ) - assert ( - str(node) - == 'Inside wrapper
| Column 1 | Column 2 |
Inside wrapper
{Wrapper}>' + ) + assert ( + res + == 'Inside wrapper
Positional arg: {whoops}
" + + 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"Positional arg: {whoops}
" + def FooterComponent(classes=("footer-default",)): + return t'' + def LayoutComponent(children, body_classes=None): + return t""" + + + + + + + + {children} + <{FooterComponent} /> + + +""" -def test_component_requiring_positional_arg_fails(): - with pytest.raises(TypeError): - _ = html(t"<{RequiresPositional} />") + content = "HTML never goes out of style." + content_str = to_html( + t"<{LayoutComponent} body_classes={['theme-default']}><{PageComponent}>{content}{PageComponent}>{LayoutComponent}>" + ) + assert ( + content_str + == """ + + + + + + + ++ The fraction + + is not a decimal number. +
""" + res = to_html(mathml_t) + assert ( + str(res) + == """+ The fraction + + is not a decimal number. +
""" + ) + + +@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"""""" + res = to_html(svg_t) + assert ( + res + == """""" + ) + + +@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"""""" + res = to_html(svg_t) + assert ( + res + == """""" + ) 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("