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"

Warning!

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

Warning!

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

Warning!

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

Warning!

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

Warning!

' def test_style_attribute_str(): @@ -734,10 +755,10 @@ def test_style_attribute_str(): node = html(t"

Warning!

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

Warning!

' + assert str(node) == '

Warning!

' 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'

Warning!

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

Warning!

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

Warning!

") assert node == Element( @@ -704,16 +720,56 @@ def test_interpolated_style_attribute(): ) -def test_merge_static_style_str_str(): - node = html(t'

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

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

Warning!

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

Warning!

' + ) -def test_override_static_style_str_builder(): - node = html(t'

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

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

Warning!

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

Warning!

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

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

' + + +def test_style_override_left_to_right(): + suffix = 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'

Warning!

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

Warning!

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

Warning!

' def test_style_in_interpolated_attr(): @@ -728,45 +726,48 @@ def test_style_in_templated_attr(): attrs={"style": "color: red"}, children=[Text("Warning!")], ) - assert ( - str(node) - == '

Warning!

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

Warning!

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

Warning!

') + node = html(t"

Warning!

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

Warning!

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

Warning!

' def test_style_merged_from_all_attrs(): attrs = dict(style="font-size: 15px") style = {"font-weight": "bold"} color = "red" - node = 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) + == '' )