From 1f88a6507e66e2526be6df08c038d10684cb6e27 Mon Sep 17 00:00:00 2001 From: MarcusL11 Date: Sun, 3 May 2026 13:55:09 +0700 Subject: [PATCH 1/3] fix: add @overload to Django datastar_response for correct mypy narrowing The decorator's single union return type prevents mypy from narrowing sync vs async views, forcing users to cast() when passing to path(). Add @overload signatures using Coroutine (not Awaitable) to match what django-stubs expects for async view callables. --- src/datastar_py/django.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/datastar_py/django.py b/src/datastar_py/django.py index 955a343..f3ce295 100644 --- a/src/datastar_py/django.py +++ b/src/datastar_py/django.py @@ -1,9 +1,9 @@ from __future__ import annotations -from collections.abc import Awaitable, Callable, Mapping +from collections.abc import Awaitable, Callable, Coroutine, Mapping from functools import wraps from inspect import isasyncgenfunction, isawaitable, iscoroutinefunction -from typing import Any, ParamSpec +from typing import Any, ParamSpec, overload from django.http import HttpRequest from django.http import StreamingHttpResponse as _StreamingHttpResponse @@ -44,6 +44,18 @@ def __init__( P = ParamSpec("P") +@overload +def datastar_response( + func: Callable[P, Coroutine[Any, Any, DatastarEvents]], +) -> Callable[P, Coroutine[Any, Any, DatastarResponse]]: ... + + +@overload +def datastar_response( + func: Callable[P, DatastarEvents], +) -> Callable[P, DatastarResponse]: ... + + def datastar_response( func: Callable[P, Awaitable[DatastarEvents] | DatastarEvents], ) -> Callable[P, Awaitable[DatastarResponse] | DatastarResponse]: From 78e04a790ec305ba1c41f1d3e468907ffdffb6df Mon Sep 17 00:00:00 2001 From: MarcusL11 Date: Thu, 23 Jul 2026 14:12:18 +0200 Subject: [PATCH 2/3] fix: add async-generator overload to Django datastar_response The async overload used Coroutine, which excluded async generator views (async def + yield). Those matched the sync DatastarEvents overload and were mistyped as returning a plain DatastarResponse instead of a coroutine. Add a dedicated overload for Callable[P, AsyncIterable[DatastarEvent]] -> Callable[P, Coroutine[Any, Any, DatastarResponse]], placed before the sync overload so async generators resolve correctly. Verified with mypy (django-stubs) via assert_type and the existing decorator matrix tests (all 4 sync/async x value/generator variants pass). --- src/datastar_py/django.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/datastar_py/django.py b/src/datastar_py/django.py index f3ce295..d7809ed 100644 --- a/src/datastar_py/django.py +++ b/src/datastar_py/django.py @@ -1,6 +1,6 @@ from __future__ import annotations -from collections.abc import Awaitable, Callable, Coroutine, Mapping +from collections.abc import AsyncIterable, Awaitable, Callable, Coroutine, Mapping from functools import wraps from inspect import isasyncgenfunction, isawaitable, iscoroutinefunction from typing import Any, ParamSpec, overload @@ -50,6 +50,12 @@ def datastar_response( ) -> Callable[P, Coroutine[Any, Any, DatastarResponse]]: ... +@overload +def datastar_response( + func: Callable[P, AsyncIterable[DatastarEvent]], +) -> Callable[P, Coroutine[Any, Any, DatastarResponse]]: ... + + @overload def datastar_response( func: Callable[P, DatastarEvents], From fea020e303288128198f2cf66a49b9abd2b6cfe8 Mon Sep 17 00:00:00 2001 From: "Marcus A. Lee" Date: Fri, 24 Jul 2026 13:13:07 +0200 Subject: [PATCH 3/3] fix: split datastar event aliases for django typing --- src/datastar_py/django.py | 17 +++++++--- src/datastar_py/sse.py | 6 ++-- tests/test_django_decorator_typing.py | 41 +++++++++++++++++++++++ tests/typing_fixtures/django_decorator.py | 39 +++++++++++++++++++++ 4 files changed, 95 insertions(+), 8 deletions(-) create mode 100644 tests/test_django_decorator_typing.py create mode 100644 tests/typing_fixtures/django_decorator.py diff --git a/src/datastar_py/django.py b/src/datastar_py/django.py index d7809ed..bda7ea6 100644 --- a/src/datastar_py/django.py +++ b/src/datastar_py/django.py @@ -1,6 +1,6 @@ from __future__ import annotations -from collections.abc import AsyncIterable, Awaitable, Callable, Coroutine, Mapping +from collections.abc import Awaitable, Callable, Coroutine, Mapping from functools import wraps from inspect import isasyncgenfunction, isawaitable, iscoroutinefunction from typing import Any, ParamSpec, overload @@ -9,7 +9,14 @@ from django.http import StreamingHttpResponse as _StreamingHttpResponse from . import _read_signals -from .sse import SSE_HEADERS, DatastarEvent, DatastarEvents, ServerSentEventGenerator +from .sse import ( + SSE_HEADERS, + AsyncDatastarEvents, + DatastarEvent, + DatastarEvents, + ServerSentEventGenerator, + SyncDatastarEvents, +) __all__ = [ "SSE_HEADERS", @@ -46,19 +53,19 @@ def __init__( @overload def datastar_response( - func: Callable[P, Coroutine[Any, Any, DatastarEvents]], + func: Callable[P, Coroutine[Any, Any, SyncDatastarEvents]], ) -> Callable[P, Coroutine[Any, Any, DatastarResponse]]: ... @overload def datastar_response( - func: Callable[P, AsyncIterable[DatastarEvent]], + func: Callable[P, AsyncDatastarEvents], ) -> Callable[P, Coroutine[Any, Any, DatastarResponse]]: ... @overload def datastar_response( - func: Callable[P, DatastarEvents], + func: Callable[P, SyncDatastarEvents], ) -> Callable[P, DatastarResponse]: ... diff --git a/src/datastar_py/sse.py b/src/datastar_py/sse.py index 94124c2..ccf5081 100644 --- a/src/datastar_py/sse.py +++ b/src/datastar_py/sse.py @@ -32,9 +32,9 @@ class DatastarEvent(str): # 0..N datastar events -DatastarEvents: TypeAlias = ( - DatastarEvent | Iterable[DatastarEvent] | AsyncIterable[DatastarEvent] | None -) +SyncDatastarEvents: TypeAlias = DatastarEvent | Iterable[DatastarEvent] | None +AsyncDatastarEvents: TypeAlias = AsyncIterable[DatastarEvent] +DatastarEvents: TypeAlias = SyncDatastarEvents | AsyncDatastarEvents class ServerSentEventGenerator: diff --git a/tests/test_django_decorator_typing.py b/tests/test_django_decorator_typing.py new file mode 100644 index 0000000..96cbc5a --- /dev/null +++ b/tests/test_django_decorator_typing.py @@ -0,0 +1,41 @@ +"""Static typing regression tests for Django datastar_response overloads.""" + +from __future__ import annotations + +import importlib.util +import subprocess +import sys +from pathlib import Path + +import pytest + +FIXTURE_PATH = Path(__file__).parent / "typing_fixtures" / "django_decorator.py" + + +@pytest.mark.skipif(importlib.util.find_spec("mypy") is None, reason="mypy not installed") +@pytest.mark.skipif(importlib.util.find_spec("django") is None, reason="django not installed") +def test_django_datastar_response_mypy_overloads() -> None: + result = subprocess.run( + [ + sys.executable, + "-m", + "mypy", + "--hide-error-context", + "--no-error-summary", + str(FIXTURE_PATH), + ], + capture_output=True, + text=True, + check=False, + ) + output = result.stdout + result.stderr + + assert result.returncode == 0, output + assert ( + 'Revealed type is "def (request: django.http.request.HttpRequest) ' + '-> datastar_py.django.DatastarResponse"' + ) in output + assert output.count( + 'Revealed type is "def (request: django.http.request.HttpRequest) ' + '-> typing.Coroutine[Any, Any, datastar_py.django.DatastarResponse]"' + ) == 2 diff --git a/tests/typing_fixtures/django_decorator.py b/tests/typing_fixtures/django_decorator.py new file mode 100644 index 0000000..16e66ab --- /dev/null +++ b/tests/typing_fixtures/django_decorator.py @@ -0,0 +1,39 @@ +from collections.abc import Callable, Coroutine +from typing import Any, reveal_type + +from django.http import HttpRequest +from django.urls import path + +from datastar_py.django import DatastarResponse, datastar_response +from datastar_py.sse import AsyncDatastarEvents, SyncDatastarEvents +from datastar_py.sse import ServerSentEventGenerator as SSE + + +@datastar_response +def sync_view(request: HttpRequest) -> SyncDatastarEvents: + return SSE.patch_elements('
') + + +@datastar_response +async def async_coro_view(request: HttpRequest) -> SyncDatastarEvents: + return SSE.patch_elements('
') + + +@datastar_response +async def async_gen_view(request: HttpRequest) -> AsyncDatastarEvents: + yield SSE.patch_elements('
') + + +sync_typed: Callable[[HttpRequest], DatastarResponse] = sync_view +async_coro_typed: Callable[[HttpRequest], Coroutine[Any, Any, DatastarResponse]] = async_coro_view +async_gen_typed: Callable[[HttpRequest], Coroutine[Any, Any, DatastarResponse]] = async_gen_view + +reveal_type(sync_view) +reveal_type(async_coro_view) +reveal_type(async_gen_view) + +urlpatterns = [ + path("sync/", sync_view), + path("coro/", async_coro_view), + path("gen/", async_gen_view), +]