Unclosed")
+
+
+def test_parse_html_iter_preserves_chunks():
+ chunks = [
+ "
",
+ "Hello ",
+ "there, ",
+ "world ",
+ "!
",
+ ]
+ node = parse_html_iter(chunks)
+ assert node == Element(
+ "div",
+ children=[
+ Text("Hello "),
+ Text("there, "),
+ Element("span", children=[Text("world")]),
+ Text("!"),
+ ],
+ )
diff --git a/html_tstring/processor.py b/html_tstring/processor.py
new file mode 100644
index 0000000..26611b1
--- /dev/null
+++ b/html_tstring/processor.py
@@ -0,0 +1,179 @@
+import random
+import string
+import typing as t
+from collections.abc import Iterable
+from functools import lru_cache
+from string.templatelib import Interpolation, Template
+
+from markupsafe import Markup
+
+from .nodes import Element, Fragment, HasHTMLDunder, Node, Text
+from .parser import parse_html_iter
+from .utils import format_interpolation as base_format_interpolation
+
+# --------------------------------------------------------------------------
+# Value formatting
+# --------------------------------------------------------------------------
+
+
+def _format_safe(value: object, format_spec: str) -> str:
+ assert format_spec == "safe"
+ return Markup(value)
+
+
+CUSTOM_FORMATTERS = (("safe", _format_safe),)
+
+
+def format_interpolation(interpolation: Interpolation) -> object:
+ return base_format_interpolation(
+ interpolation,
+ formatters=CUSTOM_FORMATTERS,
+ )
+
+
+# --------------------------------------------------------------------------
+# Instrumentation, Parsing, and Caching
+# --------------------------------------------------------------------------
+
+_PLACEHOLDER_PREFIX = f"t🐍-{''.join(random.choices(string.ascii_lowercase, k=4))}-"
+_PP_LEN = len(_PLACEHOLDER_PREFIX)
+
+
+def _placeholder(i: int) -> str:
+ """Generate a placeholder for the i-th interpolation."""
+ return f"{_PLACEHOLDER_PREFIX}{i}"
+
+
+def _placholder_index(s: str) -> int:
+ """Extract the index from a placeholder string."""
+ return int(s[_PP_LEN:])
+
+
+def _instrument(strings: tuple[str, ...]) -> t.Iterable[str]:
+ """
+ Join the strings with placeholders in between where interpolations go.
+
+ This is used to prepare the template string for parsing, so that we can
+ later substitute the actual interpolated values into the parse tree.
+
+ The placeholders are chosen to be unlikely to collide with typical HTML
+ content.
+ """
+ count = len(strings)
+
+ # TODO: special case callables() so that we use the same placeholder
+ # to open *and* close tags.
+
+ for i, s in enumerate(strings):
+ yield s
+ # There are always count-1 placeholders between count strings.
+ if i < count - 1:
+ yield _placeholder(i)
+
+
+@lru_cache()
+def _instrument_and_parse(strings: tuple[str, ...]) -> Node:
+ """
+ Instrument the strings and parse the resulting HTML.
+
+ The result is cached to avoid re-parsing the same template multiple times.
+ """
+ instrumented = _instrument(strings)
+ return parse_html_iter(instrumented)
+
+
+# --------------------------------------------------------------------------
+# Placeholder Substitution
+# --------------------------------------------------------------------------
+
+
+def _substitute_attrs(
+ attrs: dict[str, str | None], interpolations: tuple[Interpolation, ...]
+) -> dict[str, str | None]:
+ new_attrs: dict[str, str | None] = {}
+ for key, value in attrs.items():
+ if key.startswith(_PLACEHOLDER_PREFIX):
+ index = _placholder_index(key)
+ interpolation = interpolations[index]
+ value = format_interpolation(interpolation)
+ if not isinstance(value, str):
+ raise ValueError(
+ f"Attribute interpolation must be a string, got: {value!r}"
+ )
+ new_attrs[key] = value
+ else:
+ new_attrs[key] = value
+ return new_attrs
+
+
+def _substitute_and_flatten_children(
+ children: t.Iterable[Node], interpolations: tuple[Interpolation, ...]
+) -> list[Node]:
+ """Substitute placeholders in a list of children and flatten any fragments."""
+ new_children: list[Node] = []
+ for child in children:
+ substituted = _substitute_node(child, interpolations)
+ if isinstance(substituted, Fragment):
+ # This can happen if an interpolation results in a Fragment, for
+ # instance if it is iterable.
+ new_children.extend(substituted.children)
+ else:
+ new_children.append(substituted)
+ return new_children
+
+
+def _node_from_value(value: object) -> Node:
+ """Convert a value to a Node, if possible."""
+ # This is a bit of a hack, but it lets us handle Markup and
+ # other objects that implement __html__ without special-casing them here.
+ # We use a Text node to wrap the value, then parse it back out.
+ # This is not the most efficient, but it is simple and works.
+ node = Text(_placeholder(0))
+ interpolations = (Interpolation(value, "", None, ""),)
+ return _substitute_node(node, interpolations)
+
+
+def _substitute_node(p_node: Node, interpolations: tuple[Interpolation, ...]) -> Node:
+ match p_node:
+ case Text(text) if str(text).startswith(_PLACEHOLDER_PREFIX):
+ index = _placholder_index(str(text))
+ interpolation = interpolations[index]
+ value = format_interpolation(interpolation)
+ match value:
+ case str():
+ return Text(value)
+ case Node():
+ return value
+ case Template():
+ return html(value)
+ case HasHTMLDunder():
+ return Text(value)
+ case False:
+ return Text("")
+ case Iterable():
+ children = [_node_from_value(v) for v in value]
+ return Fragment(children=children)
+ case _:
+ return Text(str(value))
+ case Element(tag=tag, attrs=attrs, children=children):
+ new_attrs = _substitute_attrs(attrs, interpolations)
+ new_children = _substitute_and_flatten_children(children, interpolations)
+ return Element(tag=tag, attrs=new_attrs, children=new_children)
+ case Fragment(children=children):
+ new_children = _substitute_and_flatten_children(children, interpolations)
+ return Fragment(children=new_children)
+ case _:
+ return p_node
+
+
+# --------------------------------------------------------------------------
+# Public API
+# --------------------------------------------------------------------------
+
+
+def html(template: Template) -> Node:
+ """Create an HTML element from a string."""
+ # Parse the HTML, returning a tree of nodes with placeholders
+ # where interpolations go.
+ p_node = _instrument_and_parse(template.strings)
+ return _substitute_node(p_node, template.interpolations)
diff --git a/html_tstring/processor_test.py b/html_tstring/processor_test.py
new file mode 100644
index 0000000..5ea7e75
--- /dev/null
+++ b/html_tstring/processor_test.py
@@ -0,0 +1,514 @@
+from string.templatelib import Template
+
+import pytest
+from markupsafe import Markup
+
+from .nodes import Element, Fragment, Text
+from .processor import html
+
+# --------------------------------------------------------------------------
+# Basic HTML parsing tests
+# --------------------------------------------------------------------------
+
+
+def test_parse_empty():
+ node = html(t"")
+ assert node == Text("")
+ assert str(node) == ""
+
+
+def test_parse_text():
+ node = html(t"Hello, world!")
+ assert node == Text("Hello, world!")
+ assert str(node) == "Hello, world!"
+
+
+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) == '
'
+
+
+def test_parse_element_with_text():
+ node = html(t"
Hello, world!
")
+ assert node == Element(
+ "p",
+ children=[
+ Text("Hello, world!"),
+ ],
+ )
+ assert str(node) == "
Hello, world!
"
+
+
+def test_parse_element_with_attributes():
+ node = html(t'
Link ')
+ assert node == Element(
+ "a",
+ attrs={"href": "https://example.com", "target": "_blank"},
+ children=[
+ Text("Link"),
+ ],
+ )
+ assert str(node) == '
Link '
+
+
+def test_parse_nested_elements():
+ node = html(t"
")
+ assert node == Element(
+ "div",
+ children=[
+ Element("p", children=[Text("Hello")]),
+ Element("p", children=[Text("World")]),
+ ],
+ )
+ assert str(node) == "
"
+
+
+# --------------------------------------------------------------------------
+# Interpolated text content
+# --------------------------------------------------------------------------
+
+
+def text_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_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>!
"
+
+
+class Convertible:
+ def __str__(self):
+ return "string"
+
+ def __repr__(self):
+ return "repr"
+
+
+def test_conversions():
+ c = Convertible()
+ assert f"{c!s}" == "string"
+ assert f"{c!r}" == "repr"
+ node = 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'")]),
+ ],
+ )
+
+
+# --------------------------------------------------------------------------
+# Raw HTML injection tests
+# --------------------------------------------------------------------------
+
+
+def test_raw_html_injection_with_helper():
+ 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_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(content), 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.
"
+
+
+# --------------------------------------------------------------------------
+# Conditional rendering and control flow
+# --------------------------------------------------------------------------
+
+
+def test_conditional_rendering_with_if_else():
+ is_logged_in = True
+ user_profile = t"Welcome, User! "
+ login_prompt = t"Please log in "
+ node = html(t"{user_profile if is_logged_in else login_prompt}
")
+
+ assert node == Element(
+ "div", children=[Element("span", children=[Text("Welcome, User!")])]
+ )
+ assert str(node) == "Welcome, User!
"
+
+ is_logged_in = False
+ node = html(t"{user_profile if is_logged_in else login_prompt}
")
+ assert str(node) == ''
+
+
+def test_conditional_rendering_with_and():
+ show_warning = True
+ warning_message = t'Warning!
'
+ node = html(t"{show_warning and warning_message} ")
+
+ assert node == Element(
+ "main",
+ children=[
+ Element("div", attrs={"class": "warning"}, children=[Text("Warning!")]),
+ ],
+ )
+ assert str(node) == 'Warning!
'
+
+ show_warning = False
+ node = html(t"{show_warning and warning_message} ")
+ # Assuming False renders nothing
+ assert str(node) == " "
+
+
+# --------------------------------------------------------------------------
+# Interpolated nesting of templates and elements
+# --------------------------------------------------------------------------
+
+
+def test_interpolated_template_content():
+ child = t"Child "
+ node = html(t"{child}
")
+ assert node == Element("div", children=[html(child)])
+ assert str(node) == "Child
"
+
+
+def test_interpolated_element_content():
+ child = html(t"Child ")
+ node = html(t"{child}
")
+ assert node == Element("div", children=[child])
+ assert str(node) == "Child
"
+
+
+def test_interpolated_nonstring_content():
+ number = 42
+ node = html(t"The answer is {number}.
")
+ assert node == Element(
+ "p", children=[Text("The answer is "), Text("42"), Text(".")]
+ )
+ assert str(node) == "The answer is 42.
"
+
+
+def test_list_items():
+ items = ["Apple", "Banana", "Cherry"]
+ node = html(t"{[t'{item} ' for item in items]} ")
+ assert node == Element(
+ "ul",
+ children=[
+ Element("li", children=[Text("Apple")]),
+ Element("li", children=[Text("Banana")]),
+ Element("li", children=[Text("Cherry")]),
+ ],
+ )
+ assert str(node) == ""
+
+
+def test_nested_list_items():
+ # TODO XXX this is a pretty abusrd test case; clean it up when refactoring
+ outer = ["fruit", "more fruit"]
+ inner = ["apple", "banana", "cherry"]
+ inner_items = [t"{item} " for item in inner]
+ outer_items = [t"{category} " for category in outer]
+ node = html(t"")
+ assert node == Element(
+ "ul",
+ children=[
+ Element(
+ "li",
+ children=[
+ Text("fruit"),
+ Element(
+ "ul",
+ children=[
+ Element("li", children=[Text("apple")]),
+ Element("li", children=[Text("banana")]),
+ Element("li", children=[Text("cherry")]),
+ ],
+ ),
+ ],
+ ),
+ Element(
+ "li",
+ children=[
+ Text("more fruit"),
+ Element(
+ "ul",
+ children=[
+ Element("li", children=[Text("apple")]),
+ Element("li", children=[Text("banana")]),
+ Element("li", children=[Text("cherry")]),
+ ],
+ ),
+ ],
+ ),
+ ],
+ )
+ assert (
+ str(node)
+ == ""
+ )
+
+
+# --------------------------------------------------------------------------
+# Interpolated attribute content
+# --------------------------------------------------------------------------
+
+
+def test_interpolated_attribute_value():
+ url = "https://example.com/"
+ node = html(t'Link ')
+ assert node == Element(
+ "a", attrs={"href": "https://example.com/"}, children=[Text("Link")]
+ )
+ assert str(node) == 'Link '
+
+
+def test_escaping_of_interpolated_attribute_value():
+ url = 'https://example.com/?q="test"&lang=en'
+ node = html(t'Link ')
+ assert node == Element(
+ "a",
+ attrs={"href": 'https://example.com/?q="test"&lang=en'},
+ children=[Text("Link")],
+ )
+ assert (
+ str(node)
+ == 'Link '
+ )
+
+
+def test_interpolated_unquoted_attribute_value():
+ id = "roquefort"
+ node = html(t"Cheese
")
+ assert node == Element("div", attrs={"id": "roquefort"}, children=[Text("Cheese")])
+ assert str(node) == 'Cheese
'
+
+
+def test_interpolated_attribute_value_true():
+ disabled = True
+ node = html(t"Click me ")
+ assert node == Element(
+ "button", attrs={"disabled": None}, children=[Text("Click me")]
+ )
+ assert str(node) == "Click me "
+
+
+def test_interpolated_attribute_value_falsy():
+ disabled = False
+ crumpled = None
+ node = html(t"Click me ")
+ assert node == Element("button", attrs={}, children=[Text("Click me")])
+ assert str(node) == "Click me "
+
+
+def test_interpolated_attribute_spread_dict():
+ attrs = {"href": "https://example.com/", "target": "_blank"}
+ node = html(t"Link ")
+ assert node == Element(
+ "a",
+ attrs={"href": "https://example.com/", "target": "_blank"},
+ children=[Text("Link")],
+ )
+ assert str(node) == 'Link '
+
+
+def test_interpolated_mixed_attribute_values_and_spread_dict():
+ attrs = {"href": "https://example.com/", "id": "link1"}
+ target = "_blank"
+ node = html(t'Link ')
+ assert node == Element(
+ "a",
+ attrs={"href": "https://example.com/", "id": "link1", "target": "_blank"},
+ children=[Text("Link")],
+ )
+ assert (
+ str(node)
+ == 'Link '
+ )
+
+
+def test_multiple_attribute_spread_dicts():
+ attrs1 = {"href": "https://example.com/", "id": "overwrtten"}
+ attrs2 = {"target": "_blank", "id": "link1"}
+ node = html(t"Link ")
+ assert node == Element(
+ "a",
+ attrs={"href": "https://example.com/", "id": "link1", "target": "_blank"},
+ children=[Text("Link")],
+ )
+ assert (
+ str(node)
+ == 'Link '
+ )
+
+
+def test_interpolated_class_attribute():
+ classes = ["btn", "btn-primary", False and "disabled", None, {"active": True}]
+ node = html(t'Click me ')
+ assert node == Element(
+ "button",
+ attrs={"class": "btn btn-primary active"},
+ children=[Text("Click me")],
+ )
+ assert str(node) == 'Click me '
+
+
+def test_interpolated_attribute_spread_with_class_attribute():
+ attrs = {"id": "button1", "class": ["btn", "btn-primary"]}
+ node = html(t"Click me ")
+ assert node == Element(
+ "button",
+ attrs={"id": "button1", "class": "btn btn-primary"},
+ children=[Text("Click me")],
+ )
+ assert str(node) == 'Click me '
+
+
+def test_interpolated_data_attributes():
+ data = {"user-id": 123, "role": "admin"}
+ node = html(t"User Info
")
+ assert node == Element(
+ "div",
+ attrs={"data-user-id": "123", "data-role": "admin"},
+ children=[Text("User Info")],
+ )
+ assert str(node) == 'User Info
'
+
+
+def test_interpolated_aria_attributes():
+ aria = {"label": "Close", "hidden": True}
+ node = html(t"X ")
+ assert node == Element(
+ "button",
+ attrs={"aria-label": "Close", "aria-hidden": "True"},
+ children=[Text("X")],
+ )
+ assert str(node) == 'X '
+
+
+def test_interpolated_style_attribute():
+ 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!
'
+ )
+
+
+# --------------------------------------------------------------------------
+# Function component interpolation tests
+# --------------------------------------------------------------------------
+
+
+def TemplateComponent(
+ *children: Element | str, first: int, second: int, third: str, **props: str
+) -> Template:
+ attrs = {
+ "id": third,
+ "data": {"first": first, "second": second},
+ **props,
+ }
+ return t"Component: {children}
"
+
+
+def test_interpolated_template_component():
+ node = html(
+ t'<{TemplateComponent} first=1 second={99} third="comp1" class="my-comp">Hello, Component!{TemplateComponent}>'
+ )
+ 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_invalid_component_invocation():
+ with pytest.raises(TypeError):
+ _ = html(t"<{TemplateComponent}>Missing props{TemplateComponent}>") # type: ignore
+
+
+def ColumnsComponent() -> Template:
+ return t"""Column 1 Column 2 """
+
+
+def test_fragment_from_component():
+ # This test assumes that if a component returns a template that parses
+ # into multiple root elements, they are treated as a fragment.
+ node = html(t"")
+ assert node == Element(
+ "table",
+ children=[
+ Element(
+ "tr",
+ children=[
+ Element("td", children=[Text("Column 1")]),
+ Element("td", children=[Text("Column 2")]),
+ ],
+ ),
+ ],
+ )
+ assert str(node) == ""
diff --git a/html_tstring/utils.py b/html_tstring/utils.py
new file mode 100644
index 0000000..d1c25b4
--- /dev/null
+++ b/html_tstring/utils.py
@@ -0,0 +1,88 @@
+import typing as t
+from string.templatelib import Interpolation
+
+
+@t.overload
+def convert[T](value: T, conversion: None) -> T: ...
+
+
+@t.overload
+def convert(value: object, conversion: t.Literal["a", "r", "s"]) -> str: ...
+
+
+def convert[T](value: T, conversion: t.Literal["a", "r", "s"] | None) -> T | str:
+ """
+ Convert a value according to the given conversion specifier.
+
+ In the future, something like this should probably ship with Python itself.
+ """
+ if conversion == "a":
+ return ascii(value)
+ elif conversion == "r":
+ return repr(value)
+ elif conversion == "s":
+ return str(value)
+ else:
+ return value
+
+
+type FormatMatcher = t.Callable[[str], bool]
+"""A predicate function that returns True if a given format specifier matches its criteria."""
+
+type CustomFormatter = t.Callable[[object, str], str]
+"""A function that takes a value and a format specifier and returns a formatted string."""
+
+type MatcherAndFormatter = tuple[str | FormatMatcher, CustomFormatter]
+"""
+A pair of a matcher and its corresponding formatter.
+
+The matcher is used to determine if the formatter should be applied to a given
+format specifier. If the matcher is a string, it must exactly match the format
+specifier. If it is a FormatMatcher, it is called with the format specifier and
+should return True if the formatter should be used.
+"""
+
+
+def _matcher_matches(matcher: str | FormatMatcher, format_spec: str) -> bool:
+ """Check if a matcher matches a given format specifier."""
+ return matcher == format_spec if isinstance(matcher, str) else matcher(format_spec)
+
+
+def _format_interpolation(
+ value: object,
+ format_spec: str,
+ conversion: t.Literal["a", "r", "s"] | None,
+ *,
+ formatters: t.Sequence[MatcherAndFormatter],
+) -> object:
+ converted = convert(value, conversion)
+ if format_spec:
+ for matcher, formatter in formatters:
+ if _matcher_matches(matcher, format_spec):
+ return formatter(converted, format_spec)
+ return format(converted, format_spec)
+ return converted
+
+
+def format_interpolation(
+ interpolation: Interpolation,
+ *,
+ formatters: t.Sequence[MatcherAndFormatter] = tuple(),
+) -> object:
+ """
+ Format an Interpolation's value according to its format spec and conversion.
+
+ PEP 750 allows t-string processing code to decide whether, and how, to
+ interpret format specifiers. This function takes an optional sequence of
+ (matcher, formatter) pairs. If a matcher returns True for the given format
+ spec, the corresponding formatter is used to format the value. If no
+ matchers match, the default formatting behavior is used.
+
+ Conversions are always applied before formatting.
+ """
+ return _format_interpolation(
+ interpolation.value,
+ interpolation.format_spec,
+ interpolation.conversion,
+ formatters=formatters,
+ )
diff --git a/html_tstring/utils_test.py b/html_tstring/utils_test.py
new file mode 100644
index 0000000..f00de23
--- /dev/null
+++ b/html_tstring/utils_test.py
@@ -0,0 +1,93 @@
+from string.templatelib import Interpolation
+
+from .utils import convert, format_interpolation
+
+
+class Convertible:
+ def __str__(self) -> str:
+ return "Convertible str"
+
+ def __repr__(self) -> str:
+ return "Convertible repr"
+
+
+def test_convert_none():
+ value = Convertible()
+ assert convert(value, None) is value
+
+
+def test_convert_a():
+ value = Convertible()
+ assert convert(value, "a") == "Convertible repr"
+ assert convert("Café", "a") == "'Caf\\xe9'"
+
+
+def test_convert_r():
+ value = Convertible()
+ assert convert(value, "r") == "Convertible repr"
+
+
+def test_convert_s():
+ value = Convertible()
+ assert convert(value, "s") == "Convertible str"
+
+
+def test_format_interpolation_no_formatting():
+ value = Convertible()
+ interp = Interpolation(value, expression="", conversion=None, format_spec="")
+ assert format_interpolation(interp) is value
+
+
+def test_format_interpolation_a():
+ value = Convertible()
+ interp = Interpolation(value, expression="", conversion="a", format_spec="")
+ assert format_interpolation(interp) == "Convertible repr"
+
+
+def test_format_interpolation_r():
+ value = Convertible()
+ interp = Interpolation(value, expression="", conversion="r", format_spec="")
+ assert format_interpolation(interp) == "Convertible repr"
+
+
+def test_format_interpolation_s():
+ value = Convertible()
+ interp = Interpolation(value, expression="", conversion="s", format_spec="")
+ assert format_interpolation(interp) == "Convertible str"
+
+
+def test_format_interpolation_default_formatting():
+ value = 42
+ interp = Interpolation(value, expression="", conversion=None, format_spec="5d")
+ assert format_interpolation(interp) == " 42"
+
+
+def test_format_interpolation_custom_formatter_match_exact():
+ value = 42
+ interp = Interpolation(value, expression="", conversion=None, format_spec="custom")
+
+ def formatter(val: object, spec: str) -> str:
+ return f"formatted-{val}-{spec}"
+
+ assert (
+ format_interpolation(interp, formatters=[("custom", formatter)])
+ == "formatted-42-custom"
+ )
+
+
+def test_format_interpolation_custom_formatter_match_predicate():
+ value = 42
+ interp = Interpolation(
+ value, expression="", conversion=None, format_spec="custom123"
+ )
+
+ def matcher(spec: str) -> bool:
+ return spec.startswith("custom")
+
+ def formatter(val: object, spec: str) -> str:
+ return f"formatted-{val}-{spec}"
+
+ assert (
+ format_interpolation(interp, formatters=[(matcher, formatter)])
+ == "formatted-42-custom123"
+ )
diff --git a/pyproject.toml b/pyproject.toml
index e892d24..0e6a31a 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -8,7 +8,9 @@ version = "0.1.0"
description = "Tools to manipulate and render HTML using Python 3.14's t-strings."
readme = "README.md"
requires-python = ">=3.14"
-dependencies = []
+dependencies = [
+ "markupsafe>=3.0.2",
+]
authors = [{ name = "Dave Peck", email = "davepeck@davepeck.org" }]
license = { text = "MIT" }
classifiers = [
diff --git a/uv.lock b/uv.lock
index b620fe9..dd1c547 100644
--- a/uv.lock
+++ b/uv.lock
@@ -15,6 +15,9 @@ wheels = [
name = "html-tstring"
version = "0.1.0"
source = { editable = "." }
+dependencies = [
+ { name = "markupsafe" },
+]
[package.dev-dependencies]
dev = [
@@ -25,6 +28,7 @@ dev = [
]
[package.metadata]
+requires-dist = [{ name = "markupsafe", specifier = ">=3.0.2" }]
[package.metadata.requires-dev]
dev = [
@@ -43,6 +47,12 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" },
]
+[[package]]
+name = "markupsafe"
+version = "3.0.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", size = 20537, upload-time = "2024-10-18T15:21:54.129Z" }
+
[[package]]
name = "nodeenv"
version = "1.9.1"
@@ -81,20 +91,20 @@ wheels = [
[[package]]
name = "pyright"
-version = "1.1.404"
+version = "1.1.405"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "nodeenv" },
{ name = "typing-extensions" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/e2/6e/026be64c43af681d5632722acd100b06d3d39f383ec382ff50a71a6d5bce/pyright-1.1.404.tar.gz", hash = "sha256:455e881a558ca6be9ecca0b30ce08aa78343ecc031d37a198ffa9a7a1abeb63e", size = 4065679, upload-time = "2025-08-20T18:46:14.029Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/fb/6c/ba4bbee22e76af700ea593a1d8701e3225080956753bee9750dcc25e2649/pyright-1.1.405.tar.gz", hash = "sha256:5c2a30e1037af27eb463a1cc0b9f6d65fec48478ccf092c1ac28385a15c55763", size = 4068319, upload-time = "2025-09-04T03:37:06.776Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/84/30/89aa7f7d7a875bbb9a577d4b1dc5a3e404e3d2ae2657354808e905e358e0/pyright-1.1.404-py3-none-any.whl", hash = "sha256:c7b7ff1fdb7219c643079e4c3e7d4125f0dafcc19d253b47e898d130ea426419", size = 5902951, upload-time = "2025-08-20T18:46:12.096Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/1a/524f832e1ff1962a22a1accc775ca7b143ba2e9f5924bb6749dce566784a/pyright-1.1.405-py3-none-any.whl", hash = "sha256:a2cb13700b5508ce8e5d4546034cb7ea4aedb60215c6c33f56cec7f53996035a", size = 5905038, upload-time = "2025-09-04T03:37:04.913Z" },
]
[[package]]
name = "pytest"
-version = "8.4.1"
+version = "8.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
@@ -103,9 +113,9 @@ dependencies = [
{ name = "pluggy" },
{ name = "pygments" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/08/ba/45911d754e8eba3d5a841a5ce61a65a685ff1798421ac054f85aa8747dfb/pytest-8.4.1.tar.gz", hash = "sha256:7c67fd69174877359ed9371ec3af8a3d2b04741818c51e5e99cc1742251fa93c", size = 1517714, upload-time = "2025-06-18T05:48:06.109Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/29/16/c8a903f4c4dffe7a12843191437d7cd8e32751d5de349d45d3fe69544e87/pytest-8.4.1-py3-none-any.whl", hash = "sha256:539c70ba6fcead8e78eebbf1115e8b589e7565830d7d006a8723f19ac8a0afb7", size = 365474, upload-time = "2025-06-18T05:48:03.955Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" },
]
[[package]]
@@ -122,28 +132,28 @@ wheels = [
[[package]]
name = "ruff"
-version = "0.12.11"
+version = "0.12.12"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/de/55/16ab6a7d88d93001e1ae4c34cbdcfb376652d761799459ff27c1dc20f6fa/ruff-0.12.11.tar.gz", hash = "sha256:c6b09ae8426a65bbee5425b9d0b82796dbb07cb1af045743c79bfb163001165d", size = 5347103, upload-time = "2025-08-28T13:59:08.87Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/a8/f0/e0965dd709b8cabe6356811c0ee8c096806bb57d20b5019eb4e48a117410/ruff-0.12.12.tar.gz", hash = "sha256:b86cd3415dbe31b3b46a71c598f4c4b2f550346d1ccf6326b347cc0c8fd063d6", size = 5359915, upload-time = "2025-09-04T16:50:18.273Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/d6/a2/3b3573e474de39a7a475f3fbaf36a25600bfeb238e1a90392799163b64a0/ruff-0.12.11-py3-none-linux_armv6l.whl", hash = "sha256:93fce71e1cac3a8bf9200e63a38ac5c078f3b6baebffb74ba5274fb2ab276065", size = 11979885, upload-time = "2025-08-28T13:58:26.654Z" },
- { url = "https://files.pythonhosted.org/packages/76/e4/235ad6d1785a2012d3ded2350fd9bc5c5af8c6f56820e696b0118dfe7d24/ruff-0.12.11-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b8e33ac7b28c772440afa80cebb972ffd823621ded90404f29e5ab6d1e2d4b93", size = 12742364, upload-time = "2025-08-28T13:58:30.256Z" },
- { url = "https://files.pythonhosted.org/packages/2c/0d/15b72c5fe6b1e402a543aa9d8960e0a7e19dfb079f5b0b424db48b7febab/ruff-0.12.11-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d69fb9d4937aa19adb2e9f058bc4fbfe986c2040acb1a4a9747734834eaa0bfd", size = 11920111, upload-time = "2025-08-28T13:58:33.677Z" },
- { url = "https://files.pythonhosted.org/packages/3e/c0/f66339d7893798ad3e17fa5a1e587d6fd9806f7c1c062b63f8b09dda6702/ruff-0.12.11-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:411954eca8464595077a93e580e2918d0a01a19317af0a72132283e28ae21bee", size = 12160060, upload-time = "2025-08-28T13:58:35.74Z" },
- { url = "https://files.pythonhosted.org/packages/03/69/9870368326db26f20c946205fb2d0008988aea552dbaec35fbacbb46efaa/ruff-0.12.11-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6a2c0a2e1a450f387bf2c6237c727dd22191ae8c00e448e0672d624b2bbd7fb0", size = 11799848, upload-time = "2025-08-28T13:58:38.051Z" },
- { url = "https://files.pythonhosted.org/packages/25/8c/dd2c7f990e9b3a8a55eee09d4e675027d31727ce33cdb29eab32d025bdc9/ruff-0.12.11-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ca4c3a7f937725fd2413c0e884b5248a19369ab9bdd850b5781348ba283f644", size = 13536288, upload-time = "2025-08-28T13:58:40.046Z" },
- { url = "https://files.pythonhosted.org/packages/7a/30/d5496fa09aba59b5e01ea76775a4c8897b13055884f56f1c35a4194c2297/ruff-0.12.11-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:4d1df0098124006f6a66ecf3581a7f7e754c4df7644b2e6704cd7ca80ff95211", size = 14490633, upload-time = "2025-08-28T13:58:42.285Z" },
- { url = "https://files.pythonhosted.org/packages/9b/2f/81f998180ad53445d403c386549d6946d0748e536d58fce5b5e173511183/ruff-0.12.11-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5a8dd5f230efc99a24ace3b77e3555d3fbc0343aeed3fc84c8d89e75ab2ff793", size = 13888430, upload-time = "2025-08-28T13:58:44.641Z" },
- { url = "https://files.pythonhosted.org/packages/87/71/23a0d1d5892a377478c61dbbcffe82a3476b050f38b5162171942a029ef3/ruff-0.12.11-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4dc75533039d0ed04cd33fb8ca9ac9620b99672fe7ff1533b6402206901c34ee", size = 12913133, upload-time = "2025-08-28T13:58:47.039Z" },
- { url = "https://files.pythonhosted.org/packages/80/22/3c6cef96627f89b344c933781ed38329bfb87737aa438f15da95907cbfd5/ruff-0.12.11-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4fc58f9266d62c6eccc75261a665f26b4ef64840887fc6cbc552ce5b29f96cc8", size = 13169082, upload-time = "2025-08-28T13:58:49.157Z" },
- { url = "https://files.pythonhosted.org/packages/05/b5/68b3ff96160d8b49e8dd10785ff3186be18fd650d356036a3770386e6c7f/ruff-0.12.11-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:5a0113bd6eafd545146440225fe60b4e9489f59eb5f5f107acd715ba5f0b3d2f", size = 13139490, upload-time = "2025-08-28T13:58:51.593Z" },
- { url = "https://files.pythonhosted.org/packages/59/b9/050a3278ecd558f74f7ee016fbdf10591d50119df8d5f5da45a22c6afafc/ruff-0.12.11-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:0d737b4059d66295c3ea5720e6efc152623bb83fde5444209b69cd33a53e2000", size = 11958928, upload-time = "2025-08-28T13:58:53.943Z" },
- { url = "https://files.pythonhosted.org/packages/f9/bc/93be37347db854806904a43b0493af8d6873472dfb4b4b8cbb27786eb651/ruff-0.12.11-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:916fc5defee32dbc1fc1650b576a8fed68f5e8256e2180d4d9855aea43d6aab2", size = 11764513, upload-time = "2025-08-28T13:58:55.976Z" },
- { url = "https://files.pythonhosted.org/packages/7a/a1/1471751e2015a81fd8e166cd311456c11df74c7e8769d4aabfbc7584c7ac/ruff-0.12.11-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c984f07d7adb42d3ded5be894fb4007f30f82c87559438b4879fe7aa08c62b39", size = 12745154, upload-time = "2025-08-28T13:58:58.16Z" },
- { url = "https://files.pythonhosted.org/packages/68/ab/2542b14890d0f4872dd81b7b2a6aed3ac1786fae1ce9b17e11e6df9e31e3/ruff-0.12.11-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e07fbb89f2e9249f219d88331c833860489b49cdf4b032b8e4432e9b13e8a4b9", size = 13227653, upload-time = "2025-08-28T13:59:00.276Z" },
- { url = "https://files.pythonhosted.org/packages/22/16/2fbfc61047dbfd009c58a28369a693a1484ad15441723be1cd7fe69bb679/ruff-0.12.11-py3-none-win32.whl", hash = "sha256:c792e8f597c9c756e9bcd4d87cf407a00b60af77078c96f7b6366ea2ce9ba9d3", size = 11944270, upload-time = "2025-08-28T13:59:02.347Z" },
- { url = "https://files.pythonhosted.org/packages/08/a5/34276984705bfe069cd383101c45077ee029c3fe3b28225bf67aa35f0647/ruff-0.12.11-py3-none-win_amd64.whl", hash = "sha256:a3283325960307915b6deb3576b96919ee89432ebd9c48771ca12ee8afe4a0fd", size = 13046600, upload-time = "2025-08-28T13:59:04.751Z" },
- { url = "https://files.pythonhosted.org/packages/84/a8/001d4a7c2b37623a3fd7463208267fb906df40ff31db496157549cfd6e72/ruff-0.12.11-py3-none-win_arm64.whl", hash = "sha256:bae4d6e6a2676f8fb0f98b74594a048bae1b944aab17e9f5d504062303c6dbea", size = 12135290, upload-time = "2025-08-28T13:59:06.933Z" },
+ { url = "https://files.pythonhosted.org/packages/09/79/8d3d687224d88367b51c7974cec1040c4b015772bfbeffac95face14c04a/ruff-0.12.12-py3-none-linux_armv6l.whl", hash = "sha256:de1c4b916d98ab289818e55ce481e2cacfaad7710b01d1f990c497edf217dafc", size = 12116602, upload-time = "2025-09-04T16:49:18.892Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/c3/6e599657fe192462f94861a09aae935b869aea8a1da07f47d6eae471397c/ruff-0.12.12-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:7acd6045e87fac75a0b0cdedacf9ab3e1ad9d929d149785903cff9bb69ad9727", size = 12868393, upload-time = "2025-09-04T16:49:23.043Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/d2/9e3e40d399abc95336b1843f52fc0daaceb672d0e3c9290a28ff1a96f79d/ruff-0.12.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:abf4073688d7d6da16611f2f126be86523a8ec4343d15d276c614bda8ec44edb", size = 12036967, upload-time = "2025-09-04T16:49:26.04Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/03/6816b2ed08836be272e87107d905f0908be5b4a40c14bfc91043e76631b8/ruff-0.12.12-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:968e77094b1d7a576992ac078557d1439df678a34c6fe02fd979f973af167577", size = 12276038, upload-time = "2025-09-04T16:49:29.056Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/d5/707b92a61310edf358a389477eabd8af68f375c0ef858194be97ca5b6069/ruff-0.12.12-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:42a67d16e5b1ffc6d21c5f67851e0e769517fb57a8ebad1d0781b30888aa704e", size = 11901110, upload-time = "2025-09-04T16:49:32.07Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/3d/f8b1038f4b9822e26ec3d5b49cf2bc313e3c1564cceb4c1a42820bf74853/ruff-0.12.12-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b216ec0a0674e4b1214dcc998a5088e54eaf39417327b19ffefba1c4a1e4971e", size = 13668352, upload-time = "2025-09-04T16:49:35.148Z" },
+ { url = "https://files.pythonhosted.org/packages/98/0e/91421368ae6c4f3765dd41a150f760c5f725516028a6be30e58255e3c668/ruff-0.12.12-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:59f909c0fdd8f1dcdbfed0b9569b8bf428cf144bec87d9de298dcd4723f5bee8", size = 14638365, upload-time = "2025-09-04T16:49:38.892Z" },
+ { url = "https://files.pythonhosted.org/packages/74/5d/88f3f06a142f58ecc8ecb0c2fe0b82343e2a2b04dcd098809f717cf74b6c/ruff-0.12.12-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9ac93d87047e765336f0c18eacad51dad0c1c33c9df7484c40f98e1d773876f5", size = 14060812, upload-time = "2025-09-04T16:49:42.732Z" },
+ { url = "https://files.pythonhosted.org/packages/13/fc/8962e7ddd2e81863d5c92400820f650b86f97ff919c59836fbc4c1a6d84c/ruff-0.12.12-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:01543c137fd3650d322922e8b14cc133b8ea734617c4891c5a9fccf4bfc9aa92", size = 13050208, upload-time = "2025-09-04T16:49:46.434Z" },
+ { url = "https://files.pythonhosted.org/packages/53/06/8deb52d48a9a624fd37390555d9589e719eac568c020b27e96eed671f25f/ruff-0.12.12-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2afc2fa864197634e549d87fb1e7b6feb01df0a80fd510d6489e1ce8c0b1cc45", size = 13311444, upload-time = "2025-09-04T16:49:49.931Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/81/de5a29af7eb8f341f8140867ffb93f82e4fde7256dadee79016ac87c2716/ruff-0.12.12-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:0c0945246f5ad776cb8925e36af2438e66188d2b57d9cf2eed2c382c58b371e5", size = 13279474, upload-time = "2025-09-04T16:49:53.465Z" },
+ { url = "https://files.pythonhosted.org/packages/7f/14/d9577fdeaf791737ada1b4f5c6b59c21c3326f3f683229096cccd7674e0c/ruff-0.12.12-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a0fbafe8c58e37aae28b84a80ba1817f2ea552e9450156018a478bf1fa80f4e4", size = 12070204, upload-time = "2025-09-04T16:49:56.882Z" },
+ { url = "https://files.pythonhosted.org/packages/77/04/a910078284b47fad54506dc0af13839c418ff704e341c176f64e1127e461/ruff-0.12.12-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b9c456fb2fc8e1282affa932c9e40f5ec31ec9cbb66751a316bd131273b57c23", size = 11880347, upload-time = "2025-09-04T16:49:59.729Z" },
+ { url = "https://files.pythonhosted.org/packages/df/58/30185fcb0e89f05e7ea82e5817b47798f7fa7179863f9d9ba6fd4fe1b098/ruff-0.12.12-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5f12856123b0ad0147d90b3961f5c90e7427f9acd4b40050705499c98983f489", size = 12891844, upload-time = "2025-09-04T16:50:02.591Z" },
+ { url = "https://files.pythonhosted.org/packages/21/9c/28a8dacce4855e6703dcb8cdf6c1705d0b23dd01d60150786cd55aa93b16/ruff-0.12.12-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:26a1b5a2bf7dd2c47e3b46d077cd9c0fc3b93e6c6cc9ed750bd312ae9dc302ee", size = 13360687, upload-time = "2025-09-04T16:50:05.8Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/fa/05b6428a008e60f79546c943e54068316f32ec8ab5c4f73e4563934fbdc7/ruff-0.12.12-py3-none-win32.whl", hash = "sha256:173be2bfc142af07a01e3a759aba6f7791aa47acf3604f610b1c36db888df7b1", size = 12052870, upload-time = "2025-09-04T16:50:09.121Z" },
+ { url = "https://files.pythonhosted.org/packages/85/60/d1e335417804df452589271818749d061b22772b87efda88354cf35cdb7a/ruff-0.12.12-py3-none-win_amd64.whl", hash = "sha256:e99620bf01884e5f38611934c09dd194eb665b0109104acae3ba6102b600fd0d", size = 13178016, upload-time = "2025-09-04T16:50:12.559Z" },
+ { url = "https://files.pythonhosted.org/packages/28/7e/61c42657f6e4614a4258f1c3b0c5b93adc4d1f8575f5229d1906b483099b/ruff-0.12.12-py3-none-win_arm64.whl", hash = "sha256:2a8199cab4ce4d72d158319b63370abf60991495fb733db96cd923a34c52d093", size = 12256762, upload-time = "2025-09-04T16:50:15.737Z" },
]
[[package]]