Skip to content

Commit 3b9a5b8

Browse files
committed
WIP action generator
1 parent 6b23c46 commit 3b9a5b8

File tree

2 files changed

+172
-16
lines changed

2 files changed

+172
-16
lines changed

src/datastar_py/attributes/__init__.py

Lines changed: 47 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,33 @@
111111
]
112112

113113

114+
class JSExpression(str):
115+
MARKER = "_!EXPR!_"
116+
REPLACEMENT = ""
117+
118+
def __new__(cls, value: str) -> Self:
119+
return str.__new__(cls, f"{cls.MARKER}{value}{cls.MARKER}")
120+
121+
122+
class JSRegex(str):
123+
MARKER = "_!REGEX!_"
124+
REPLACEMENT = "/"
125+
126+
def __new__(cls, value: str) -> Self:
127+
return str.__new__(cls, f"{cls.MARKER}{value}{cls.MARKER}")
128+
129+
130+
def _js_object(obj: dict) -> str:
131+
"""Create a JS object where the values can be JS expressions or regex."""
132+
result = json.dumps(obj)
133+
return (
134+
result.replace(f'"{JSExpression.MARKER}', JSExpression.REPLACEMENT)
135+
.replace(f'{JSExpression.MARKER}"', JSExpression.REPLACEMENT)
136+
.replace(f'"{JSRegex.MARKER}', JSRegex.REPLACEMENT)
137+
.replace(f'{JSRegex.MARKER}"', JSRegex.REPLACEMENT)
138+
)
139+
140+
114141
class AttributeGenerator:
115142
def __init__(self, alias: str = "data-") -> None:
116143
"""A helper which can generate all the Datastar attributes.
@@ -134,7 +161,11 @@ def signals(
134161
rather than literals.
135162
"""
136163
signals = {**(signals_dict if signals_dict else {}), **signals}
137-
val = _js_object(signals) if expressions_ else json.dumps(signals)
164+
val = (
165+
_js_object({k: JSExpression(v) for k, v in signals.items()})
166+
if expressions_
167+
else json.dumps(signals)
168+
)
138169
return SignalsAttr(value=val, alias=self._alias)
139170

140171
def computed(self, computed_dict: Mapping | None = None, /, **computed: str) -> BaseAttr:
@@ -159,7 +190,11 @@ def ignore(self) -> IgnoreAttr:
159190
def attr(self, attr_dict: Mapping | None = None, /, **attrs: str) -> BaseAttr:
160191
"""Set the value of any HTML attributes to expressions, and keep them in sync."""
161192
attrs = {**(attr_dict if attr_dict else {}), **attrs}
162-
return BaseAttr("attr", value=_js_object(attrs), alias=self._alias)
193+
return BaseAttr(
194+
"attr",
195+
value=_js_object({k: JSExpression(v) for k, v in attrs.items()}),
196+
alias=self._alias,
197+
)
163198

164199
def bind(self, signal_name: str) -> BaseAttr:
165200
"""Set up two-way data binding between a signal and an element's value."""
@@ -168,7 +203,11 @@ def bind(self, signal_name: str) -> BaseAttr:
168203
def class_(self, class_dict: Mapping | None = None, /, **classes: str) -> BaseAttr:
169204
"""Add or removes classes to or from an element based on expressions."""
170205
classes = {**(class_dict if class_dict else {}), **classes}
171-
return BaseAttr("class", value=_js_object(classes), alias=self._alias)
206+
return BaseAttr(
207+
"class",
208+
value=_js_object({k: JSExpression(v) for k, v in classes.items()}),
209+
alias=self._alias,
210+
)
172211

173212
@overload
174213
def on(self, event: Literal["interval"], expression: str) -> OnIntervalAttr: ...
@@ -259,7 +298,11 @@ def show(self, expression: str) -> BaseAttr:
259298
def style(self, style_dict: Mapping | None = None, /, **styles: str) -> BaseAttr:
260299
"""Sets the value of inline CSS styles on an element based on an expression, and keeps them in sync."""
261300
styles = {**(style_dict if style_dict else {}), **styles}
262-
return BaseAttr("style", value=_js_object(styles), alias=self._alias)
301+
return BaseAttr(
302+
"style",
303+
value=_js_object({k: JSExpression(v) for k, v in styles.items()}),
304+
alias=self._alias,
305+
)
263306

264307
def text(self, expression: str) -> BaseAttr:
265308
"""Bind the text content of an element to an expression."""
@@ -728,16 +771,4 @@ def _escape(s: str) -> str:
728771
)
729772

730773

