From b2a0ea02c35808de797ff56f3ecf8e78cb4b18e5 Mon Sep 17 00:00:00 2001
From: Ian Wilson
Date: Wed, 17 Dec 2025 22:10:41 -0800
Subject: [PATCH 01/23] Wrap up low level placeholder handling into
PlaceholderConfig helper class.
---
tdom/placeholders.py | 79 ++++++++++++++++++++++-----------------
tdom/placeholders_test.py | 36 +++++++++---------
tdom/processor_test.py | 12 +++---
3 files changed, 68 insertions(+), 59 deletions(-)
diff --git a/tdom/placeholders.py b/tdom/placeholders.py
index d5094ca..08f039a 100644
--- a/tdom/placeholders.py
+++ b/tdom/placeholders.py
@@ -1,3 +1,4 @@
+from dataclasses import dataclass, field
import random
import re
import string
@@ -5,59 +6,67 @@
from .template_utils import TemplateRef
-_PLACEHOLDER_PREFIX = f"tš{''.join(random.choices(string.ascii_lowercase, k=2))}-"
-_PLACEHOLDER_SUFFIX = f"-{''.join(random.choices(string.ascii_lowercase, k=2))}št"
-_PLACEHOLDER_PATTERN = re.compile(
- re.escape(_PLACEHOLDER_PREFIX) + r"(\d+)" + re.escape(_PLACEHOLDER_SUFFIX)
-)
+def make_placeholder_config() -> PlaceholderConfig:
+ prefix = f"tš{''.join(random.choices(string.ascii_lowercase, k=2))}-"
+ suffix = f"-{''.join(random.choices(string.ascii_lowercase, k=2))}št"
+ return PlaceholderConfig(
+ prefix=prefix,
+ suffix=suffix,
+ pattern=re.compile(re.escape(prefix) + r"(\d+)" + re.escape(suffix)),
+ )
-def make_placeholder(i: int) -> str:
- """Generate a placeholder for the i-th interpolation."""
- return f"{_PLACEHOLDER_PREFIX}{i}{_PLACEHOLDER_SUFFIX}"
+@dataclass(frozen=True)
+class PlaceholderConfig:
+ """String operations for working with a placeholder pattern."""
+ prefix: str
+ suffix: str
+ pattern: re.Pattern
-def match_placeholders(s: str) -> list[re.Match[str]]:
- """Find all placeholders in a string."""
- return list(_PLACEHOLDER_PATTERN.finditer(s))
+ def make_placeholder(self, i: int) -> str:
+ """Generate a placeholder for the i-th interpolation."""
+ return f"{self.prefix}{i}{self.suffix}"
+ def match_placeholders(self, s: str) -> list[re.Match[str]]:
+ """Find all placeholders in a string."""
+ return list(self.pattern.finditer(s))
-def find_placeholders(s: str) -> TemplateRef:
- """
- Find all placeholders in a string and return a TemplateRef.
+ def find_placeholders(self, s: str) -> TemplateRef:
+ """
+ Find all placeholders in a string and return a TemplateRef.
- If no placeholders are found, returns a static TemplateRef.
- """
- matches = match_placeholders(s)
- if not matches:
- return TemplateRef.literal(s)
+ If no placeholders are found, returns a static TemplateRef.
+ """
+ matches = self.match_placeholders(s)
+ if not matches:
+ return TemplateRef.literal(s)
- strings: list[str] = []
- i_indexes: list[int] = []
- last_index = 0
- for match in matches:
- start, end = match.span()
- strings.append(s[last_index:start])
- i_indexes.append(int(match[1]))
- last_index = end
- strings.append(s[last_index:])
+ strings: list[str] = []
+ i_indexes: list[int] = []
+ last_index = 0
+ for match in matches:
+ start, end = match.span()
+ strings.append(s[last_index:start])
+ i_indexes.append(int(match[1]))
+ last_index = end
+ strings.append(s[last_index:])
- return TemplateRef(tuple(strings), tuple(i_indexes))
+ return TemplateRef(tuple(strings), tuple(i_indexes))
+@dataclass
class PlaceholderState:
- known: set[int]
+ known: set[int] = field(default_factory=set)
+ config: PlaceholderConfig = field(default_factory=make_placeholder_config)
"""Collection of currently 'known and active' placeholder indexes."""
- def __init__(self):
- self.known = set()
-
@property
def is_empty(self) -> bool:
return len(self.known) == 0
def add_placeholder(self, index: int) -> str:
- placeholder = make_placeholder(index)
+ placeholder = self.config.make_placeholder(index)
self.known.add(index)
return placeholder
@@ -69,7 +78,7 @@ def remove_placeholders(self, text: str) -> TemplateRef:
If no placeholders are found, returns a static PlaceholderRef.
"""
- pt = find_placeholders(text)
+ pt = self.config.find_placeholders(text)
for index in pt.i_indexes:
if index not in self.known:
raise ValueError(f"Unknown placeholder index {index} found in text.")
diff --git a/tdom/placeholders_test.py b/tdom/placeholders_test.py
index 1f6b93c..80a7e8b 100644
--- a/tdom/placeholders_test.py
+++ b/tdom/placeholders_test.py
@@ -1,52 +1,52 @@
import pytest
from .placeholders import (
- _PLACEHOLDER_PREFIX,
- _PLACEHOLDER_SUFFIX,
+ make_placeholder_config,
PlaceholderState,
- find_placeholders,
- make_placeholder,
- match_placeholders,
)
def test_make_placeholder() -> None:
- assert make_placeholder(0) == f"{_PLACEHOLDER_PREFIX}0{_PLACEHOLDER_SUFFIX}"
- assert make_placeholder(42) == f"{_PLACEHOLDER_PREFIX}42{_PLACEHOLDER_SUFFIX}"
+ config = make_placeholder_config()
+ assert config.make_placeholder(0) == f"{config.prefix}0{config.suffix}"
+ assert config.make_placeholder(42) == f"{config.prefix}42{config.suffix}"
def test_match_placeholders() -> None:
- s = f"Start {_PLACEHOLDER_PREFIX}0{_PLACEHOLDER_SUFFIX} middle {_PLACEHOLDER_PREFIX}1{_PLACEHOLDER_SUFFIX} end"
- matches = match_placeholders(s)
+ config = make_placeholder_config()
+ s = f"Start {config.prefix}0{config.suffix} middle {config.prefix}1{config.suffix} end"
+ matches = config.match_placeholders(s)
assert len(matches) == 2
- assert matches[0].group(0) == f"{_PLACEHOLDER_PREFIX}0{_PLACEHOLDER_SUFFIX}"
+ assert matches[0].group(0) == f"{config.prefix}0{config.suffix}"
assert matches[0][1] == "0"
- assert matches[1].group(0) == f"{_PLACEHOLDER_PREFIX}1{_PLACEHOLDER_SUFFIX}"
+ assert matches[1].group(0) == f"{config.prefix}1{config.suffix}"
assert matches[1][1] == "1"
def test_find_placeholders() -> None:
- s = f"Hello {_PLACEHOLDER_PREFIX}0{_PLACEHOLDER_SUFFIX}, today is {_PLACEHOLDER_PREFIX}1{_PLACEHOLDER_SUFFIX}."
- pt = find_placeholders(s)
+ config = make_placeholder_config()
+ s = f"Hello {config.prefix}0{config.suffix}, today is {config.prefix}1{config.suffix}."
+ pt = config.find_placeholders(s)
assert pt.strings == ("Hello ", ", today is ", ".")
assert pt.i_indexes == (0, 1)
literal_s = "No placeholders here."
- literal_pt = find_placeholders(literal_s)
+ literal_pt = config.find_placeholders(literal_s)
assert literal_pt.strings == (literal_s,)
assert literal_pt.i_indexes == ()
def test_placeholder_state() -> None:
- state = PlaceholderState()
+ config = make_placeholder_config()
+ state = PlaceholderState(config=config)
assert state.is_empty
p0 = state.add_placeholder(0)
- assert p0 == make_placeholder(0)
+ assert p0 == config.make_placeholder(0)
assert not state.is_empty
p1 = state.add_placeholder(1)
- assert p1 == make_placeholder(1)
+ assert p1 == config.make_placeholder(1)
text = f"Values: {p0}, {p1}"
pt = state.remove_placeholders(text)
@@ -55,4 +55,4 @@ def test_placeholder_state() -> None:
assert state.is_empty
with pytest.raises(ValueError):
- state.remove_placeholders(f"Unknown placeholder: {make_placeholder(2)}")
+ state.remove_placeholders(f"Unknown placeholder: {config.make_placeholder(2)}")
diff --git a/tdom/processor_test.py b/tdom/processor_test.py
index 9f2c289..937863e 100644
--- a/tdom/processor_test.py
+++ b/tdom/processor_test.py
@@ -7,7 +7,7 @@
from markupsafe import Markup
from .nodes import Comment, DocumentType, Element, Fragment, Node, Text
-from .placeholders import _PLACEHOLDER_PREFIX, _PLACEHOLDER_SUFFIX
+from .placeholders import make_placeholder_config
from .processor import html
# --------------------------------------------------------------------------
@@ -597,25 +597,25 @@ def test_interpolated_attribute_value_tricky_multiple_placeholders():
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 = "123"
template = Template(
'',
)
node = html(template)
assert node == Element(
"div",
- attrs={"data-tricky": _PLACEHOLDER_PREFIX + tricky + _PLACEHOLDER_SUFFIX},
+ attrs={"data-tricky": config.prefix + tricky + config.suffix},
children=[],
)
assert (
- str(node)
- == f''
+ str(node) == f''
)
From ca44b4aa36cfe66c78f6fcab359150906c6b3e42 Mon Sep 17 00:00:00 2001
From: Ian Wilson
Date: Sat, 20 Dec 2025 22:52:42 -0800
Subject: [PATCH 02/23] Draft version of special class handling with merging
and distinct sequence vs dict typing.
---
tdom/processor.py | 114 ++++++++++++++++++++++++++++++++++++-----
tdom/processor_test.py | 5 +-
2 files changed, 105 insertions(+), 14 deletions(-)
diff --git a/tdom/processor.py b/tdom/processor.py
index 6641026..7f6d252 100644
--- a/tdom/processor.py
+++ b/tdom/processor.py
@@ -7,7 +7,6 @@
from markupsafe import Markup
from .callables import get_callable_info
-from .classnames import classnames
from .format import format_interpolation as base_format_interpolation
from .format import format_template
from .nodes import Comment, DocumentType, Element, Fragment, Node, Text
@@ -118,11 +117,6 @@ def _process_data_attr(value: object) -> t.Iterable[Attribute]:
yield f"data-{sub_k}", str(sub_v)
-def _process_class_attr(value: object) -> t.Iterable[HTMLAttribute]:
- """Substitute a class attribute based on the interpolated value."""
- yield ("class", classnames(value))
-
-
def _process_style_attr(value: object) -> t.Iterable[HTMLAttribute]:
"""Substitute a style attribute based on the interpolated value."""
if isinstance(value, str):
@@ -153,7 +147,6 @@ def _substitute_spread_attrs(value: object) -> t.Iterable[Attribute]:
# special semantics. This is in addition to the special-casing in
# _substitute_attr() itself.
CUSTOM_ATTR_PROCESSORS = {
- "class": _process_class_attr,
"data": _process_data_attr,
"style": _process_style_attr,
"aria": _process_aria_attr,
@@ -176,6 +169,65 @@ def _process_attr(key: str, value: object) -> t.Iterable[Attribute]:
yield (key, value)
+def _init_class(old_value: object) -> dict[str, bool]:
+ """
+ Initialize the class accumulator.
+
+ @NOTE: This should only be run if the special class has not been initialized
+ already.
+ """
+ match old_value:
+ case str():
+ special_class = {cn: True for cn in old_value.split()}
+ case True | False | None: # We ignore all these and just start with empty.
+ special_class = {}
+ case _:
+ raise ValueError(f"Unexpected value {old_value}")
+ return special_class
+
+
+def _merge_class(
+ special_class: dict[str, bool],
+ value: object, # str | None | bool | dict[str, bool | None] | t.Sequence[str | None | bool],
+) -> None:
+ """
+ Merge in an interpolated class value.
+
+ @NOTE: This should only be run after special class is initialized with `_init_class()`.
+ """
+ match value:
+ case str():
+ special_class.update({cn: True for cn in value.split()})
+ case [*items]:
+ for item in items:
+ if isinstance(item, str):
+ special_class.update({cn: True for cn in item.split()})
+ elif item is True or item is False or item is None:
+ continue # Skip these as they are no-ops.
+ else:
+ raise TypeError(
+ f"Unknown item in interpolated class value, {item} in {value}"
+ )
+ case dict():
+ special_class.update(
+ {str(cn): bool(toggle) for cn, toggle in value.items()}
+ )
+ case True | False | None:
+ pass
+ case _:
+ raise TypeError(f"Unknown interpolated class value {value}")
+
+
+def _finalize_class(special_class: dict[str, bool]) -> str | None:
+ """
+ Serialize the special class value back into a string.
+
+ @NOTE: If the result would be `''` then use `None` to omit the attribute.
+ """
+ class_value = " ".join((cn for cn, toggle in special_class.items() if toggle))
+ return class_value if class_value else None
+
+
def _resolve_t_attrs(
attrs: t.Sequence[TAttribute], interpolations: tuple[Interpolation, ...]
) -> AttributesDict:
@@ -186,26 +238,64 @@ def _resolve_t_attrs(
in a later step.
"""
new_attrs: AttributesDict = LastUpdatedOrderedDict()
+ special_class: dict[str, bool] | None = None
for attr in attrs:
match attr:
case TLiteralAttribute(name=name, value=value):
- new_attrs[name] = True if value is None else value
+ # Normalize None to True so that all attribute values are
+ # consistent between literals and interpolations.
+ attr_value = True if value is None else value
+ # Only trigger class special handling if a prior value exists
+ # and a merge becomes necessary.
+ if name == "class" and name in new_attrs:
+ if special_class is None:
+ new_attrs["class"] = special_class = _init_class(
+ new_attrs["class"]
+ )
+ _merge_class(special_class, attr_value)
+ else:
+ # A single class literal value does NOT activate special
+ # handling.
+ new_attrs[name] = attr_value
case TInterpolatedAttribute(name=name, value_i_index=i_index):
interpolation = interpolations[i_index]
attr_value = format_interpolation(interpolation)
- for sub_k, sub_v in _process_attr(name, attr_value):
- new_attrs[sub_k] = sub_v
+ if name == "class":
+ if special_class is None:
+ new_attrs["class"] = special_class = _init_class(
+ new_attrs.get("class", None)
+ )
+ _merge_class(special_class, attr_value)
+ else:
+ for sub_k, sub_v in _process_attr(name, attr_value):
+ new_attrs[sub_k] = sub_v
case TTemplatedAttribute(name=name, value_ref=ref):
attr_t = _resolve_ref(ref, interpolations)
attr_value = format_template(attr_t)
- new_attrs[name] = attr_value
+ if name == "class":
+ if special_class is None:
+ new_attrs["class"] = special_class = _init_class(
+ new_attrs.get("class", None)
+ )
+ _merge_class(special_class, attr_value)
+ else:
+ new_attrs[name] = attr_value
case TSpreadAttribute(i_index=i_index):
interpolation = interpolations[i_index]
spread_value = format_interpolation(interpolation)
for sub_k, sub_v in _substitute_spread_attrs(spread_value):
- new_attrs[sub_k] = sub_v
+ if sub_k == "class":
+ if special_class is None:
+ new_attrs["class"] = special_class = _init_class(
+ new_attrs.get("class", None)
+ )
+ _merge_class(special_class, sub_v)
+ else:
+ new_attrs[sub_k] = sub_v
case _:
raise ValueError(f"Unknown TAttribute type: {type(attr).__name__}")
+ if special_class is not None:
+ new_attrs["class"] = _finalize_class(special_class)
return new_attrs
diff --git a/tdom/processor_test.py b/tdom/processor_test.py
index 937863e..1d26a55 100644
--- a/tdom/processor_test.py
+++ b/tdom/processor_test.py
@@ -507,8 +507,9 @@ def test_multiple_attribute_spread_dicts():
def test_interpolated_class_attribute():
- classes = ["btn", "btn-primary", False and "disabled", None, {"active": True}]
- node = html(t'')
+ classes = ["btn", "btn-primary", False and "disabled", None]
+ toggle_classes = {"active": True}
+ node = html(t'')
assert node == Element(
"button",
attrs={"class": "btn btn-primary active"},
From b3ca458374c79bfc7e48166fcdb410867d307f25 Mon Sep 17 00:00:00 2001
From: Ian Wilson
Date: Sat, 20 Dec 2025 23:33:37 -0800
Subject: [PATCH 03/23] Use True instead of None since we only init from
literal.
---
tdom/processor.py | 10 +++++-----
1 file changed, 5 insertions(+), 5 deletions(-)
diff --git a/tdom/processor.py b/tdom/processor.py
index 7f6d252..4eef819 100644
--- a/tdom/processor.py
+++ b/tdom/processor.py
@@ -179,7 +179,7 @@ def _init_class(old_value: object) -> dict[str, bool]:
match old_value:
case str():
special_class = {cn: True for cn in old_value.split()}
- case True | False | None: # We ignore all these and just start with empty.
+ case True:
special_class = {}
case _:
raise ValueError(f"Unexpected value {old_value}")
@@ -224,7 +224,7 @@ def _finalize_class(special_class: dict[str, bool]) -> str | None:
@NOTE: If the result would be `''` then use `None` to omit the attribute.
"""
- class_value = " ".join((cn for cn, toggle in special_class.items() if toggle))
+ class_value = " ".join([cn for cn, toggle in special_class.items() if toggle])
return class_value if class_value else None
@@ -263,7 +263,7 @@ def _resolve_t_attrs(
if name == "class":
if special_class is None:
new_attrs["class"] = special_class = _init_class(
- new_attrs.get("class", None)
+ new_attrs.get("class", True)
)
_merge_class(special_class, attr_value)
else:
@@ -275,7 +275,7 @@ def _resolve_t_attrs(
if name == "class":
if special_class is None:
new_attrs["class"] = special_class = _init_class(
- new_attrs.get("class", None)
+ new_attrs.get("class", True)
)
_merge_class(special_class, attr_value)
else:
@@ -287,7 +287,7 @@ def _resolve_t_attrs(
if sub_k == "class":
if special_class is None:
new_attrs["class"] = special_class = _init_class(
- new_attrs.get("class", None)
+ new_attrs.get("class", True)
)
_merge_class(special_class, sub_v)
else:
From decae08652fea17b0979fbf71b364be747bd41bd Mon Sep 17 00:00:00 2001
From: Ian Wilson
Date: Sat, 20 Dec 2025 23:35:03 -0800
Subject: [PATCH 04/23] Add style equivalent to class merging.
---
tdom/processor.py | 98 ++++++++++++++++++++++++++++++++++++++++++
tdom/processor_test.py | 41 +++++++++++++-----
2 files changed, 129 insertions(+), 10 deletions(-)
diff --git a/tdom/processor.py b/tdom/processor.py
index 4eef819..1a86ffa 100644
--- a/tdom/processor.py
+++ b/tdom/processor.py
@@ -169,6 +169,77 @@ def _process_attr(key: str, value: object) -> t.Iterable[Attribute]:
yield (key, value)
+def _parse_styles(style_str: str) -> list[tuple[str, str | None]]:
+ """
+ Parse the style declaractions out of a style attribute string.
+ """
+ props = [p.strip() for p in style_str.split(";")]
+ styles: list[tuple[str, str | None]] = []
+ for prop in props:
+ if prop:
+ prop_parts = [p.strip() for p in prop.split(":") if p.strip()]
+ if len(prop_parts) != 2:
+ raise ValueError(
+ f"Invalid number of parts for style property {prop} in {style_str}"
+ )
+ styles.append((prop_parts[0], prop_parts[1]))
+ return styles
+
+
+def _init_style(old_value: object) -> dict[str, str | None]:
+ """
+ Initialize the style accumulator.
+
+ @NOTE: This should only be run if the special style has not been initialized
+ already.
+ """
+ match old_value:
+ case str():
+ special_style = {name: value for name, value in _parse_styles(old_value)}
+ case True: # We ignore all these and just start with empty.
+ special_style = {}
+ case _:
+ raise ValueError(f"Unexpected value {old_value}")
+ return special_style
+
+
+def _merge_style(
+ special_style: dict[str, str | None],
+ value: object, # str | None | bool | dict[str, bool | None] | t.Sequence[str | None | bool],
+) -> None:
+ """
+ Merge in an interpolated style value.
+
+ @NOTE: This should only be run after special style is initialized with `_init_style()`.
+ """
+ match value:
+ case str():
+ special_style.update({name: value for name, value in _parse_styles(value)})
+ case dict():
+ special_style.update(
+ {
+ str(pn): str(pv) if pv is not None else None
+ for pn, pv in value.items()
+ } # @TODO: Default units?
+ )
+ case _:
+ raise TypeError(
+ f"Unknown interpolated style value {value}, use '' to omit."
+ )
+
+
+def _finalize_style(special_style: dict[str, str | None]) -> str | None:
+ """
+ Serialize the special style value back into a string.
+
+ @NOTE: If the result would be `''` then use `None` to omit the attribute.
+ """
+ style_value = "; ".join(
+ [f"{pn}: {pv}" for pn, pv in special_style.items() if pv is not None]
+ )
+ return style_value if style_value else None
+
+
def _init_class(old_value: object) -> dict[str, bool]:
"""
Initialize the class accumulator.
@@ -239,6 +310,7 @@ def _resolve_t_attrs(
"""
new_attrs: AttributesDict = LastUpdatedOrderedDict()
special_class: dict[str, bool] | None = None
+ special_style: dict[str, str | None] | None = None
for attr in attrs:
match attr:
case TLiteralAttribute(name=name, value=value):
@@ -253,6 +325,12 @@ def _resolve_t_attrs(
new_attrs["class"]
)
_merge_class(special_class, attr_value)
+ elif name == "style" and name in new_attrs:
+ if special_style is None:
+ new_attrs["style"] = special_style = _init_style(
+ new_attrs["style"]
+ )
+ _merge_style(special_style, attr_value)
else:
# A single class literal value does NOT activate special
# handling.
@@ -266,6 +344,12 @@ def _resolve_t_attrs(
new_attrs.get("class", True)
)
_merge_class(special_class, attr_value)
+ elif name == "style":
+ if special_style is None:
+ new_attrs["style"] = special_style = _init_style(
+ new_attrs.get("style", True)
+ )
+ _merge_style(special_style, attr_value)
else:
for sub_k, sub_v in _process_attr(name, attr_value):
new_attrs[sub_k] = sub_v
@@ -278,6 +362,12 @@ def _resolve_t_attrs(
new_attrs.get("class", True)
)
_merge_class(special_class, attr_value)
+ elif name == "style":
+ if special_style is None:
+ new_attrs["style"] = special_style = _init_style(
+ new_attrs.get("style", True)
+ )
+ _merge_style(special_style, attr_value)
else:
new_attrs[name] = attr_value
case TSpreadAttribute(i_index=i_index):
@@ -290,12 +380,20 @@ def _resolve_t_attrs(
new_attrs.get("class", True)
)
_merge_class(special_class, sub_v)
+ elif sub_k == "style":
+ if special_style is None:
+ new_attrs["style"] = special_style = _init_style(
+ new_attrs.get("style", True)
+ )
+ _merge_style(special_style, sub_v)
else:
new_attrs[sub_k] = sub_v
case _:
raise ValueError(f"Unknown TAttribute type: {type(attr).__name__}")
if special_class is not None:
new_attrs["class"] = _finalize_class(special_class)
+ if special_style is not None:
+ new_attrs["style"] = _finalize_style(special_style)
return new_attrs
diff --git a/tdom/processor_test.py b/tdom/processor_test.py
index 1d26a55..cae9097 100644
--- a/tdom/processor_test.py
+++ b/tdom/processor_test.py
@@ -704,29 +704,50 @@ def test_interpolated_style_attribute():
)
-def test_override_static_style_str_str():
+def test_merge_static_style_str_str():
node = html(t'')
- assert node == Element("p", {"style": "font-size: 15px"})
- assert str(node) == ''
+ assert node == Element("p", {"style": "font-color: red; font-size: 15px"})
+ assert str(node) == ''
def test_override_static_style_str_builder():
node = html(t'')
- assert node == Element("p", {"style": "font-size: 15px"})
- assert str(node) == ''
+ assert node == Element("p", {"style": "font-color: red; font-size: 15px"})
+ assert str(node) == ''
def test_interpolated_style_attribute_multiple_placeholders():
styles1 = {"color": "red"}
styles2 = {"font-weight": "bold"}
- node = html(t"
Warning!
")
# CONSIDER: Is this what we want? Currently, when we have multiple
- # placeholders in a single attribute, we treat it as a string attribute.
+ # placeholders in a single attribute, we treat it as a string attribute
+ # which produces an invalid style attribute.
+ with pytest.raises(ValueError):
+ _ = html(t"
'
def test_style_attribute_non_str_non_dict():
From 3acbb4bbfd52f1ee1906ca307b56a7519a7a4382 Mon Sep 17 00:00:00 2001
From: Ian Wilson
Date: Sat, 20 Dec 2025 23:37:06 -0800
Subject: [PATCH 05/23] Remove old style processing code.
---
tdom/processor.py | 14 --------------
1 file changed, 14 deletions(-)
diff --git a/tdom/processor.py b/tdom/processor.py
index 1a86ffa..9439da0 100644
--- a/tdom/processor.py
+++ b/tdom/processor.py
@@ -117,19 +117,6 @@ def _process_data_attr(value: object) -> t.Iterable[Attribute]:
yield f"data-{sub_k}", str(sub_v)
-def _process_style_attr(value: object) -> t.Iterable[HTMLAttribute]:
- """Substitute a style attribute based on the interpolated value."""
- if isinstance(value, str):
- yield ("style", value)
- return
- try:
- d = _force_dict(value, kind="style")
- style_str = "; ".join(f"{k}: {v}" for k, v in d.items())
- yield ("style", style_str)
- except TypeError:
- raise TypeError("'style' attribute value must be a string or dict") from None
-
-
def _substitute_spread_attrs(value: object) -> t.Iterable[Attribute]:
"""
Substitute a spread attribute based on the interpolated value.
@@ -148,7 +135,6 @@ def _substitute_spread_attrs(value: object) -> t.Iterable[Attribute]:
# _substitute_attr() itself.
CUSTOM_ATTR_PROCESSORS = {
"data": _process_data_attr,
- "style": _process_style_attr,
"aria": _process_aria_attr,
}
From f3290a2e51cd76db997b12a83dc889afa9e632d1 Mon Sep 17 00:00:00 2001
From: Ian Wilson
Date: Sun, 21 Dec 2025 00:09:56 -0800
Subject: [PATCH 06/23] Add mixed class types back in but without sequence
nesting.
---
tdom/processor.py | 48 +++++++++++++++++++++--------------------------
1 file changed, 21 insertions(+), 27 deletions(-)
diff --git a/tdom/processor.py b/tdom/processor.py
index 9439da0..dcac189 100644
--- a/tdom/processor.py
+++ b/tdom/processor.py
@@ -1,6 +1,6 @@
import sys
import typing as t
-from collections.abc import Iterable
+from collections.abc import Iterable, Sequence
from functools import lru_cache
from string.templatelib import Interpolation, Template
@@ -189,10 +189,7 @@ def _init_style(old_value: object) -> dict[str, str | None]:
return special_style
-def _merge_style(
- special_style: dict[str, str | None],
- value: object, # str | None | bool | dict[str, bool | None] | t.Sequence[str | None | bool],
-) -> None:
+def _merge_style(special_style: dict[str, str | None], value: object) -> None:
"""
Merge in an interpolated style value.
@@ -243,36 +240,33 @@ def _init_class(old_value: object) -> dict[str, bool]:
return special_class
-def _merge_class(
- special_class: dict[str, bool],
- value: object, # str | None | bool | dict[str, bool | None] | t.Sequence[str | None | bool],
-) -> None:
+def _merge_class(special_class: dict[str, bool], value: object) -> None:
"""
Merge in an interpolated class value.
@NOTE: This should only be run after special class is initialized with `_init_class()`.
"""
- match value:
- case str():
- special_class.update({cn: True for cn in value.split()})
- case [*items]:
- for item in items:
- if isinstance(item, str):
- special_class.update({cn: True for cn in item.split()})
- elif item is True or item is False or item is None:
- continue # Skip these as they are no-ops.
+ if not isinstance(value, str) and isinstance(value, Sequence):
+ items = value[:]
+ else:
+ items = (value,)
+ for item in items:
+ match item:
+ case str():
+ special_class.update({cn: True for cn in item.split()})
+ case dict():
+ special_class.update(
+ {str(cn): bool(toggle) for cn, toggle in item.items()}
+ )
+ case True | False | None:
+ pass
+ case _:
+ if item == value:
+ raise TypeError(f"Unknown interpolated class value: {value}")
else:
raise TypeError(
- f"Unknown item in interpolated class value, {item} in {value}"
+ f"Unknown interpolated class item in {value}: {item}"
)
- case dict():
- special_class.update(
- {str(cn): bool(toggle) for cn, toggle in value.items()}
- )
- case True | False | None:
- pass
- case _:
- raise TypeError(f"Unknown interpolated class value {value}")
def _finalize_class(special_class: dict[str, bool]) -> str | None:
From b83ed6d72de3b86302ef4f3ed4f8f5dee5d8036f Mon Sep 17 00:00:00 2001
From: Ian Wilson
Date: Mon, 22 Dec 2025 13:43:51 -0800
Subject: [PATCH 07/23] Allow attributes created via aria dict to clear literal
attributes.
---
tdom/processor.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/tdom/processor.py b/tdom/processor.py
index dcac189..78fa224 100644
--- a/tdom/processor.py
+++ b/tdom/processor.py
@@ -102,7 +102,7 @@ def _process_aria_attr(value: object) -> t.Iterable[HTMLAttribute]:
elif sub_v is False:
yield f"aria-{sub_k}", "false"
elif sub_v is None:
- pass
+ yield f"aria-{sub_k}", None
else:
yield f"aria-{sub_k}", str(sub_v)
From 86167676ddcd867af9554f6fe56150bba6842209 Mon Sep 17 00:00:00 2001
From: Ian Wilson
Date: Mon, 22 Dec 2025 13:45:00 -0800
Subject: [PATCH 08/23] Allow attributes created via data dict to clear literal
attributes.
---
tdom/processor.py | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/tdom/processor.py b/tdom/processor.py
index 78fa224..f823f33 100644
--- a/tdom/processor.py
+++ b/tdom/processor.py
@@ -111,9 +111,9 @@ def _process_data_attr(value: object) -> t.Iterable[Attribute]:
"""Produce data-* attributes based on the interpolated value for "data"."""
d = _force_dict(value, kind="data")
for sub_k, sub_v in d.items():
- if sub_v is True:
- yield f"data-{sub_k}", True
- elif sub_v is not False and sub_v is not None:
+ if sub_v is True or sub_v is False or sub_v is None:
+ yield f"data-{sub_k}", sub_v
+ else:
yield f"data-{sub_k}", str(sub_v)
From 662fe248415bd5111864b8b7d4775e47ceef65f0 Mon Sep 17 00:00:00 2001
From: Ian Wilson
Date: Mon, 22 Dec 2025 13:46:31 -0800
Subject: [PATCH 09/23] Move special class and style handling state and logic
into helper classes.
---
tdom/processor.py | 238 +++++++++++++++++++++-------------------------
1 file changed, 107 insertions(+), 131 deletions(-)
diff --git a/tdom/processor.py b/tdom/processor.py
index f823f33..4074b73 100644
--- a/tdom/processor.py
+++ b/tdom/processor.py
@@ -3,6 +3,7 @@
from collections.abc import Iterable, Sequence
from functools import lru_cache
from string.templatelib import Interpolation, Template
+from dataclasses import dataclass
from markupsafe import Markup
@@ -155,7 +156,7 @@ def _process_attr(key: str, value: object) -> t.Iterable[Attribute]:
yield (key, value)
-def _parse_styles(style_str: str) -> list[tuple[str, str | None]]:
+def parse_style_attribute_value(style_str: str) -> list[tuple[str, str | None]]:
"""
Parse the style declaractions out of a style attribute string.
"""
@@ -172,7 +173,7 @@ def _parse_styles(style_str: str) -> list[tuple[str, str | None]]:
return styles
-def _init_style(old_value: object) -> dict[str, str | None]:
+def make_style_accumulator(old_value: object) -> dict[str, str | None]:
"""
Initialize the style accumulator.
@@ -181,49 +182,51 @@ def _init_style(old_value: object) -> dict[str, str | None]:
"""
match old_value:
case str():
- special_style = {name: value for name, value in _parse_styles(old_value)}
- case True: # We ignore all these and just start with empty.
- special_style = {}
+ styles = {name: value for name, value in parse_style_attribute_value(old_value)}
+ case True: # A bare attribute will just default to {}.
+ styles = {}
case _:
- raise ValueError(f"Unexpected value {old_value}")
- return special_style
+ raise TypeError(f"Unexpected value: {old_value}")
+ return StyleAccumulator(styles=styles)
-def _merge_style(special_style: dict[str, str | None], value: object) -> None:
- """
- Merge in an interpolated style value.
+@dataclass
+class StyleAccumulator:
- @NOTE: This should only be run after special style is initialized with `_init_style()`.
- """
- match value:
- case str():
- special_style.update({name: value for name, value in _parse_styles(value)})
- case dict():
- special_style.update(
- {
- str(pn): str(pv) if pv is not None else None
- for pn, pv in value.items()
- } # @TODO: Default units?
- )
- case _:
- raise TypeError(
- f"Unknown interpolated style value {value}, use '' to omit."
- )
+ styles: dict[str, str | None]
+ def merge_value(self, value: object) -> None:
+ """
+ Merge in an interpolated style value.
+ """
+ match value:
+ case str():
+ self.styles.update({name: value for name, value in parse_style_attribute_value(value)})
+ case dict():
+ self.styles.update(
+ {
+ str(pn): str(pv) if pv is not None else None
+ for pn, pv in value.items()
+ }
+ )
+ case _:
+ raise TypeError(
+ f"Unknown interpolated style value {value}, use '' to omit."
+ )
-def _finalize_style(special_style: dict[str, str | None]) -> str | None:
- """
- Serialize the special style value back into a string.
+ def to_value(self) -> str | None:
+ """
+ Serialize the special style value back into a string.
- @NOTE: If the result would be `''` then use `None` to omit the attribute.
- """
- style_value = "; ".join(
- [f"{pn}: {pv}" for pn, pv in special_style.items() if pv is not None]
- )
- return style_value if style_value else None
+ @NOTE: If the result would be `''` then use `None` to omit the attribute.
+ """
+ style_value = "; ".join(
+ [f"{pn}: {pv}" for pn, pv in self.styles.items() if pv is not None]
+ )
+ return style_value if style_value else None
-def _init_class(old_value: object) -> dict[str, bool]:
+def make_class_accumulator(old_value: object) -> dict[str, bool]:
"""
Initialize the class accumulator.
@@ -232,51 +235,65 @@ def _init_class(old_value: object) -> dict[str, bool]:
"""
match old_value:
case str():
- special_class = {cn: True for cn in old_value.split()}
+ toggled_classes = {cn: True for cn in old_value.split()}
case True:
- special_class = {}
+ toggled_classes = {}
case _:
raise ValueError(f"Unexpected value {old_value}")
- return special_class
+ return ClassAccumulator(toggled_classes=toggled_classes)
-def _merge_class(special_class: dict[str, bool], value: object) -> None:
- """
- Merge in an interpolated class value.
+@dataclass
+class ClassAccumulator:
- @NOTE: This should only be run after special class is initialized with `_init_class()`.
- """
- if not isinstance(value, str) and isinstance(value, Sequence):
- items = value[:]
- else:
- items = (value,)
- for item in items:
- match item:
- case str():
- special_class.update({cn: True for cn in item.split()})
- case dict():
- special_class.update(
- {str(cn): bool(toggle) for cn, toggle in item.items()}
- )
- case True | False | None:
- pass
- case _:
- if item == value:
- raise TypeError(f"Unknown interpolated class value: {value}")
- else:
- raise TypeError(
- f"Unknown interpolated class item in {value}: {item}"
+ toggled_classes: dict[str, bool]
+
+ def merge_value(self, value: object) -> None:
+ """
+ Merge in an interpolated class value.
+ """
+ if not isinstance(value, str) and isinstance(value, Sequence):
+ items = value[:]
+ else:
+ items = (value,)
+ for item in items:
+ match item:
+ case str():
+ self.toggled_classes.update({cn: True for cn in item.split()})
+ case dict():
+ self.toggled_classes.update(
+ {str(cn): bool(toggle) for cn, toggle in item.items()}
)
+ case True | False | None:
+ pass
+ case _:
+ if item == value:
+ raise TypeError(f"Unknown interpolated class value: {value}")
+ else:
+ raise TypeError(
+ f"Unknown interpolated class item in {value}: {item}"
+ )
+ def to_value(self) -> str | None:
+ """
+ Serialize the special class value back into a string.
-def _finalize_class(special_class: dict[str, bool]) -> str | None:
- """
- Serialize the special class value back into a string.
+ @NOTE: If the result would be `''` then use `None` to omit the attribute.
+ """
+ class_value = " ".join([cn for cn, toggle in self.toggled_classes.items() if toggle])
+ return class_value if class_value else None
- @NOTE: If the result would be `''` then use `None` to omit the attribute.
- """
- class_value = " ".join([cn for cn, toggle in special_class.items() if toggle])
- return class_value if class_value else None
+
+attr_acc_makers = {
+ 'class': make_class_accumulator,
+ 'style': make_style_accumulator,
+}
+
+
+attr_acc_names = ("class", "style")
+
+
+type AttributeValueAccumulator = StyleAccumulator | ClassAccumulator
def _resolve_t_attrs(
@@ -289,91 +306,50 @@ def _resolve_t_attrs(
in a later step.
"""
new_attrs: AttributesDict = LastUpdatedOrderedDict()
- special_class: dict[str, bool] | None = None
- special_style: dict[str, str | None] | None = None
+ attr_accs: dict[str, AttributeValueAccumulator | None] = {}
for attr in attrs:
match attr:
case TLiteralAttribute(name=name, value=value):
- # Normalize None to True so that all attribute values are
- # consistent between literals and interpolations.
attr_value = True if value is None else value
- # Only trigger class special handling if a prior value exists
- # and a merge becomes necessary.
- if name == "class" and name in new_attrs:
- if special_class is None:
- new_attrs["class"] = special_class = _init_class(
- new_attrs["class"]
- )
- _merge_class(special_class, attr_value)
- elif name == "style" and name in new_attrs:
- if special_style is None:
- new_attrs["style"] = special_style = _init_style(
- new_attrs["style"]
- )
- _merge_style(special_style, attr_value)
+ if name in attr_acc_names and name in new_attrs:
+ if name not in attr_accs:
+ attr_accs[name] = attr_acc_makers[name](new_attrs[name])
+ new_attrs[name] = attr_accs[name].merge_value(attr_value)
else:
- # A single class literal value does NOT activate special
- # handling.
new_attrs[name] = attr_value
case TInterpolatedAttribute(name=name, value_i_index=i_index):
interpolation = interpolations[i_index]
attr_value = format_interpolation(interpolation)
- if name == "class":
- if special_class is None:
- new_attrs["class"] = special_class = _init_class(
- new_attrs.get("class", True)
- )
- _merge_class(special_class, attr_value)
- elif name == "style":
- if special_style is None:
- new_attrs["style"] = special_style = _init_style(
- new_attrs.get("style", True)
- )
- _merge_style(special_style, attr_value)
+ if name in attr_acc_names:
+ if name not in attr_accs:
+ attr_accs[name] = attr_acc_makers[name](new_attrs.get(name, True))
+ new_attrs[name] = attr_accs[name].merge_value(attr_value)
else:
for sub_k, sub_v in _process_attr(name, attr_value):
new_attrs[sub_k] = sub_v
case TTemplatedAttribute(name=name, value_ref=ref):
attr_t = _resolve_ref(ref, interpolations)
attr_value = format_template(attr_t)
- if name == "class":
- if special_class is None:
- new_attrs["class"] = special_class = _init_class(
- new_attrs.get("class", True)
- )
- _merge_class(special_class, attr_value)
- elif name == "style":
- if special_style is None:
- new_attrs["style"] = special_style = _init_style(
- new_attrs.get("style", True)
- )
- _merge_style(special_style, attr_value)
+ if name in attr_acc_names:
+ if name not in attr_accs:
+ attr_accs[name] = attr_acc_makers[name](new_attrs.get(name, True))
+ new_attrs[name] = attr_accs[name].merge_value(attr_value)
else:
new_attrs[name] = attr_value
case TSpreadAttribute(i_index=i_index):
interpolation = interpolations[i_index]
spread_value = format_interpolation(interpolation)
for sub_k, sub_v in _substitute_spread_attrs(spread_value):
- if sub_k == "class":
- if special_class is None:
- new_attrs["class"] = special_class = _init_class(
- new_attrs.get("class", True)
- )
- _merge_class(special_class, sub_v)
- elif sub_k == "style":
- if special_style is None:
- new_attrs["style"] = special_style = _init_style(
- new_attrs.get("style", True)
- )
- _merge_style(special_style, sub_v)
+ if sub_k in attr_acc_names:
+ if sub_k not in attr_accs:
+ attr_accs[sub_k] = attr_acc_makers[sub_k](new_attrs.get(sub_k, True))
+ new_attrs[sub_k] = attr_accs[sub_k].merge_value(sub_v)
else:
new_attrs[sub_k] = sub_v
case _:
raise ValueError(f"Unknown TAttribute type: {type(attr).__name__}")
- if special_class is not None:
- new_attrs["class"] = _finalize_class(special_class)
- if special_style is not None:
- new_attrs["style"] = _finalize_style(special_style)
+ for acc_name, acc in attr_accs.items():
+ new_attrs[acc_name] = acc.to_value()
return new_attrs
From 119fffb451e3fcc21c5cbe94c03c6a4303755de8 Mon Sep 17 00:00:00 2001
From: Ian Wilson
Date: Mon, 22 Dec 2025 22:03:00 -0800
Subject: [PATCH 10/23] Expand style testing.
---
tdom/processor_test.py | 76 ++++++++++++++++++++++++++++++++++++------
1 file changed, 66 insertions(+), 10 deletions(-)
diff --git a/tdom/processor_test.py b/tdom/processor_test.py
index cae9097..b978a11 100644
--- a/tdom/processor_test.py
+++ b/tdom/processor_test.py
@@ -689,8 +689,24 @@ def test_interpolated_aria_attributes():
== ''
)
+#
+# Special style attribute handling.
+#
+def test_style_in_literal_attr():
+ p_id = "para1" # non-literal attribute to cause attr resolution
+ node = html(t'
'
+ parts = [
+ (t''
def test_interpolated_style_attribute_multiple_placeholders():
From e0b8c8e49f9bb0e4afb57c0f5f97996de8cb7c97 Mon Sep 17 00:00:00 2001
From: Ian Wilson
Date: Thu, 25 Dec 2025 23:12:12 -0800
Subject: [PATCH 11/23] Sp.
---
tdom/processor.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/tdom/processor.py b/tdom/processor.py
index 4074b73..fcdc2b6 100644
--- a/tdom/processor.py
+++ b/tdom/processor.py
@@ -158,7 +158,7 @@ def _process_attr(key: str, value: object) -> t.Iterable[Attribute]:
def parse_style_attribute_value(style_str: str) -> list[tuple[str, str | None]]:
"""
- Parse the style declaractions out of a style attribute string.
+ Parse the style declarations out of a style attribute string.
"""
props = [p.strip() for p in style_str.split(";")]
styles: list[tuple[str, str | None]] = []
From bc97947ed6ea8cb9c2be89bb0dfc1ba0f42a9217 Mon Sep 17 00:00:00 2001
From: Ian Wilson
Date: Thu, 25 Dec 2025 23:13:34 -0800
Subject: [PATCH 12/23] Try to uniform design with prior processor.
---
tdom/processor.py | 21 +++++++++------------
1 file changed, 9 insertions(+), 12 deletions(-)
diff --git a/tdom/processor.py b/tdom/processor.py
index fcdc2b6..60eed8a 100644
--- a/tdom/processor.py
+++ b/tdom/processor.py
@@ -284,15 +284,12 @@ def to_value(self) -> str | None:
return class_value if class_value else None
-attr_acc_makers = {
+ATTR_ACCUMULATOR_MAKERS = {
'class': make_class_accumulator,
'style': make_style_accumulator,
}
-attr_acc_names = ("class", "style")
-
-
type AttributeValueAccumulator = StyleAccumulator | ClassAccumulator
@@ -311,18 +308,18 @@ def _resolve_t_attrs(
match attr:
case TLiteralAttribute(name=name, value=value):
attr_value = True if value is None else value
- if name in attr_acc_names and name in new_attrs:
+ if name in ATTR_ACCUMULATOR_MAKERS and name in new_attrs:
if name not in attr_accs:
- attr_accs[name] = attr_acc_makers[name](new_attrs[name])
+ attr_accs[name] = ATTR_ACCUMULATOR_MAKERS[name](new_attrs[name])
new_attrs[name] = attr_accs[name].merge_value(attr_value)
else:
new_attrs[name] = attr_value
case TInterpolatedAttribute(name=name, value_i_index=i_index):
interpolation = interpolations[i_index]
attr_value = format_interpolation(interpolation)
- if name in attr_acc_names:
+ if name in ATTR_ACCUMULATOR_MAKERS:
if name not in attr_accs:
- attr_accs[name] = attr_acc_makers[name](new_attrs.get(name, True))
+ attr_accs[name] = ATTR_ACCUMULATOR_MAKERS[name](new_attrs.get(name, True))
new_attrs[name] = attr_accs[name].merge_value(attr_value)
else:
for sub_k, sub_v in _process_attr(name, attr_value):
@@ -330,9 +327,9 @@ def _resolve_t_attrs(
case TTemplatedAttribute(name=name, value_ref=ref):
attr_t = _resolve_ref(ref, interpolations)
attr_value = format_template(attr_t)
- if name in attr_acc_names:
+ if name in ATTR_ACCUMULATOR_MAKERS:
if name not in attr_accs:
- attr_accs[name] = attr_acc_makers[name](new_attrs.get(name, True))
+ attr_accs[name] = ATTR_ACCUMULATOR_MAKERS[name](new_attrs.get(name, True))
new_attrs[name] = attr_accs[name].merge_value(attr_value)
else:
new_attrs[name] = attr_value
@@ -340,9 +337,9 @@ def _resolve_t_attrs(
interpolation = interpolations[i_index]
spread_value = format_interpolation(interpolation)
for sub_k, sub_v in _substitute_spread_attrs(spread_value):
- if sub_k in attr_acc_names:
+ if sub_k in ATTR_ACCUMULATOR_MAKERS:
if sub_k not in attr_accs:
- attr_accs[sub_k] = attr_acc_makers[sub_k](new_attrs.get(sub_k, True))
+ attr_accs[sub_k] = ATTR_ACCUMULATOR_MAKERS[sub_k](new_attrs.get(sub_k, True))
new_attrs[sub_k] = attr_accs[sub_k].merge_value(sub_v)
else:
new_attrs[sub_k] = sub_v
From 87bea0e99c226392b5dd35f82131761ba7f7026f Mon Sep 17 00:00:00 2001
From: Ian Wilson
Date: Thu, 25 Dec 2025 23:51:28 -0800
Subject: [PATCH 13/23] Make things *right.
---
tdom/processor.py | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/tdom/processor.py b/tdom/processor.py
index 60eed8a..b274710 100644
--- a/tdom/processor.py
+++ b/tdom/processor.py
@@ -173,7 +173,7 @@ def parse_style_attribute_value(style_str: str) -> list[tuple[str, str | None]]:
return styles
-def make_style_accumulator(old_value: object) -> dict[str, str | None]:
+def make_style_accumulator(old_value: object) -> StyleAccumulator:
"""
Initialize the style accumulator.
@@ -226,7 +226,7 @@ def to_value(self) -> str | None:
return style_value if style_value else None
-def make_class_accumulator(old_value: object) -> dict[str, bool]:
+def make_class_accumulator(old_value: object) -> ClassAccumulator:
"""
Initialize the class accumulator.
@@ -303,7 +303,7 @@ def _resolve_t_attrs(
in a later step.
"""
new_attrs: AttributesDict = LastUpdatedOrderedDict()
- attr_accs: dict[str, AttributeValueAccumulator | None] = {}
+ attr_accs: dict[str, AttributeValueAccumulator] = {}
for attr in attrs:
match attr:
case TLiteralAttribute(name=name, value=value):
From 93e8d40c418cfc525a20c99f7d294531177597bf Mon Sep 17 00:00:00 2001
From: Ian Wilson
Date: Thu, 25 Dec 2025 23:55:09 -0800
Subject: [PATCH 14/23] Rename to expander to accommodate allocator, pull up
into main resolution.
---
tdom/processor.py | 41 +++++++++++++----------------------------
1 file changed, 13 insertions(+), 28 deletions(-)
diff --git a/tdom/processor.py b/tdom/processor.py
index b274710..02c9758 100644
--- a/tdom/processor.py
+++ b/tdom/processor.py
@@ -94,7 +94,7 @@ def _force_dict(value: t.Any, *, kind: str) -> dict:
) from None
-def _process_aria_attr(value: object) -> t.Iterable[HTMLAttribute]:
+def _expand_aria_attr(value: object) -> t.Iterable[HTMLAttribute]:
"""Produce aria-* attributes based on the interpolated value for "aria"."""
d = _force_dict(value, kind="aria")
for sub_k, sub_v in d.items():
@@ -108,7 +108,7 @@ def _process_aria_attr(value: object) -> t.Iterable[HTMLAttribute]:
yield f"aria-{sub_k}", str(sub_v)
-def _process_data_attr(value: object) -> t.Iterable[Attribute]:
+def _expand_data_attr(value: object) -> t.Iterable[Attribute]:
"""Produce data-* attributes based on the interpolated value for "data"."""
d = _force_dict(value, kind="data")
for sub_k, sub_v in d.items():
@@ -127,35 +127,15 @@ def _substitute_spread_attrs(value: object) -> t.Iterable[Attribute]:
The value must be a dict or iterable of key-value pairs.
"""
d = _force_dict(value, kind="spread")
- for sub_k, sub_v in d.items():
- yield from _process_attr(sub_k, sub_v)
+ yield from d.items()
-# A collection of custom handlers for certain attribute names that have
-# special semantics. This is in addition to the special-casing in
-# _substitute_attr() itself.
-CUSTOM_ATTR_PROCESSORS = {
- "data": _process_data_attr,
- "aria": _process_aria_attr,
+ATTR_EXPANDERS = {
+ "data": _expand_data_attr,
+ "aria": _expand_aria_attr,
}
-def _process_attr(key: str, value: object) -> t.Iterable[Attribute]:
- """
- Substitute a single attribute based on its key and the interpolated value.
-
- A single parsed attribute with a placeholder may result in multiple
- attributes in the final output, for instance if the value is a dict or
- iterable of key-value pairs. Likewise, a value of False will result in
- the attribute being omitted entirely; nothing is yielded in that case.
- """
- # Special handling for certain attribute names that have special semantics
- if custom_processor := CUSTOM_ATTR_PROCESSORS.get(key):
- yield from custom_processor(value)
- return
- yield (key, value)
-
-
def parse_style_attribute_value(style_str: str) -> list[tuple[str, str | None]]:
"""
Parse the style declarations out of a style attribute string.
@@ -321,9 +301,11 @@ def _resolve_t_attrs(
if name not in attr_accs:
attr_accs[name] = ATTR_ACCUMULATOR_MAKERS[name](new_attrs.get(name, True))
new_attrs[name] = attr_accs[name].merge_value(attr_value)
- else:
- for sub_k, sub_v in _process_attr(name, attr_value):
+ elif expander := ATTR_EXPANDERS.get(name):
+ for sub_k, sub_v in expander(attr_value):
new_attrs[sub_k] = sub_v
+ else:
+ new_attrs[name] = attr_value
case TTemplatedAttribute(name=name, value_ref=ref):
attr_t = _resolve_ref(ref, interpolations)
attr_value = format_template(attr_t)
@@ -341,6 +323,9 @@ def _resolve_t_attrs(
if sub_k not in attr_accs:
attr_accs[sub_k] = ATTR_ACCUMULATOR_MAKERS[sub_k](new_attrs.get(sub_k, True))
new_attrs[sub_k] = attr_accs[sub_k].merge_value(sub_v)
+ elif expander := ATTR_EXPANDERS.get(sub_k):
+ for exp_k, exp_v in expander(sub_v):
+ new_attrs[exp_k] = exp_v
else:
new_attrs[sub_k] = sub_v
case _:
From 50ef974512cab759d5bec7fd3e05637cef8ed24b Mon Sep 17 00:00:00 2001
From: Ian Wilson
Date: Thu, 25 Dec 2025 23:58:25 -0800
Subject: [PATCH 15/23] Re-format.
---
tdom/processor.py | 32 +++++++++++++++++----------
tdom/processor_test.py | 49 +++++++++++++++++++++---------------------
2 files changed, 46 insertions(+), 35 deletions(-)
diff --git a/tdom/processor.py b/tdom/processor.py
index 02c9758..1118892 100644
--- a/tdom/processor.py
+++ b/tdom/processor.py
@@ -162,8 +162,10 @@ def make_style_accumulator(old_value: object) -> StyleAccumulator:
"""
match old_value:
case str():
- styles = {name: value for name, value in parse_style_attribute_value(old_value)}
- case True: # A bare attribute will just default to {}.
+ styles = {
+ name: value for name, value in parse_style_attribute_value(old_value)
+ }
+ case True: # A bare attribute will just default to {}.
styles = {}
case _:
raise TypeError(f"Unexpected value: {old_value}")
@@ -172,7 +174,6 @@ def make_style_accumulator(old_value: object) -> StyleAccumulator:
@dataclass
class StyleAccumulator:
-
styles: dict[str, str | None]
def merge_value(self, value: object) -> None:
@@ -181,7 +182,9 @@ def merge_value(self, value: object) -> None:
"""
match value:
case str():
- self.styles.update({name: value for name, value in parse_style_attribute_value(value)})
+ self.styles.update(
+ {name: value for name, value in parse_style_attribute_value(value)}
+ )
case dict():
self.styles.update(
{
@@ -225,7 +228,6 @@ def make_class_accumulator(old_value: object) -> ClassAccumulator:
@dataclass
class ClassAccumulator:
-
toggled_classes: dict[str, bool]
def merge_value(self, value: object) -> None:
@@ -260,13 +262,15 @@ def to_value(self) -> str | None:
@NOTE: If the result would be `''` then use `None` to omit the attribute.
"""
- class_value = " ".join([cn for cn, toggle in self.toggled_classes.items() if toggle])
+ class_value = " ".join(
+ [cn for cn, toggle in self.toggled_classes.items() if toggle]
+ )
return class_value if class_value else None
ATTR_ACCUMULATOR_MAKERS = {
- 'class': make_class_accumulator,
- 'style': make_style_accumulator,
+ "class": make_class_accumulator,
+ "style": make_style_accumulator,
}
@@ -299,7 +303,9 @@ def _resolve_t_attrs(
attr_value = format_interpolation(interpolation)
if name in ATTR_ACCUMULATOR_MAKERS:
if name not in attr_accs:
- attr_accs[name] = ATTR_ACCUMULATOR_MAKERS[name](new_attrs.get(name, True))
+ attr_accs[name] = ATTR_ACCUMULATOR_MAKERS[name](
+ new_attrs.get(name, True)
+ )
new_attrs[name] = attr_accs[name].merge_value(attr_value)
elif expander := ATTR_EXPANDERS.get(name):
for sub_k, sub_v in expander(attr_value):
@@ -311,7 +317,9 @@ def _resolve_t_attrs(
attr_value = format_template(attr_t)
if name in ATTR_ACCUMULATOR_MAKERS:
if name not in attr_accs:
- attr_accs[name] = ATTR_ACCUMULATOR_MAKERS[name](new_attrs.get(name, True))
+ attr_accs[name] = ATTR_ACCUMULATOR_MAKERS[name](
+ new_attrs.get(name, True)
+ )
new_attrs[name] = attr_accs[name].merge_value(attr_value)
else:
new_attrs[name] = attr_value
@@ -321,7 +329,9 @@ def _resolve_t_attrs(
for sub_k, sub_v in _substitute_spread_attrs(spread_value):
if sub_k in ATTR_ACCUMULATOR_MAKERS:
if sub_k not in attr_accs:
- attr_accs[sub_k] = ATTR_ACCUMULATOR_MAKERS[sub_k](new_attrs.get(sub_k, True))
+ attr_accs[sub_k] = ATTR_ACCUMULATOR_MAKERS[sub_k](
+ new_attrs.get(sub_k, True)
+ )
new_attrs[sub_k] = attr_accs[sub_k].merge_value(sub_v)
elif expander := ATTR_EXPANDERS.get(sub_k):
for exp_k, exp_v in expander(sub_v):
diff --git a/tdom/processor_test.py b/tdom/processor_test.py
index b978a11..21b2f46 100644
--- a/tdom/processor_test.py
+++ b/tdom/processor_test.py
@@ -689,21 +689,19 @@ def test_interpolated_aria_attributes():
== ''
)
+
#
# Special style attribute handling.
#
def test_style_in_literal_attr():
- p_id = "para1" # non-literal attribute to cause attr resolution
+ p_id = "para1" # non-literal attribute to cause attr resolution
node = 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) == ''
+ node = html(
+ t''
+ )
+ assert node == Element(
+ "p",
+ {"style": "font-family: serif; color: red; font-weight: bold; font-size: 15px"},
+ )
+ assert (
+ str(node)
+ == ''
+ )
def test_style_override_left_to_right():
- suffix = t'>'
+ suffix = t">"
parts = [
- (t''
From df2708e0757ea1c4d198b6b26f0470e995d0cb1e Mon Sep 17 00:00:00 2001
From: Ian Wilson
Date: Fri, 26 Dec 2025 00:26:24 -0800
Subject: [PATCH 16/23] Remove excess docstrings.
---
tdom/processor.py | 6 ------
1 file changed, 6 deletions(-)
diff --git a/tdom/processor.py b/tdom/processor.py
index 1118892..ac1e807 100644
--- a/tdom/processor.py
+++ b/tdom/processor.py
@@ -156,9 +156,6 @@ def parse_style_attribute_value(style_str: str) -> list[tuple[str, str | None]]:
def make_style_accumulator(old_value: object) -> StyleAccumulator:
"""
Initialize the style accumulator.
-
- @NOTE: This should only be run if the special style has not been initialized
- already.
"""
match old_value:
case str():
@@ -212,9 +209,6 @@ def to_value(self) -> str | None:
def make_class_accumulator(old_value: object) -> ClassAccumulator:
"""
Initialize the class accumulator.
-
- @NOTE: This should only be run if the special class has not been initialized
- already.
"""
match old_value:
case str():
From c495ab8b8596462291a554ed780cec0b70489a21 Mon Sep 17 00:00:00 2001
From: Ian Wilson
Date: Fri, 26 Dec 2025 00:30:16 -0800
Subject: [PATCH 17/23] Adjust docs, drop classnames helper.
---
README.md | 60 ++++++++++---------------------
tdom/__init__.py | 2 --
tdom/classnames.py | 50 --------------------------
tdom/classnames_test.py | 78 -----------------------------------------
4 files changed, 19 insertions(+), 171 deletions(-)
delete mode 100644 tdom/classnames.py
delete mode 100644 tdom/classnames_test.py
diff --git a/README.md b/README.md
index 86346eb..58c2209 100644
--- a/README.md
+++ b/README.md
@@ -139,14 +139,20 @@ button = html(t'')
#
```
-See the
-[`classnames()`](https://github.com/t-strings/tdom/blob/main/tdom/classnames_test.py)
-helper function for more information on how class names are combined.
+A `class` attribute hard coded in your template can also be merged with dynamic
+sources. A common use case would be to extend a base css class:
+
+```python
+add_classes = ["btn-primary"]
+button = html(t'')
+assert str(button) == ''
+```
#### The `style` Attribute
-In addition to strings, you can also provide a dictionary of CSS properties and
-values for the `style` attribute:
+The `style` attribute has special handling to make it easy to combine multiple
+styles from different sources. The simplest way is to provide a dictionary of
+CSS properties and values for the `style` attribute:
```python
# Style attributes from dictionaries
@@ -155,6 +161,14 @@ styled = html(t"
Important text
")
#
Important text
```
+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
'
+```
+
#### The `data` and `aria` Attributes
The `data` and `aria` attributes also have special handling to convert
@@ -539,42 +553,6 @@ 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.
-#### The `classnames()` Helper
-
-The `classnames()` function provides a flexible way to build class name strings
-from various input types. It's particularly useful when you need to
-conditionally include classes:
-
-```python
-from tdom import classnames
-
-# Combine strings
-assert classnames("btn", "btn-primary") == "btn btn-primary"
-
-# Use dictionaries for conditional classes
-is_active = True
-is_disabled = False
-assert classnames("btn", {
- "btn-active": is_active,
- "btn-disabled": is_disabled
-}) == "btn btn-active"
-
-# Mix lists, dicts, and strings
-assert classnames(
- "btn",
- ["btn-large", "rounded"],
- {"btn-primary": True, "btn-secondary": False},
- None, # Ignored
- False # Ignored
-) == "btn btn-large rounded btn-primary"
-
-# Nested lists are flattened
-assert classnames(["btn", ["btn-primary", ["active"]]]) == "btn btn-primary active"
-```
-
-This function is automatically used when processing `class` attributes in
-templates, so you can pass any of these input types directly in your t-strings.
-
#### Utilities
The `tdom` package includes several utility functions for working with
diff --git a/tdom/__init__.py b/tdom/__init__.py
index d5fa015..4503582 100644
--- a/tdom/__init__.py
+++ b/tdom/__init__.py
@@ -1,13 +1,11 @@
from markupsafe import Markup, escape
-from .classnames import classnames
from .nodes import Comment, DocumentType, Element, Fragment, Node, Text
from .processor import html
# We consider `Markup` and `escape` to be part of this module's public API
__all__ = [
- "classnames",
"Comment",
"DocumentType",
"Element",
diff --git a/tdom/classnames.py b/tdom/classnames.py
deleted file mode 100644
index c82128c..0000000
--- a/tdom/classnames.py
+++ /dev/null
@@ -1,50 +0,0 @@
-def classnames(*args: object) -> str:
- """
- Construct a space-separated class string from various inputs.
-
- Accepts strings, lists/tuples of strings, and dicts mapping class names to
- boolean values. Ignores None and False values.
-
- Examples:
- classnames("btn", "btn-primary") -> "btn btn-primary"
- classnames("btn", {"btn-primary": True, "disabled": False}) -> "btn btn-primary"
- classnames(["btn", "btn-primary"], {"disabled": True}) -> "btn btn-primary disabled"
- classnames("btn", None, False, "active") -> "btn active"
-
- Args:
- *args: Variable length argument list containing strings, lists/tuples,
- or dicts.
-
- Returns:
- A single string with class names separated by spaces.
- """
- classes: list[str] = []
- # Use a queue to process arguments iteratively, preserving order.
- queue = list(args)
-
- while queue:
- arg = queue.pop(0)
-
- if not arg: # Handles None, False, empty strings/lists/dicts
- continue
-
- if isinstance(arg, str):
- classes.append(arg)
- elif isinstance(arg, dict):
- for key, value in arg.items():
- if value:
- if not isinstance(key, str):
- raise ValueError(
- f"Classnames dictionary keys must be strings, found {key!r} of type {type(key).__name__}"
- )
- classes.append(key)
- elif isinstance(arg, (list, tuple)):
- # Add items to the front of the queue to process them next, in order.
- queue[0:0] = arg
- elif isinstance(arg, bool):
- pass # Explicitly ignore booleans not in a dict
- else:
- raise ValueError(f"Invalid class argument type: {type(arg).__name__}")
-
- # Filter out empty strings and join the result.
- return " ".join(stripped for c in classes if (stripped := c.strip()))
diff --git a/tdom/classnames_test.py b/tdom/classnames_test.py
deleted file mode 100644
index 62e4d70..0000000
--- a/tdom/classnames_test.py
+++ /dev/null
@@ -1,78 +0,0 @@
-import pytest
-
-from .classnames import classnames
-
-
-def test_classnames_empty():
- assert classnames() == ""
-
-
-def test_classnames_strings():
- assert classnames("btn", "btn-primary") == "btn btn-primary"
-
-
-def test_classnames_strings_strip():
- assert classnames(" btn ", " btn-primary ") == "btn btn-primary"
-
-
-def test_cslx_empty_strings():
- assert classnames("", "btn", "", "btn-primary", "") == "btn btn-primary"
-
-
-def test_clsx_booleans():
- assert classnames(True, False) == ""
-
-
-def test_classnames_lists_and_tuples():
- assert (
- classnames(["btn", "btn-primary"], ("active", "disabled"))
- == "btn btn-primary active disabled"
- )
-
-
-def test_classnames_dicts():
- assert (
- classnames(
- "btn",
- {"btn-primary": True, "disabled": False, "active": True, "shown": "yes"},
- )
- == "btn btn-primary active shown"
- )
-
-
-def test_classnames_mixed_inputs():
- assert (
- classnames(
- "btn",
- ["btn-primary", "active"],
- {"disabled": True, "hidden": False},
- ("extra",),
- )
- == "btn btn-primary active disabled extra"
- )
-
-
-def test_classnames_ignores_none_and_false():
- assert (
- classnames("btn", None, False, "active", {"hidden": None, "visible": True})
- == "btn active visible"
- )
-
-
-def test_classnames_raises_type_error_on_invalid_input():
- with pytest.raises(ValueError):
- classnames(123)
-
- with pytest.raises(ValueError):
- classnames(["btn", 456])
-
-
-def test_classnames_kitchen_sink():
- assert (
- classnames(
- "foo",
- [1 and "bar", {"baz": False, "bat": None}, ["hello", ["world"]]],
- "cya",
- )
- == "foo bar hello world cya"
- )
From 61ead885c24875c6cb6f6d0e2f539467c54a0e4c Mon Sep 17 00:00:00 2001
From: Ian Wilson
Date: Thu, 1 Jan 2026 23:29:55 -0800
Subject: [PATCH 18/23] Ignore None to easily support passing through a kwarg
with a None default.
---
tdom/processor.py | 2 ++
1 file changed, 2 insertions(+)
diff --git a/tdom/processor.py b/tdom/processor.py
index ac1e807..e522d97 100644
--- a/tdom/processor.py
+++ b/tdom/processor.py
@@ -189,6 +189,8 @@ def merge_value(self, value: object) -> None:
for pn, pv in value.items()
}
)
+ case None:
+ pass
case _:
raise TypeError(
f"Unknown interpolated style value {value}, use '' to omit."
From 0028f2d6ee8fb94158e7d2c98f91ced45fff18c6 Mon Sep 17 00:00:00 2001
From: Ian Wilson
Date: Mon, 5 Jan 2026 13:51:44 -0800
Subject: [PATCH 19/23] Allow special attributes to be None to allow
pass-through from components or template functions.
---
tdom/processor.py | 4 ++++
tdom/processor_test.py | 14 ++++++++++++++
2 files changed, 18 insertions(+)
diff --git a/tdom/processor.py b/tdom/processor.py
index e522d97..460a270 100644
--- a/tdom/processor.py
+++ b/tdom/processor.py
@@ -96,6 +96,8 @@ def _force_dict(value: t.Any, *, kind: str) -> dict:
def _expand_aria_attr(value: object) -> t.Iterable[HTMLAttribute]:
"""Produce aria-* attributes based on the interpolated value for "aria"."""
+ if value is None:
+ return
d = _force_dict(value, kind="aria")
for sub_k, sub_v in d.items():
if sub_v is True:
@@ -110,6 +112,8 @@ def _expand_aria_attr(value: object) -> t.Iterable[HTMLAttribute]:
def _expand_data_attr(value: object) -> t.Iterable[Attribute]:
"""Produce data-* attributes based on the interpolated value for "data"."""
+ if value is None:
+ return
d = _force_dict(value, kind="data")
for sub_k, sub_v in d.items():
if sub_v is True or sub_v is False or sub_v is None:
diff --git a/tdom/processor_test.py b/tdom/processor_test.py
index 21b2f46..0dd30ec 100644
--- a/tdom/processor_test.py
+++ b/tdom/processor_test.py
@@ -690,6 +690,20 @@ def test_interpolated_aria_attributes():
)
+def test_special_aria_none():
+ button_aria = None
+ node = html(t"")
+ assert node == Element("button", children=[Text("X")])
+ assert str(node) == ""
+
+
+def test_special_data_none():
+ button_data = None
+ node = html(t"")
+ assert node == Element("button", children=[Text("X")])
+ assert str(node) == ""
+
+
#
# Special style attribute handling.
#
From 224e1207bc4c9d517a8d2958ec5f5dbdf4cfb10f Mon Sep 17 00:00:00 2001
From: Ian Wilson
Date: Thu, 8 Jan 2026 22:44:04 -0800
Subject: [PATCH 20/23] Try to add all forms to class attr smoketest.
---
tdom/processor_test.py | 26 +++++++++++++++++++++-----
1 file changed, 21 insertions(+), 5 deletions(-)
diff --git a/tdom/processor_test.py b/tdom/processor_test.py
index 0dd30ec..04e97ee 100644
--- a/tdom/processor_test.py
+++ b/tdom/processor_test.py
@@ -507,15 +507,31 @@ def test_multiple_attribute_spread_dicts():
def test_interpolated_class_attribute():
- classes = ["btn", "btn-primary", False and "disabled", None]
- toggle_classes = {"active": True}
- node = html(t'')
+ class_list = ["btn", "btn-primary", True, False, None]
+ class_dict = {"active": True, "btn-secondary": False}
+ class_str = "blue"
+ class_none = None
+ class_true = True
+ class_false = False
+ class_empty_list = []
+ class_empty_dict = {}
+ button_t = (
+ t""
+ )
+ node = html(button_t)
assert node == Element(
"button",
- attrs={"class": "btn btn-primary active"},
+ attrs={"class": "red btn btn-primary active blue"},
children=[Text("Click me")],
)
- assert str(node) == ''
+ assert (
+ str(node) == ''
+ )
def test_interpolated_class_attribute_with_multiple_placeholders():
From 9ea05125d6b093f3a2f94cd4d18697260dd02d3a Mon Sep 17 00:00:00 2001
From: Ian Wilson
Date: Thu, 8 Jan 2026 22:47:03 -0800
Subject: [PATCH 21/23] Restrict each class attr to either toggle or add list
for now.
---
README.md | 18 +++++++++---------
tdom/processor.py | 43 +++++++++++++++++++++++--------------------
2 files changed, 32 insertions(+), 29 deletions(-)
diff --git a/README.md b/README.md
index 58c2209..2265d27 100644
--- a/README.md
+++ b/README.md
@@ -130,21 +130,21 @@ button = html(t'')
#
```
-For flexibility, you can also provide a list of strings, dictionaries, or a mix
-of both:
+The `class` attribute can also be a dictionary to toggle classes on or off:
```python
-classes = ["btn", "btn-primary", {"active": True}, None, False and "disabled"]
-button = html(t'')
+classes = {"active": True, "btn-primary": True}
+button = html(t'')
#
```
-A `class` attribute hard coded in your template can also be merged with dynamic
-sources. A common use case would be to extend a base css class:
+The `class` attribute can be specified more than once. The values are merged
+from left to right. A common use case would be to update and/or extend default
+classes:
```python
-add_classes = ["btn-primary"]
-button = html(t'')
+classes = {"btn-primary": True, "btn-secondary": False}
+button = html(t'')
assert str(button) == ''
```
@@ -209,7 +209,7 @@ Special attributes likes `class` behave as expected when combined with
spreading:
```python
-classes = ["btn", {"active": True}]
+classes = {"btn": True, "active": True}
attrs = {"class": classes, "id": "act_now", "data": {"wow": "such-attr"}}
button = html(t'')
#
diff --git a/tdom/processor.py b/tdom/processor.py
index 460a270..3e03589 100644
--- a/tdom/processor.py
+++ b/tdom/processor.py
@@ -234,27 +234,30 @@ def merge_value(self, value: object) -> None:
"""
Merge in an interpolated class value.
"""
- if not isinstance(value, str) and isinstance(value, Sequence):
- items = value[:]
+ if isinstance(value, dict):
+ self.toggled_classes.update(
+ {str(cn): bool(toggle) for cn, toggle in value.items()}
+ )
else:
- items = (value,)
- for item in items:
- match item:
- case str():
- self.toggled_classes.update({cn: True for cn in item.split()})
- case dict():
- self.toggled_classes.update(
- {str(cn): bool(toggle) for cn, toggle in item.items()}
- )
- case True | False | None:
- pass
- case _:
- if item == value:
- raise TypeError(f"Unknown interpolated class value: {value}")
- else:
- raise TypeError(
- f"Unknown interpolated class item in {value}: {item}"
- )
+ if not isinstance(value, str) and isinstance(value, Sequence):
+ items = value[:]
+ else:
+ items = (value,)
+ for item in items:
+ match item:
+ case str():
+ self.toggled_classes.update({cn: True for cn in item.split()})
+ case True | False | None:
+ pass
+ case _:
+ if item == value:
+ raise TypeError(
+ f"Unknown interpolated class value: {value}"
+ )
+ else:
+ raise TypeError(
+ f"Unknown interpolated class item in {value}: {item}"
+ )
def to_value(self) -> str | None:
"""
From 45be1643112ef01a29dd77f139666b37556d85c8 Mon Sep 17 00:00:00 2001
From: Ian Wilson
Date: Thu, 8 Jan 2026 22:55:15 -0800
Subject: [PATCH 22/23] Drop support for booleans in interpolated class
attributes.
---
tdom/processor.py | 2 +-
tdom/processor_test.py | 6 ++----
2 files changed, 3 insertions(+), 5 deletions(-)
diff --git a/tdom/processor.py b/tdom/processor.py
index 3e03589..e3773a9 100644
--- a/tdom/processor.py
+++ b/tdom/processor.py
@@ -247,7 +247,7 @@ def merge_value(self, value: object) -> None:
match item:
case str():
self.toggled_classes.update({cn: True for cn in item.split()})
- case True | False | None:
+ case None:
pass
case _:
if item == value:
diff --git a/tdom/processor_test.py b/tdom/processor_test.py
index 04e97ee..146c2b4 100644
--- a/tdom/processor_test.py
+++ b/tdom/processor_test.py
@@ -507,19 +507,17 @@ def test_multiple_attribute_spread_dicts():
def test_interpolated_class_attribute():
- class_list = ["btn", "btn-primary", True, False, None]
+ class_list = ["btn", "btn-primary", None]
class_dict = {"active": True, "btn-secondary": False}
class_str = "blue"
class_none = None
- class_true = True
- class_false = False
class_empty_list = []
class_empty_dict = {}
button_t = (
t""
)
From 0862d14142f88ff31f0996e60c85de3e42cb716a Mon Sep 17 00:00:00 2001
From: Ian Wilson
Date: Thu, 8 Jan 2026 22:59:57 -0800
Subject: [PATCH 23/23] Add space separated classes to smoketest.
---
tdom/processor_test.py | 10 ++++++----
1 file changed, 6 insertions(+), 4 deletions(-)
diff --git a/tdom/processor_test.py b/tdom/processor_test.py
index 146c2b4..30a4ed5 100644
--- a/tdom/processor_test.py
+++ b/tdom/processor_test.py
@@ -507,9 +507,10 @@ def test_multiple_attribute_spread_dicts():
def test_interpolated_class_attribute():
- class_list = ["btn", "btn-primary", None]
+ 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 = {}
@@ -518,17 +519,18 @@ def test_interpolated_class_attribute():
t' class="red" class={class_list} class={class_dict}'
t" class={class_empty_list} class={class_empty_dict}" # ignored
t" class={class_none}" # ignored
- t" class={class_str}"
+ t" class={class_str} class={class_space_sep_str}"
t" >Click me"
)
node = html(button_t)
assert node == Element(
"button",
- attrs={"class": "red btn btn-primary active blue"},
+ attrs={"class": "red btn btn-primary one two active blue green yellow"},
children=[Text("Click me")],
)
assert (
- str(node) == ''
+ str(node)
+ == ''
)