From 7e99404c2abef4b1bf3fda697d8ca83a9db9135b Mon Sep 17 00:00:00 2001 From: Dheepak Krishnamurthy <1813121+kdheepak@users.noreply.github.com> Date: Sat, 12 Sep 2026 18:32:46 -0400 Subject: [PATCH 1/9] feat: Add expression serializer for AttributeGenerator --- Makefile | 2 +- src/datastar_py/attributes.py | 106 +++++++++++--- tests/test_attributes.py | 264 ++++++++++++++++++++++++++++++++++ 3 files changed, 354 insertions(+), 18 deletions(-) create mode 100644 tests/test_attributes.py diff --git a/Makefile b/Makefile index 9e2a8d2..37e23f7 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ .PHONY: test lint test: - uv run --dev pytest + @trap 'rm -f uv.lock' EXIT; uv run --with pytest pytest lint: uv run --dev pre-commit run --all-files diff --git a/src/datastar_py/attributes.py b/src/datastar_py/attributes.py index 6950792..86328e0 100644 --- a/src/datastar_py/attributes.py +++ b/src/datastar_py/attributes.py @@ -1,5 +1,7 @@ from __future__ import annotations +import collections.abc +import dataclasses import json import re from collections.abc import Iterable, Iterator, Mapping @@ -101,10 +103,80 @@ SignalValue: TypeAlias = ( - str | int | float | bool | dict[str, "SignalValue"] | list["SignalValue"] | None + str + | int + | float + | bool + | dict[str, "SignalValue"] + | list["SignalValue"] + | tuple["SignalValue", ...] + | None ) +@dataclasses.dataclass(frozen=True) +class JSExpression: + """JavaScript expression.""" + + value: str + + def __post_init__(self) -> None: + """Reject empty or non-string expression source.""" + _require_nonblank_string("JSExpression.value", self.value) + + +def _require_nonblank_string(name: str, value: object) -> None: + if not isinstance(value, str): + raise TypeError(f"{name} must be a string") + if not value.strip(): + raise ValueError(f"{name} must be non-empty") + + +def javascript(value: object) -> str: + """Serialize data recursively.""" + if isinstance(value, JSExpression): + return f"({value.value})" + if isinstance(value, collections.abc.Mapping): + if any(not isinstance(key, str) for key in value): + raise TypeError("JavaScript object keys must be strings") + # TODO: Revisit when `__proto__` should be special cased + return ( + "{" + + ", ".join(f"{javascript(key)}: {javascript(item)}" for key, item in value.items()) + + "}" + ) + if isinstance(value, list | tuple): + return "[" + ", ".join(javascript(item) for item in value) + "]" + serialized = json.dumps(value, allow_nan=False) + # NOTE: Escape `@` in action-like syntax + # Browser decodes \u0040 back to `@` symbol. + return re.sub( + r""" + @ # Match the action marker. + (?= # positive lookahead for an action name followed + # by an opening parenthesis. e.g. `@foo(` or `@QUX(` + [A-Za-z_$] # First name character: ASCII letter, underscore, or dollar sign. + [A-Za-z0-9_$]* # Zero or more name characters, also allowing digits. + \( # Opening parenthesis immediately after the name. + ) + """, + r"\\u0040", + serialized, + flags=re.VERBOSE, + ) + + +def _as_javascript_expressions(value: object) -> object: + """Wrap strings in a nested javascript expressions.""" + if isinstance(value, collections.abc.Mapping): + return {key: _as_javascript_expressions(item) for key, item in value.items()} + if isinstance(value, list | tuple): + return [_as_javascript_expressions(item) for item in value] + if isinstance(value, str): + return JSExpression(value) + return value + + class AttributeGenerator: def __init__(self, alias: str = "data-") -> None: """A helper which can generate all the Datastar attributes. @@ -128,7 +200,7 @@ def signals( rather than literals. """ signals = {**(signals_dict or {}), **signals} - val = _js_object(signals) if expressions_ else json.dumps(signals) + val = javascript(_as_javascript_expressions(signals) if expressions_ else signals) return SignalsAttr(value=val, alias=self._alias) def computed(self, computed_dict: Mapping | None = None, /, **computed: str) -> BaseAttr: @@ -153,7 +225,11 @@ def ignore(self) -> IgnoreAttr: def attr(self, attr_dict: Mapping | None = None, /, **attrs: str) -> BaseAttr: """Set the value of any HTML attributes to expressions, and keep them in sync.""" attrs = {**(attr_dict or {}), **attrs} - return BaseAttr("attr", value=_js_object(attrs), alias=self._alias) + return BaseAttr( + "attr", + value=javascript(_as_javascript_expressions(attrs)), + alias=self._alias, + ) def bind(self, signal_name: str) -> BaseAttr: """Set up two-way data binding between a signal and an element's value.""" @@ -162,7 +238,11 @@ def bind(self, signal_name: str) -> BaseAttr: def class_(self, class_dict: Mapping | None = None, /, **classes: str) -> BaseAttr: """Add or removes classes to or from an element based on expressions.""" classes = {**(class_dict or {}), **classes} - return BaseAttr("class", value=_js_object(classes), alias=self._alias) + return BaseAttr( + "class", + value=javascript(_as_javascript_expressions(classes)), + alias=self._alias, + ) def init(self, expression: str) -> InitAttr: """Execute an expression when the element is loaded into the DOM.""" @@ -217,7 +297,11 @@ def show(self, expression: str) -> BaseAttr: def style(self, style_dict: Mapping | None = None, /, **styles: str) -> BaseAttr: """Set the value of inline CSS styles on an element based on an expression, and keeps them in sync.""" styles = {**(style_dict or {}), **styles} - return BaseAttr("style", value=_js_object(styles), alias=self._alias) + return BaseAttr( + "style", + value=javascript(_as_javascript_expressions(styles)), + alias=self._alias, + ) def text(self, expression: str) -> BaseAttr: """Bind the text content of an element to an expression.""" @@ -728,16 +812,4 @@ def _filter_dict(include: str | None = None, exclude: str | None = None) -> dict return filter_dict -def _js_object(obj: dict) -> str: - """Create a JS object where the values are expressions rather than strings.""" - return ( - "{" - + ", ".join( - f"{json.dumps(k)}: {_js_object(v) if isinstance(v, dict) else v}" - for k, v in obj.items() - ) - + "}" - ) - - attribute_generator = AttributeGenerator() diff --git a/tests/test_attributes.py b/tests/test_attributes.py new file mode 100644 index 0000000..1e13d68 --- /dev/null +++ b/tests/test_attributes.py @@ -0,0 +1,264 @@ +from __future__ import annotations + +import json +import math +import unittest + +from datastar_py.attributes import JSExpression, javascript +from datastar_py.attributes import attribute_generator as ds + + +class AttributeJavascriptTests(unittest.TestCase): + def test_expression_apis_parenthesize_ambiguous_values(self) -> None: + cases = ( + ( + ds.attr(title="first, second"), + {"data-attr": '{"title": (first, second)}'}, + ), + ( + ds.class_({"active": "first, second"}), + {"data-class": '{"active": (first, second)}'}, + ), + ( + ds.style(width="first, second"), + {"data-style": '{"width": (first, second)}'}, + ), + ( + ds.signals( + items1=["first", "second"], + items2=["first, second"], + items3="first, second", + ), + { + "data-signals": ( + '{"items1": ["first", "second"], "items2": ["first, second"], "items3": "first, second"}' + ) + }, + ), + ( + ds.signals( + items1=["first", "second"], + items2=["first, second"], + items3="first, second", + expressions_=True, + ), + { + "data-signals": ( + '{"items1": [(first), (second)], "items2": [(first, second)], "items3": (first, second)}' + ) + }, + ), + ( + ds.signals( + { + "items1": ["first", "second"], + "items2": ["first, second"], + "items3": "first, second", + }, + ), + { + "data-signals": ( + '{"items1": ["first", "second"], "items2": ["first, second"], "items3": "first, second"}' + ) + }, + ), + ( + ds.signals( + { + "items1": ["first", "second"], + "items2": ["first, second"], + "items3": "first, second", + "expressions_": False, + }, + expressions_=True, + ), + { + "data-signals": ( + '{"items1": [(first), (second)], "items2": [(first, second)], "items3": (first, second), "expressions_": false}' + ) + }, + ), + ( + ds.signals( + { + "items1": ["first", "second"], + "items2": ["first, second"], + "items3": "first, second", + }, + expressions_=True, + ), + { + "data-signals": ( + '{"items1": [(first), (second)], "items2": [(first, second)], "items3": (first, second)}' + ) + }, + ), + ( + ds.signals( + { + "items1": ("first", "second"), + "items2": ("first, second",), + "items3": "first, second", + }, + ), + { + "data-signals": ( + '{"items1": ["first", "second"], "items2": ["first, second"], "items3": "first, second"}' + ) + }, + ), + ( + ds.signals( + { + "items1": ("first", "second"), + "items2": ("first, second",), + "items3": "first, second", + }, + expressions_=True, + ), + { + "data-signals": ( + '{"items1": [(first), (second)], "items2": [(first, second)], "items3": (first, second)}' + ) + }, + ), + ( + ds.signals( + { + "answer1": '{"value": 42}', + "answer2": {"value": 42}, + }, + expressions_=True, + ), + {"data-signals": ('{"answer1": ({"value": 42}), "answer2": {"value": 42}}')}, + ), + ( + ds.signals( + { + "answer1": '{"value": 42}', + "answer2": {"value": 42}, + }, + ), + {"data-signals": ('{"answer1": "{\\"value\\": 42}", "answer2": {"value": 42}}')}, + ), + ) + + for attribute, expected in cases: + with self.subTest(attribute=next(iter(attribute))): + self.assertEqual(dict(attribute), expected) + + def test_expression_apis_wrap_values_explicitly(self) -> None: + cases = ( + ( + ds.attr(title='"hello"', hidden="$closed"), + {"data-attr": '{"title": ("hello"), "hidden": ($closed)}'}, + ), + ( + ds.class_({"active item": "$selected", "plain": "true"}), + {"data-class": '{"active item": ($selected), "plain": (true)}'}, + ), + ( + ds.style(width="$width + 'px'"), + {"data-style": "{\"width\": ($width + 'px')}"}, + ), + ( + ds.signals({"form": {"count": "1 + 1"}}), + {"data-signals": '{"form": {"count": "1 + 1"}}'}, + ), + ( + ds.signals({"form": {"count": "1 + 1"}}, expressions_=True), + {"data-signals": '{"form": {"count": (1 + 1)}}'}, + ), + ) + + for attribute, expected in cases: + with self.subTest(attribute=next(iter(attribute))): + self.assertEqual(dict(attribute), expected) + + def test_expression_signals_recurse_through_nested_lists(self) -> None: + self.assertEqual( + dict( + ds.signals( + { + "form": { + "total": "2 * 3", + "items": ["$first", "$second"], + "groups": [["$third"]], + } + }, + expressions_=True, + ) + ), + { + "data-signals": ( + '{"form": {"total": (2 * 3), "items": [($first), ($second)], ' + '"groups": [[($third)]]}}' + ) + }, + ) + + def test_literal_signals_remain_data_and_escape_action_tokens(self) -> None: + signals = { + "signal_example": "$otherSignal", + "action_example": '@post("/sse")', + "email_example": "person@example.com", + "expression_example": "1 + 1", + "quoted_example": 'a "quoted" value', + } + + rendered = dict(ds.signals(signals))["data-signals"] + assert isinstance(rendered, str) + + self.assertEqual( + rendered, + "".join( + [ + '{"signal_example": "$otherSignal", ', + '"action_example": "\\u0040post(\\"/sse\\")", ', + '"email_example": "person@example.com", ', + '"expression_example": "1 + 1", ', + '"quoted_example": "a \\"quoted\\" value"}', + ] + ), + ) + self.assertEqual(json.loads(rendered), signals) + + def test_mapping_values_serialize_recursively(self) -> None: + value = { + "literal": "text", + "nested": { + # These Python spellings differ from JavaScript + # This test can catch accidental fallbacks to str() + # instead of JSON serialization. + "items": [True, False, None, 3], + "expression": JSExpression("1 + 1"), + }, + } + + self.assertEqual( + javascript(value), + '{"literal": "text", "nested": {"items": [true, false, null, 3], "expression": (1 + 1)}}', + ) + + def test_mapping_keys_are_strings_and_escaped_as_data(self) -> None: + with self.assertRaisesRegex(TypeError, "object keys must be strings"): + javascript({1: "value"}) + + self.assertEqual( + javascript( + { + 'quote"\\snow雪': "value", + '@post("key")': "action-looking key", + } + ), + '{"quote\\"\\\\snow\\u96ea": "value", "\\u0040post(\\"key\\")": "action-looking key"}', + ) + + def test_non_finite_signal_values_are_rejected_in_both_modes(self) -> None: + for expressions in (False, True): + for value in (math.nan, math.inf, -math.inf): + with ( + self.subTest(expressions=expressions, value=value), + self.assertRaises(ValueError), + ): + ds.signals({"value": value}, expressions_=expressions) From 081be825599a6b9f152dd79537a4d874ec74c3eb Mon Sep 17 00:00:00 2001 From: Dheepak Krishnamurthy <1813121+kdheepak@users.noreply.github.com> Date: Sun, 13 Sep 2026 00:17:28 -0400 Subject: [PATCH 2/9] chore(format): run `make lint` for formatting --- tests/test_django_decorator_typing.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/tests/test_django_decorator_typing.py b/tests/test_django_decorator_typing.py index 96cbc5a..2ca7a10 100644 --- a/tests/test_django_decorator_typing.py +++ b/tests/test_django_decorator_typing.py @@ -35,7 +35,10 @@ def test_django_datastar_response_mypy_overloads() -> None: 'Revealed type is "def (request: django.http.request.HttpRequest) ' '-> datastar_py.django.DatastarResponse"' ) in output - assert output.count( - 'Revealed type is "def (request: django.http.request.HttpRequest) ' - '-> typing.Coroutine[Any, Any, datastar_py.django.DatastarResponse]"' - ) == 2 + assert ( + output.count( + 'Revealed type is "def (request: django.http.request.HttpRequest) ' + '-> typing.Coroutine[Any, Any, datastar_py.django.DatastarResponse]"' + ) + == 2 + ) From 4c176998ee3d4d5e8dc42d73df8b814a52289ab1 Mon Sep 17 00:00:00 2001 From: Dheepak Krishnamurthy <1813121+kdheepak@users.noreply.github.com> Date: Sun, 13 Sep 2026 09:18:37 -0400 Subject: [PATCH 3/9] fix(attributes): stop escaping @ in javascript JSON serialization --- src/datastar_py/attributes.py | 18 +----------------- tests/test_attributes.py | 4 ++-- 2 files changed, 3 insertions(+), 19 deletions(-) diff --git a/src/datastar_py/attributes.py b/src/datastar_py/attributes.py index 86328e0..e64b550 100644 --- a/src/datastar_py/attributes.py +++ b/src/datastar_py/attributes.py @@ -147,23 +147,7 @@ def javascript(value: object) -> str: ) if isinstance(value, list | tuple): return "[" + ", ".join(javascript(item) for item in value) + "]" - serialized = json.dumps(value, allow_nan=False) - # NOTE: Escape `@` in action-like syntax - # Browser decodes \u0040 back to `@` symbol. - return re.sub( - r""" - @ # Match the action marker. - (?= # positive lookahead for an action name followed - # by an opening parenthesis. e.g. `@foo(` or `@QUX(` - [A-Za-z_$] # First name character: ASCII letter, underscore, or dollar sign. - [A-Za-z0-9_$]* # Zero or more name characters, also allowing digits. - \( # Opening parenthesis immediately after the name. - ) - """, - r"\\u0040", - serialized, - flags=re.VERBOSE, - ) + return json.dumps(value, allow_nan=False) def _as_javascript_expressions(value: object) -> object: diff --git a/tests/test_attributes.py b/tests/test_attributes.py index 1e13d68..7d08c80 100644 --- a/tests/test_attributes.py +++ b/tests/test_attributes.py @@ -214,7 +214,7 @@ def test_literal_signals_remain_data_and_escape_action_tokens(self) -> None: "".join( [ '{"signal_example": "$otherSignal", ', - '"action_example": "\\u0040post(\\"/sse\\")", ', + '"action_example": "@post(\\"/sse\\")", ', '"email_example": "person@example.com", ', '"expression_example": "1 + 1", ', '"quoted_example": "a \\"quoted\\" value"}', @@ -251,7 +251,7 @@ def test_mapping_keys_are_strings_and_escaped_as_data(self) -> None: '@post("key")': "action-looking key", } ), - '{"quote\\"\\\\snow\\u96ea": "value", "\\u0040post(\\"key\\")": "action-looking key"}', + '{"quote\\"\\\\snow\\u96ea": "value", "@post(\\"key\\")": "action-looking key"}', ) def test_non_finite_signal_values_are_rejected_in_both_modes(self) -> None: From b12b25fd4ac10df15195bcf08035d9755fd98154 Mon Sep 17 00:00:00 2001 From: Dheepak Krishnamurthy <1813121+kdheepak@users.noreply.github.com> Date: Sun, 13 Sep 2026 09:19:58 -0400 Subject: [PATCH 4/9] chore(makefile): remove uv.lock cleanup from test target --- .gitignore | 1 + Makefile | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 3eb6c87..36f7538 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ .idea .zed __pycache__ +uv.lock diff --git a/Makefile b/Makefile index 37e23f7..c5d499d 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ .PHONY: test lint test: - @trap 'rm -f uv.lock' EXIT; uv run --with pytest pytest + uv run --with pytest pytest lint: uv run --dev pre-commit run --all-files From 2632bd022a87622eca827cec13fb8030cd7b1a2f Mon Sep 17 00:00:00 2001 From: Dheepak Krishnamurthy <1813121+kdheepak@users.noreply.github.com> Date: Sun, 13 Sep 2026 09:26:37 -0400 Subject: [PATCH 5/9] test(attributes): migrate attribute tests from unittest to pytest --- tests/test_attributes.py | 432 +++++++++++++++++++-------------------- 1 file changed, 213 insertions(+), 219 deletions(-) diff --git a/tests/test_attributes.py b/tests/test_attributes.py index 7d08c80..bcc42be 100644 --- a/tests/test_attributes.py +++ b/tests/test_attributes.py @@ -1,264 +1,258 @@ -from __future__ import annotations - import json import math -import unittest -from datastar_py.attributes import JSExpression, javascript -from datastar_py.attributes import attribute_generator as ds +import pytest +from datastar_py import attributes -class AttributeJavascriptTests(unittest.TestCase): - def test_expression_apis_parenthesize_ambiguous_values(self) -> None: - cases = ( - ( - ds.attr(title="first, second"), - {"data-attr": '{"title": (first, second)}'}, - ), - ( - ds.class_({"active": "first, second"}), - {"data-class": '{"active": (first, second)}'}, + +@pytest.mark.parametrize( + ("attribute", "expected"), + ( + ( + attributes.attribute_generator.attr(title="first, second"), + {"data-attr": '{"title": (first, second)}'}, + ), + ( + attributes.attribute_generator.class_({"active": "first, second"}), + {"data-class": '{"active": (first, second)}'}, + ), + ( + attributes.attribute_generator.style(width="first, second"), + {"data-style": '{"width": (first, second)}'}, + ), + ( + attributes.attribute_generator.signals( + items1=["first", "second"], + items2=["first, second"], + items3="first, second", ), - ( - ds.style(width="first, second"), - {"data-style": '{"width": (first, second)}'}, + { + "data-signals": ( + '{"items1": ["first", "second"], "items2": ["first, second"], "items3": "first, second"}' + ) + }, + ), + ( + attributes.attribute_generator.signals( + items1=["first", "second"], + items2=["first, second"], + items3="first, second", + expressions_=True, ), - ( - ds.signals( - items1=["first", "second"], - items2=["first, second"], - items3="first, second", - ), + { + "data-signals": ( + '{"items1": [(first), (second)], "items2": [(first, second)], "items3": (first, second)}' + ) + }, + ), + ( + attributes.attribute_generator.signals( { - "data-signals": ( - '{"items1": ["first", "second"], "items2": ["first, second"], "items3": "first, second"}' - ) + "items1": ["first", "second"], + "items2": ["first, second"], + "items3": "first, second", }, ), - ( - ds.signals( - items1=["first", "second"], - items2=["first, second"], - items3="first, second", - expressions_=True, - ), + { + "data-signals": ( + '{"items1": ["first", "second"], "items2": ["first, second"], "items3": "first, second"}' + ) + }, + ), + ( + attributes.attribute_generator.signals( { - "data-signals": ( - '{"items1": [(first), (second)], "items2": [(first, second)], "items3": (first, second)}' - ) + "items1": ["first", "second"], + "items2": ["first, second"], + "items3": "first, second", + "expressions_": False, }, + expressions_=True, ), - ( - ds.signals( - { - "items1": ["first", "second"], - "items2": ["first, second"], - "items3": "first, second", - }, - ), + { + "data-signals": ( + '{"items1": [(first), (second)], "items2": [(first, second)], "items3": (first, second), "expressions_": false}' + ) + }, + ), + ( + attributes.attribute_generator.signals( { - "data-signals": ( - '{"items1": ["first", "second"], "items2": ["first, second"], "items3": "first, second"}' - ) + "items1": ["first", "second"], + "items2": ["first, second"], + "items3": "first, second", }, + expressions_=True, ), - ( - ds.signals( - { - "items1": ["first", "second"], - "items2": ["first, second"], - "items3": "first, second", - "expressions_": False, - }, - expressions_=True, - ), + { + "data-signals": ( + '{"items1": [(first), (second)], "items2": [(first, second)], "items3": (first, second)}' + ) + }, + ), + ( + attributes.attribute_generator.signals( { - "data-signals": ( - '{"items1": [(first), (second)], "items2": [(first, second)], "items3": (first, second), "expressions_": false}' - ) + "items1": ("first", "second"), + "items2": ("first, second",), + "items3": "first, second", }, ), - ( - ds.signals( - { - "items1": ["first", "second"], - "items2": ["first, second"], - "items3": "first, second", - }, - expressions_=True, - ), + { + "data-signals": ( + '{"items1": ["first", "second"], "items2": ["first, second"], "items3": "first, second"}' + ) + }, + ), + ( + attributes.attribute_generator.signals( { - "data-signals": ( - '{"items1": [(first), (second)], "items2": [(first, second)], "items3": (first, second)}' - ) + "items1": ("first", "second"), + "items2": ("first, second",), + "items3": "first, second", }, + expressions_=True, ), - ( - ds.signals( - { - "items1": ("first", "second"), - "items2": ("first, second",), - "items3": "first, second", - }, - ), + { + "data-signals": ( + '{"items1": [(first), (second)], "items2": [(first, second)], "items3": (first, second)}' + ) + }, + ), + ( + attributes.attribute_generator.signals( { - "data-signals": ( - '{"items1": ["first", "second"], "items2": ["first, second"], "items3": "first, second"}' - ) + "answer1": '{"value": 42}', + "answer2": {"value": 42}, }, + expressions_=True, ), - ( - ds.signals( - { - "items1": ("first", "second"), - "items2": ("first, second",), - "items3": "first, second", - }, - expressions_=True, - ), + {"data-signals": ('{"answer1": ({"value": 42}), "answer2": {"value": 42}}')}, + ), + ( + attributes.attribute_generator.signals( { - "data-signals": ( - '{"items1": [(first), (second)], "items2": [(first, second)], "items3": (first, second)}' - ) + "answer1": '{"value": 42}', + "answer2": {"value": 42}, }, ), - ( - ds.signals( - { - "answer1": '{"value": 42}', - "answer2": {"value": 42}, - }, - expressions_=True, - ), - {"data-signals": ('{"answer1": ({"value": 42}), "answer2": {"value": 42}}')}, - ), - ( - ds.signals( - { - "answer1": '{"value": 42}', - "answer2": {"value": 42}, - }, - ), - {"data-signals": ('{"answer1": "{\\"value\\": 42}", "answer2": {"value": 42}}')}, - ), - ) + {"data-signals": ('{"answer1": "{\\"value\\": 42}", "answer2": {"value": 42}}')}, + ), + ), +) +def test_expression_apis_parenthesize_ambiguous_values(attribute, expected): + assert dict(attribute) == expected - for attribute, expected in cases: - with self.subTest(attribute=next(iter(attribute))): - self.assertEqual(dict(attribute), expected) - def test_expression_apis_wrap_values_explicitly(self) -> None: - cases = ( - ( - ds.attr(title='"hello"', hidden="$closed"), - {"data-attr": '{"title": ("hello"), "hidden": ($closed)}'}, - ), - ( - ds.class_({"active item": "$selected", "plain": "true"}), - {"data-class": '{"active item": ($selected), "plain": (true)}'}, - ), - ( - ds.style(width="$width + 'px'"), - {"data-style": "{\"width\": ($width + 'px')}"}, +@pytest.mark.parametrize( + ("attribute", "expected"), + ( + ( + attributes.attribute_generator.attr(title='"hello"', hidden="$closed"), + {"data-attr": '{"title": ("hello"), "hidden": ($closed)}'}, + ), + ( + attributes.attribute_generator.class_({"active item": "$selected", "plain": "true"}), + {"data-class": '{"active item": ($selected), "plain": (true)}'}, + ), + ( + attributes.attribute_generator.style(width="$width + 'px'"), + {"data-style": "{\"width\": ($width + 'px')}"}, + ), + ( + attributes.attribute_generator.signals({"form": {"count": "1 + 1"}}), + {"data-signals": '{"form": {"count": "1 + 1"}}'}, + ), + ( + attributes.attribute_generator.signals( + {"form": {"count": "1 + 1"}}, expressions_=True ), - ( - ds.signals({"form": {"count": "1 + 1"}}), - {"data-signals": '{"form": {"count": "1 + 1"}}'}, - ), - ( - ds.signals({"form": {"count": "1 + 1"}}, expressions_=True), - {"data-signals": '{"form": {"count": (1 + 1)}}'}, - ), - ) + {"data-signals": '{"form": {"count": (1 + 1)}}'}, + ), + ), +) +def test_expression_apis_wrap_values_explicitly(attribute, expected): + assert dict(attribute) == expected - for attribute, expected in cases: - with self.subTest(attribute=next(iter(attribute))): - self.assertEqual(dict(attribute), expected) - def test_expression_signals_recurse_through_nested_lists(self) -> None: - self.assertEqual( - dict( - ds.signals( - { - "form": { - "total": "2 * 3", - "items": ["$first", "$second"], - "groups": [["$third"]], - } - }, - expressions_=True, - ) - ), +def test_expression_signals_recurse_through_nested_lists(): + assert dict( + attributes.attribute_generator.signals( { - "data-signals": ( - '{"form": {"total": (2 * 3), "items": [($first), ($second)], ' - '"groups": [[($third)]]}}' - ) + "form": { + "total": "2 * 3", + "items": ["$first", "$second"], + "groups": [["$third"]], + } }, + expressions_=True, + ) + ) == { + "data-signals": ( + '{"form": {"total": (2 * 3), "items": [($first), ($second)], "groups": [[($third)]]}}' ) + } - def test_literal_signals_remain_data_and_escape_action_tokens(self) -> None: - signals = { - "signal_example": "$otherSignal", - "action_example": '@post("/sse")', - "email_example": "person@example.com", - "expression_example": "1 + 1", - "quoted_example": 'a "quoted" value', - } - rendered = dict(ds.signals(signals))["data-signals"] - assert isinstance(rendered, str) +def test_literal_signals_remain_data_and_escape_action_tokens(): + signals = { + "signal_example": "$otherSignal", + "action_example": '@post("/sse")', + "email_example": "person@example.com", + "expression_example": "1 + 1", + "quoted_example": 'a "quoted" value', + } - self.assertEqual( - rendered, - "".join( - [ - '{"signal_example": "$otherSignal", ', - '"action_example": "@post(\\"/sse\\")", ', - '"email_example": "person@example.com", ', - '"expression_example": "1 + 1", ', - '"quoted_example": "a \\"quoted\\" value"}', - ] - ), - ) - self.assertEqual(json.loads(rendered), signals) + rendered = dict(attributes.attribute_generator.signals(signals))["data-signals"] + assert isinstance(rendered, str) - def test_mapping_values_serialize_recursively(self) -> None: - value = { - "literal": "text", - "nested": { - # These Python spellings differ from JavaScript - # This test can catch accidental fallbacks to str() - # instead of JSON serialization. - "items": [True, False, None, 3], - "expression": JSExpression("1 + 1"), - }, - } + assert rendered == "".join( + [ + '{"signal_example": "$otherSignal", ', + '"action_example": "@post(\\"/sse\\")", ', + '"email_example": "person@example.com", ', + '"expression_example": "1 + 1", ', + '"quoted_example": "a \\"quoted\\" value"}', + ] + ) + assert json.loads(rendered) == signals - self.assertEqual( - javascript(value), - '{"literal": "text", "nested": {"items": [true, false, null, 3], "expression": (1 + 1)}}', - ) - def test_mapping_keys_are_strings_and_escaped_as_data(self) -> None: - with self.assertRaisesRegex(TypeError, "object keys must be strings"): - javascript({1: "value"}) +def test_mapping_values_serialize_recursively(): + value = { + "literal": "text", + "nested": { + # These Python spellings differ from JavaScript + # This test can catch accidental fallbacks to str() + # instead of JSON serialization. + "items": [True, False, None, 3], + "expression": attributes.JSExpression("1 + 1"), + }, + } - self.assertEqual( - javascript( - { - 'quote"\\snow雪': "value", - '@post("key")': "action-looking key", - } - ), - '{"quote\\"\\\\snow\\u96ea": "value", "@post(\\"key\\")": "action-looking key"}', + assert attributes.javascript(value) == ( + '{"literal": "text", "nested": {"items": [true, false, null, 3], "expression": (1 + 1)}}' + ) + + +def test_mapping_keys_are_strings_and_escaped_as_data(): + with pytest.raises(TypeError, match="object keys must be strings"): + attributes.javascript({1: "value"}) + + assert ( + attributes.javascript( + { + 'quote"\\snow雪': "value", + '@post("key")': "action-looking key", + } ) + == '{"quote\\"\\\\snow\\u96ea": "value", "@post(\\"key\\")": "action-looking key"}' + ) + - def test_non_finite_signal_values_are_rejected_in_both_modes(self) -> None: - for expressions in (False, True): - for value in (math.nan, math.inf, -math.inf): - with ( - self.subTest(expressions=expressions, value=value), - self.assertRaises(ValueError), - ): - ds.signals({"value": value}, expressions_=expressions) +@pytest.mark.parametrize("expressions", (False, True)) +@pytest.mark.parametrize("value", (math.nan, math.inf, -math.inf)) +def test_non_finite_signal_values_are_rejected_in_both_modes(expressions, value): + with pytest.raises(ValueError): + attributes.attribute_generator.signals({"value": value}, expressions_=expressions) From d9ebfd49097169c7047aeffb25052b79048f5614 Mon Sep 17 00:00:00 2001 From: Dheepak Krishnamurthy <1813121+kdheepak@users.noreply.github.com> Date: Thu, 17 Sep 2026 07:39:58 -0400 Subject: [PATCH 6/9] test(attributes): simplify attribute imports with direct aliases --- tests/test_attributes.py | 53 ++++++++++++++++++++-------------------- 1 file changed, 26 insertions(+), 27 deletions(-) diff --git a/tests/test_attributes.py b/tests/test_attributes.py index bcc42be..dd8d933 100644 --- a/tests/test_attributes.py +++ b/tests/test_attributes.py @@ -3,26 +3,27 @@ import pytest -from datastar_py import attributes +from datastar_py.attributes import JSExpression, javascript +from datastar_py.attributes import attribute_generator as ds @pytest.mark.parametrize( ("attribute", "expected"), ( ( - attributes.attribute_generator.attr(title="first, second"), + ds.attr(title="first, second"), {"data-attr": '{"title": (first, second)}'}, ), ( - attributes.attribute_generator.class_({"active": "first, second"}), + ds.class_({"active": "first, second"}), {"data-class": '{"active": (first, second)}'}, ), ( - attributes.attribute_generator.style(width="first, second"), + ds.style(width="first, second"), {"data-style": '{"width": (first, second)}'}, ), ( - attributes.attribute_generator.signals( + ds.signals( items1=["first", "second"], items2=["first, second"], items3="first, second", @@ -34,7 +35,7 @@ }, ), ( - attributes.attribute_generator.signals( + ds.signals( items1=["first", "second"], items2=["first, second"], items3="first, second", @@ -47,7 +48,7 @@ }, ), ( - attributes.attribute_generator.signals( + ds.signals( { "items1": ["first", "second"], "items2": ["first, second"], @@ -61,7 +62,7 @@ }, ), ( - attributes.attribute_generator.signals( + ds.signals( { "items1": ["first", "second"], "items2": ["first, second"], @@ -77,7 +78,7 @@ }, ), ( - attributes.attribute_generator.signals( + ds.signals( { "items1": ["first", "second"], "items2": ["first, second"], @@ -92,7 +93,7 @@ }, ), ( - attributes.attribute_generator.signals( + ds.signals( { "items1": ("first", "second"), "items2": ("first, second",), @@ -106,7 +107,7 @@ }, ), ( - attributes.attribute_generator.signals( + ds.signals( { "items1": ("first", "second"), "items2": ("first, second",), @@ -121,7 +122,7 @@ }, ), ( - attributes.attribute_generator.signals( + ds.signals( { "answer1": '{"value": 42}', "answer2": {"value": 42}, @@ -131,7 +132,7 @@ {"data-signals": ('{"answer1": ({"value": 42}), "answer2": {"value": 42}}')}, ), ( - attributes.attribute_generator.signals( + ds.signals( { "answer1": '{"value": 42}', "answer2": {"value": 42}, @@ -149,25 +150,23 @@ def test_expression_apis_parenthesize_ambiguous_values(attribute, expected): ("attribute", "expected"), ( ( - attributes.attribute_generator.attr(title='"hello"', hidden="$closed"), + ds.attr(title='"hello"', hidden="$closed"), {"data-attr": '{"title": ("hello"), "hidden": ($closed)}'}, ), ( - attributes.attribute_generator.class_({"active item": "$selected", "plain": "true"}), + ds.class_({"active item": "$selected", "plain": "true"}), {"data-class": '{"active item": ($selected), "plain": (true)}'}, ), ( - attributes.attribute_generator.style(width="$width + 'px'"), + ds.style(width="$width + 'px'"), {"data-style": "{\"width\": ($width + 'px')}"}, ), ( - attributes.attribute_generator.signals({"form": {"count": "1 + 1"}}), + ds.signals({"form": {"count": "1 + 1"}}), {"data-signals": '{"form": {"count": "1 + 1"}}'}, ), ( - attributes.attribute_generator.signals( - {"form": {"count": "1 + 1"}}, expressions_=True - ), + ds.signals({"form": {"count": "1 + 1"}}, expressions_=True), {"data-signals": '{"form": {"count": (1 + 1)}}'}, ), ), @@ -178,7 +177,7 @@ def test_expression_apis_wrap_values_explicitly(attribute, expected): def test_expression_signals_recurse_through_nested_lists(): assert dict( - attributes.attribute_generator.signals( + ds.signals( { "form": { "total": "2 * 3", @@ -204,7 +203,7 @@ def test_literal_signals_remain_data_and_escape_action_tokens(): "quoted_example": 'a "quoted" value', } - rendered = dict(attributes.attribute_generator.signals(signals))["data-signals"] + rendered = dict(ds.signals(signals))["data-signals"] assert isinstance(rendered, str) assert rendered == "".join( @@ -227,21 +226,21 @@ def test_mapping_values_serialize_recursively(): # This test can catch accidental fallbacks to str() # instead of JSON serialization. "items": [True, False, None, 3], - "expression": attributes.JSExpression("1 + 1"), + "expression": JSExpression("1 + 1"), }, } - assert attributes.javascript(value) == ( + assert javascript(value) == ( '{"literal": "text", "nested": {"items": [true, false, null, 3], "expression": (1 + 1)}}' ) def test_mapping_keys_are_strings_and_escaped_as_data(): with pytest.raises(TypeError, match="object keys must be strings"): - attributes.javascript({1: "value"}) + javascript({1: "value"}) assert ( - attributes.javascript( + javascript( { 'quote"\\snow雪': "value", '@post("key")': "action-looking key", @@ -255,4 +254,4 @@ def test_mapping_keys_are_strings_and_escaped_as_data(): @pytest.mark.parametrize("value", (math.nan, math.inf, -math.inf)) def test_non_finite_signal_values_are_rejected_in_both_modes(expressions, value): with pytest.raises(ValueError): - attributes.attribute_generator.signals({"value": value}, expressions_=expressions) + ds.signals({"value": value}, expressions_=expressions) From 8fcbb0de6abfb06b6e5d0c59850925d6e96e9991 Mon Sep 17 00:00:00 2001 From: Dheepak Krishnamurthy <1813121+kdheepak@users.noreply.github.com> Date: Thu, 17 Sep 2026 08:57:23 -0400 Subject: [PATCH 7/9] test(attributes): reorder tests and add clarifying comments --- tests/test_attributes.py | 38 +++++++++++++++----------------------- 1 file changed, 15 insertions(+), 23 deletions(-) diff --git a/tests/test_attributes.py b/tests/test_attributes.py index dd8d933..0944f57 100644 --- a/tests/test_attributes.py +++ b/tests/test_attributes.py @@ -23,10 +23,12 @@ {"data-style": '{"width": (first, second)}'}, ), ( + # kwargs form ds.signals( items1=["first", "second"], items2=["first, second"], items3="first, second", + # expressions_ = False, # <- this is the default ), { "data-signals": ( @@ -35,6 +37,7 @@ }, ), ( + # kwargs form ds.signals( items1=["first", "second"], items2=["first, second"], @@ -48,10 +51,11 @@ }, ), ( + # dict form with tuples ds.signals( { - "items1": ["first", "second"], - "items2": ["first, second"], + "items1": ("first", "second"), + "items2": ("first, second",), "items3": "first, second", }, ), @@ -62,55 +66,42 @@ }, ), ( - ds.signals( - { - "items1": ["first", "second"], - "items2": ["first, second"], - "items3": "first, second", - "expressions_": False, - }, - expressions_=True, - ), - { - "data-signals": ( - '{"items1": [(first), (second)], "items2": [(first, second)], "items3": (first, second), "expressions_": false}' - ) - }, - ), - ( + # dict form with lists ds.signals( { "items1": ["first", "second"], "items2": ["first, second"], "items3": "first, second", }, - expressions_=True, ), { "data-signals": ( - '{"items1": [(first), (second)], "items2": [(first, second)], "items3": (first, second)}' + '{"items1": ["first", "second"], "items2": ["first, second"], "items3": "first, second"}' ) }, ), ( + # dict form with tuples and as expressions ds.signals( { "items1": ("first", "second"), "items2": ("first, second",), "items3": "first, second", }, + expressions_=True, ), { "data-signals": ( - '{"items1": ["first", "second"], "items2": ["first, second"], "items3": "first, second"}' + '{"items1": [(first), (second)], "items2": [(first, second)], "items3": (first, second)}' ) }, ), ( + # dict form with lists and as expressions ds.signals( { - "items1": ("first", "second"), - "items2": ("first, second",), + "items1": ["first", "second"], + "items2": ["first, second"], "items3": "first, second", }, expressions_=True, @@ -122,6 +113,7 @@ }, ), ( + # escape double quotes appropriately ds.signals( { "answer1": '{"value": 42}', From df45c47800d8b527e0aa4c6e2041ee39a4a2ca5a Mon Sep 17 00:00:00 2001 From: Dheepak Krishnamurthy <1813121+kdheepak@users.noreply.github.com> Date: Thu, 17 Sep 2026 08:58:51 -0400 Subject: [PATCH 8/9] test(attributes): clarify signals kwargs test case comments --- tests/test_attributes.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_attributes.py b/tests/test_attributes.py index 0944f57..9d865de 100644 --- a/tests/test_attributes.py +++ b/tests/test_attributes.py @@ -23,7 +23,7 @@ {"data-style": '{"width": (first, second)}'}, ), ( - # kwargs form + # kwargs form with default expressions_ = False ds.signals( items1=["first", "second"], items2=["first, second"], @@ -37,7 +37,7 @@ }, ), ( - # kwargs form + # kwargs form with expressions ds.signals( items1=["first", "second"], items2=["first, second"], From b2c428ccf3723a869dc3d7340a499da153a18e39 Mon Sep 17 00:00:00 2001 From: Dheepak Krishnamurthy <1813121+kdheepak@users.noreply.github.com> Date: Fri, 18 Sep 2026 00:27:23 -0400 Subject: [PATCH 9/9] feat(attributes): allow JSExpression in SignalValue type --- src/datastar_py/attributes.py | 23 ++++++++++++----------- tests/test_attributes.py | 7 +++++++ 2 files changed, 19 insertions(+), 11 deletions(-) diff --git a/src/datastar_py/attributes.py b/src/datastar_py/attributes.py index e64b550..49e081b 100644 --- a/src/datastar_py/attributes.py +++ b/src/datastar_py/attributes.py @@ -102,11 +102,23 @@ ] +@dataclasses.dataclass(frozen=True) +class JSExpression: + """JavaScript expression.""" + + value: str + + def __post_init__(self) -> None: + """Reject empty or non-string expression source.""" + _require_nonblank_string("JSExpression.value", self.value) + + SignalValue: TypeAlias = ( str | int | float | bool + | JSExpression | dict[str, "SignalValue"] | list["SignalValue"] | tuple["SignalValue", ...] @@ -114,17 +126,6 @@ ) -@dataclasses.dataclass(frozen=True) -class JSExpression: - """JavaScript expression.""" - - value: str - - def __post_init__(self) -> None: - """Reject empty or non-string expression source.""" - _require_nonblank_string("JSExpression.value", self.value) - - def _require_nonblank_string(name: str, value: object) -> None: if not isinstance(value, str): raise TypeError(f"{name} must be a string") diff --git a/tests/test_attributes.py b/tests/test_attributes.py index 9d865de..9e28459 100644 --- a/tests/test_attributes.py +++ b/tests/test_attributes.py @@ -138,6 +138,13 @@ def test_expression_apis_parenthesize_ambiguous_values(attribute, expected): assert dict(attribute) == expected +def test_signals_support_mixed_literals_and_expressions(): + assert ds.signals( + literal="window.innerWidth", + expression=JSExpression("window.innerWidth"), + ) == {"data-signals": ('{"literal": "window.innerWidth", "expression": (window.innerWidth)}')} + + @pytest.mark.parametrize( ("attribute", "expected"), (