731-
def _js_object(obj: dict) -> str:
732-
"""Create a JS object where the values are expressions rather than strings."""
733-
return (
734-
"{"
735-
+ ", ".join(
736-
f"{json.dumps(k)}: {_js_object(v) if isinstance(v, dict) else v}"
737-
for k, v in obj.items()
738-
)
739-
+ "}"
740-
)
741-
742-
743774
attribute_generator = AttributeGenerator()
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
from __future__ import annotations
2+
3+
from typing import Literal, TypedDict, Unpack
4+
5+
from datastar_py.attributes import JSExpression, JSRegex, SignalValue, _js_object
6+
7+
8+
class _FetchOptions(TypedDict, total=False):
9+
content_type: Literal["json", "form"]
10+
include_signals: str
11+
exclude_signals: str
12+
selector: str
13+
headers: dict[str, str]
14+
open_when_hidden: bool
15+
retry_interval: int
16+
retry_scalar: float
17+
retry_max_wait_ms: int
18+
retry_max_count: int
19+
request_cancellation: Literal["auto", "disabled"] | str
20+
21+
22+
def _fetch(
23+
method: Literal["get", "post", "put", "patch", "delete"],
24+
url: str,
25+
**options: Unpack[_FetchOptions],
26+
) -> str:
27+
result = f"@{method}('{url}'"
28+
if options:
29+
mapped_options = {}
30+
if "content_type" in options:
31+
mapped_options["contentType"] = options["content_type"]
32+
if "include_signals" in options or "exclude_signals" in options:
33+
filter_signals = {}
34+
if "include_signals" in options:
35+
filter_signals["include"] = JSRegex(options["include_signals"])
36+
if "exclude_signals" in options:
37+
filter_signals["exclude"] = JSRegex(options["exclude_signals"])
38+
mapped_options["filterSignals"] = filter_signals
39+
if "selector" in options:
40+
mapped_options["selector"] = options["selector"]
41+
if "headers" in options:
42+
mapped_options["headers"] = _js_object(options["headers"])
43+
if "open_when_hidden" in options:
44+
mapped_options["openWhenHidden"] = options["open_when_hidden"]
45+
if "retry_interval" in options:
46+
mapped_options["retryInterval"] = options["retry_interval"]
47+
if "retry_scalar" in options:
48+
mapped_options["retryScalar"] = options["retry_scalar"]
49+
if "retry_max_wait_ms" in options:
50+
mapped_options["retryMaxWaitMs"] = options["retry_max_wait_ms"]
51+
if "request_cancellation" in options:
52+
if options["request_cancellation"] in ("auto", "disabled"):
53+
mapped_options["requestCancellation"] = options["request_cancellation"]
54+
else:
55+
mapped_options["requestCancellation"] = JSExpression(
56+
options["request_cancellation"]
57+
)
58+
result += f", {_js_object(mapped_options)}"
59+
result += ")"
60+
return result
61+
62+
63+
def get(url: str, **options: Unpack[_FetchOptions]) -> str:
64+
return _fetch("get", url, **options)
65+
66+
67+
def post(url: str, **options: Unpack[_FetchOptions]) -> str:
68+
return _fetch("post", url, **options)
69+
70+
71+
def put(url: str, **options: Unpack[_FetchOptions]) -> str:
72+
return _fetch("put", url, **options)
73+
74+
75+
def patch(url: str, **options: Unpack[_FetchOptions]) -> str:
76+
return _fetch("patch", url, **options)
77+
78+
79+
def delete(url: str, **options: Unpack[_FetchOptions]) -> str:
80+
return _fetch("delete", url, **options)
81+
82+
83+
def peek(expression: str) -> str:
84+
"""Evaluate an expression containing signals without subscribing to changes in those signals."""
85+
return f"@peek(() => {expression})"
86+
87+
88+
def set_all(value: SignalValue, include: str | None = None, exclude: str | None = None) -> str:
89+
"""Set the value of all matching signals."""
90+
filter_dict = {}
91+
if include:
92+
filter_dict["include"] = JSRegex(include)
93+
if exclude:
94+
filter_dict["exclude"] = JSRegex(exclude)
95+
filter_string = f", {_js_object(filter_dict)}" if filter_dict else ""
96+
return f"@setAll({value}{filter_string})"
97+
98+
99+
def toggle_all(include: str | None = None, exclude: str | None = None) -> str:
100+
"""Toggle the boolean value of all matching signals."""
101+
filter_dict = {}
102+
if include:
103+
filter_dict["include"] = JSRegex(include)
104+
if exclude:
105+
filter_dict["exclude"] = JSRegex(exclude)
106+
filter_string = _js_object(filter_dict) if filter_dict else ""
107+
return f"@toggleAll({filter_string})"
108+
109+
110+
def clipboard(text: str, is_base_64: bool = False) -> str:
111+
"""PRO: Copy text to the clipboard."""
112+
return f"@clipboard({text}{', true' if is_base_64 else ''})"
113+
114+
115+
def fit(
116+
value: float | str,
117+
old_min: float | str,
118+
old_max: float | str,
119+
new_min: float | str,
120+
new_max: float | str,
121+
should_clamp: bool = False,
122+
should_round: bool = False,
123+
) -> str:
124+
"""PRO: Linearly interpolate a value from one range to another."""
125+
return f"@fit({value}, {old_min}, {old_max}, {new_min}, {new_max}, {'true' if should_clamp else 'false'}{', true' if should_round else ''})"

0 commit comments

Comments
 (0)