From 3d7a572b1fa0f3f7af59ce35f7e53ed867b84743 Mon Sep 17 00:00:00 2001 From: Viicos <65306057+Viicos@users.noreply.github.com> Date: Wed, 15 May 2024 18:35:45 +0200 Subject: [PATCH 1/6] Improve typing of view decorators A new type variable is defined, matching (async) callables taking a request and returning a response. Some decorators are defined as views to comply with pyright --- django-stubs/views/decorators/__init__.pyi | 5 +++++ django-stubs/views/decorators/cache.pyi | 10 ++++----- .../views/decorators/clickjacking.pyi | 11 ++++------ django-stubs/views/decorators/common.pyi | 7 ++---- django-stubs/views/decorators/csrf.pyi | 16 +++++--------- django-stubs/views/decorators/debug.pyi | 8 +++---- django-stubs/views/decorators/gzip.pyi | 7 ++---- django-stubs/views/decorators/http.pyi | 22 ++++++++----------- django-stubs/views/decorators/vary.pyi | 7 +++--- 9 files changed, 40 insertions(+), 53 deletions(-) diff --git a/django-stubs/views/decorators/__init__.pyi b/django-stubs/views/decorators/__init__.pyi index e69de29bb..32cfb3f99 100644 --- a/django-stubs/views/decorators/__init__.pyi +++ b/django-stubs/views/decorators/__init__.pyi @@ -0,0 +1,5 @@ +from typing import TypeVar + +from django.utils.deprecation import _AsyncGetResponseCallable, _GetResponseCallable + +_ViewFuncT = TypeVar("_ViewFuncT", bound=_AsyncGetResponseCallable | _GetResponseCallable) # noqa: PYI018 diff --git a/django-stubs/views/decorators/cache.pyi b/django-stubs/views/decorators/cache.pyi index cb6e5a087..76cfac741 100644 --- a/django-stubs/views/decorators/cache.pyi +++ b/django-stubs/views/decorators/cache.pyi @@ -1,10 +1,10 @@ from collections.abc import Callable -from typing import Any, TypeVar +from typing import Any -_F = TypeVar("_F", bound=Callable[..., Any]) +from . import _ViewFuncT def cache_page( timeout: float | None, *, cache: Any | None = ..., key_prefix: Any | None = ... -) -> Callable[[_F], _F]: ... -def cache_control(**kwargs: Any) -> Callable[[_F], _F]: ... -def never_cache(view_func: _F) -> _F: ... +) -> Callable[[_ViewFuncT], _ViewFuncT]: ... +def cache_control(**kwargs: Any) -> Callable[[_ViewFuncT], _ViewFuncT]: ... +def never_cache(view_func: _ViewFuncT, /) -> _ViewFuncT: ... diff --git a/django-stubs/views/decorators/clickjacking.pyi b/django-stubs/views/decorators/clickjacking.pyi index f0ba61c7e..b8ea31391 100644 --- a/django-stubs/views/decorators/clickjacking.pyi +++ b/django-stubs/views/decorators/clickjacking.pyi @@ -1,8 +1,5 @@ -from collections.abc import Callable -from typing import Any, TypeVar +from . import _ViewFuncT -_F = TypeVar("_F", bound=Callable[..., Any]) - -def xframe_options_deny(view_func: _F) -> _F: ... -def xframe_options_sameorigin(view_func: _F) -> _F: ... -def xframe_options_exempt(view_func: _F) -> _F: ... +def xframe_options_deny(view_func: _ViewFuncT, /) -> _ViewFuncT: ... +def xframe_options_sameorigin(view_func: _ViewFuncT, /) -> _ViewFuncT: ... +def xframe_options_exempt(view_func: _ViewFuncT, /) -> _ViewFuncT: ... diff --git a/django-stubs/views/decorators/common.pyi b/django-stubs/views/decorators/common.pyi index a129e01e7..c917c0292 100644 --- a/django-stubs/views/decorators/common.pyi +++ b/django-stubs/views/decorators/common.pyi @@ -1,6 +1,3 @@ -from collections.abc import Callable -from typing import Any, TypeVar +from . import _ViewFuncT -_C = TypeVar("_C", bound=Callable[..., Any]) - -def no_append_slash(view_func: _C) -> _C: ... +def no_append_slash(view_func: _ViewFuncT, /) -> _ViewFuncT: ... diff --git a/django-stubs/views/decorators/csrf.pyi b/django-stubs/views/decorators/csrf.pyi index a9e8b2bca..c3b9e368e 100644 --- a/django-stubs/views/decorators/csrf.pyi +++ b/django-stubs/views/decorators/csrf.pyi @@ -1,18 +1,14 @@ -from collections.abc import Callable -from typing import Any, TypeVar - from django.middleware.csrf import CsrfViewMiddleware -csrf_protect: Callable[[_F], _F] +from . import _ViewFuncT + +def csrf_protect(view_func: _ViewFuncT, /) -> _ViewFuncT: ... class _EnsureCsrfToken(CsrfViewMiddleware): ... -requires_csrf_token: Callable[[_F], _F] +def requires_csrf_token(view_func: _ViewFuncT, /) -> _ViewFuncT: ... class _EnsureCsrfCookie(CsrfViewMiddleware): ... -ensure_csrf_cookie: Callable[[_F], _F] - -_F = TypeVar("_F", bound=Callable[..., Any]) - -def csrf_exempt(view_func: _F) -> _F: ... +def ensure_csrf_cookie(view_func: _ViewFuncT, /) -> _ViewFuncT: ... +def csrf_exempt(view_func: _ViewFuncT, /) -> _ViewFuncT: ... diff --git a/django-stubs/views/decorators/debug.pyi b/django-stubs/views/decorators/debug.pyi index 240bd0957..7ee9d6aa0 100644 --- a/django-stubs/views/decorators/debug.pyi +++ b/django-stubs/views/decorators/debug.pyi @@ -1,9 +1,9 @@ from collections.abc import Callable -from typing import Any, Literal, TypeVar +from typing import Literal -_F = TypeVar("_F", bound=Callable[..., Any]) +from . import _ViewFuncT coroutine_functions_to_sensitive_variables: dict[int, Literal["__ALL__"] | tuple[str, ...]] -def sensitive_variables(*variables: str) -> Callable[[_F], _F]: ... -def sensitive_post_parameters(*parameters: str) -> Callable[[_F], _F]: ... +def sensitive_variables(*variables: str) -> Callable[[_ViewFuncT], _ViewFuncT]: ... +def sensitive_post_parameters(*parameters: str) -> Callable[[_ViewFuncT], _ViewFuncT]: ... diff --git a/django-stubs/views/decorators/gzip.pyi b/django-stubs/views/decorators/gzip.pyi index 9a9de9880..c7f939533 100644 --- a/django-stubs/views/decorators/gzip.pyi +++ b/django-stubs/views/decorators/gzip.pyi @@ -1,6 +1,3 @@ -from collections.abc import Callable -from typing import Any, TypeVar +from . import _ViewFuncT -_C = TypeVar("_C", bound=Callable[..., Any]) - -gzip_page: Callable[[_C], _C] +def gzip_page(view_func: _ViewFuncT, /) -> _ViewFuncT: ... diff --git a/django-stubs/views/decorators/http.pyi b/django-stubs/views/decorators/http.pyi index 9147bec1f..430962719 100644 --- a/django-stubs/views/decorators/http.pyi +++ b/django-stubs/views/decorators/http.pyi @@ -1,19 +1,15 @@ from collections.abc import Callable, Container from datetime import datetime -from typing import Any, TypeVar -_F = TypeVar("_F", bound=Callable[..., Any]) - -conditional_page: Callable[[_F], _F] - -def require_http_methods(request_method_list: Container[str]) -> Callable[[_F], _F]: ... - -require_GET: Callable[[_F], _F] -require_POST: Callable[[_F], _F] -require_safe: Callable[[_F], _F] +from . import _ViewFuncT +def conditional_page(view_func: _ViewFuncT, /) -> _ViewFuncT: ... +def require_http_methods(request_method_list: Container[str]) -> Callable[[_ViewFuncT], _ViewFuncT]: ... +def require_GET(func: _ViewFuncT, /) -> _ViewFuncT: ... +def require_POST(func: _ViewFuncT, /) -> _ViewFuncT: ... +def require_safe(func: _ViewFuncT, /) -> _ViewFuncT: ... def condition( etag_func: Callable[..., str | None] | None = ..., last_modified_func: Callable[..., datetime | None] | None = ... -) -> Callable[[_F], _F]: ... -def etag(etag_func: Callable[..., str | None]) -> Callable[[_F], _F]: ... -def last_modified(last_modified_func: Callable[..., datetime | None]) -> Callable[[_F], _F]: ... +) -> Callable[[_ViewFuncT], _ViewFuncT]: ... +def etag(etag_func: Callable[..., str | None]) -> Callable[[_ViewFuncT], _ViewFuncT]: ... +def last_modified(last_modified_func: Callable[..., datetime | None]) -> Callable[[_ViewFuncT], _ViewFuncT]: ... diff --git a/django-stubs/views/decorators/vary.pyi b/django-stubs/views/decorators/vary.pyi index 7c8b1d159..634001df7 100644 --- a/django-stubs/views/decorators/vary.pyi +++ b/django-stubs/views/decorators/vary.pyi @@ -1,7 +1,6 @@ from collections.abc import Callable -from typing import Any, TypeVar -_F = TypeVar("_F", bound=Callable[..., Any]) +from . import _ViewFuncT -def vary_on_headers(*headers: str) -> Callable[[_F], _F]: ... -def vary_on_cookie(func: _F) -> _F: ... +def vary_on_headers(*headers: str) -> Callable[[_ViewFuncT], _ViewFuncT]: ... +def vary_on_cookie(func: _ViewFuncT, /) -> _ViewFuncT: ... From a7827f9868e93650dce6f0691c8c67710ae87c45 Mon Sep 17 00:00:00 2001 From: Viicos <65306057+Viicos@users.noreply.github.com> Date: Wed, 15 May 2024 18:43:38 +0200 Subject: [PATCH 2/6] Use `str` for `key_prefix` --- django-stubs/views/decorators/cache.pyi | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/django-stubs/views/decorators/cache.pyi b/django-stubs/views/decorators/cache.pyi index 76cfac741..e9b2298d4 100644 --- a/django-stubs/views/decorators/cache.pyi +++ b/django-stubs/views/decorators/cache.pyi @@ -4,7 +4,7 @@ from typing import Any from . import _ViewFuncT def cache_page( - timeout: float | None, *, cache: Any | None = ..., key_prefix: Any | None = ... + timeout: float | None, *, cache: Any | None = ..., key_prefix: str | None = ... ) -> Callable[[_ViewFuncT], _ViewFuncT]: ... def cache_control(**kwargs: Any) -> Callable[[_ViewFuncT], _ViewFuncT]: ... def never_cache(view_func: _ViewFuncT, /) -> _ViewFuncT: ... From 40e0a36ca13da810cd4d960575b1d2cae250b6ec Mon Sep 17 00:00:00 2001 From: Viicos <65306057+Viicos@users.noreply.github.com> Date: Wed, 15 May 2024 18:52:05 +0200 Subject: [PATCH 3/6] Add test --- tests/typecheck/views/decorators/test_csrf.yml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 tests/typecheck/views/decorators/test_csrf.yml diff --git a/tests/typecheck/views/decorators/test_csrf.yml b/tests/typecheck/views/decorators/test_csrf.yml new file mode 100644 index 000000000..2a4dc936e --- /dev/null +++ b/tests/typecheck/views/decorators/test_csrf.yml @@ -0,0 +1,15 @@ +- case: view_decorator + main: | + from django.http.request import HttpRequest + from django.http.response import HttpResponse + from django.views.decorators.csrf import csrf_protect + + @csrf_protect + def good_view(request: HttpRequest) -> HttpResponse: + return HttpResponse() + + @csrf_protect + def bad_view(request: int) -> str: + return "" + out: | + main:9: error: Value of type variable "_ViewFuncT" of "csrf_protect" cannot be "Callable[[int], str]" [type-var] From a65d50da13fa5895479c43e965a9c959af98fc24 Mon Sep 17 00:00:00 2001 From: Viicos <65306057+Viicos@users.noreply.github.com> Date: Tue, 28 May 2024 19:52:11 +0200 Subject: [PATCH 4/6] Feedback --- django-stubs/views/decorators/__init__.pyi | 14 ++++++-- django-stubs/views/decorators/debug.pyi | 8 ++--- .../assert_type/views/decorators/test_csrf.py | 33 +++++++++++++++++++ .../typecheck/views/decorators/test_csrf.yml | 15 --------- 4 files changed, 49 insertions(+), 21 deletions(-) create mode 100644 tests/assert_type/views/decorators/test_csrf.py delete mode 100644 tests/typecheck/views/decorators/test_csrf.yml diff --git a/django-stubs/views/decorators/__init__.pyi b/django-stubs/views/decorators/__init__.pyi index 32cfb3f99..3b8e631b2 100644 --- a/django-stubs/views/decorators/__init__.pyi +++ b/django-stubs/views/decorators/__init__.pyi @@ -1,5 +1,15 @@ +from collections.abc import Awaitable, Callable from typing import TypeVar -from django.utils.deprecation import _AsyncGetResponseCallable, _GetResponseCallable +from django.http.request import HttpRequest +from django.http.response import HttpResponseBase +from typing_extensions import Concatenate -_ViewFuncT = TypeVar("_ViewFuncT", bound=_AsyncGetResponseCallable | _GetResponseCallable) # noqa: PYI018 +# Examples: +# def (request: HttpRequest, path_param: str) -> HttpResponseBase +# async def (request: HttpRequest) -> HttpResponseBase +_ViewFuncT = TypeVar( # noqa: PYI018 + "_ViewFuncT", + bound=Callable[Concatenate[HttpRequest, ...], HttpResponseBase] + | Callable[Concatenate[HttpRequest, ...], Awaitable[HttpResponseBase]], +) diff --git a/django-stubs/views/decorators/debug.pyi b/django-stubs/views/decorators/debug.pyi index 7ee9d6aa0..240bd0957 100644 --- a/django-stubs/views/decorators/debug.pyi +++ b/django-stubs/views/decorators/debug.pyi @@ -1,9 +1,9 @@ from collections.abc import Callable -from typing import Literal +from typing import Any, Literal, TypeVar -from . import _ViewFuncT +_F = TypeVar("_F", bound=Callable[..., Any]) coroutine_functions_to_sensitive_variables: dict[int, Literal["__ALL__"] | tuple[str, ...]] -def sensitive_variables(*variables: str) -> Callable[[_ViewFuncT], _ViewFuncT]: ... -def sensitive_post_parameters(*parameters: str) -> Callable[[_ViewFuncT], _ViewFuncT]: ... +def sensitive_variables(*variables: str) -> Callable[[_F], _F]: ... +def sensitive_post_parameters(*parameters: str) -> Callable[[_F], _F]: ... diff --git a/tests/assert_type/views/decorators/test_csrf.py b/tests/assert_type/views/decorators/test_csrf.py new file mode 100644 index 000000000..55811993f --- /dev/null +++ b/tests/assert_type/views/decorators/test_csrf.py @@ -0,0 +1,33 @@ +from django.http.request import HttpRequest +from django.http.response import HttpResponse +from django.views.decorators.csrf import csrf_protect + + +@csrf_protect +def good_view(request: HttpRequest) -> HttpResponse: + return HttpResponse() + + +@csrf_protect +async def good_async_view(request: HttpRequest) -> HttpResponse: + return HttpResponse() + + +@csrf_protect +def good_view_with_arguments(request: HttpRequest, other: int, args: str) -> HttpResponse: + return HttpResponse() + + +@csrf_protect +async def good_async_view_with_arguments(request: HttpRequest, other: int, args: str) -> HttpResponse: + return HttpResponse() + + +@csrf_protect # type: ignore # pyright: ignore +def bad_view(request: int) -> str: + return "" + + +@csrf_protect # type: ignore # pyright: ignore +def bad_view_no_arguments() -> HttpResponse: + return HttpResponse() diff --git a/tests/typecheck/views/decorators/test_csrf.yml b/tests/typecheck/views/decorators/test_csrf.yml deleted file mode 100644 index 2a4dc936e..000000000 --- a/tests/typecheck/views/decorators/test_csrf.yml +++ /dev/null @@ -1,15 +0,0 @@ -- case: view_decorator - main: | - from django.http.request import HttpRequest - from django.http.response import HttpResponse - from django.views.decorators.csrf import csrf_protect - - @csrf_protect - def good_view(request: HttpRequest) -> HttpResponse: - return HttpResponse() - - @csrf_protect - def bad_view(request: int) -> str: - return "" - out: | - main:9: error: Value of type variable "_ViewFuncT" of "csrf_protect" cannot be "Callable[[int], str]" [type-var] From 7b47826f14c322054461a9b4e23aa9d32dbfb5f5 Mon Sep 17 00:00:00 2001 From: Viicos <65306057+Viicos@users.noreply.github.com> Date: Thu, 30 May 2024 22:04:55 +0200 Subject: [PATCH 5/6] Use callback protocols, apply feedback --- django-stubs/views/decorators/__init__.pyi | 23 +++++++++---------- .../assert_type/views/decorators/test_csrf.py | 17 ++++++++++++-- 2 files changed, 26 insertions(+), 14 deletions(-) diff --git a/django-stubs/views/decorators/__init__.pyi b/django-stubs/views/decorators/__init__.pyi index 3b8e631b2..0f9d4b11d 100644 --- a/django-stubs/views/decorators/__init__.pyi +++ b/django-stubs/views/decorators/__init__.pyi @@ -1,15 +1,14 @@ -from collections.abc import Awaitable, Callable -from typing import TypeVar +from typing import Any, TypeVar, Protocol from django.http.request import HttpRequest from django.http.response import HttpResponseBase -from typing_extensions import Concatenate - -# Examples: -# def (request: HttpRequest, path_param: str) -> HttpResponseBase -# async def (request: HttpRequest) -> HttpResponseBase -_ViewFuncT = TypeVar( # noqa: PYI018 - "_ViewFuncT", - bound=Callable[Concatenate[HttpRequest, ...], HttpResponseBase] - | Callable[Concatenate[HttpRequest, ...], Awaitable[HttpResponseBase]], -) + +# `*args: Any, **kwargs: Any` means any extra argument(s) can be provided, or none. +class _View(Protocol): + def __call__(self, request: HttpRequest, /, *args: Any, **kwargs: Any) -> HttpResponseBase: ... + +class _AsyncView(Protocol): + async def __call__(self, request: HttpRequest, /, *args: Any, **kwargs: Any) -> HttpResponseBase: ... + + +_ViewFuncT = TypeVar("_ViewFuncT", bound=_View | _AsyncView) # noqa: PYI018 diff --git a/tests/assert_type/views/decorators/test_csrf.py b/tests/assert_type/views/decorators/test_csrf.py index 55811993f..242ef5a25 100644 --- a/tests/assert_type/views/decorators/test_csrf.py +++ b/tests/assert_type/views/decorators/test_csrf.py @@ -1,8 +1,21 @@ +from typing import Callable + from django.http.request import HttpRequest from django.http.response import HttpResponse from django.views.decorators.csrf import csrf_protect +from typing_extensions import assert_type + + +@csrf_protect +def good_view_positional(request: HttpRequest, /) -> HttpResponse: + return HttpResponse() + + +# `assert_type` can only be used when `request` is pos. only. +assert_type(good_view_positional, Callable[[HttpRequest], HttpResponse]) +# The decorator works too if `request` is not explicitly pos. only. @csrf_protect def good_view(request: HttpRequest) -> HttpResponse: return HttpResponse() @@ -23,11 +36,11 @@ async def good_async_view_with_arguments(request: HttpRequest, other: int, args: return HttpResponse() -@csrf_protect # type: ignore # pyright: ignore +@csrf_protect # type: ignore[type-var] # pyright: ignore[reportArgumentType, reportUntypedFunctionDecorator] def bad_view(request: int) -> str: return "" -@csrf_protect # type: ignore # pyright: ignore +@csrf_protect # type: ignore[type-var] # pyright: ignore[reportArgumentType, reportUntypedFunctionDecorator] def bad_view_no_arguments() -> HttpResponse: return HttpResponse() From 74e07c0ea98b1c4c46db59a416e82535cd6de0ac Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 30 May 2024 20:05:12 +0000 Subject: [PATCH 6/6] [pre-commit.ci] auto fixes from pre-commit.com hooks --- django-stubs/views/decorators/__init__.pyi | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/django-stubs/views/decorators/__init__.pyi b/django-stubs/views/decorators/__init__.pyi index 0f9d4b11d..52ca1a727 100644 --- a/django-stubs/views/decorators/__init__.pyi +++ b/django-stubs/views/decorators/__init__.pyi @@ -1,4 +1,4 @@ -from typing import Any, TypeVar, Protocol +from typing import Any, Protocol, TypeVar from django.http.request import HttpRequest from django.http.response import HttpResponseBase @@ -10,5 +10,4 @@ class _View(Protocol): class _AsyncView(Protocol): async def __call__(self, request: HttpRequest, /, *args: Any, **kwargs: Any) -> HttpResponseBase: ... - _ViewFuncT = TypeVar("_ViewFuncT", bound=_View | _AsyncView) # noqa: PYI018