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
4 changes: 4 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
.PHONY: test

test:
uv run --dev pytest
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,10 @@ headers of the SSE response yourself.

A datastar response consists of 0..N datastar events. There are response
classes included to make this easy in all of the supported frameworks.
Each framework also exposes a `@datastar_response` decorator that will wrap
return values (including generators) into the right response class while
preserving sync handlers as sync so frameworks can keep them in their
threadpools.

The following examples will work across all supported frameworks when the
response class is imported from the appropriate framework package.
Expand Down
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -50,12 +50,14 @@ urls.GitHub = "https://github.com/starfederation/datastar-python"
dev = [
"django>=4.2.23",
"fastapi>=0.116.1",
"httpx>=0.27",
"litestar>=2.17",
"pre-commit>=4.2",
"python-fasthtml>=0.12.25; python_full_version>='3.10'",
"quart>=0.20",
"sanic>=25.3",
"starlette>=0.47.3",
"uvicorn>=0.30",
]

[tool.ruff]
Expand Down
28 changes: 21 additions & 7 deletions src/datastar_py/django.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from collections.abc import Awaitable, Mapping
from functools import wraps
from inspect import isasyncgenfunction, iscoroutinefunction
from typing import Any, Callable, ParamSpec

from django.http import HttpRequest
Expand Down Expand Up @@ -45,20 +46,33 @@ def __init__(

def datastar_response(
func: Callable[P, Awaitable[DatastarEvents] | DatastarEvents],
) -> Callable[P, Awaitable[DatastarResponse]]:
) -> Callable[P, Awaitable[DatastarResponse] | DatastarResponse]:
"""A decorator which wraps a function result in DatastarResponse.

Can be used on a sync or async function or generator function.
Preserves the sync/async nature of the decorated function.
"""
if isasyncgenfunction(func):
raise NotImplementedError(
"Async generators are not yet supported by the Django adapter; "
"use a sync generator or return a single value/awaitable instead."
)

if iscoroutinefunction(func):

@wraps(func)
async def async_wrapper(*args: P.args, **kwargs: P.kwargs) -> DatastarResponse:
return DatastarResponse(await func(*args, **kwargs))

async_wrapper.__annotations__["return"] = DatastarResponse
return async_wrapper

@wraps(func)
async def wrapper(*args: P.args, **kwargs: P.kwargs) -> DatastarResponse:
r = func(*args, **kwargs)
if isinstance(r, Awaitable):
return DatastarResponse(await r)
return DatastarResponse(r)
def sync_wrapper(*args: P.args, **kwargs: P.kwargs) -> DatastarResponse:
return DatastarResponse(func(*args, **kwargs))

return wrapper
sync_wrapper.__annotations__["return"] = DatastarResponse
return sync_wrapper


