From e483f5344779056220bbe6498ecfb5b0a741241a Mon Sep 17 00:00:00 2001 From: Dave Date: Sun, 14 Sep 2025 17:19:07 -0700 Subject: [PATCH 01/10] Start work on better component and callable handling --- docs/usage/components.md | 8 ++++---- tdom/__init__.py | 3 +-- tdom/processor.py | 19 ++++++++++--------- tdom/processor_test.py | 6 ++---- 4 files changed, 17 insertions(+), 19 deletions(-) diff --git a/docs/usage/components.md b/docs/usage/components.md index 0e54757..211c085 100644 --- a/docs/usage/components.md +++ b/docs/usage/components.md @@ -29,8 +29,8 @@ a `Node`: ```python @@ -116,7 +116,7 @@ def DefaultHeading() -> Template: def OtherHeading() -> Template: return t"

Other Heading

" -def Body(heading: ComponentCallable) -> Template: +def Body(heading: Callable) -> Template: return html(t"<{heading} />") result = html(t"<{Body} heading={OtherHeading}>") @@ -135,7 +135,7 @@ def DefaultHeading() -> Template: def OtherHeading() -> Template: return t"

Other Heading

" -def Body(heading: ComponentCallable | None = None) -> Template: +def Body(heading: Callable | None = None) -> Template: return t"<{heading if heading else DefaultHeading} />" result = html(t"<{Body} heading={OtherHeading}>") diff --git a/tdom/__init__.py b/tdom/__init__.py index 6c98faa..4503582 100644 --- a/tdom/__init__.py +++ b/tdom/__init__.py @@ -1,13 +1,12 @@ from markupsafe import Markup, escape from .nodes import Comment, DocumentType, Element, Fragment, Node, Text -from .processor import ComponentCallable, html +from .processor import html # We consider `Markup` and `escape` to be part of this module's public API __all__ = [ "Comment", - "ComponentCallable", "DocumentType", "Element", "escape", diff --git a/tdom/processor.py b/tdom/processor.py index f2f981b..5ebd969 100644 --- a/tdom/processor.py +++ b/tdom/processor.py @@ -281,8 +281,8 @@ def _node_from_value(value: object) -> Node: """ Convert an arbitrary value to a Node. - This is the primary substitution performed when replacing interpolations - in child content positions. + This is the primary action performed when replacing interpolations in child + content positions. """ match value: case str(): @@ -291,23 +291,24 @@ def _node_from_value(value: object) -> Node: return value case Template(): return html(value) - case False: + # Consider: falsey values, not just False and None? + case False | None: return Text("") case Iterable(): children = [_node_from_value(v) for v in value] return Fragment(children=children) case HasHTMLDunder(): - # CONSIDER: could we return a lazy Text? + # CONSIDER: should we do this lazily? return Text(Markup(value.__html__())) + case c if callable(c): + # Treat all callable values in child content positions as if + # they are zero-arg functions that return a value to be rendered. + return _node_from_value(c()) case _: - # CONSIDER: could we return a lazy Text? + # CONSIDER: should we do this lazily? return Text(str(value)) -type ComponentReturn = Node | Template | str | HasHTMLDunder -type ComponentCallable = t.Callable[..., ComponentReturn | t.Iterable[ComponentReturn]] - - def _invoke_component( tag: str, new_attrs: dict[str, object | None], diff --git a/tdom/processor_test.py b/tdom/processor_test.py index f444459..e0b8abd 100644 --- a/tdom/processor_test.py +++ b/tdom/processor_test.py @@ -6,7 +6,7 @@ from markupsafe import Markup from .nodes import Element, Fragment, Node, Text -from .processor import ComponentCallable, html +from .processor import html # -------------------------------------------------------------------------- # Basic HTML parsing tests @@ -540,9 +540,7 @@ def test_fragment_from_component(): def test_component_passed_as_attr_value(): - def Wrapper( - *children: Node, sub_component: ComponentCallable, **attrs: t.Any - ) -> Template: + def Wrapper(*children: Node, sub_component: t.Callable, **attrs: t.Any) -> Template: return t"<{sub_component} {attrs}>{children}" node = html( From ec7babfdbf7710ac4ef6f2925bd36e03fddc6ad9 Mon Sep 17 00:00:00 2001 From: Dave Date: Sun, 14 Sep 2025 18:02:40 -0700 Subject: [PATCH 02/10] Add basic function calling tests --- tdom/processor.py | 2 +- tdom/processor_test.py | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/tdom/processor.py b/tdom/processor.py index 5ebd969..9a17a70 100644 --- a/tdom/processor.py +++ b/tdom/processor.py @@ -293,7 +293,7 @@ def _node_from_value(value: object) -> Node: return html(value) # Consider: falsey values, not just False and None? case False | None: - return Text("") + return Fragment(children=[]) case Iterable(): children = [_node_from_value(v) for v in value] return Fragment(children=children) diff --git a/tdom/processor_test.py b/tdom/processor_test.py index e0b8abd..c25b53d 100644 --- a/tdom/processor_test.py +++ b/tdom/processor_test.py @@ -130,6 +130,41 @@ def test_conversions(): ) +# -------------------------------------------------------------------------- +# Interpolated non-text content +# -------------------------------------------------------------------------- + + +def test_interpolated_false_content(): + node = html(t"
{False}
") + assert node == Element("div", children=[]) + assert str(node) == "
" + + +def test_interpolated_none_content(): + node = html(t"
{None}
") + assert node == Element("div", children=[]) + assert str(node) == "
" + + +def test_interpolated_zero_arg_function(): + def get_value(): + return "dynamic" + + node = html(t"

The value is {get_value}.

") + assert node == Element( + "p", children=[Text("The value is "), Text("dynamic"), Text(".")] + ) + + +def test_interpolated_multi_arg_function_fails(): + def add(a, b): + return a + b + + with pytest.raises(TypeError): + _ = html(t"

The sum is {add}.

") + + # -------------------------------------------------------------------------- # Raw HTML injection tests # -------------------------------------------------------------------------- From 829148d9b267a2745841568304b0f6ead0354ca5 Mon Sep 17 00:00:00 2001 From: Dave Date: Sun, 14 Sep 2025 18:08:41 -0700 Subject: [PATCH 03/10] Don't lru_cache if pytests are running --- tdom/processor.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tdom/processor.py b/tdom/processor.py index 9a17a70..5c86128 100644 --- a/tdom/processor.py +++ b/tdom/processor.py @@ -1,5 +1,6 @@ import random import string +import sys import typing as t from collections.abc import Iterable from functools import lru_cache @@ -95,7 +96,7 @@ def _instrument( yield placeholder -@lru_cache() +@lru_cache(maxsize=0 if "pytest" in sys.modules else 256) def _instrument_and_parse_internal( strings: tuple[str, ...], callable_ids: tuple[int | None, ...] ) -> Node: From aef2c55efa59effd8d16b653c1728f79ab38f5e9 Mon Sep 17 00:00:00 2001 From: Dave Date: Mon, 15 Sep 2025 09:43:45 -0700 Subject: [PATCH 04/10] Add signature support for callable nodes --- tdom/callables.py | 76 +++++++++++++++++++ tdom/callables_test.py | 163 +++++++++++++++++++++++++++++++++++++++++ tdom/processor.py | 2 +- 3 files changed, 240 insertions(+), 1 deletion(-) create mode 100644 tdom/callables.py create mode 100644 tdom/callables_test.py diff --git a/tdom/callables.py b/tdom/callables.py new file mode 100644 index 0000000..662a37e --- /dev/null +++ b/tdom/callables.py @@ -0,0 +1,76 @@ +import sys +import typing as t +from dataclasses import dataclass +from functools import lru_cache + + +@dataclass(slots=True, frozen=True) +class CallableInfo: + """Information about a callable necessary for `tdom` to safely invoke it.""" + + id: int + """The unique identifier of the callable (from id()).""" + + named_args: frozenset[str] + """The names of the callable's named arguments.""" + + required_named_args: frozenset[str] + """The names of the callable's required named arguments.""" + + requires_positional: bool + """Whether the callable requires positional-only argument values.""" + + kwargs: bool + """Whether the callable accepts **kwargs.""" + + @classmethod + def from_callable(cls, c: t.Callable) -> t.Self: + """Create a CallableInfo from a callable.""" + import inspect + + sig = inspect.signature(c) + named_args = [] + required_named_args = [] + requires_positional = False + kwargs = False + + for param in sig.parameters.values(): + match param.kind: + case inspect.Parameter.POSITIONAL_ONLY: + if param.default is param.empty: + requires_positional = True + case inspect.Parameter.POSITIONAL_OR_KEYWORD: + named_args.append(param.name) + if param.default is param.empty: + required_named_args.append(param.name) + case inspect.Parameter.VAR_POSITIONAL: + # Does this necessarily mean it requires positional args? + # Answer: No, but we have no way of knowing how many + # positional args it *might* require, so we have to assume + # it does. + requires_positional = True + case inspect.Parameter.KEYWORD_ONLY: + named_args.append(param.name) + if param.default is param.empty: + required_named_args.append(param.name) + case inspect.Parameter.VAR_KEYWORD: + kwargs = True + + return cls( + id=id(c), + named_args=frozenset(named_args), + required_named_args=frozenset(required_named_args), + requires_positional=requires_positional, + kwargs=kwargs, + ) + + @property + def supports_zero_args(self) -> bool: + """Whether the callable can be called with zero arguments.""" + return not self.requires_positional and not self.required_named_args + + +@lru_cache(maxsize=0 if "pytest" in sys.modules else 512) +def get_callable_info(c: t.Callable) -> CallableInfo: + """Get the CallableInfo for a callable, caching the result.""" + return CallableInfo.from_callable(c) diff --git a/tdom/callables_test.py b/tdom/callables_test.py new file mode 100644 index 0000000..22e21e6 --- /dev/null +++ b/tdom/callables_test.py @@ -0,0 +1,163 @@ +import typing as t + +from .callables import get_callable_info + + +def callable_zero_args() -> None: + """Test callable that takes zero arguments.""" + pass + + +def test_zero_args() -> None: + """Test that a callable that takes zero arguments is detected.""" + info = get_callable_info(callable_zero_args) + assert info.id == id(callable_zero_args) + assert info.named_args == frozenset() + assert info.required_named_args == frozenset() + assert not info.requires_positional + assert not info.kwargs + assert info.supports_zero_args + + +def callable_positional(a: int, b: str) -> None: + """Test callable that takes positional arguments.""" + pass + + +def test_positional() -> None: + """Test that a callable that takes positional arguments is detected.""" + info = get_callable_info(callable_positional) + assert info.id == id(callable_positional) + assert info.named_args == frozenset(["a", "b"]) + assert info.required_named_args == frozenset(["a", "b"]) + assert not info.requires_positional + assert not info.kwargs + assert not info.supports_zero_args + + +def callable_positional_only(a: int, /, b: str) -> None: + """Test callable that takes positional-only arguments.""" + pass + + +def test_positional_only() -> None: + """Test that a callable that takes positional-only arguments is detected.""" + info = get_callable_info(callable_positional_only) + assert info.id == id(callable_positional_only) + assert info.named_args == frozenset(["b"]) + assert info.required_named_args == frozenset(["b"]) + assert info.requires_positional + assert not info.kwargs + assert not info.supports_zero_args + + +def callable_positional_only_default(a: int = 1, /, b: str = "b") -> None: + """Test callable that takes positional-only arguments with defaults.""" + pass + + +def test_positional_only_default() -> None: + """Test that a callable that takes positional-only arguments with defaults is detected.""" + info = get_callable_info(callable_positional_only_default) + assert info.id == id(callable_positional_only_default) + assert info.named_args == frozenset(["b"]) + assert info.required_named_args == frozenset() + assert not info.requires_positional + assert not info.kwargs + assert info.supports_zero_args + + +def callable_kwargs(**kwargs: t.Any) -> None: + """Test callable that takes **kwargs.""" + pass + + +def test_kwargs() -> None: + """Test that a callable that takes **kwargs is detected.""" + info = get_callable_info(callable_kwargs) + assert info.id == id(callable_kwargs) + assert info.named_args == frozenset() + assert info.required_named_args == frozenset() + assert not info.requires_positional + assert info.kwargs + assert info.supports_zero_args + + +def callable_mixed(a: int, /, b: str, *, c: float = 1.0, **kwargs: t.Any) -> None: + """Test callable that takes a mix of argument types.""" + pass + + +def test_mixed() -> None: + """Test that a callable that takes a mix of argument types is detected.""" + info = get_callable_info(callable_mixed) + assert info.id == id(callable_mixed) + assert info.named_args == frozenset(["b", "c"]) + assert info.required_named_args == frozenset(["b"]) + assert info.requires_positional + assert info.kwargs + assert not info.supports_zero_args + + +def callable_positional_with_defaults(a: int = 1, b: str = "b", /) -> None: + """Test callable that takes positional arguments with defaults.""" + pass + + +def test_positional_with_defaults() -> None: + """Test that a callable that takes positional arguments with defaults is detected.""" + info = get_callable_info(callable_positional_with_defaults) + assert info.id == id(callable_positional_with_defaults) + assert info.named_args == frozenset() + assert info.required_named_args == frozenset() + assert not info.requires_positional + assert not info.kwargs + assert info.supports_zero_args + + +def callable_keyword_only(*, a: int, b: str = "b") -> None: + """Test callable that takes keyword-only arguments.""" + pass + + +def test_keyword_only() -> None: + """Test that a callable that takes keyword-only arguments is detected.""" + info = get_callable_info(callable_keyword_only) + assert info.id == id(callable_keyword_only) + assert info.named_args == frozenset(["a", "b"]) + assert info.required_named_args == frozenset(["a"]) + assert not info.requires_positional + assert not info.kwargs + assert not info.supports_zero_args + + +def callable_var_positional(*args: t.Any) -> None: + """Test callable that takes *args.""" + pass + + +def test_var_positional() -> None: + """Test that a callable that takes *args is detected.""" + info = get_callable_info(callable_var_positional) + assert info.id == id(callable_var_positional) + assert info.named_args == frozenset() + assert info.required_named_args == frozenset() + assert info.requires_positional + assert not info.kwargs + assert not info.supports_zero_args + + +def callable_all_types(a: int, /, b: str, *, c: float = 1.0, **kwargs: t.Any) -> None: + """Test callable that takes all types of arguments.""" + pass + + +def test_all_types() -> None: + """Test that a callable that takes all types of arguments is detected.""" + info = get_callable_info(callable_all_types) + assert info.id == id(callable_all_types) + assert info.named_args == frozenset(["b", "c"]) + assert info.required_named_args == frozenset(["b"]) + assert info.requires_positional + assert info.kwargs + assert not info.supports_zero_args diff --git a/tdom/processor.py b/tdom/processor.py index 5c86128..f0bc133 100644 --- a/tdom/processor.py +++ b/tdom/processor.py @@ -96,7 +96,7 @@ def _instrument( yield placeholder -@lru_cache(maxsize=0 if "pytest" in sys.modules else 256) +@lru_cache(maxsize=0 if "pytest" in sys.modules else 512) def _instrument_and_parse_internal( strings: tuple[str, ...], callable_ids: tuple[int | None, ...] ) -> Node: From 2e3a07b2de4c96a73b425da495ed428cdb90cc9b Mon Sep 17 00:00:00 2001 From: Dave Date: Mon, 15 Sep 2025 10:43:09 -0700 Subject: [PATCH 05/10] Full pass for current tests; lots of new tests still to write. --- tdom/callables.py | 26 +++++----- tdom/callables_test.py | 40 +++++++-------- tdom/processor.py | 109 ++++++++++++++++++++++++++++++++++------- tdom/processor_test.py | 6 ++- 4 files changed, 129 insertions(+), 52 deletions(-) diff --git a/tdom/callables.py b/tdom/callables.py index 662a37e..edbd61e 100644 --- a/tdom/callables.py +++ b/tdom/callables.py @@ -11,10 +11,10 @@ class CallableInfo: id: int """The unique identifier of the callable (from id()).""" - named_args: frozenset[str] + named_params: frozenset[str] """The names of the callable's named arguments.""" - required_named_args: frozenset[str] + required_named_params: frozenset[str] """The names of the callable's required named arguments.""" requires_positional: bool @@ -29,8 +29,8 @@ def from_callable(cls, c: t.Callable) -> t.Self: import inspect sig = inspect.signature(c) - named_args = [] - required_named_args = [] + named_params = [] + required_named_params = [] requires_positional = False kwargs = False @@ -40,26 +40,26 @@ def from_callable(cls, c: t.Callable) -> t.Self: if param.default is param.empty: requires_positional = True case inspect.Parameter.POSITIONAL_OR_KEYWORD: - named_args.append(param.name) + named_params.append(param.name) if param.default is param.empty: - required_named_args.append(param.name) + required_named_params.append(param.name) case inspect.Parameter.VAR_POSITIONAL: # Does this necessarily mean it requires positional args? # Answer: No, but we have no way of knowing how many - # positional args it *might* require, so we have to assume - # it does. + # positional args it *might* expect, so we have to assume + # that it does. requires_positional = True case inspect.Parameter.KEYWORD_ONLY: - named_args.append(param.name) + named_params.append(param.name) if param.default is param.empty: - required_named_args.append(param.name) + required_named_params.append(param.name) case inspect.Parameter.VAR_KEYWORD: kwargs = True return cls( id=id(c), - named_args=frozenset(named_args), - required_named_args=frozenset(required_named_args), + named_params=frozenset(named_params), + required_named_params=frozenset(required_named_params), requires_positional=requires_positional, kwargs=kwargs, ) @@ -67,7 +67,7 @@ def from_callable(cls, c: t.Callable) -> t.Self: @property def supports_zero_args(self) -> bool: """Whether the callable can be called with zero arguments.""" - return not self.requires_positional and not self.required_named_args + return not self.requires_positional and not self.required_named_params @lru_cache(maxsize=0 if "pytest" in sys.modules else 512) diff --git a/tdom/callables_test.py b/tdom/callables_test.py index 22e21e6..1a35b11 100644 --- a/tdom/callables_test.py +++ b/tdom/callables_test.py @@ -12,8 +12,8 @@ def test_zero_args() -> None: """Test that a callable that takes zero arguments is detected.""" info = get_callable_info(callable_zero_args) assert info.id == id(callable_zero_args) - assert info.named_args == frozenset() - assert info.required_named_args == frozenset() + assert info.named_params == frozenset() + assert info.required_named_params == frozenset() assert not info.requires_positional assert not info.kwargs assert info.supports_zero_args @@ -28,8 +28,8 @@ def test_positional() -> None: """Test that a callable that takes positional arguments is detected.""" info = get_callable_info(callable_positional) assert info.id == id(callable_positional) - assert info.named_args == frozenset(["a", "b"]) - assert info.required_named_args == frozenset(["a", "b"]) + assert info.named_params == frozenset(["a", "b"]) + assert info.required_named_params == frozenset(["a", "b"]) assert not info.requires_positional assert not info.kwargs assert not info.supports_zero_args @@ -44,8 +44,8 @@ def test_positional_only() -> None: """Test that a callable that takes positional-only arguments is detected.""" info = get_callable_info(callable_positional_only) assert info.id == id(callable_positional_only) - assert info.named_args == frozenset(["b"]) - assert info.required_named_args == frozenset(["b"]) + assert info.named_params == frozenset(["b"]) + assert info.required_named_params == frozenset(["b"]) assert info.requires_positional assert not info.kwargs assert not info.supports_zero_args @@ -60,8 +60,8 @@ def test_positional_only_default() -> None: """Test that a callable that takes positional-only arguments with defaults is detected.""" info = get_callable_info(callable_positional_only_default) assert info.id == id(callable_positional_only_default) - assert info.named_args == frozenset(["b"]) - assert info.required_named_args == frozenset() + assert info.named_params == frozenset(["b"]) + assert info.required_named_params == frozenset() assert not info.requires_positional assert not info.kwargs assert info.supports_zero_args @@ -76,8 +76,8 @@ def test_kwargs() -> None: """Test that a callable that takes **kwargs is detected.""" info = get_callable_info(callable_kwargs) assert info.id == id(callable_kwargs) - assert info.named_args == frozenset() - assert info.required_named_args == frozenset() + assert info.named_params == frozenset() + assert info.required_named_params == frozenset() assert not info.requires_positional assert info.kwargs assert info.supports_zero_args @@ -92,8 +92,8 @@ def test_mixed() -> None: """Test that a callable that takes a mix of argument types is detected.""" info = get_callable_info(callable_mixed) assert info.id == id(callable_mixed) - assert info.named_args == frozenset(["b", "c"]) - assert info.required_named_args == frozenset(["b"]) + assert info.named_params == frozenset(["b", "c"]) + assert info.required_named_params == frozenset(["b"]) assert info.requires_positional assert info.kwargs assert not info.supports_zero_args @@ -108,8 +108,8 @@ def test_positional_with_defaults() -> None: """Test that a callable that takes positional arguments with defaults is detected.""" info = get_callable_info(callable_positional_with_defaults) assert info.id == id(callable_positional_with_defaults) - assert info.named_args == frozenset() - assert info.required_named_args == frozenset() + assert info.named_params == frozenset() + assert info.required_named_params == frozenset() assert not info.requires_positional assert not info.kwargs assert info.supports_zero_args @@ -124,8 +124,8 @@ def test_keyword_only() -> None: """Test that a callable that takes keyword-only arguments is detected.""" info = get_callable_info(callable_keyword_only) assert info.id == id(callable_keyword_only) - assert info.named_args == frozenset(["a", "b"]) - assert info.required_named_args == frozenset(["a"]) + assert info.named_params == frozenset(["a", "b"]) + assert info.required_named_params == frozenset(["a"]) assert not info.requires_positional assert not info.kwargs assert not info.supports_zero_args @@ -140,8 +140,8 @@ def test_var_positional() -> None: """Test that a callable that takes *args is detected.""" info = get_callable_info(callable_var_positional) assert info.id == id(callable_var_positional) - assert info.named_args == frozenset() - assert info.required_named_args == frozenset() + assert info.named_params == frozenset() + assert info.required_named_params == frozenset() assert info.requires_positional assert not info.kwargs assert not info.supports_zero_args @@ -156,8 +156,8 @@ def test_all_types() -> None: """Test that a callable that takes all types of arguments is detected.""" info = get_callable_info(callable_all_types) assert info.id == id(callable_all_types) - assert info.named_args == frozenset(["b", "c"]) - assert info.required_named_args == frozenset(["b"]) + assert info.named_params == frozenset(["b", "c"]) + assert info.required_named_params == frozenset(["b"]) assert info.requires_positional assert info.kwargs assert not info.supports_zero_args diff --git a/tdom/processor.py b/tdom/processor.py index f0bc133..7d797db 100644 --- a/tdom/processor.py +++ b/tdom/processor.py @@ -8,6 +8,7 @@ from markupsafe import Markup +from .callables import CallableInfo, get_callable_info from .classnames import classnames from .nodes import Element, Fragment, Node, Text from .parser import parse_html @@ -65,7 +66,7 @@ def _placholder_index(s: str) -> int: def _instrument( - strings: tuple[str, ...], callable_ids: tuple[int | None, ...] + strings: tuple[str, ...], callable_infos: tuple[CallableInfo | None, ...] ) -> t.Iterable[str]: """ Join the strings with placeholders in between where interpolations go. @@ -89,29 +90,31 @@ def _instrument( # Special case for component callables: if the interpolation # is a callable, we need to make sure that any matching closing # tag uses the same placeholder. - callable_id = callable_ids[i] - if callable_id: - placeholder = callable_placeholders.setdefault(callable_id, placeholder) + callable_info = callable_infos[i] + if callable_info: + placeholder = callable_placeholders.setdefault( + callable_info.id, placeholder + ) yield placeholder @lru_cache(maxsize=0 if "pytest" in sys.modules else 512) def _instrument_and_parse_internal( - strings: tuple[str, ...], callable_ids: tuple[int | None, ...] + strings: tuple[str, ...], callable_infos: tuple[CallableInfo | None, ...] ) -> Node: """ Instrument the strings and parse the resulting HTML. The result is cached to avoid re-parsing the same template multiple times. """ - instrumented = _instrument(strings, callable_ids) + instrumented = _instrument(strings, callable_infos) return parse_html(instrumented) -def _callable_id(value: object) -> int | None: +def _callable_info(value: object) -> CallableInfo | None: """Return a unique identifier for a callable, or None if not callable.""" - return id(value) if callable(value) else None + return get_callable_info(value) if callable(value) else None def _instrument_and_parse(template: Template) -> Node: @@ -126,10 +129,10 @@ def _instrument_and_parse(template: Template) -> Node: # wouldn't have to do this. But I worry that tdom's syntax is harder to read # (it's easy to miss the closing tag) and may prove unfamiliar for # users coming from other templating systems. - callable_ids = tuple( - _callable_id(interpolation.value) for interpolation in template.interpolations + callable_infos = tuple( + _callable_info(interpolation.value) for interpolation in template.interpolations ) - return _instrument_and_parse_internal(template.strings, callable_ids) + return _instrument_and_parse_internal(template.strings, callable_infos) # -------------------------------------------------------------------------- @@ -310,13 +313,56 @@ def _node_from_value(value: object) -> Node: return Text(str(value)) +def _accepts_children(callable_info: CallableInfo) -> bool: + """Return True if the callable accepts a "children" parameter.""" + return "children" in callable_info.named_params or callable_info.kwargs + + +def _kebab_to_snake(name: str) -> str: + """Convert a kebab-case name to snake_case.""" + return name.replace("-", "_").lower() + + def _invoke_component( tag: str, new_attrs: dict[str, object | None], new_children: list[Node], interpolations: tuple[Interpolation, ...], ) -> Node: - """Substitute a component invocation based on the corresponding interpolations.""" + """ + Invoke a component callable with the provided attributes and children. + + Components are any callable that meets the required calling signature. + Typically, that's a function, but it could also be the constructor or + __call__() method for a class; dataclass constructors match our expected + invocation style. + + We validate the callable's signature and invoke it with keyword-only + arguments, then convert the result to a Node. + + Component invocation rules: + + 1. All arguments are passed as keywords only. Components cannot require + positional arguments. + + 2. Children are passed via a "children" parameter when: + + - Child content exists in the template AND + - The callable accepts "children" OR has **kwargs + + If no children exist but the callable accepts "children", we pass an + empty tuple. + + 3. Components that don't accept "children" and have no **kwargs cannot + receive child content (will raise TypeError if attempted). + + 4. All component attributes must map to the callable's named parameters, + except "children" which is handled separately. Missing required + parameters will raise TypeError. + + 5. Extra attributes that don't match parameters are silently ignored + (unless the callable uses **kwargs to capture them). + """ index = _placholder_index(tag) interpolation = interpolations[index] value = format_interpolation(interpolation) @@ -324,12 +370,38 @@ def _invoke_component( raise TypeError( f"Expected a callable for component invocation, got {type(value).__name__}" ) - # Replace attr names hyphens with underscores for Python kwargs - kwargs = {k.replace("-", "_"): v for k, v in new_attrs.items()} + callable_info = get_callable_info(value) + + call_kwargs: dict[str, object] = {} + + # Rule 1: no required positional arguments + if callable_info.requires_positional: + raise TypeError( + "Component callables cannot have required positional arguments." + ) + + # Rules 2 and 3: handle children + if _accepts_children(callable_info): + call_kwargs["children"] = tuple(new_children) + elif new_children: + raise TypeError( + "Component does not accept children, but child content was provided." + ) + + # Rule 4: match attributes to named parameters + for attr_name, attr_value in new_attrs.items(): + snake_name = _kebab_to_snake(attr_name) + if snake_name in callable_info.named_params or callable_info.kwargs: + call_kwargs[snake_name] = attr_value + + # Rule 5: extra attributes are ignored + missing = callable_info.required_named_params - call_kwargs.keys() + if missing: + raise TypeError( + f"Missing required parameters for component: {', '.join(missing)}" + ) - # Call the component and return the resulting node - # TODO: handle failed calls more gracefully; consider using signature()? - result = value(*new_children, **kwargs) + result = value(**call_kwargs) return _node_from_value(result) @@ -350,6 +422,9 @@ def _substitute_node(p_node: Node, interpolations: tuple[Interpolation, ...]) -> new_attrs = _substitute_attrs(attrs, interpolations) new_children = _substitute_and_flatten_children(children, interpolations) if tag.startswith(_PLACEHOLDER_PREFIX): + # TODO: bug: at this point, we've replaced boolean-valued + # attributes with False and None, which is *not* what we want + # if we're invoking a component. This should wait until _stringify_attrs() return _invoke_component(tag, new_attrs, new_children, interpolations) else: new_attrs = _stringify_attrs(new_attrs) diff --git a/tdom/processor_test.py b/tdom/processor_test.py index c25b53d..1ba184c 100644 --- a/tdom/processor_test.py +++ b/tdom/processor_test.py @@ -512,7 +512,7 @@ def test_interpolated_style_attribute(): def TemplateComponent( - *children: Node, first: str, second: int, third_arg: str, **attrs: t.Any + children: t.Iterable[Node], first: str, second: int, third_arg: str, **attrs: t.Any ) -> Template: # Ensure type correctness of props at runtime for testing purposes assert isinstance(first, str) @@ -575,7 +575,9 @@ def test_fragment_from_component(): def test_component_passed_as_attr_value(): - def Wrapper(*children: Node, sub_component: t.Callable, **attrs: t.Any) -> Template: + def Wrapper( + children: t.Iterable[Node], sub_component: t.Callable, **attrs: t.Any + ) -> Template: return t"<{sub_component} {attrs}>{children}" node = html( From b0008121cefef52ad7b7dd19de00338b15cce21f Mon Sep 17 00:00:00 2001 From: Dave Date: Mon, 15 Sep 2025 12:00:46 -0700 Subject: [PATCH 06/10] Fix several test issues. --- docs/usage/components.md | 4 ++-- tdom/callables_test.py | 28 ++++++++++++++++--------- tdom/processor_test.py | 44 +++++++++++++++++++++++++++++++++------- 3 files changed, 57 insertions(+), 19 deletions(-) diff --git a/docs/usage/components.md b/docs/usage/components.md index 211c085..e546d4e 100644 --- a/docs/usage/components.md +++ b/docs/usage/components.md @@ -61,7 +61,7 @@ If your template has children inside the component element, your component will receive them as `*children` positional arguments: ```python -def Heading(*children: Node, title: str) -> Node: +def Heading(children: Iterable[Node], title: str) -> Node: return html(t"

{title}

{children}
") result = html(t'<{Heading} title="My Title">Child') @@ -97,7 +97,7 @@ driving: def DefaultHeading() -> Template: return t"

Default Heading

" -def Body(heading: str) -> Template: +def Body(heading: Callable) -> Template: return t"<{heading} />" result = html(t"<{Body} heading={DefaultHeading} />") diff --git a/tdom/callables_test.py b/tdom/callables_test.py index 1a35b11..2a6c34f 100644 --- a/tdom/callables_test.py +++ b/tdom/callables_test.py @@ -3,7 +3,7 @@ from .callables import get_callable_info -def callable_zero_args() -> None: +def callable_zero_args() -> None: # pragma: no cover """Test callable that takes zero arguments.""" pass @@ -19,7 +19,7 @@ def test_zero_args() -> None: assert info.supports_zero_args -def callable_positional(a: int, b: str) -> None: +def callable_positional(a: int, b: str) -> None: # pragma: no cover """Test callable that takes positional arguments.""" pass @@ -35,7 +35,7 @@ def test_positional() -> None: assert not info.supports_zero_args -def callable_positional_only(a: int, /, b: str) -> None: +def callable_positional_only(a: int, /, b: str) -> None: # pragma: no cover """Test callable that takes positional-only arguments.""" pass @@ -51,7 +51,9 @@ def test_positional_only() -> None: assert not info.supports_zero_args -def callable_positional_only_default(a: int = 1, /, b: str = "b") -> None: +def callable_positional_only_default( + a: int = 1, /, b: str = "b" +) -> None: # pragma: no cover """Test callable that takes positional-only arguments with defaults.""" pass @@ -67,7 +69,7 @@ def test_positional_only_default() -> None: assert info.supports_zero_args -def callable_kwargs(**kwargs: t.Any) -> None: +def callable_kwargs(**kwargs: t.Any) -> None: # pragma: no cover """Test callable that takes **kwargs.""" pass @@ -83,7 +85,9 @@ def test_kwargs() -> None: assert info.supports_zero_args -def callable_mixed(a: int, /, b: str, *, c: float = 1.0, **kwargs: t.Any) -> None: +def callable_mixed( + a: int, /, b: str, *, c: float = 1.0, **kwargs: t.Any +) -> None: # pragma: no cover """Test callable that takes a mix of argument types.""" pass @@ -99,7 +103,9 @@ def test_mixed() -> None: assert not info.supports_zero_args -def callable_positional_with_defaults(a: int = 1, b: str = "b", /) -> None: +def callable_positional_with_defaults( + a: int = 1, b: str = "b", / +) -> None: # pragma: no cover """Test callable that takes positional arguments with defaults.""" pass @@ -115,7 +121,7 @@ def test_positional_with_defaults() -> None: assert info.supports_zero_args -def callable_keyword_only(*, a: int, b: str = "b") -> None: +def callable_keyword_only(*, a: int, b: str = "b") -> None: # pragma: no cover """Test callable that takes keyword-only arguments.""" pass @@ -131,7 +137,7 @@ def test_keyword_only() -> None: assert not info.supports_zero_args -def callable_var_positional(*args: t.Any) -> None: +def callable_var_positional(*args: t.Any) -> None: # pragma: no cover """Test callable that takes *args.""" pass @@ -147,7 +153,9 @@ def test_var_positional() -> None: assert not info.supports_zero_args -def callable_all_types(a: int, /, b: str, *, c: float = 1.0, **kwargs: t.Any) -> None: +def callable_all_types( + a: int, /, b: str, *, c: float = 1.0, **kwargs: t.Any +) -> None: # pragma: no cover """Test callable that takes all types of arguments.""" pass diff --git a/tdom/processor_test.py b/tdom/processor_test.py index 1ba184c..9acf84a 100644 --- a/tdom/processor_test.py +++ b/tdom/processor_test.py @@ -511,7 +511,7 @@ def test_interpolated_style_attribute(): # -------------------------------------------------------------------------- -def TemplateComponent( +def FunctionComponent( children: t.Iterable[Node], first: str, second: int, third_arg: str, **attrs: t.Any ) -> Template: # Ensure type correctness of props at runtime for testing purposes @@ -528,7 +528,7 @@ def TemplateComponent( def test_interpolated_template_component(): node = html( - t'<{TemplateComponent} first=1 second={99} third-arg="comp1" class="my-comp">Hello, Component!' + t'<{FunctionComponent} first=1 second={99} third-arg="comp1" class="my-comp">Hello, Component!' ) assert node == Element( "div", @@ -548,7 +548,7 @@ def test_interpolated_template_component(): def test_invalid_component_invocation(): with pytest.raises(TypeError): - _ = html(t"<{TemplateComponent}>Missing props") # type: ignore + _ = html(t"<{FunctionComponent}>Missing props") # type: ignore def ColumnsComponent() -> Template: @@ -581,7 +581,7 @@ def Wrapper( return t"<{sub_component} {attrs}>{children}" node = html( - t'<{Wrapper} sub-component={TemplateComponent} class="wrapped" first=1 second={99} third-arg="comp1">

Inside wrapper

' + t'<{Wrapper} sub-component={FunctionComponent} class="wrapped" first=1 second={99} third-arg="comp1">

Inside wrapper

' ) assert node == Element( "div", @@ -644,7 +644,7 @@ def Items() -> Node: @dataclass -class Avatar: +class ClassComponent: """Example class-based component.""" user_name: str @@ -662,8 +662,38 @@ def __call__(self) -> Node: ) -def test_class_based_component(): - avatar = Avatar( +def test_class_component_implicit_invocation(): + node = html( + t"<{ClassComponent} user-name='Alice' image-url='https://example.com/alice.png' />" + ) + assert node == Element( + "div", + attrs={"class": "avatar"}, + children=[ + Element( + "a", + attrs={"href": "#"}, + children=[ + Element( + "img", + attrs={ + "src": "https://example.com/alice.png", + "alt": "Avatar of Alice", + }, + ) + ], + ), + Element("span", children=[Text("Alice")]), + ], + ) + assert ( + str(node) + == '
Avatar of AliceAlice
' + ) + + +def test_class_component_direct_invocation(): + avatar = ClassComponent( user_name="Alice", image_url="https://example.com/alice.png", homepage="https://example.com/users/alice", From ba8d291841d5de3d690aff914a91ed4ac9a1e479 Mon Sep 17 00:00:00 2001 From: Dave Date: Mon, 15 Sep 2025 12:44:24 -0700 Subject: [PATCH 07/10] Getting close; one more new test that I need to get passing. --- README.md | 80 ++++++++++++++++++++++++++++++++---------- tdom/processor_test.py | 35 ++++++++++++++++++ 2 files changed, 97 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 7920e89..781851a 100644 --- a/README.md +++ b/README.md @@ -305,11 +305,11 @@ content and attributes. Use these like custom HTML elements in your templates. The basic form of all component functions is: ```python -from typing import Any +from typing import Any, Iterable +from tdom import Node, html -def MyComponent(*children: Node, **attrs: Any) -> Template: - # Build your template using the provided props - return t"
{children}
" +def MyComponent(children: Iterable[Node], **attrs: Any) -> Node: + return html(t"
Cool: {children}
") ``` To _invoke_ your component within an HTML template, use the special @@ -317,7 +317,7 @@ To _invoke_ your component within an HTML template, use the special ```python result = html(t"<{MyComponent} id='comp1'>Hello, Component!") -#
Hello, Component!
+#
Cool: Hello, Component!
``` Because attributes are passed as keyword arguments, you can explicitly provide @@ -325,9 +325,10 @@ type hints for better editor support: ```python from typing import Any +from tdom import Node, html -def Link(*, href: str, text: str, data_value: int, **attrs: Any) -> Template: - return t'{text}: {data_value}' +def Link(*, href: str, text: str, data_value: int, **attrs: Any) -> Node: + return html(t'{text}: {data_value}') result = html(t'<{Link} href="https://example.com" text="Example" data-value={42} target="_blank" />') # Example: 42 @@ -336,26 +337,27 @@ result = html(t'<{Link} href="https://example.com" text="Example" data-value={42 Note that attributes with hyphens (like `data-value`) are converted to underscores (`data_value`) in the function signature. -In addition to returning `Template`, component functions may also return any -`Node` type found in [`tdom.nodes`](https://github.com/t-strings/tdom/blob/main/tdom/nodes.py): +Component functions build children and can return _any_ type of value; the returned value will be treated exactly as if it were placed directly in a child position in the template. + +Among other things, this means you can return a `Template` directly from a component function: ```python -def Link(*, href: str, text: str) -> Node: - return html(t'{text}') +def Greeting(name: str) -> Template: + return t"

Hello, {name}!

" -result = html(t'<{Link} href="https://example.com" text="Example" />') -# Example +result = html(t"<{Greeting} name='Alice' />") +#

Hello, Alice!

``` -You may also return an `Iterable[Node | Template]` if you want to return multiple -elements; this is treated as implicitly wrapping the children in a `Fragment`: +You may also return an iterable: ```python +from typing import Iterable + def Items() -> Iterable[Template]: - for item in ["first", "second"]: - yield t'
  • {item}
  • ' + return [t"
  • first
  • ", t"
  • second
  • "] -result = html(t'
      <{Items} />
    ') +result = html(t"
      <{Items} />
    ") #
    • first
    • second
    ``` @@ -369,6 +371,48 @@ result = html(t'
      <{Items} />
    ') #
    • first
    • second
    ``` +This is not required, but it can make your intent clearer. + +#### Class-based components + +Component functions are great for simple use cases, but for more complex components +you may want to use a class-based approach. Remember that the component +invocation syntax (`<{ComponentName} ... />`) works with any callable. That includes +the `__init__` method or `__call__` method of a class. + +One particularly useful pattern is to build class-based components with dataclasses: + +```python +from dataclasses import dataclass, field +from typing import Any, Iterable +from tdom import Node, html + +@dataclass +class Card: + children: Iterable[Node] + title: str + subtitle: str | None = None + + def __call__(self) -> Node: + return html(t""" +
    +

    {self.title}

    + {self.subtitle and t'

    {self.subtitle}

    '} +
    {self.children}
    +
    + """) + +result = html(t"<{Card} title='My Card' subtitle='A subtitle'>

    Card content

    ") +#
    +#

    My Card

    +#

    A subtitle

    +#

    Card content

    +#
    +``` + +This approach allows you to encapsulate component logic and state within a class, +making it easier to manage complex components. + #### SVG Support TODO: say more about SVG support diff --git a/tdom/processor_test.py b/tdom/processor_test.py index 9acf84a..88f8ebc 100644 --- a/tdom/processor_test.py +++ b/tdom/processor_test.py @@ -1,3 +1,4 @@ +import datetime import typing as t from dataclasses import dataclass from string.templatelib import Template @@ -723,3 +724,37 @@ def test_class_component_direct_invocation(): str(node) == '
    Avatar of AliceAlice
    ' ) + + +def AttributeTypeComponent( + data_int: int, + data_true: bool, + data_false: bool, + data_none: None, + data_float: float, + data_dt: datetime.datetime, +) -> Template: + """Component to test that we don't incorrectly convert attribute types.""" + assert isinstance(data_int, int) + assert data_true is True + assert data_false is False + assert data_none is None + assert isinstance(data_float, float) + assert isinstance(data_dt, datetime.datetime) + return t"Looks good!" + + +def test_attribute_type_component(): + an_int: int = 42 + a_true: bool = True + a_false: bool = False + a_none: None = None + a_float: float = 3.14 + a_dt: datetime.datetime = datetime.datetime(2024, 1, 1, 12, 0, 0) + node = html( + t"<{AttributeTypeComponent} data-int={an_int} data-true={a_true} " + t"data-false={a_false} data-none={a_none} data-float={a_float} " + t"data-dt={a_dt} />" + ) + assert node == Text("Looks good!") + assert str(node) == "Looks good!" From 5ec9e09078de8accee6ac26e9edb18a7ed79d23a Mon Sep 17 00:00:00 2001 From: Dave Date: Mon, 15 Sep 2025 13:04:16 -0700 Subject: [PATCH 08/10] Full test pass for typed component invocation. --- tdom/processor.py | 94 +++++++++++++++++++++++++++--------------- tdom/processor_test.py | 11 +++-- 2 files changed, 68 insertions(+), 37 deletions(-) diff --git a/tdom/processor.py b/tdom/processor.py index 7d797db..a395094 100644 --- a/tdom/processor.py +++ b/tdom/processor.py @@ -150,7 +150,7 @@ def _force_dict(value: t.Any, *, kind: str) -> dict: ) from None -def _substitute_aria_attrs(value: object) -> t.Iterable[tuple[str, str | None]]: +def _process_aria_attr(value: object) -> t.Iterable[tuple[str, str | None]]: """Produce aria-* attributes based on the interpolated value for "aria".""" d = _force_dict(value, kind="aria") for sub_k, sub_v in d.items(): @@ -164,7 +164,7 @@ def _substitute_aria_attrs(value: object) -> t.Iterable[tuple[str, str | None]]: yield f"aria-{sub_k}", str(sub_v) -def _substitute_data_attrs(value: object) -> t.Iterable[tuple[str, str | None]]: +def _process_data_attr(value: object) -> t.Iterable[tuple[str, str | None]]: """Produce data-* attributes based on the interpolated value for "data".""" d = _force_dict(value, kind="data") for sub_k, sub_v in d.items(): @@ -174,12 +174,12 @@ def _substitute_data_attrs(value: object) -> t.Iterable[tuple[str, str | None]]: yield f"data-{sub_k}", str(sub_v) -def _substitute_class_attr(value: object) -> t.Iterable[tuple[str, str | None]]: +def _process_class_attr(value: object) -> t.Iterable[tuple[str, str | None]]: """Substitute a class attribute based on the interpolated value.""" yield ("class", classnames(value)) -def _substitute_style_attr(value: object) -> t.Iterable[tuple[str, str | None]]: +def _process_style_attr(value: object) -> t.Iterable[tuple[str, str | None]]: """Substitute a style attribute based on the interpolated value.""" try: d = _force_dict(value, kind="style") @@ -201,21 +201,21 @@ def _substitute_spread_attrs( """ d = _force_dict(value, kind="spread") for sub_k, sub_v in d.items(): - yield from _substitute_attr(sub_k, sub_v) + yield from _process_attr(sub_k, sub_v) # 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_HANDLERS = { - "class": _substitute_class_attr, - "data": _substitute_data_attrs, - "style": _substitute_style_attr, - "aria": _substitute_aria_attrs, +CUSTOM_ATTR_PROCESSORS = { + "class": _process_class_attr, + "data": _process_data_attr, + "style": _process_style_attr, + "aria": _process_aria_attr, } -def _substitute_attr( +def _process_attr( key: str, value: object, ) -> t.Iterable[tuple[str, object | None]]: @@ -228,8 +228,8 @@ def _substitute_attr( the attribute being omitted entirely; nothing is yielded in that case. """ # Special handling for certain attribute names that have special semantics - if custom_handler := CUSTOM_ATTR_HANDLERS.get(key): - yield from custom_handler(value) + if custom_processor := CUSTOM_ATTR_PROCESSORS.get(key): + yield from custom_processor(value) return # General handling for all other attributes: @@ -242,29 +242,63 @@ def _substitute_attr( yield (key, value) -def _substitute_attrs( +def _substitute_interpolated_attrs( attrs: dict[str, str | None], interpolations: tuple[Interpolation, ...] -) -> dict[str, object | None]: - """Substitute placeholders in attributes based on the corresponding interpolations.""" +) -> dict[str, object]: + """ + Replace placeholder values in attributes with their interpolated values. + + This only handles step (1): value substitution. No special processing + of attribute names or value types is performed. + """ new_attrs: dict[str, object | None] = {} for key, value in attrs.items(): if value and value.startswith(_PLACEHOLDER_PREFIX): + # Interpolated attribute value index = _placholder_index(value) interpolation = interpolations[index] - value = format_interpolation(interpolation) - for sub_k, sub_v in _substitute_attr(key, value): - new_attrs[sub_k] = sub_v + interpolated_value = format_interpolation(interpolation) + new_attrs[key] = interpolated_value elif key.startswith(_PLACEHOLDER_PREFIX): + # Spread attributes index = _placholder_index(key) interpolation = interpolations[index] - value = format_interpolation(interpolation) - for sub_k, sub_v in _substitute_spread_attrs(value): + spread_value = format_interpolation(interpolation) + for sub_k, sub_v in _substitute_spread_attrs(spread_value): new_attrs[sub_k] = sub_v else: + # Static attribute new_attrs[key] = value return new_attrs +def _process_html_attrs(attrs: dict[str, object]) -> dict[str, str | None]: + """ + Process attributes for HTML elements. + + This handles steps (2) and (3): special attribute name handling and + value type processing (True -> None, False -> omit, etc.) + """ + processed_attrs: dict[str, str | None] = {} + for key, value in attrs.items(): + for sub_k, sub_v in _process_attr(key, value): + # Convert to string, preserving None + processed_attrs[sub_k] = str(sub_v) if sub_v is not None else None + return processed_attrs + + +def _substitute_attrs( + attrs: dict[str, str | None], interpolations: tuple[Interpolation, ...] +) -> dict[str, str | None]: + """ + Substitute placeholders in attributes for HTML elements. + + This is the full pipeline: interpolation + HTML processing. + """ + interpolated_attrs = _substitute_interpolated_attrs(attrs, interpolations) + return _process_html_attrs(interpolated_attrs) + + def _substitute_and_flatten_children( children: t.Iterable[Node], interpolations: tuple[Interpolation, ...] ) -> list[Node]: @@ -405,11 +439,6 @@ def _invoke_component( return _node_from_value(result) -def _stringify_attrs(attrs: dict[str, object | None]) -> dict[str, str | None]: - """Convert all attribute values to strings, preserving None values.""" - return {k: str(v) if v is not None else None for k, v in attrs.items()} - - def _substitute_node(p_node: Node, interpolations: tuple[Interpolation, ...]) -> Node: """Substitute placeholders in a node based on the corresponding interpolations.""" match p_node: @@ -419,16 +448,15 @@ def _substitute_node(p_node: Node, interpolations: tuple[Interpolation, ...]) -> value = format_interpolation(interpolation) return _node_from_value(value) case Element(tag=tag, attrs=attrs, children=children): - new_attrs = _substitute_attrs(attrs, interpolations) new_children = _substitute_and_flatten_children(children, interpolations) if tag.startswith(_PLACEHOLDER_PREFIX): - # TODO: bug: at this point, we've replaced boolean-valued - # attributes with False and None, which is *not* what we want - # if we're invoking a component. This should wait until _stringify_attrs() - return _invoke_component(tag, new_attrs, new_children, interpolations) + component_attrs = _substitute_interpolated_attrs(attrs, interpolations) + return _invoke_component( + tag, component_attrs, new_children, interpolations + ) else: - new_attrs = _stringify_attrs(new_attrs) - return Element(tag=tag, attrs=new_attrs, children=new_children) + html_attrs = _substitute_attrs(attrs, interpolations) + return Element(tag=tag, attrs=html_attrs, children=new_children) case Fragment(children=children): new_children = _substitute_and_flatten_children(children, interpolations) return Fragment(children=new_children) diff --git a/tdom/processor_test.py b/tdom/processor_test.py index 88f8ebc..bcedd68 100644 --- a/tdom/processor_test.py +++ b/tdom/processor_test.py @@ -1,6 +1,6 @@ import datetime import typing as t -from dataclasses import dataclass +from dataclasses import dataclass, field from string.templatelib import Template import pytest @@ -651,6 +651,7 @@ class ClassComponent: user_name: str image_url: str homepage: str = "#" + children: t.Iterable[Node] = field(default_factory=list) def __call__(self) -> Node: return html( @@ -659,13 +660,14 @@ def __call__(self) -> Node: t"{f" t"" t"{self.user_name}" - t"" + t"{self.children}" + t"", ) def test_class_component_implicit_invocation(): node = html( - t"<{ClassComponent} user-name='Alice' image-url='https://example.com/alice.png' />" + t"<{ClassComponent} user-name='Alice' image-url='https://example.com/alice.png'>Fun times!" ) assert node == Element( "div", @@ -685,11 +687,12 @@ def test_class_component_implicit_invocation(): ], ), Element("span", children=[Text("Alice")]), + Text("Fun times!"), ], ) assert ( str(node) - == '
    Avatar of AliceAlice
    ' + == '
    Avatar of AliceAliceFun times!
    ' ) From c8a06de99bbd8345eb20a7e37930a23fb2a083d4 Mon Sep 17 00:00:00 2001 From: Dave Date: Mon, 15 Sep 2025 13:22:39 -0700 Subject: [PATCH 09/10] Test several corner cases to 100% coverage. --- tdom/processor.py | 5 +++- tdom/processor_test.py | 62 +++++++++++++++++++++++++++++++++++++----- 2 files changed, 59 insertions(+), 8 deletions(-) diff --git a/tdom/processor.py b/tdom/processor.py index a395094..06eb542 100644 --- a/tdom/processor.py +++ b/tdom/processor.py @@ -181,12 +181,15 @@ def _process_class_attr(value: object) -> t.Iterable[tuple[str, str | None]]: def _process_style_attr(value: object) -> t.Iterable[tuple[str, str | None]]: """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: - yield ("style", str(value)) + raise TypeError("'style' attribute value must be a string or dict") from None def _substitute_spread_attrs( diff --git a/tdom/processor_test.py b/tdom/processor_test.py index bcedd68..aa37469 100644 --- a/tdom/processor_test.py +++ b/tdom/processor_test.py @@ -159,7 +159,7 @@ def get_value(): def test_interpolated_multi_arg_function_fails(): - def add(a, b): + def add(a, b): # pragma: no cover return a + b with pytest.raises(TypeError): @@ -472,25 +472,31 @@ def test_interpolated_attribute_spread_with_class_attribute(): def test_interpolated_data_attributes(): - data = {"user-id": 123, "role": "admin"} + data = {"user-id": 123, "role": "admin", "wild": True} node = html(t"
    User Info
    ") assert node == Element( "div", - attrs={"data-user-id": "123", "data-role": "admin"}, + attrs={"data-user-id": "123", "data-role": "admin", "data-wild": None}, children=[Text("User Info")], ) - assert str(node) == '
    User Info
    ' + assert ( + str(node) + == '
    User Info
    ' + ) def test_interpolated_aria_attributes(): - aria = {"label": "Close", "hidden": True} + aria = {"label": "Close", "hidden": True, "another": False, "more": None} node = html(t"") assert node == Element( "button", - attrs={"aria-label": "Close", "aria-hidden": "true"}, + attrs={"aria-label": "Close", "aria-hidden": "true", "aria-another": "false"}, children=[Text("X")], ) - assert str(node) == '' + assert ( + str(node) + == '' + ) def test_interpolated_style_attribute(): @@ -507,6 +513,23 @@ def test_interpolated_style_attribute(): ) +def test_style_attribute_str(): + styles = "color: red; font-weight: bold;" + node = html(t"

    Warning!

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

    Warning!

    ' + + +def test_style_attribute_non_str_non_dict(): + with pytest.raises(TypeError): + styles = [1, 2] + _ = html(t"

    Warning!

    ") + + # -------------------------------------------------------------------------- # Function component interpolation tests # -------------------------------------------------------------------------- @@ -761,3 +784,28 @@ def test_attribute_type_component(): ) assert node == Text("Looks good!") assert str(node) == "Looks good!" + + +def test_component_non_callable_fails(): + with pytest.raises(TypeError): + _ = html(t"<{'not a function'} />") + + +def DoesNotAcceptChildren() -> Template: # pragma: no cover + return t"

    No children allowed!

    " + + +def test_component_not_accepting_children_with_children_fails(): + with pytest.raises(TypeError): + _ = html( + t"<{DoesNotAcceptChildren}>I should not be here" + ) + + +def RequiresPositional(whoops: int, /) -> Template: # pragma: no cover + return t"

    Positional arg: {whoops}

    " + + +def test_component_requiring_positional_arg_fails(): + with pytest.raises(TypeError): + _ = html(t"<{RequiresPositional} />") From 3271bba7416092a46db3e44e5c20f97f0a78f767 Mon Sep 17 00:00:00 2001 From: Dave Date: Mon, 15 Sep 2025 13:31:37 -0700 Subject: [PATCH 10/10] Bump to 0.1.7 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index b9321ae..623c4c5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "uv_build" [project] name = "tdom" -version = "0.1.6" +version = "0.1.7" description = "A 🤘 rockin' t-string HTML templating system for Python 3.14." readme = "README.md" requires-python = ">=3.14"