Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
90 changes: 73 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 @@ -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.
Expand All @@ -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:
Expand All @@ -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,
)
Comment on lines +212 to +216

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. 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 +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."""
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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()
Loading