def read_signals(request: HttpRequest) -> dict[str, Any] | None:
Expand Down
28 changes: 19 additions & 9 deletions src/datastar_py/litestar.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from collections.abc import Awaitable, Mapping
from functools import wraps
from inspect import isasyncgenfunction, isawaitable, iscoroutinefunction
from typing import (
TYPE_CHECKING,
Any,
Expand Down Expand Up @@ -64,21 +65,30 @@ def __init__(

def datastar_response(
func: Callable[P, Awaitable[DatastarEvents] | DatastarEvents],
) -> Callable[P, Awaitable[DatastarResponse]]:
) -> Callable[P, Awaitable[DatastarResponse] | DatastarResponse]:
"""A decorator which wraps a function result in DatastarResponse.

Can be used on a sync or async function or generator function.
Preserves the sync/async nature of the decorated function.
"""
if iscoroutinefunction(func) or isasyncgenfunction(func):

@wraps(func)
async def async_wrapper(*args: P.args, **kwargs: P.kwargs) -> DatastarResponse:
result = func(*args, **kwargs)
if isawaitable(result):
result = await result
return DatastarResponse(result)

async_wrapper.__annotations__["return"] = DatastarResponse
return async_wrapper

@wraps(func)
async def wrapper(*args: P.args, **kwargs: P.kwargs) -> DatastarResponse:
r = func(*args, **kwargs)
if isinstance(r, Awaitable):
return DatastarResponse(await r)
return DatastarResponse(r)

wrapper.__annotations__["return"] = DatastarResponse
return wrapper
def sync_wrapper(*args: P.args, **kwargs: P.kwargs) -> DatastarResponse:
return DatastarResponse(func(*args, **kwargs))

sync_wrapper.__annotations__["return"] = DatastarResponse
return sync_wrapper


async def read_signals(request: Request) -> dict[str, Any] | None:
Expand Down
35 changes: 26 additions & 9 deletions src/datastar_py/quart.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@

from collections.abc import Awaitable, Mapping
from functools import wraps
from inspect import isasyncgen, isasyncgenfunction, isgenerator
from inspect import isasyncgen, isasyncgenfunction, iscoroutinefunction, isgenerator
from typing import Any, Callable, ParamSpec

from quart import Response, copy_current_request_context, request, stream_with_context
from quart import Response, request, stream_with_context

from . import _read_signals
from .sse import SSE_HEADERS, DatastarEvents, ServerSentEventGenerator
Expand Down Expand Up @@ -43,20 +43,37 @@ def __init__(

def datastar_response(
func: Callable[P, Awaitable[DatastarEvents] | DatastarEvents],
) -> Callable[P, Awaitable[DatastarResponse]]:
) -> Callable[P, Awaitable[DatastarResponse] | DatastarResponse]:
"""A decorator which wraps a function result in DatastarResponse.

Can be used on a sync or async function or generator function.
Preserves the sync/async nature of the decorated function.
"""
# Async generators require stream_with_context wrapping at decoration time
if isasyncgenfunction(func):

@wraps(func)
async def wrapper(*args: P.args, **kwargs: P.kwargs) -> DatastarResponse:
if isasyncgenfunction(func):
@wraps(func)
async def async_gen_wrapper(*args: P.args, **kwargs: P.kwargs) -> DatastarResponse:
return DatastarResponse(stream_with_context(func)(*args, **kwargs))
return DatastarResponse(await copy_current_request_context(func)(*args, **kwargs))

wrapper.__annotations__["return"] = DatastarResponse
return wrapper
async_gen_wrapper.__annotations__["return"] = DatastarResponse
return async_gen_wrapper

if iscoroutinefunction(func):

@wraps(func)
async def async_wrapper(*args: P.args, **kwargs: P.kwargs) -> DatastarResponse:
return DatastarResponse(await func(*args, **kwargs))

async_wrapper.__annotations__["return"] = DatastarResponse
return async_wrapper

@wraps(func)
def sync_wrapper(*args: P.args, **kwargs: P.kwargs) -> DatastarResponse:
return DatastarResponse(func(*args, **kwargs))

sync_wrapper.__annotations__["return"] = DatastarResponse
return sync_wrapper


async def read_signals() -> dict[str, Any] | None:
Expand Down
3 changes: 2 additions & 1 deletion src/datastar_py/sanic.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from contextlib import aclosing, closing
from functools import wraps
from inspect import isasyncgen, isgenerator
from inspect import isawaitable
from typing import Any, Callable, ParamSpec, Union

from sanic import HTTPResponse, Request
Expand Down Expand Up @@ -70,7 +71,7 @@ def datastar_response(
@wraps(func)
async def wrapper(*args: P.args, **kwargs: P.kwargs) -> DatastarResponse | None:
r = func(*args, **kwargs)
if isinstance(r, Awaitable):
if isawaitable(r):
return DatastarResponse(await r)
if isasyncgen(r):
request = args[0]
Expand Down
28 changes: 19 additions & 9 deletions src/datastar_py/starlette.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from collections.abc import Awaitable, Mapping
from functools import wraps
from inspect import isasyncgenfunction, isawaitable, iscoroutinefunction
from typing import (
TYPE_CHECKING,
Any,
Expand Down Expand Up @@ -54,21 +55,30 @@ def __init__(

def datastar_response(
func: Callable[P, Awaitable[DatastarEvents] | DatastarEvents],
) -> Callable[P, Awaitable[DatastarResponse]]:
) -> Callable[P, Awaitable[DatastarResponse] | DatastarResponse]:
"""A decorator which wraps a function result in DatastarResponse.

Can be used on a sync or async function or generator function.
Preserves the sync/async nature of the decorated function.
"""
if iscoroutinefunction(func) or isasyncgenfunction(func):

@wraps(func)
async def async_wrapper(*args: P.args, **kwargs: P.kwargs) -> DatastarResponse:
result = func(*args, **kwargs)
if isawaitable(result):
result = await result
return DatastarResponse(result)

async_wrapper.__annotations__["return"] = DatastarResponse
return async_wrapper

@wraps(func)
async def wrapper(*args: P.args, **kwargs: P.kwargs) -> DatastarResponse:
r = func(*args, **kwargs)
if isinstance(r, Awaitable):
return DatastarResponse(await r)
return DatastarResponse(r)

wrapper.__annotations__["return"] = DatastarResponse
return wrapper
def sync_wrapper(*args: P.args, **kwargs: P.kwargs) -> DatastarResponse:
return DatastarResponse(func(*args, **kwargs))

sync_wrapper.__annotations__["return"] = DatastarResponse
return sync_wrapper


async def read_signals(request: Request) -> dict[str, Any] | None:
Expand Down
Loading