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 9e2a8d2..c5d499d 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ .PHONY: test lint test: - uv run --dev pytest + 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..e64b550 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,64 @@ 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) + "]" + return json.dumps(value, allow_nan=False) + + +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 +184,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 +209,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 +222,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 +281,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 +796,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..bcc42be --- /dev/null +++ b/tests/test_attributes.py @@ -0,0 +1,258 @@ +import json +import math + +import pytest + +from datastar_py import attributes + + +@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", + ), + { + "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, + ), + { + "data-signals": ( + '{"items1": [(first), (second)], "items2": [(first, second)], "items3": (first, second)}' + ) + }, + ), + ( + attributes.attribute_generator.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( + { + "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}' + ) + }, + ), + ( + attributes.attribute_generator.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( + { + "items1": ("first", "second"), + "items2": ("first, second",), + "items3": "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, + ), + { + "data-signals": ( + '{"items1": [(first), (second)], "items2": [(first, second)], "items3": (first, second)}' + ) + }, + ), + ( + attributes.attribute_generator.signals( + { + "answer1": '{"value": 42}', + "answer2": {"value": 42}, + }, + expressions_=True, + ), + {"data-signals": ('{"answer1": ({"value": 42}), "answer2": {"value": 42}}')}, + ), + ( + attributes.attribute_generator.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 + + +@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 + ), + {"data-signals": '{"form": {"count": (1 + 1)}}'}, + ), + ), +) +def test_expression_apis_wrap_values_explicitly(attribute, expected): + assert dict(attribute) == expected + + +def test_expression_signals_recurse_through_nested_lists(): + assert dict( + attributes.attribute_generator.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(): + signals = { + "signal_example": "$otherSignal", + "action_example": '@post("/sse")', + "email_example": "person@example.com", + "expression_example": "1 + 1", + "quoted_example": 'a "quoted" value', + } + + rendered = dict(attributes.attribute_generator.signals(signals))["data-signals"] + assert isinstance(rendered, str) + + 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 + + +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"), + }, + } + + 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"}' + ) + + +@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) 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 + )