From 59fd40dc06c4a918090ffc6e5183341c17bec928 Mon Sep 17 00:00:00 2001 From: Samuel Searles-Bryant Date: Mon, 13 Oct 2025 16:08:44 +0100 Subject: [PATCH 1/2] Make `View` generic over its response type This will allow subclasses of `View` to narrow the type of their responses. Co-authored-by: Charlie Denton --- django-stubs/views/generic/base.pyi | 10 ++++++---- ext/django_stubs_ext/patch.py | 2 ++ 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/django-stubs/views/generic/base.pyi b/django-stubs/views/generic/base.pyi index 464bfb05b..c637f8849 100644 --- a/django-stubs/views/generic/base.pyi +++ b/django-stubs/views/generic/base.pyi @@ -1,6 +1,6 @@ import logging from collections.abc import Callable, Mapping, Sequence -from typing import Any +from typing import Any, Generic, TypeVar from django.http.request import HttpRequest from django.http.response import HttpResponse, HttpResponseBase @@ -12,7 +12,9 @@ class ContextMixin: extra_context: Mapping[str, Any] | None def get_context_data(self, **kwargs: Any) -> dict[str, Any]: ... -class View: +_ViewResponse = TypeVar("_ViewResponse", bound=HttpResponseBase, default=HttpResponseBase) + +class View(Generic[_ViewResponse]): http_method_names: Sequence[str] request: HttpRequest args: Any @@ -20,9 +22,9 @@ class View: def __init__(self, **kwargs: Any) -> None: ... view_is_async: _Getter[bool] | bool @classmethod - def as_view(cls: Any, **initkwargs: Any) -> Callable[..., HttpResponseBase]: ... + def as_view(cls: Any, **initkwargs: Any) -> Callable[..., _ViewResponse]: ... def setup(self, request: HttpRequest, *args: Any, **kwargs: Any) -> None: ... - def dispatch(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponseBase: ... + def dispatch(self, request: HttpRequest, *args: Any, **kwargs: Any) -> _ViewResponse: ... def http_method_not_allowed(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: ... def options(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponseBase: ... diff --git a/ext/django_stubs_ext/patch.py b/ext/django_stubs_ext/patch.py index 7f2f482e8..3e3d758bb 100644 --- a/ext/django_stubs_ext/patch.py +++ b/ext/django_stubs_ext/patch.py @@ -27,6 +27,7 @@ from django.forms.models import BaseModelForm, BaseModelFormSet, ModelChoiceField, ModelFormOptions from django.utils.connection import BaseConnectionHandler, ConnectionProxy from django.utils.functional import classproperty +from django.views import View from django.views.generic.detail import SingleObjectMixin from django.views.generic.edit import DeletionMixin, FormMixin from django.views.generic.list import MultipleObjectMixin @@ -84,6 +85,7 @@ def __repr__(self) -> str: MPGeneric(ExpressionWrapper), MPGeneric(ReverseManyToOneDescriptor), MPGeneric(ModelIterable), + MPGeneric(View), # These types do have native `__class_getitem__` method since django 3.1: MPGeneric(QuerySet, (3, 1)), MPGeneric(BaseManager, (3, 1)), From 4f1f831f0a78dd41bb498457f455e1674c0812fa Mon Sep 17 00:00:00 2001 From: Samuel Searles-Bryant Date: Mon, 13 Oct 2025 16:19:56 +0100 Subject: [PATCH 2/2] Make `TemplateView` (and `TemplateResponseMixin`) generic over the response type This allows us to specify that the default response type for template views is a `TemplateResponse`. N.B. The `LogoutView` may return a response that is not a template response (i.e. a redirect), so we annotate it as returning a less specific type. We have not been more specific about exactly which types it returns because doing so might require it to also become generic to support subclasses that return different specific response types. Co-authored-by: Charlie Denton --- django-stubs/contrib/auth/views.pyi | 2 +- django-stubs/views/generic/base.pyi | 13 +++++++----- ext/django_stubs_ext/patch.py | 2 ++ .../typecheck/views/generic/test_template.yml | 20 +++++++++++++++++++ 4 files changed, 31 insertions(+), 6 deletions(-) diff --git a/django-stubs/contrib/auth/views.pyi b/django-stubs/contrib/auth/views.pyi index 8b45cf9bb..40163cf16 100644 --- a/django-stubs/contrib/auth/views.pyi +++ b/django-stubs/contrib/auth/views.pyi @@ -25,7 +25,7 @@ class LoginView(RedirectURLMixin, FormView[AuthenticationForm]): extra_context: Any def get_redirect_url(self) -> str: ... -class LogoutView(RedirectURLMixin, TemplateView): +class LogoutView(RedirectURLMixin, TemplateView[HttpResponse]): next_page: str | None redirect_field_name: str extra_context: Any diff --git a/django-stubs/views/generic/base.pyi b/django-stubs/views/generic/base.pyi index c637f8849..6a0176838 100644 --- a/django-stubs/views/generic/base.pyi +++ b/django-stubs/views/generic/base.pyi @@ -4,6 +4,7 @@ from typing import Any, Generic, TypeVar from django.http.request import HttpRequest from django.http.response import HttpResponse, HttpResponseBase +from django.template.response import TemplateResponse from django.utils.functional import _Getter logger: logging.Logger @@ -28,17 +29,19 @@ class View(Generic[_ViewResponse]): def http_method_not_allowed(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: ... def options(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponseBase: ... -class TemplateResponseMixin: +_TemplateResponse = TypeVar("_TemplateResponse", bound=HttpResponse, default=TemplateResponse) + +class TemplateResponseMixin(Generic[_TemplateResponse]): template_name: str | None template_engine: str | None - response_class: type[HttpResponse] + response_class: type[_TemplateResponse] content_type: str | None request: HttpRequest - def render_to_response(self, context: dict[str, Any], **response_kwargs: Any) -> HttpResponse: ... + def render_to_response(self, context: dict[str, Any], **response_kwargs: Any) -> _TemplateResponse: ... def get_template_names(self) -> list[str]: ... -class TemplateView(TemplateResponseMixin, ContextMixin, View): - def get(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: ... +class TemplateView(TemplateResponseMixin[_TemplateResponse], ContextMixin, View[_TemplateResponse]): + def get(self, request: HttpRequest, *args: Any, **kwargs: Any) -> _TemplateResponse: ... class RedirectView(View): permanent: bool diff --git a/ext/django_stubs_ext/patch.py b/ext/django_stubs_ext/patch.py index 3e3d758bb..1db452817 100644 --- a/ext/django_stubs_ext/patch.py +++ b/ext/django_stubs_ext/patch.py @@ -28,6 +28,7 @@ from django.utils.connection import BaseConnectionHandler, ConnectionProxy from django.utils.functional import classproperty from django.views import View +from django.views.generic.base import TemplateResponseMixin from django.views.generic.detail import SingleObjectMixin from django.views.generic.edit import DeletionMixin, FormMixin from django.views.generic.list import MultipleObjectMixin @@ -86,6 +87,7 @@ def __repr__(self) -> str: MPGeneric(ReverseManyToOneDescriptor), MPGeneric(ModelIterable), MPGeneric(View), + MPGeneric(TemplateResponseMixin), # These types do have native `__class_getitem__` method since django 3.1: MPGeneric(QuerySet, (3, 1)), MPGeneric(BaseManager, (3, 1)), diff --git a/tests/typecheck/views/generic/test_template.yml b/tests/typecheck/views/generic/test_template.yml index 41115f4e6..e9229f1b6 100644 --- a/tests/typecheck/views/generic/test_template.yml +++ b/tests/typecheck/views/generic/test_template.yml @@ -14,3 +14,23 @@ class MyTemplateView(TemplateView): template_name = "template.html" extra_context = MappingProxyType({}) + +- case: template_view_returns_template_response + main: | + from typing import reveal_type + + from django.views.generic import TemplateView + from django.test import RequestFactory + + + class MyView(TemplateView): + template_name = "template.html" + + + rf = RequestFactory() + request = rf.get("/") + view = MyView.as_view() + response = view(request) + + reveal_type(response) # N: Revealed type is "django.template.response.TemplateResponse" + reveal_type(response.rendered_content) # N: Revealed type is "builtins.str"