Skip to content
Merged
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@
.idea
.zed
__pycache__
uv.lock
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -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
91 changes: 74 additions & 17 deletions src/datastar_py/attributes.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
from __future__ import annotations

import collections.abc
import dataclasses
import json
import re
from collections.abc import Iterable, Iterator, Mapping
Expand Down Expand Up @@ -100,11 +102,66 @@
]


@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 | dict[str, "SignalValue"] | list["SignalValue"] | None
str
| int
| float
| bool
| JSExpression
| dict[str, "SignalValue"]
| list["SignalValue"]
| tuple["SignalValue", ...]
| None
)


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.
Expand All @@ -128,7 +185,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:
Expand All @@ -153,7 +210,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,
)
Comment on lines +213 to +217

@kdheepak kdheepak Sep 13, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe _as_javascript_expressions(attrs) is not needed for attr and class_ since we don't expect it to be recursive and this line could be done with a dictionary comprehension instead. e.g.

return BaseAttr(
    "attr",
    value=javascript({key: JSExpression(value) for key, value in attrs.items()}),
    alias=self._alias,
)

But I left it with _as_javascript_expressions since it felt cleaner this way.


def bind(self, signal_name: str) -> BaseAttr:
"""Set up two-way data binding between a signal and an element's value."""
Expand All @@ -162,7 +223,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."""
Expand Down Expand Up @@ -217,7 +282,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."""
Expand Down Expand Up @@ -728,16 +797,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()
Loading
Loading