Skip to content

Commit 1e614ba

Browse files
authored
Python SDK: Add a decorator to auto-wrap responses in DatastarResponse (#925)
* Add a decorator to auto-wrap responses in DatastarResponse * Add datastar_response decorator for sanic * Make the datastar_response generator work the same for sanic as it does for other frameworks * Explicitly close connection when done with sanic streaming * Document response decorator in the readme More readme updates * Formatting
1 parent 8563fbf commit 1e614ba

File tree

11 files changed

+199
-34
lines changed

11 files changed

+199
-34
lines changed

README.md

Lines changed: 40 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -58,34 +58,54 @@ e.g. `from datastar_py.quart import DatastarResponse` The containing functions
5858
are not shown here, as they will differ per framework.
5959

6060
```python
61+
from datastar_py import ServerSentEventGenerator as SSE
62+
6163
# 0 events, a 204
6264
return DatastarResponse()
6365
# 1 event
64-
return DatastarResponse(ServerSentEventGenerator.patch_elements("<div id='mydiv'></div>"))
66+
return DatastarResponse(SSE.patch_elements("<div id='mydiv'></div>"))
6567
# 2 events
6668
return DatastarResponse([
67-
ServerSentEventGenerator.patch_elements("<div id='mydiv'></div>"),
68-
ServerSentEventGenerator.patch_signals({"mysignal": "myval"}),
69+
SSE.patch_elements("<div id='mydiv'></div>"),
70+
SSE.patch_signals({"mysignal": "myval"}),
6971
])
7072

71-
7273
# N events, a long lived stream (for all frameworks but sanic)
7374
async def updates():
7475
while True:
75-
yield ServerSentEventGenerator.patch_elements("<div id='mydiv'></div>")
76+
yield SSE.patch_elements("<div id='mydiv'></div>")
7677
await asyncio.sleep(1)
77-
78-
7978
return DatastarResponse(updates())
79+
8080
# A long lived stream for sanic
8181
response = await datastar_respond(request)
8282
# which is just a helper for the following
8383
# response = await request.respond(DatastarResponse())
8484
while True:
85-
await response.send(ServerSentEventGenerator.patch_elements("<div id='mydiv'></div>"))
85+
await response.send(SSE.patch_elements("<div id='mydiv'></div>"))
8686
await asyncio.sleep(1)
8787
```
8888

89+
### Response Decorator
90+
To make returning a `DatastarResponse` simpler, there is a decorator
91+
`datastar_response` available that automatically wraps a function result in
92+
`DatastarResponse`. It works on async and regular functions and generator
93+
functions. The main use case is when using a generator function, as you can
94+
avoid a second generator function inside your response function. The decorator
95+
works the same for any of the supported frameworks, and should be used under
96+
any routing decorator from the framework.
97+
98+
```python
99+
from datastar_py.sanic import datastar_response, ServerSentEventGenerator as SSE
100+
101+
@app.get('/my_route')
102+
@datastar_response
103+
def my_route(request):
104+
while True:
105+
yield SSE.patch_elements("<div id='mydiv'></div>")
106+
await asyncio.sleep(1)
107+
```
108+
89109
## Signal Helpers
90110
The current state of the datastar signals is included by default in every
91111
datastar request. A helper is included to load those signals for each
@@ -117,4 +137,16 @@ Button("My Button", data.on("click", "console.log('clicked')").debounce(1000).st
117137
f"<button {data.on("click", "console.log('clicked')").debounce(1000).stop}>My Button</button>"
118138
# Jinja, but no editor completion :(
119139
<button {{data.on("click", "console.log('clicked')").debounce(1000).stop}}>My Button</button>
140+
```
141+
142+
When using datastar with a different alias, you can instantiate the class yourself.
143+
144+
```python
145+
from datastar_py.attributes import AttributeGenerator
146+
147+
data = AttributeGenerator(alias="data-star-")
148+
149+
# htmy (htmy will transform _ into - unless the attribute starts with _, which will be stripped)
150+
data = AttributeGenerator(alias="_data-")
151+
html.button("My Button", **data.on("click", "console.log('clicked')").debounce("1s").stop)
120152
```

sdk-test.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
2. Move to the sdk/tests folder.
1515
3. Run `test-all.sh http://127.0.0.1:8000` to run the tests.
1616
"""
17+
1718
import re
1819

1920
from sanic import Request, Sanic

src/datastar_py/attributes.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,8 @@ def computed(self, computed_dict: Mapping | None = None, /, **computed: str) ->
138138
"""Create signals that are computed based on an expression."""
139139
computed = {**(computed_dict if computed_dict else {}), **computed}
140140
first, *rest = (
141-
BaseAttr("computed", key=sig, value=expr, alias=self._alias) for sig, expr in computed.items()
141+
BaseAttr("computed", key=sig, value=expr, alias=self._alias)
142+
for sig, expr in computed.items()
142143
)
143144
first._other_attrs = rest
144145
return first

src/datastar_py/django.py

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,15 @@
11
from __future__ import annotations
22

3-
from typing import TYPE_CHECKING, Any
3+
from collections.abc import Awaitable, Mapping
4+
from functools import wraps
5+
from typing import Any, Callable, ParamSpec
46

57
from django.http import HttpRequest
68
from django.http import StreamingHttpResponse as _StreamingHttpResponse
79

810
from . import _read_signals
911
from .sse import SSE_HEADERS, DatastarEvent, DatastarEvents, ServerSentEventGenerator
1012

11-
if TYPE_CHECKING:
12-
from collections.abc import Mapping
13-
14-
1513
__all__ = [
1614
"SSE_HEADERS",
1715
"DatastarResponse",
@@ -42,5 +40,26 @@ def __init__(
4240
super().__init__(content, status=status, headers=headers)
4341

4442

43+
P = ParamSpec("P")
44+
45+
46+
def datastar_response(
47+
func: Callable[P, Awaitable[DatastarEvents] | DatastarEvents],
48+
) -> Callable[P, Awaitable[DatastarResponse]]:
49+
"""A decorator which wraps a function result in DatastarResponse.
50+
51+
Can be used on a sync or async function or generator function.
52+
"""
53+
54+
@wraps(func)
55+
async def wrapper(*args: P.args, **kwargs: P.kwargs) -> DatastarResponse:
56+
r = func(*args, **kwargs)
57+
if isinstance(r, Awaitable):
58+
return DatastarResponse(await r)
59+
return DatastarResponse(r)
60+
61+
return wrapper
62+
63+
4564
def read_signals(request: HttpRequest) -> dict[str, Any] | None:
4665
return _read_signals(request.method, request.headers, request.GET, request.body)

src/datastar_py/fastapi.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,14 @@
33
from fastapi import Depends
44

55
from .sse import SSE_HEADERS, ServerSentEventGenerator
6-
from .starlette import DatastarResponse, read_signals
6+
from .starlette import DatastarResponse, datastar_response, read_signals
77

88
__all__ = [
99
"SSE_HEADERS",
1010
"DatastarResponse",
1111
"ReadSignals",
1212
"ServerSentEventGenerator",
13+
"datastar_response",
1314
"read_signals",
1415
]
1516

src/datastar_py/fasthtml.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
from .sse import SSE_HEADERS, ServerSentEventGenerator
2-
from .starlette import DatastarResponse, read_signals
2+
from .starlette import DatastarResponse, datastar_response, read_signals
33

44
__all__ = [
55
"SSE_HEADERS",
66
"DatastarResponse",
77
"ServerSentEventGenerator",
8+
"datastar_response",
89
"read_signals",
910
]

src/datastar_py/litestar.py

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,20 @@
11
from __future__ import annotations
22

3-
from typing import TYPE_CHECKING, Any
3+
from collections.abc import Awaitable, Mapping
4+
from functools import wraps
5+
from typing import (
6+
TYPE_CHECKING,
7+
Any,
8+
Callable,
9+
ParamSpec,
10+
)
411

512
from litestar.response import Stream
613

714
from . import _read_signals
815
from .sse import SSE_HEADERS, DatastarEvent, DatastarEvents, ServerSentEventGenerator
916

1017
if TYPE_CHECKING:
11-
from collections.abc import Mapping
12-
1318
from litestar import Request
1419
from litestar.background_tasks import BackgroundTask, BackgroundTasks
1520
from litestar.types import ResponseCookies
@@ -54,6 +59,28 @@ def __init__(
5459
)
5560

5661

62+
P = ParamSpec("P")
63+
64+
65+
def datastar_response(
66+
func: Callable[P, Awaitable[DatastarEvents] | DatastarEvents],
67+
) -> Callable[P, Awaitable[DatastarResponse]]:
68+
"""A decorator which wraps a function result in DatastarResponse.
69+
70+
Can be used on a sync or async function or generator function.
71+
"""
72+
73+
@wraps(func)
74+
async def wrapper(*args: P.args, **kwargs: P.kwargs) -> DatastarResponse:
75+
r = func(*args, **kwargs)
76+
if isinstance(r, Awaitable):
77+
return DatastarResponse(await r)
78+
return DatastarResponse(r)
79+
80+
wrapper.__annotations__["return"] = "DatastarResponse"
81+
return wrapper
82+
83+
5784
async def read_signals(request: Request) -> dict[str, Any] | None:
5885
return _read_signals(
5986
request.method, request.headers, request.query_params, await request.body()

src/datastar_py/quart.py

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,15 @@
11
from __future__ import annotations
22

3-
from inspect import isasyncgen, isgenerator
4-
from typing import TYPE_CHECKING, Any
3+
from collections.abc import Awaitable, Mapping
4+
from functools import wraps
5+
from inspect import isasyncgen, isasyncgenfunction, isgenerator
6+
from typing import Any, Callable, ParamSpec
57

6-
from quart import Response, request
8+
from quart import Response, copy_current_request_context, request, stream_with_context
79

810
from . import _read_signals
911
from .sse import SSE_HEADERS, DatastarEvents, ServerSentEventGenerator
1012

11-
if TYPE_CHECKING:
12-
from collections.abc import Mapping
13-
1413
__all__ = [
1514
"SSE_HEADERS",
1615
"DatastarResponse",
@@ -39,5 +38,26 @@ def __init__(
3938
self.timeout = None
4039

4140

41+
P = ParamSpec("P")
42+
43+
44+
def datastar_response(
45+
func: Callable[P, Awaitable[DatastarEvents] | DatastarEvents],
46+
) -> Callable[P, Awaitable[DatastarResponse]]:
47+
"""A decorator which wraps a function result in DatastarResponse.
48+
49+
Can be used on a sync or async function or generator function.
50+
"""
51+
52+
@wraps(func)
53+
async def wrapper(*args: P.args, **kwargs: P.kwargs) -> DatastarResponse:
54+
if isasyncgenfunction(func):
55+
return DatastarResponse(stream_with_context(func)(*args, **kwargs))
56+
return DatastarResponse(await copy_current_request_context(func)(*args, **kwargs))
57+
58+
wrapper.__annotations__["return"] = "DatastarResponse"
59+
return wrapper
60+
61+
4262
async def read_signals() -> dict[str, Any] | None:
4363
return _read_signals(request.method, request.headers, request.args, await request.get_data())

src/datastar_py/sanic.py

Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
from __future__ import annotations
22

3-
from typing import TYPE_CHECKING, Any
3+
from collections.abc import Awaitable, Collection, Mapping
4+
from functools import wraps
5+
from inspect import isasyncgen, isgenerator
6+
from typing import Any, Callable, ParamSpec
47

58
from sanic import HTTPResponse, Request
69

710
from . import _read_signals
8-
from .sse import SSE_HEADERS, DatastarEvent, ServerSentEventGenerator
9-
10-
if TYPE_CHECKING:
11-
from collections.abc import Collection, Mapping
11+
from .sse import SSE_HEADERS, DatastarEvent, DatastarEvents, ServerSentEventGenerator
1212

1313
__all__ = [
1414
"SSE_HEADERS",
@@ -52,5 +52,41 @@ async def datastar_respond(
5252
return await request.respond(DatastarResponse(status=status, headers=headers))
5353

5454

55+
P = ParamSpec("P")
56+
57+
58+
def datastar_response(
59+
func: Callable[P, Awaitable[DatastarEvents] | DatastarEvents],
60+
) -> Callable[P, Awaitable[DatastarResponse]]:
61+
"""A decorator which wraps a function result in DatastarResponse.
62+
63+
Can be used on a sync or async function or generator function.
64+
"""
65+
66+
@wraps(func)
67+
async def wrapper(*args: P.args, **kwargs: P.kwargs) -> DatastarResponse:
68+
r = func(*args, **kwargs)
69+
if isinstance(r, Awaitable):
70+
return DatastarResponse(await r)
71+
if isasyncgen(r):
72+
request = args[0]
73+
response = await request.respond(response=DatastarResponse())
74+
async for event in r:
75+
await response.send(event)
76+
await response.eof()
77+
return response
78+
if isgenerator(r):
79+
request = args[0]
80+
response = await request.respond(response=DatastarResponse())
81+
for event in r:
82+
await response.send(event)
83+
await response.eof()
84+
return response
85+
return DatastarResponse(r)
86+
87+
wrapper.__annotations__["return"] = "DatastarResponse"
88+
return wrapper
89+
90+
5591
async def read_signals(request: Request) -> dict[str, Any] | None:
5692
return _read_signals(request.method, request.headers, request.args, request.body)

src/datastar_py/sse.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -150,8 +150,7 @@ def patch_signals(
150150
signals if isinstance(signals, str) else json.dumps(signals, separators=(",", ":"))
151151
)
152152
data_lines.extend(
153-
f"{consts.SIGNALS_DATALINE_LITERAL} {line}"
154-
for line in signals_str.splitlines()
153+
f"{consts.SIGNALS_DATALINE_LITERAL} {line}" for line in signals_str.splitlines()
155154
)
156155

157156
return ServerSentEventGenerator._send(

0 commit comments

Comments
 (0)