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"

Hello, World!

") -print(type(greeting)) # print(greeting) #

Hello, World!

``` @@ -145,7 +142,7 @@ classes: ```python classes = {"btn-primary": True, "btn-secondary": False} button = html(t'') -assert str(button) == '' +assert button == '' ``` #### The `style` Attribute @@ -166,7 +163,7 @@ Style attributes can also be merged to extend a base style: ```python add_styles = {"font-weight": "bold"} para = html(t'

Important text

') -assert str(para) == '

Important text

' +assert para == '

Important text

' ``` #### The `data` and `aria` Attributes @@ -312,18 +309,6 @@ page = html(t"
{content}
") #

My Site

``` -In the example above, `content` is a `Template` object that gets correctly -parsed and embedded within the outer template. You can also explicitly call -`html()` on nested templates if you prefer: - -```python -content = html(t"

My Site

") -page = html(t"
{content}
") -#

My Site

-``` - -The result is the same either way. - #### Component Functions You can create reusable component functions that generate templates with dynamic @@ -333,10 +318,11 @@ The basic form of all component functions is: ```python from typing import Any, Iterable -from tdom import Node, html +from tdom import html +from string.templatelib import Template -def MyComponent(children: Iterable[Node], **attrs: Any) -> Node: - return html(t"
Cool: {children}
") +def MyComponent(children: Template, **attrs: Any) -> Template: + return t"
Cool: {children}
" ``` To _invoke_ your component within an HTML template, use the special @@ -352,10 +338,11 @@ type hints for better editor support: ```python from typing import Any -from tdom import Node, html +from tdom import html +from string.templatelib import Template -def Link(*, href: str, text: str, data_value: int, **attrs: Any) -> Node: - return html(t'{text}: {data_value}') +def Link(*, href: str, text: str, data_value: int, **attrs: Any) -> Template: + return t'{text}: {data_value}' result = html(t'<{Link} href="https://example.com" text="Example" data-value={42} target="_blank" />') # Example: 42 @@ -364,12 +351,7 @@ result = html(t'<{Link} href="https://example.com" text="Example" data-value={42 Note that attributes with hyphens (like `data-value`) are converted to underscores (`data_value`) in the function signature. -Component functions build children and can return _any_ type of value; the -returned value will be treated exactly as if it were placed directly in a child -position in the template. - -Among other things, this means you can return a `Template` directly from a -component function: +Component functions build Templates that conceptually will replace the component tags with the new template. - -```python -from typing import Iterable - -def Items() -> Iterable[Template]: - return [t"
  • first
  • ", t"
  • second
  • "] - -result = html(t"") -assert str(result) == "" +assert result == "

    Hello, Alice!

    " ``` #### Class-based components @@ -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'' -result = html(t"") -assert str(result) == '' +result = html(t"<{Todos} />") +assert result == '' ``` ## 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

    ' +assert result == '

    My Todos

    ' ``` 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"") -assert str(result) == '' +assert result == '' ``` 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
    ' ``` diff --git a/tdom/__init__.py b/tdom/__init__.py index 4503582..4734801 100644 --- a/tdom/__init__.py +++ b/tdom/__init__.py @@ -1,7 +1,12 @@ from markupsafe import Markup, escape -from .nodes import Comment, DocumentType, Element, Fragment, Node, Text -from .processor import html +from .nodes.nodes import Comment, DocumentType, Element, Fragment, Node, Text +from .nodes.processor import to_node +from .processor import to_html + + +html = to_html + # We consider `Markup` and `escape` to be part of this module's public API @@ -12,6 +17,9 @@ "escape", "Fragment", "html", + "to_node", + "to_html", + "html", "Markup", "Node", "Text", 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 = "" + return f"" @dataclass(slots=True) @@ -118,11 +90,16 @@ def _children_to_str(self): raise ValueError( "Cannot serialize non-text content inside a script tag." ) - raw_children_str = "".join(chunks) + 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) + return escape_html_script(raw_children_str, allow_markup=True) elif self.tag == "style": - return escape_html_style(raw_children_str) + return escape_html_style(raw_children_str, allow_markup=True) else: raise ValueError("Unsupported tag for single-level bulk escaping.") else: @@ -141,3 +118,6 @@ def __str__(self) -> str: 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_test.py b/tdom/nodes/nodes_test.py similarity index 95% rename from tdom/nodes_test.py rename to tdom/nodes/nodes_test.py index 184d387..70e20b8 100644 --- a/tdom/nodes_test.py +++ b/tdom/nodes/nodes_test.py @@ -4,21 +4,6 @@ from .nodes import Comment, DocumentType, Element, Fragment, Text -def test_comment(): - comment = Comment("This is a comment") - assert str(comment) == "" - - -def test_comment_empty(): - comment = Comment("") - assert str(comment) == "" - - -def test_comment_special_chars(): - comment = Comment("Special chars: <>&\"'") - assert str(comment) == "" - - def test_doctype_default(): doctype = DocumentType() assert str(doctype) == "" @@ -29,6 +14,11 @@ def test_doctype_custom(): 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!" diff --git a/tdom/nodes/processor.py b/tdom/nodes/processor.py new file mode 100644 index 0000000..99034a7 --- /dev/null +++ b/tdom/nodes/processor.py @@ -0,0 +1,346 @@ +from dataclasses import dataclass +from string.templatelib import Template +from typing import cast +from collections.abc import Iterable + +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, + ComponentCallableProto, + format_interpolation, + WalkerProto, + NormalTextInterpolationValue, + CachedParserService, +) +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 ..template_utils import TemplateRef + + +@dataclass(frozen=True) +class NodeProcessorService(BaseProcessorService): + """Iteratively process a tdom compatible Template into a `Node` tree.""" + + def process_template( + self, root_template: Template, assume_ctx: ProcessContext | None = None + ) -> Node: + root_tnode = self.parser_api.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): + self._process_comment(parent_node, template, last_ctx, ref) + case TFragment(children): + q.extend( + [ + (last_ctx, tchild, parent_node) + for tchild in reversed(children) + ] + ) + case TComponent(start_i_index, end_i_index, attrs, children): + res = self._process_component( + parent_node, + template, + last_ctx, + attrs, + start_i_index, + end_i_index, + ) + if res is not None: + yield res + case TElement(tag, attrs, children): + our_ctx = last_ctx.copy(parent_tag=tag) + if attrs: + resolved_attrs = _resolve_t_attrs( + attrs, template.interpolations + ) + el_attrs = _resolve_html_attrs(resolved_attrs) + else: + el_attrs = {} + el = Element(tag, attrs=el_attrs, children=[]) + parent_node.children.append(el) + if tag not in VOID_ELEMENTS: # Or just check children? + q.extend( + [(our_ctx, tchild, el) for tchild in reversed(children)] + ) + case TText(ref): + if last_ctx.parent_tag is None: + raise NotImplementedError( + "We cannot interpolate texts without knowing what tag they are contained in." + ) + elif last_ctx.parent_tag in CDATA_CONTENT_ELEMENTS: + # Must be handled all at once. + self._process_raw_texts(parent_node, template, last_ctx, ref) + elif last_ctx.parent_tag in RCDATA_CONTENT_ELEMENTS: + # We can handle all at once because there are no non-text children and everything must be string-ified. + self._process_escapable_raw_texts( + parent_node, template, last_ctx, ref + ) + else: + for part in ref: + if isinstance(part, str): + parent_node.children.append(Text(part)) + else: + res = self._process_normal_text( + parent_node, template, last_ctx, part + ) + if res is not None: + yield res + case _: + raise ValueError(f"Unrecognized tnode: {tnode}") + + def _process_comment( + self, + parent_node: NodeContainer, + template: Template, + last_ctx: ProcessContext, + content_ref: TemplateRef, + ) -> None: + content = resolve_text_without_recursion(template, "" + t"" + t"

    >
    " + t"
    {[0, 1]}
    " + t'{content}' + t'<{simple_comp} label="The Children">Child0Child1' + ) + ) + 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={ + "class": "red", + "title": title, + "href": f"/{url_path}", + "id": "my-link", + }, + children=[Text(content)], + ), + Element( + "section", + children=[ + Text("The Children"), + Text(": "), + Element("span", children=[Text("Child0")]), + Element("span", children=[Text("Child1")]), + ], + ), + Element("span", children=[Text("Tail")]), + ] + ) 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/parser_test.py b/tdom/parser_test.py index 298adc2..159e50c 100644 --- a/tdom/parser_test.py +++ b/tdom/parser_test.py @@ -1,7 +1,7 @@ import pytest from .parser import TemplateParser -from .placeholders import TemplateRef +from .placeholders import TemplateRef, make_placeholder_config from .tnodes import ( TComment, TComponent, @@ -14,6 +14,7 @@ TTemplatedAttribute, TText, ) +from string.templatelib import Template, Interpolation def test_parse_mixed_literal_content(): @@ -356,6 +357,26 @@ def test_adjacent_spread_attrs_error(): _ = TemplateParser.parse(t"
    ") +class TestPlaceholders: + """Test placeholder parsing.""" + + def test_placeholder_collision_avoidance(self): + 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 = TemplateParser.parse(template) + value_ref = TemplateRef(strings=(config.prefix, config.suffix), i_indexes=(0,)) + assert node == TElement( + "div", attrs=(TTemplatedAttribute("data-tricky", value_ref),) + ) + + # # Comments # diff --git a/tdom/processor.py b/tdom/processor.py index 1e2528d..064bb34 100644 --- a/tdom/processor.py +++ b/tdom/processor.py @@ -1,16 +1,20 @@ -import sys -import typing as t -from collections.abc import Iterable, Sequence +from typing import cast +from collections.abc import Iterable, Sequence, Callable from functools import lru_cache -from string.templatelib import Interpolation, Template +from string.templatelib import Template, Interpolation from dataclasses import dataclass 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 +from .htmlspec import ( + DEFAULT_NORMAL_TEXT_ELEMENT, + VOID_ELEMENTS, + CDATA_CONTENT_ELEMENTS, + RCDATA_CONTENT_ELEMENTS, +) from .parser import ( HTMLAttribute, HTMLAttributesDict, @@ -28,24 +32,16 @@ TTemplatedAttribute, TText, ) -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 - - -# TODO: in Ian's original PR, this caching was tethered to the -# TemplateParser. Here, it's tethered to the processor. I suspect we'll -# revisit this soon enough. - - -@lru_cache(maxsize=0 if "pytest" in sys.modules else 512) -def _parse_and_cache(cachable: CachableTemplate) -> TNode: - return TemplateParser.parse(cachable.template) +from .escaping import ( + escape_html_script as default_escape_html_script, + escape_html_style as default_escape_html_style, + escape_html_text as default_escape_html_text, + escape_html_comment as default_escape_html_comment, +) +from .protocols import HasHTMLDunder +from .sentinel import NOT_SET, NotSet +from .template_utils import TemplateRef type Attribute = tuple[str, object] @@ -69,7 +65,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: @@ -84,7 +90,7 @@ def format_interpolation(interpolation: Interpolation) -> object: # -------------------------------------------------------------------------- -def _expand_aria_attr(value: object) -> t.Iterable[HTMLAttribute]: +def _expand_aria_attr(value: object) -> Iterable[HTMLAttribute]: """Produce aria-* attributes based on the interpolated value for "aria".""" if value is None: return @@ -104,7 +110,7 @@ def _expand_aria_attr(value: object) -> t.Iterable[HTMLAttribute]: ) -def _expand_data_attr(value: object) -> t.Iterable[Attribute]: +def _expand_data_attr(value: object) -> Iterable[Attribute]: """Produce data-* attributes based on the interpolated value for "data".""" if value is None: return @@ -120,7 +126,7 @@ def _expand_data_attr(value: object) -> t.Iterable[Attribute]: ) -def _substitute_spread_attrs(value: object) -> t.Iterable[Attribute]: +def _substitute_spread_attrs(value: object) -> Iterable[Attribute]: """ Substitute a spread attribute based on the interpolated value. @@ -285,7 +291,7 @@ def to_value(self) -> str | None: def _resolve_t_attrs( - attrs: t.Sequence[TAttribute], interpolations: tuple[Interpolation, ...] + attrs: Sequence[TAttribute], interpolations: tuple[Interpolation, ...] ) -> AttributesDict: """ Replace placeholder values in attributes with their interpolated values. @@ -320,7 +326,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: @@ -368,115 +374,16 @@ def _resolve_html_attrs(attrs: AttributesDict) -> HTMLAttributesDict: return html_attrs -def _resolve_attrs( - attrs: t.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: t.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: t.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 html(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 rendered. - 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 _invoke_component( +def prep_component_kwargs( + callable_info: CallableInfo, 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) - + system_kwargs: AttributesDict, +) -> AttributesDict: if callable_info.requires_positional: raise TypeError( "Component callables cannot have required positional arguments." @@ -490,9 +397,9 @@ def _invoke_component( 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) + 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() @@ -501,88 +408,518 @@ def _invoke_component( f"Missing required parameters for component: {', '.join(missing)}" ) - result = value(**kwargs) - return _node_from_value(result) + return kwargs -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 +@dataclass +class EndTag: + end_tag: str + + +def serialize_html_attrs( + html_attrs: HTMLAttributesDict, escape: Callable = default_escape_html_text +) -> str: + return "".join( + ( + f' {k}="{escape(v)}"' if v is not None else f" {k}" + for k, v in html_attrs.items() + ) + ) + + +def make_ctx(parent_tag: str | None = None, ns: str | None = "html"): + return ProcessContext(parent_tag=parent_tag, ns=ns) + + +@dataclass(frozen=True, slots=True) +class ProcessContext: + # None means unknown not just a missing value. + parent_tag: str | None = None + # None means unknown not just a missing value. + ns: str | None = None + + def copy( + self, + ns: NotSet | str | None = NOT_SET, + parent_tag: NotSet | str | None = NOT_SET, + ): + if isinstance(ns, NotSet): + resolved_ns = self.ns + else: + resolved_ns = ns + if isinstance(parent_tag, NotSet): + resolved_parent_tag = self.parent_tag + else: + resolved_parent_tag = parent_tag + return make_ctx( + parent_tag=resolved_parent_tag, + ns=resolved_ns, + ) + + +type FunctionComponentProto = Callable[..., Template] +type FactoryComponentProto = Callable[..., ComponentObjectProto] +type ComponentCallableProto = FunctionComponentProto | FactoryComponentProto +type ComponentObjectProto = Callable[[], Template] + + +type WalkerProto = Iterable[WalkerProto | None] + + +type NormalTextInterpolationValue = ( + None | str | Template | Iterable[NormalTextInterpolationValue] | object +) +# Applies to both escapable raw text and raw text. +type RawTextExactInterpolationValue = None | str | HasHTMLDunder | object +# Applies to both escapable raw text and raw text. +type RawTextInexactInterpolationValue = None | str | object + + +@dataclass(frozen=True) +class ParserService: + def to_tnode(self, template: Template) -> TNode: + return TemplateParser.parse(template) + + +@dataclass(frozen=True) +class CachedParserService(ParserService): + @lru_cache(512) + def _to_tnode(self, ct: CachableTemplate): + return super().to_tnode(ct.template) + + def to_tnode(self, template: Template): + return self._to_tnode(CachableTemplate(template)) + + +@dataclass(frozen=True) +class BaseProcessorService: + parser_api: ParserService + + 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 + + +@dataclass(frozen=True) +class ProcessorService(BaseProcessorService): + slash_void: bool = False # Apply a xhtml-style slash to void html elements. + + uppercase_doctype: bool = False # DOCTYPE vs doctype + + def process_template( + self, root_template: Template, assume_ctx: ProcessContext | None = None + ) -> str: + return "".join(self.process_template_chunks(root_template, assume_ctx)) + + def process_template_chunks( + self, root_template: Template, assume_ctx: ProcessContext | None = None + ) -> Iterable[str]: + if assume_ctx is None: + # @DESIGN: What do we want to do here? Should we assume we are in + # a tag with normal text? + assume_ctx = make_ctx(parent_tag=DEFAULT_NORMAL_TEXT_ELEMENT, ns="html") + root = self.parser_api.to_tnode(root_template) + + bf: list[str] = [] + q: list[WalkerProto] = [ + self.walk_from_tnode(bf, root_template, assume_ctx, root) + ] + while q: + it = q.pop() + if bf: + yield "".join(bf) + bf.clear() + for new_it in it: + if new_it is not None: + q.append(it) + q.append(new_it) + break + if bf: + yield "".join(bf) + bf.clear() # Remove later maybe. + + def walk_from_tnode( + self, bf: list[str], template: Template, assume_ctx: ProcessContext, root: TNode + ) -> Iterable[WalkerProto]: + """ + Walk around tree and try not to get lost. + """ + + q: list[tuple[ProcessContext, TNode | EndTag]] = [(assume_ctx, root)] + while q: + last_ctx, tnode = q.pop() + match tnode: + case EndTag(end_tag): + bf.append(end_tag) + case TDocumentType(text): + if last_ctx.ns != "html": + # Nit + raise ValueError( + "Cannot process document type in subtree of a foreign element." + ) + if self.uppercase_doctype: + bf.append(f"") + else: + bf.append(f"") + case TComment(ref): + self._process_comment(bf, template, last_ctx, ref) + case TFragment(children): + q.extend([(last_ctx, child) for child in reversed(children)]) + case TComponent(start_i_index, end_i_index, attrs, children): + res = self._process_component( + bf, template, last_ctx, attrs, start_i_index, end_i_index + ) + if res is not None: + yield res + case TElement(tag, attrs, children): + bf.append(f"<{tag}") + our_ctx = last_ctx.copy(parent_tag=tag) + if attrs: + 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: + bf.append(" />") + else: + bf.append(">") + if tag not in VOID_ELEMENTS: + q.append((last_ctx, EndTag(f""))) + q.extend([(our_ctx, child) for child in reversed(children)]) + case TText(ref): + if last_ctx.parent_tag is None: + raise NotImplementedError( + "We cannot interpolate texts without knowing what tag they are contained in." + ) + elif last_ctx.parent_tag in CDATA_CONTENT_ELEMENTS: + # Must be handled all at once. + self._process_raw_texts(bf, template, last_ctx, ref) + elif last_ctx.parent_tag in RCDATA_CONTENT_ELEMENTS: + # We can handle all at once because there are no non-text children and everything must be string-ified. + self._process_escapable_raw_texts(bf, template, last_ctx, ref) + else: + for part in ref: + if isinstance(part, str): + bf.append(self.escape_html_text(part)) + else: + res = self._process_normal_text( + bf, template, last_ctx, part + ) + if res is not None: + yield res + case _: + raise ValueError(f"Unrecognized tnode: {tnode}") + + def _process_comment( + self, + bf: list[str], + template: Template, + last_ctx: ProcessContext, + content_ref: TemplateRef, + ) -> None: + content = resolve_text_without_recursion(template, "") + + 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)) + if attrs_str: + bf.append(attrs_str) + + def _process_component( + self, + bf: list[str], + template: Template, + last_ctx: ProcessContext, + attrs: tuple[TAttribute, ...], + start_i_index: int, + end_i_index: int | None, + ) -> None | WalkerProto: + body_start_s_index = ( + start_i_index + + 1 + + len([1 for attr in attrs if not isinstance(attr, TLiteralAttribute)]) + ) + start_i = template.interpolations[start_i_index] + component_callable = cast(ComponentCallableProto, start_i.value) + if start_i_index != end_i_index and end_i_index is not None: + # @TODO: We should do this during parsing. + children_template = extract_embedded_template( + template, body_start_s_index, end_i_index ) - 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, + if component_callable != template.interpolations[end_i_index].value: + raise TypeError( + "Component callable in start tag must match component callable in end tag." + ) + else: + children_template = t"" + + if not callable(component_callable): + raise TypeError("Component callable must be callable.") + + kwargs = prep_component_kwargs( + get_callable_info(component_callable), + _resolve_t_attrs(attrs, template.interpolations), + system_kwargs={"children": children_template}, + ) + + result_t = component_callable(**kwargs) + if ( + result_t is not None + and not isinstance(result_t, Template) + and callable(result_t) ): - start_interpolation = interpolations[start_i_index] - end_interpolation = ( - None if end_i_index is None else interpolations[end_i_index] + component_obj = cast(ComponentObjectProto, result_t) + result_t = component_obj() + else: + component_obj = None + + if isinstance(result_t, Template): + if result_t.strings == ("",): + # DO NOTHING + return + result_root = self.parser_api.to_tnode(result_t) + return self.walk_from_tnode(bf, result_t, last_ctx, result_root) + else: + raise ValueError(f"Unknown component return value: {type(result_t)}") + + def _process_raw_texts( + self, + bf: list[str], + template: Template, + last_ctx: ProcessContext, + content_ref: TemplateRef, + ) -> None: + assert last_ctx.parent_tag in CDATA_CONTENT_ELEMENTS + content = resolve_text_without_recursion( + template, last_ctx.parent_tag, content_ref + ) + if content is None or content == "": + return + elif last_ctx.parent_tag == "script": + bf.append( + self.escape_html_script( + content, + allow_markup=True, + ) ) - resolved_attrs = _resolve_t_attrs(t_attrs, interpolations) - resolved_children = _substitute_and_flatten_children( - children, interpolations + elif last_ctx.parent_tag == "style": + bf.append( + self.escape_html_style( + content, + allow_markup=True, + ) ) - # 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, + else: + raise NotImplementedError( + f"Parent tag {last_ctx.parent_tag} is not supported." ) - case _: - raise ValueError(f"Unknown TNode type: {type(t_node).__name__}") + + def _process_escapable_raw_texts( + self, + bf: list[str], + template: Template, + last_ctx: ProcessContext, + content_ref: TemplateRef, + ) -> None: + assert last_ctx.parent_tag in RCDATA_CONTENT_ELEMENTS + content = resolve_text_without_recursion( + template, last_ctx.parent_tag, content_ref + ) + if content is None or content == "": + return + else: + bf.append( + self.escape_html_text( + content, + ) + ) + + def _process_normal_text( + self, + bf: list[str], + template: Template, + last_ctx: ProcessContext, + values_index: int, + ) -> WalkerProto | None: + value = format_interpolation(template.interpolations[values_index]) + if isinstance(value, str): + bf.append(self.escape_html_text(value)) + elif isinstance(value, Template): + value_root = self.parser_api.to_tnode(value) + return self.walk_from_tnode(bf, value, last_ctx, value_root) + elif isinstance(value, Iterable): + return iter( + self._process_normal_text_from_value(bf, template, last_ctx, v) + for v in value + ) + elif value is None: + # @DESIGN: Ignore None. + return + else: + # @DESIGN: Everything that isn't an object we recognize is + # coerced to a str() and emitted. + bf.append(self.escape_html_text(value)) + + def _process_normal_text_from_value( + self, + bf: list[str], + template: Template, + last_ctx: ProcessContext, + value: NormalTextInterpolationValue, + ) -> WalkerProto | None: + if isinstance(value, str): + bf.append(self.escape_html_text(value)) + elif isinstance(value, Template): + value_root = self.parser_api.to_tnode(value) + return self.walk_from_tnode(bf, value, last_ctx, value_root) + elif isinstance(value, Iterable): + return iter( + self._process_normal_text_from_value(bf, template, last_ctx, v) + for v in value + ) + elif value is None: + # @DESIGN: Ignore None. + return + else: + # @DESIGN: Everything that isn't an object we recognize is + # coerced to a str() and emitted. + bf.append(self.escape_html_text(value)) + + +def resolve_text_without_recursion( + template: Template, parent_tag: str, content_ref: TemplateRef +) -> 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. + """ + if content_ref.is_singleton: + value = format_interpolation(template.interpolations[content_ref.i_indexes[0]]) + value = cast(RawTextExactInterpolationValue, value) + if value is None: + return None + elif isinstance(value, str): + 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}" + ) + else: + return str(value) + else: + text = [] + for part in content_ref: + if isinstance(part, str): + if part: + text.append(part) + continue + value = format_interpolation(template.interpolations[part]) + value = cast(RawTextInexactInterpolationValue, 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 isinstance(value, HasHTMLDunder): + 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 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) + + +def processor_service_factory(**config_kwargs): + return ProcessorService(parser_api=ParserService(), **config_kwargs) + + +def cached_processor_service_factory(**config_kwargs): + return ProcessorService(parser_api=CachedParserService(), **config_kwargs) + + +_default_processor_api = cached_processor_service_factory( + slash_void=True, uppercase_doctype=True +) # -------------------------------------------------------------------------- @@ -590,8 +927,6 @@ def _resolve_t_node(t_node: TNode, interpolations: tuple[Interpolation, ...]) -> # -------------------------------------------------------------------------- -def html(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) +def to_html(template: Template, assume_ctx: ProcessContext | None = None) -> str: + """Parse an HTML t-string, substitute values, and return a string of HTML.""" + return _default_processor_api.process_template(template, assume_ctx) diff --git a/tdom/processor_test.py b/tdom/processor_test.py index 11b1c4d..c6e5682 100644 --- a/tdom/processor_test.py +++ b/tdom/processor_test.py @@ -1,1446 +1,1595 @@ import datetime import typing as t -from dataclasses import dataclass, field -from string.templatelib import Interpolation, Template -from itertools import product +from dataclasses import dataclass +from string.templatelib import Template +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 .nodes import Comment, DocumentType, Element, Fragment, Node, Text -from .placeholders import make_placeholder_config -from .processor import html +from .processor import ( + to_html as to_html_str, + prep_component_kwargs, + processor_service_factory, + cached_processor_service_factory, + make_ctx, + CachedParserService, +) +from .callables import get_callable_info +from .escaping import escape_html_text -# -------------------------------------------------------------------------- -# Basic HTML parsing tests -# -------------------------------------------------------------------------- +from .nodes.processor import to_node -# -# Text -# -def test_empty(): - node = html(t"") - assert node == Fragment(children=[]) - assert str(node) == "" +def to_node_str(*args, **kwargs): + """Proxy to to_node but then strify the result to match non-node processor.""" + return str(to_node(*args, **kwargs)) -def test_text_literal(): - node = html(t"Hello, world!") - assert node == Text("Hello, world!") - assert str(node) == "Hello, world!" +processor_api = processor_service_factory(slash_void=True, uppercase_doctype=True) -def test_text_singleton(): - greeting = "Hello, Alice!" - node = html(t"{greeting}") - assert node == Text("Hello, Alice!") - assert str(node) == "Hello, Alice!" +def to_html_str_no_cache(*args, **kwargs): + return processor_api.process_template(*args, **kwargs) -def test_text_template(): - name = "Alice" - node = html(t"Hello, {name}!") - assert node == Fragment(children=[Text("Hello, "), Text("Alice"), Text("!")]) - assert str(node) == "Hello, Alice!" +PROCESSORS = [ + to_html_str, # by default this is cached + to_node_str, # by default this is cached + to_html_str_no_cache, # we only test one without a cache for test run speed +] +" List of processor functions to test against. " -def test_text_template_escaping(): - name = "Alice & Bob" - node = html(t"Hello, {name}!") - assert node == Fragment(children=[Text("Hello, "), Text("Alice & Bob"), Text("!")]) - assert str(node) == "Hello, Alice & Bob!" +# -------------------------------------------------------------------------- +# Basic HTML parsing tests +# -------------------------------------------------------------------------- # -# Comments. +# Text # -def test_comment(): - node = html(t"") - assert node == Comment("This is a comment") - assert str(node) == "" - +@pytest.mark.parametrize("to_html", PROCESSORS) +class TestBareTemplate: + def test_empty(self, to_html): + assert to_html(t"") == "" + + def test_text_literal(self, to_html): + assert to_html(t"Hello, world!") == "Hello, world!" + + def test_text_singleton(self, to_html): + greeting = "Hello, Alice!" + assert to_html(t"{greeting}", make_ctx(parent_tag="div")) == "Hello, Alice!" + assert to_html(t"{greeting}", make_ctx(parent_tag="script")) == "Hello, Alice!" + assert to_html(t"{greeting}", make_ctx(parent_tag="style")) == "Hello, Alice!" + assert ( + to_html(t"{greeting}", make_ctx(parent_tag="textarea")) == "Hello, Alice!" + ) + assert to_html(t"{greeting}", make_ctx(parent_tag="title")) == "Hello, Alice!" + + def test_text_singleton_without_parent(self, to_html): + greeting = "" + with pytest.raises(NotImplementedError): + # Explicitly set the parent tag as None. + ctx = make_ctx(parent_tag=None, ns="html") + _ = to_html(t"{greeting}", assume_ctx=ctx) + + def test_text_singleton_explicit_parent_script(self, to_html): + greeting = "" + res = to_html(t"{greeting}", assume_ctx=make_ctx(parent_tag="script")) + assert res == "\\x3c/script>" + assert res != "" + + def test_text_singleton_explicit_parent_div(self, to_html): + greeting = "
    " + res = to_html(t"{greeting}", assume_ctx=make_ctx(parent_tag="div")) + assert res == "</div>" + assert res != "
    " + + def test_text_template(self, to_html): + name = "Alice" + assert ( + to_html(t"Hello, {name}!", assume_ctx=make_ctx(parent_tag="div")) + == "Hello, Alice!" + ) -def test_comment_template(): - text = "comment" - node = html(t"") - assert node == Comment("This is a comment") - assert str(node) == "" + def test_text_template_escaping(self, to_html): + name = "Alice & Bob" + assert ( + to_html(t"Hello, {name}!", assume_ctx=make_ctx(parent_tag="div")) + == "Hello, Alice & Bob!" + ) + def test_parse_entities_are_escaped_no_parent_tag(self, to_html): + res = to_html(t"</p>") + assert res == "</p>", "Default to standard escaping." -def test_comment_template_escaping(): - text = "-->comment" - node = html(t"") - assert node == Comment("This is a -->comment") - assert str(node) == "" +@pytest.mark.parametrize("to_html", PROCESSORS) +class LiteralHTML: + """Text is returned as is by __html__.""" -# -# Document types. -# -def test_parse_document_type(): - node = html(t"") - assert node == DocumentType("html") - assert str(node) == "" + def __init__(self, text): + self.text = text + def __html__(self): + # In a real app, this would come from a sanitizer or trusted source + return self.text -# -# Elements -# -def test_parse_void_element(): - node = html(t"
    ") - assert node == Element("br") - assert str(node) == "
    " - - -def test_parse_void_element_self_closed(): - node = html(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"



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



    ' +@pytest.mark.parametrize("to_html", PROCESSORS) +class TestComment: + def test_literal(self, to_html): + assert to_html(t"") == "" -def test_parse_element_with_text(): - node = html(t"

    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

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

    Hello

    World

    " - + def test_singleton_none(self, to_html): + assert to_html(t"") == "" -def test_parse_entities_are_escaped(): - node = html(t"

    </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 = "" - node = html(t"

    Hello, {name}!

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

    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"
    ") == "
    " - def __repr__(self): - return "repr" + def test_void_self_closed(self, to_html): + assert to_html(t"
    ") == "
    " + def test_void_mixed_closing(self, to_html): + assert to_html(t"
    Is this content?
    ") == "
    Is this content?
    " -def test_conversions(): - c = Convertible() - assert f"{c!s}" == "string" - assert f"{c!r}" == "repr" - node = html(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 = html(t"") - assert node == Element( - "script", - children=[Text("if (a < b && c > d) { alert('wow'); }")], - ) - assert str(node) == ("") - + def test_chain_of_void_elements(self, to_html): + # Make sure our handling of CPython issue #69445 is reasonable. + assert ( + to_html(t"



    ") + == '



    ' + ) -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"") - _ = str(node) +@pytest.mark.parametrize("to_html", PROCESSORS) +class TestNormalTextElementLiteral: + def test_empty(self, to_html): + assert to_html(t"
    ") == "
    " -# -------------------------------------------------------------------------- -# Interpolated non-text content -# -------------------------------------------------------------------------- + def test_with_text(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

    " + ) -def test_interpolated_false_content(): - node = html(t"
    {False}
    ") - assert node == Element("div") - assert str(node) == "
    " - + 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 -def test_interpolated_none_content(): - node = html(t"
    {None}
    ") - assert node == Element("div", children=[]) - assert str(node) == "
    " +@pytest.mark.parametrize("to_html", PROCESSORS) +class TestNormalTextElementDynamic: + def test_singleton_None(self, to_html): + assert to_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"
    {raw_content}
    ") - assert node == Element("div", children=[Text(text=raw_content)]) - assert str(node) == "
    I am bold
    " + def test_templated_object(self, to_html): + assert to_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"
    {user_profile if is_logged_in else login_prompt}
    ") + def test_templated_escaping_in_literals(self, to_html): + text = "This text is fine" + assert ( + to_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) == "
    Welcome, User!
    " + 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
    " + ) - is_logged_in = False - node = html(t"
    {user_profile if is_logged_in else login_prompt}
    ") - assert str(node) == '' + 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
    " + ) -def test_conditional_rendering_with_and(): - show_warning = True - warning_message = t'
    Warning!
    ' - node = html(t"
    {show_warning and warning_message}
    ") +@pytest.mark.parametrize("to_html", PROCESSORS) +class TestRawTextElementLiteral: + def test_script_empty(self, to_html): + assert to_html(t"") == "" - assert node == Element( - "main", - children=[ - Element("div", attrs={"class": "warning"}, children=[Text("Warning!")]), - ], - ) - assert str(node) == '
    Warning!
    ' + def test_style_empty(self, to_html): + assert to_html(t"") == "" - show_warning = False - node = html(t"
    {show_warning and warning_message}
    ") - # Assuming False renders nothing - assert str(node) == "
    " + def test_script_with_content(self, to_html): + assert to_html(t"") == "" + def test_style_with_content(self, to_html): + # @NOTE: Double {{ and }} to avoid t-string interpolation. + assert ( + to_html(t"") + == "" + ) -# -------------------------------------------------------------------------- -# Interpolated nesting of templates and elements -# -------------------------------------------------------------------------- + def test_script_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_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_interpolated_template_content(): - child = t"Child" - node = html(t"
    {child}
    ") - assert node == Element("div", children=[html(child)]) - assert str(node) == "
    Child
    " + 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, to_html): + texts = ["This", "is", "a", "comment"] + with pytest.raises(ValueError, match="not supported"): + _ = to_html(t"") -def test_interpolated_element_content(): - child = html(t"Child") - node = html(t"
    {child}
    ") - assert node == Element("div", children=[child]) - assert str(node) == "
    Child
    " +@pytest.mark.parametrize("to_html", PROCESSORS) +class TestEscapableRawTextElementLiteral: + def test_title_empty(self, to_html): + assert to_html(t"") == "" -def test_interpolated_nonstring_content(): - number = 42 - node = html(t"

    The answer is {number}.

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

    The answer is 42.

    " - - -def test_list_items(): - items = ["Apple", "Banana", "Cherry"] - node = html(t"
      {[t'
    • {item}
    • ' for item in items]}
    ") - assert node == Element( - "ul", - children=[ - Element("li", children=[Text("Apple")]), - Element("li", children=[Text("Banana")]), - Element("li", children=[Text("Cherry")]), - ], - ) - assert str(node) == "
    • 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 = html(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
    " - ) + def test_textarea_empty(self, to_html): + assert to_html(t"") == "" + def test_title_with_content(self, to_html): + assert to_html(t"Content") == "Content" -# -------------------------------------------------------------------------- -# Attributes -# -------------------------------------------------------------------------- + def test_textarea_with_content(self, to_html): + assert ( + to_html(t"") == "" + ) + 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, 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, to_html): + assert to_html(t"") == "" + + def test_singleton_str(self, to_html): + content = "var x = 1;" + assert to_html(t"") == "" + + def test_singleton_object(self, to_html): + content = 0 + assert to_html(t"") == "" + + 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__? + content = LiteralHTML("") + assert to_html(t"") == "", ( + "DO NOT DO THIS! This is just an advanced escape hatch! Use a data attribute and parseJSON!" + ) -def test_literal_attrs(): - node = html( - ( - t"" + 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, to_html): + assert ( + to_html(t"") + == "" ) - ) - assert node == Element( - "a", - attrs={ - "id": "example_link", - "autofocus": None, - "title": "", - "href": "https://example.com", - "target": "_blank", - }, - ) - assert ( - str(node) - == '' - ) + def test_templated_str(self, to_html): + content = "var x = 1" + assert ( + to_html(t"") + == "" + ) -def test_literal_attr_escaped(): - node = html(t'') - assert node == Element( - "a", - attrs={"title": "<"}, - ) - assert str(node) == '' + def test_templated_object(self, to_html): + content = 0 + assert ( + to_html(t"") + == "" + ) + 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, 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, to_html): + assert ( + to_html(t"") + == "" + ) -def test_interpolated_attr(): - url = "https://example.com/" - node = html(t'') - assert node == Element("a", attrs={"href": "https://example.com/"}) - assert str(node) == '' + 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, to_html): + texts = ["This", "is", "a", "script"] + with pytest.raises(ValueError, match="not supported"): + _ = to_html(t"") -def test_interpolated_attr_escaped(): - url = 'https://example.com/?q="test"&lang=en' - node = html(t'') - assert node == Element( - "a", - attrs={"href": 'https://example.com/?q="test"&lang=en'}, - ) - assert ( - str(node) == '' - ) +@pytest.mark.parametrize("to_html", PROCESSORS) +class TestRawTextStyleDynamic: + def test_singleton_none(self, to_html): + assert to_html(t"") == "" -def test_interpolated_attr_unquoted(): - id = "roquefort" - node = html(t"
    ") - assert node == Element("div", attrs={"id": "roquefort"}) - assert str(node) == '
    ' + def test_singleton_str(self, to_html): + content = "div { background-color: red; }" + assert ( + to_html(t"") + == "" + ) + def test_singleton_object(self, to_html): + content = 0 + assert to_html(t"") == "" + + 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__? + content = LiteralHTML("") + assert to_html(t"") == "", ( + "DO NOT DO THIS! This is just an advanced escape hatch!" + ) -def test_interpolated_attr_true(): - disabled = True - node = html(t"") - assert node == Element("button", attrs={"disabled": None}) - assert str(node) == "" + 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, to_html): + assert ( + to_html(t"") + == "" + ) + def test_templated_str(self, to_html): + content = " h2 { background-color: blue; }" + assert ( + to_html(t"") + == "" + ) -def test_interpolated_attr_false(): - disabled = False - node = html(t"") - assert node == Element("button") - assert str(node) == "" + def test_templated_object(self, to_html): + padding_right = 0 + assert ( + to_html(t"") + == "" + ) + 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, 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_interpolated_attr_none(): - disabled = None - node = html(t"") - assert node == Element("button") - assert str(node) == "" + def test_templated_multiple_interpolations(self, to_html): + assert ( + to_html( + t"" + ) + == "" + ) + 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_interpolate_attr_empty_string(): - node = html(t'
    ') - assert node == Element( - "div", - attrs={"title": ""}, - ) - assert str(node) == '
    ' + 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"") -def test_spread_attr(): - attrs = {"href": "https://example.com/", "target": "_blank"} - node = html(t"") - assert node == Element( - "a", - attrs={"href": "https://example.com/", "target": "_blank"}, - ) - assert str(node) == '' +@pytest.mark.parametrize("to_html", PROCESSORS) +class TestEscapableRawTextTitleDynamic: + def test_singleton_none(self, to_html): + assert to_html(t"{None}") == "" + def test_singleton_str(self, to_html): + content = "Welcome To TDOM" + assert to_html(t"{content}") == "Welcome To TDOM" -def test_spread_attr_none(): - attrs = None - node = html(t"") - assert node == Element("a") - assert str(node) == "" + def test_singleton_object(self, to_html): + content = 0 + assert to_html(t"{content}") == "0" + 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_spread_attr_type_errors(): - for attrs in (0, [], (), False, True): - with pytest.raises(TypeError): - _ = html(t"") - - -def test_templated_attr_mixed_interpolations_start_end_and_nest(): - left, middle, right = 1, 3, 5 - prefix, suffix = t'
    ' - # Check interpolations at start, middle and/or end of templated attr - # or a combination of those to make sure text is not getting dropped. - for left_part, middle_part, right_part in product( - (t"{left}", Template(str(left))), - (t"{middle}", Template(str(middle))), - (t"{right}", Template(str(right))), - ): - test_t = prefix + left_part + t"-" + middle_part + t"-" + right_part + suffix - node = html(test_t) - assert node == Element( - "div", - attrs={"data-range": "1-3-5"}, - ) - assert str(node) == '
    ' - - -def test_templated_attr_no_quotes(): - start = 1 - end = 5 - node = html(t"
    ") - assert node == Element( - "div", - attrs={"data-range": "1-5"}, - ) - assert str(node) == '
    ' + def test_singleton_escaping(self, to_html): + content = "" + assert to_html(t"{content}") == "</title>" + def test_templated_none(self, to_html): + assert ( + to_html(t"A great story about: {None}") + == "A great story about: " + ) -def test_attr_merge_disjoint_interpolated_attr_spread_attr(): - attrs = {"href": "https://example.com/", "id": "link1"} - target = "_blank" - node = html(t"") - assert node == Element( - "a", - attrs={"href": "https://example.com/", "id": "link1", "target": "_blank"}, - ) - assert str(node) == '' + def test_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, to_html): + content = 0 + assert ( + to_html(t"A great number: {content}") + == "A great number: 0" + ) -def test_attr_merge_overlapping_spread_attrs(): - attrs1 = {"href": "https://example.com/", "id": "overwrtten"} - attrs2 = {"target": "_blank", "id": "link1"} - node = html(t"") - assert node == Element( - "a", - attrs={"href": "https://example.com/", "target": "_blank", "id": "link1"}, - ) - assert str(node) == '' + 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, to_html): + content = "" + assert ( + to_html(t"The end tag: {content}.") + == "The end tag: </title>." + ) -def test_attr_merge_replace_literal_attr_str_str(): - node = html(t'
    ') - assert node == Element("div", {"title": "fresh"}) - assert str(node) == '
    ' + 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, to_html): + text_t = t"title" + with pytest.raises(ValueError, match="not supported"): + _ = to_html(t"{text_t}") -def test_attr_merge_replace_literal_attr_str_true(): - node = html(t'
    ') - assert node == Element("div", {"title": None}) - assert str(node) == "
    " + 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}") -def test_attr_merge_replace_literal_attr_true_str(): - node = html(t"
    ") - assert node == Element("div", {"title": "fresh"}) - assert str(node) == '
    ' +@pytest.mark.parametrize("to_html", PROCESSORS) +class TestEscapableRawTextTextareaDynamic: + def test_singleton_none(self, to_html): + assert to_html(t"") == "" + def test_singleton_str(self, to_html): + content = "Welcome To TDOM" + assert ( + to_html(t"") + == "" + ) -def test_attr_merge_remove_literal_attr_str_none(): - node = html(t'
    ') - assert node == Element("div") - assert str(node) == "
    " + def test_singleton_object(self, to_html): + content = 0 + assert to_html(t"") == "" + + 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"") + == "" + ), "DO NOT DO THIS! This is just an advanced escape hatch!" + + def test_singleton_escaping(self, to_html): + content = "" + assert ( + to_html(t"") + == "" + ) + def test_templated_none(self, to_html): + assert ( + to_html(t"") + == "" + ) -def test_attr_merge_remove_literal_attr_true_none(): - node = html(t"
    ") - assert node == Element("div") - assert str(node) == "
    " + def test_templated_str(self, to_html): + content = "TDOM" + assert ( + to_html(t"") + == "" + ) + def test_templated_object(self, to_html): + content = 0 + assert ( + to_html(t"") + == "" + ) -def test_attr_merge_other_literal_attr_intact(): - node = html(t'') - assert node == Element("img", {"title": "default", "alt": "fresh"}) - assert str(node) == 'fresh' + 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, to_html): + 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'
    ', - ) - node = html(template) - assert node == Element( - "div", - attrs={"data-tricky": config.prefix + tricky + config.suffix}, - children=[], - ) - assert ( - str(node) == f'
    ' - ) + def test_templated_escaping(self, to_html): + content = "" + assert ( + to_html(t"") + == "" + ) + def test_not_supported_recursive_template_error(self, to_html): + text_t = t"textarea" + with pytest.raises(ValueError, match="not supported"): + _ = to_html(t"") -# -# Special data attribute handling. -# -def test_interpolated_data_attributes(): - data = {"user-id": 123, "role": "admin", "wild": True, "false": False, "none": None} - node = html(t"
    User Info
    ") - assert node == Element( - "div", - attrs={"data-user-id": "123", "data-role": "admin", "data-wild": None}, - children=[Text("User Info")], - ) - assert ( - str(node) - == '
    User Info
    ' - ) + 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"") -def test_data_attr_toggle_to_str(): - for node in [ - html(t"
    "), - html(t'
    '), - ]: - assert node == Element("div", {"data-selected": "yes"}) - assert str(node) == '
    ' +class Convertible: + def __str__(self): + return "string" + def __repr__(self): + return "repr" -def test_data_attr_toggle_to_true(): - node = html(t'
    ') - assert node == Element("div", {"data-selected": None}) - assert str(node) == "
    " +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_data_attr_unrelated_unaffected(): - node = html(t"
    ") - assert node == Element("div", {"data-selected": None, "data-active": None}) - assert str(node) == "
    " +def wrap_template_in_tags( + start_tag: str, template: Template, end_tag: str | None = None +): + """Utility for testing templated text but with different containing tags.""" + if end_tag is None: + end_tag = start_tag + return Template(f"<{start_tag}>") + template + Template(f"") -def test_data_attr_templated_error(): - data1 = {"user-id": "user-123"} - data2 = {"role": "admin"} - with pytest.raises(TypeError): - node = html(t'
    ') - print(str(node)) +def wrap_text_in_tags(start_tag: str, content: str, end_tag: str | None = None): + """Utility for testing expected text but with different containing tags.""" + if end_tag is None: + end_tag = start_tag + # Stringify to flatten `Markup()` + content = str(content) + return f"<{start_tag}>" + content + f"" -def test_data_attr_none(): - button_data = None - node = html(t"") - assert node == Element("button", children=[Text("X")]) - assert str(node) == "" +@pytest.mark.parametrize("to_html", PROCESSORS) +class TestInterpolationConversion: + def test_str(self, to_html): + c = Convertible() + for tag in ("p", "script", "title"): + assert to_html(wrap_template_in_tags(tag, t"{c!s}")) == wrap_text_in_tags( + tag, "string" + ) -def test_data_attr_errors(): - for v in [False, [], (), 0, "data?"]: - with pytest.raises(TypeError): - _ = html(t"") + def test_repr(self, to_html): + c = Convertible() + for tag in ("p", "script", "title"): + assert to_html(wrap_template_in_tags(tag, t"{c!r}")) == wrap_text_in_tags( + tag, "repr" + ) + def test_ascii_raw_text(self, to_html): + # single quotes are not escaped in raw text + assert to_html( + wrap_template_in_tags("script", t"{'😊'!a}") + ) == wrap_text_in_tags("script", ascii("😊")) + + def test_ascii_escapable_normal_and_raw(self, to_html): + # single quotes are escaped + for tag in ("p", "title"): + assert to_html( + wrap_template_in_tags(tag, t"{'😊'!a}") + ) == wrap_text_in_tags(tag, escape_html_text(ascii("😊"))) + + +@pytest.mark.parametrize("to_html", PROCESSORS) +class TestInterpolationFormatSpec: + def test_normal_text_safe(self, to_html): + raw_content = "underlined" + assert ( + to_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"") + ) + == f"<{tag}>The value is dynamic." + ) -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"") + ) -# -# 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"
    {user_profile if is_logged_in else login_prompt}
    ") + == "
    Welcome, User!
    " + ) -def test_class_merge_literal_then_interpolation(): - class_item = "blue" - node = html(t'

    ') - assert node == Element("p", {"class": "red blue"}) + is_logged_in = False + assert ( + to_html(t"
    {user_profile if is_logged_in else login_prompt}
    ") + == '' + ) -# -# Special style attribute handling. -# -def test_style_literal_attr_passthru(): - p_id = "para1" # non-literal attribute to cause attr resolution - node = html(t'

    Warning!

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

    Warning!

    ' +# -------------------------------------------------------------------------- +# Attributes +# -------------------------------------------------------------------------- +@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'') + == 'fresh' + ) -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}
    " +@pytest.mark.parametrize("to_html", PROCESSORS) +class TestSpecialDataAttribute: + """Special data attribute handling.""" + + 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_interpolated_template_component(): - node = 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) - == '
    Component: Hello, Component!
    ' - ) + 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_toggle_to_true(self, to_html): + res = to_html(t'
    ') + assert res == "
    " -def test_interpolated_template_component_no_children_provided(): - """Same test, but the caller didn't provide any children.""" - node = html( - t'<{FunctionComponent} first=1 second={99} third-arg="comp1" class="my-comp" />' - ) - assert node == Element( - "div", - attrs={ - "id": "comp1", - "data-first": "1", - "data-second": "99", - "class": "my-comp", - }, - children=[ - Text("Component: "), - ], - ) - assert ( - str(node) - == '
    Component:
    ' - ) + def test_data_attr_unrelated_unaffected(self, to_html): + res = to_html(t"
    ") + assert res == "
    " + 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_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_invalid_component_invocation(): - with pytest.raises(TypeError): - _ = html(t"<{FunctionComponent}>Missing props") +@pytest.mark.parametrize("to_html", PROCESSORS) +class TestSpecialAriaAttribute: + """Special aria attribute handling.""" -def FunctionComponentNoChildren(first: str, second: int, third_arg: str) -> Template: - # Ensure type correctness of props at runtime for testing purposes - assert isinstance(first, str) - assert isinstance(second, int) - assert isinstance(third_arg, str) - new_attrs = { - "id": third_arg, - "data": {"first": first, "second": second}, - } - return t"
    Component: ignore children
    " + def test_aria_templated_attr_error(self, to_html): + aria1 = {"label": "close"} + aria2 = {"hidden": "true"} + with pytest.raises(TypeError): + _ = 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_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_template_component_ignore_children(): - node = 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) - == '
    Component: ignore children
    ' - ) +@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 FunctionComponentKeywordArgs(first: str, **attrs: t.Any) -> Template: - # Ensure type correctness of props at runtime for testing purposes - assert isinstance(first, str) - assert "children" in attrs - _ = attrs.pop("children") - new_attrs = {"data-first": first, **attrs} - return t"
    Component with kwargs
    " + def test_interpolated_class_attribute_with_multiple_placeholders(self, 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_interpolated_attribute_spread_with_class_attribute(self, to_html): + attrs = {"id": "button1", "class": ["btn", "btn-primary"]} + res = to_html(t"") + assert res == '' -def test_children_always_passed_via_kwargs(): - node = 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
    ' - ) + 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(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_children_always_passed_via_kwargs_even_when_empty(): - node = html(t'<{FunctionComponentKeywordArgs} first="value" extra="info" />') - assert node == Element( - "div", - attrs={ - "data-first": "value", - "extra": "info", - }, - children=[Text("Component with kwargs")], - ) - assert ( - str(node) == '
    Component with kwargs
    ' - ) + def test_style_in_templated_attr(self, 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_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 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 = html(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_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={}) + == {} + ) -def test_component_passed_as_attr_value(): - def Wrapper( - children: t.Iterable[Node], sub_component: t.Callable, **attrs: t.Any +@pytest.mark.parametrize("to_html", PROCESSORS) +class TestFunctionComponent: + @staticmethod + def FunctionComponent( + children: Template, first: str, second: int, third_arg: str, **attrs: t.Any ) -> Template: - return t"<{sub_component} {attrs}>{children}" - - node = 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) - == '
    Component:

    Inside wrapper

    ' - ) - - -def test_nested_component_gh23(): - # See https://github.com/t-strings/tdom/issues/23 for context - def Header(): - return html(t"{'Hello World'}") - - node = html(t"<{Header} />") - assert node == Text("Hello World") - assert str(node) == "Hello World" - - -def test_component_returning_iterable(): - def Items() -> t.Iterable: - for i in range(2): - yield t"
  • Item {i + 1}
  • " - yield html(t"
  • Item {3}
  • ") - - node = html(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
    " - + # 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 ( + res + == '
    Component: Hello, Component!
    ' + ) -def test_component_returning_fragment(): - def Items() -> Node: - return html(t"
  • Item {1}
  • Item {2}
  • Item {3}
  • ") + 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:
    ' + ) - node = html(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_missing_props_error(self, to_html): + with pytest.raises(TypeError): + _ = to_html( + t"<{self.FunctionComponent}>Missing props" + ) -@dataclass -class ClassComponent: - """Example class-based component.""" +@pytest.mark.parametrize("to_html", PROCESSORS) +class TestFunctionComponentNoChildren: + @staticmethod + def FunctionComponentNoChildren( + first: str, second: int, third_arg: str + ) -> Template: + # Ensure type correctness of props at runtime for testing purposes + assert isinstance(first, str) + assert isinstance(second, int) + assert isinstance(third_arg, str) + new_attrs = { + "id": third_arg, + "data": {"first": first, "second": second}, + } + return t"
    Component: ignore children
    " + + def test_interpolated_template_component_ignore_children(self, 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: t.Iterable[Node] = field(default_factory=list) - def __call__(self) -> Node: - return html( - 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(): - node = 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) - == '
    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", - ) - node = html(t"<{avatar} />") - assert node == Element( - "div", - attrs={"class": "avatar"}, - children=[ - Element( - "a", - attrs={"href": "https://example.com/users/alice"}, - children=[ - Element( - "img", - attrs={ - "src": "https://example.com/alice.png", - "alt": "Avatar of Alice", - }, - ) - ], - ), - Element("span", children=[Text("Alice")]), - ], - ) - assert ( - str(node) - == '
    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) -> Node: - return html( - 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(): - node = 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) - == '
    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 @@ -1457,31 +1606,426 @@ def test_attribute_type_component(): "spread_dict": dict(), "spread_list": ["eggs", "milk"], } - node = html( + 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!" + + +@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 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_mismatched_component_closing_tag_fails(self, to_html): + def OpenTag(children: Template) -> Template: + return t"
    open
    " + + def CloseTag(children: Template) -> Template: + return t"
    close
    " + + with pytest.raises(TypeError): + _ = to_html(t"<{OpenTag}>Hello") + + +@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" + 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
    + +""" + assert to_html(smoke_t) == smoke_str + + +def struct_repr(st): + """Breakdown Templates into comparable parts for test verification.""" + return st.strings, tuple( + [ + (i.value, i.expression, i.conversion, i.format_spec) + for i in st.interpolations + ] + ) + + +def test_process_template_internal_cache(): + """Test that cache and non-cache both generally work as expected.""" + # @NOTE: We use a made-up custom element so that we can be sure to + # miss the cache. If this element is used elsewhere than the global + # cache might cache it and it will ruin our counting, specifically + # the first miss will instead be a hit. + sample_t = t"
    {'content'}
    " + sample_diff_t = t"
    {'diffcontent'}
    " + alt_t = t"{'content'}" + process_api = processor_service_factory() + cached_process_api = cached_processor_service_factory() + # Because the cache is stored on the class itself this can be affect by + # other tests, so save this off and take the difference to determine the result, + # this is not great and hopefully we can find a better solution. + assert isinstance(cached_process_api.parser_api, CachedParserService) + start_ci = cached_process_api.parser_api._to_tnode.cache_info() + tnode1 = process_api.parser_api.to_tnode(sample_t) + tnode2 = process_api.parser_api.to_tnode(sample_t) + cached_tnode1 = cached_process_api.parser_api.to_tnode(sample_t) + cached_tnode2 = cached_process_api.parser_api.to_tnode(sample_t) + cached_tnode3 = cached_process_api.parser_api.to_tnode(sample_diff_t) + # Check that the uncached and cached services are actually + # returning non-identical results. + assert tnode1 is not cached_tnode1 + assert tnode1 is not cached_tnode2 + assert tnode1 is not cached_tnode3 + # Check that the uncached service returns a brand new result everytime. + assert tnode1 is not tnode2 + # Check that the cached service is returning the exact same, identical, result. + assert cached_tnode1 is cached_tnode2 + # Even if the input templates are not identical (but are still equivalent). + assert cached_tnode1 is cached_tnode3 and sample_t is not sample_diff_t + # Check that the cached service and uncached services return + # results that are equivalent (even though they are not (id)entical). + assert tnode1 == cached_tnode1 + assert tnode2 == cached_tnode1 + # Now that we are setup we check that the cache is internally + # working as we intended. + ci = cached_process_api.parser_api._to_tnode.cache_info() + # cached_tnode2 and cached_tnode3 are hits after cached_tnode1 + assert ci.hits - start_ci.hits == 2 + # cached_tf1 was a miss because cache was empty (brand new) + assert ci.misses - start_ci.misses == 1 + cached_tnode4 = cached_process_api.parser_api.to_tnode(alt_t) + # A different template produces a brand new tf. + assert cached_tnode1 is not cached_tnode4 + # The template is new AND has a different structure so it also + # produces an unequivalent tf. + assert cached_tnode1 != cached_tnode4 + + +@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"""
    """ + + 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): + 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("to_html", PROCESSORS) +@pytest.mark.parametrize( + "provider", + ( + get_select_t_with_list, + get_select_t_with_generator, + get_select_t_with_concat, + ), +) +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])) + return provider(PRIMARY_COLORS, selected_values) + + no_selection_t = get_color_select_t(set(), provider) + assert ( + to_html(no_selection_t) + == '' + ) + selected_yellow_t = get_color_select_t({"Y"}, provider) + assert ( + to_html(selected_yellow_t) + == '' + ) -def test_component_non_callable_fails(): - with pytest.raises(TypeError): - _ = html(t"<{'not a function'} />") +@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): + return t"""
    {children}
    """ -def RequiresPositional(whoops: int, /) -> Template: # pragma: no cover - return t"

    Positional arg: {whoops}

    " + def FooterComponent(classes=("footer-default",)): + return t'' + def LayoutComponent(children, body_classes=None): + return t""" + + + + + + + + {children} + <{FooterComponent} /> + + +""" -def test_component_requiring_positional_arg_fails(): - with pytest.raises(TypeError): - _ = html(t"<{RequiresPositional} />") + content = "HTML never goes out of style." + content_str = to_html( + t"<{LayoutComponent} body_classes={['theme-default']}><{PageComponent}>{content}" + ) + assert ( + content_str + == """ + + + + + + + +
    HTML never goes out of style.
    + + + +""" + ) + + +@pytest.mark.parametrize("to_html", PROCESSORS) +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, to_html): + """Type raw text should fail because template is already not allowed.""" + content = '' + content_t = t"{content}" + with pytest.raises( + ValueError, match="Recursive includes are not supported within script" + ): + content_t = t'' + _ = to_html(t"") + + 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}" + with pytest.raises( + ValueError, match="Recursive includes are not supported within textarea" + ): + _ = to_html(t"") + + 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}" + LT, GT, DQ = map(markupsafe_escape, ["<", ">", '"']) + assert ( + to_html(t"
    {content_t}
    ") + == f"
    {LT}script{GT}console.log({DQ}123!{DQ});{LT}/script{GT}
    " + ) -def test_mismatched_component_closing_tag_fails(): - with pytest.raises(TypeError): - _ = html( - t"<{FunctionComponent} first=1 second={99} third-arg='comp1'>Hello" +@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 + == '' + ) + + +@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"""

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

    """ + res = to_html(mathml_t) + assert ( + str(res) + == """

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

    """ + ) + + +@pytest.mark.skip( + "SVG+MATHML: This needs ns context for case correcting tags and attributes." +) +@pytest.mark.parametrize("to_html", PROCESSORS) +def test_svg(to_html): + cx, cy, r, fill = 150, 100, 80, "green" + svg_t = t""" + + + SVG +""" + res = to_html(svg_t) + assert ( + res + == """ + + + SVG +""" + ) + + +@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 +""" + res = to_html(svg_t) + assert ( + res + == """ + + + SVG +""" + ) 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) diff --git a/tdom/sentinel.py b/tdom/sentinel.py new file mode 100644 index 0000000..a292332 --- /dev/null +++ b/tdom/sentinel.py @@ -0,0 +1,10 @@ +""" +Simple sentinel, ie. NOT_SET / NOT_GIVEN for use in project. +""" + + +class NotSet: + pass + + +NOT_SET = NotSet() diff --git a/tdom/template_utils.py b/tdom/template_utils.py index 39dc8b3..c6e39e1 100644 --- a/tdom/template_utils.py +++ b/tdom/template_utils.py @@ -74,3 +74,19 @@ 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 + + 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 971b1ee..1602487 100644 --- a/tdom/template_utils_test.py +++ b/tdom/template_utils_test.py @@ -55,3 +55,49 @@ 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", + ] + + +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")