From d5c1bfb12a0e8e29df4b7ac5bc4df62fa845db4c Mon Sep 17 00:00:00 2001 From: Anton Agestam Date: Fri, 29 May 2020 23:24:47 +0200 Subject: [PATCH] Increase accuracy of ModelAdmin types (#375) * Increase accuracy of ModelAdmin types * Add comment for regression test --- django-stubs/contrib/admin/options.pyi | 86 +++++++----- .../typecheck/contrib/admin/test_options.yml | 126 ++++++++++++++++++ 2 files changed, 179 insertions(+), 33 deletions(-) create mode 100644 test-data/typecheck/contrib/admin/test_options.yml diff --git a/django-stubs/contrib/admin/options.pyi b/django-stubs/contrib/admin/options.pyi index 879e8e2..6392897 100644 --- a/django-stubs/contrib/admin/options.pyi +++ b/django-stubs/contrib/admin/options.pyi @@ -1,5 +1,9 @@ from collections import OrderedDict -from typing import Any, Callable, Dict, Iterator, List, Optional, Sequence, Set, Tuple, Type, Union +from typing import Any, Callable, Dict, Iterator, List, Optional, Sequence, Set, Tuple, Type, Union, Mapping, TypeVar + +from django.forms.forms import BaseForm +from django.forms.formsets import BaseFormSet +from typing_extensions import Literal, TypedDict from django.contrib.admin.filters import ListFilter from django.contrib.admin.models import LogEntry @@ -26,8 +30,10 @@ from django.db.models.fields import Field IS_POPUP_VAR: str TO_FIELD_VAR: str -HORIZONTAL: Any -VERTICAL: Any +HORIZONTAL: Literal[1] = ... +VERTICAL: Literal[2] = ... + +_Direction = Union[Literal[1], Literal[2]] def get_content_type_for_model(obj: Union[Type[Model], Model]) -> ContentType: ... def get_ul_class(radio_style: int) -> str: ... @@ -37,21 +43,35 @@ class IncorrectLookupParameters(Exception): ... FORMFIELD_FOR_DBFIELD_DEFAULTS: Any csrf_protect_m: Any +class _OptionalFieldOpts(TypedDict, total=False): + classes: Sequence[str] + description: str + +class _FieldOpts(_OptionalFieldOpts, total=True): + fields: Sequence[Union[str, Sequence[str]]] + +# Workaround for mypy issue, a Sequence type should be preferred here. +# https://github.com/python/mypy/issues/8921 +# _FieldsetSpec = Sequence[Tuple[Optional[str], _FieldOpts]] +_T = TypeVar("_T") +_ListOrTuple = Union[Tuple[_T, ...], List[_T]] +_FieldsetSpec = _ListOrTuple[Tuple[Optional[str], _FieldOpts]] + class BaseModelAdmin: - autocomplete_fields: Any = ... - raw_id_fields: Any = ... - fields: Any = ... - exclude: Any = ... - fieldsets: Any = ... - form: Any = ... - filter_vertical: Any = ... - filter_horizontal: Any = ... - radio_fields: Any = ... - prepopulated_fields: Any = ... - formfield_overrides: Any = ... - readonly_fields: Any = ... - ordering: Any = ... - sortable_by: Any = ... + autocomplete_fields: Sequence[str] = ... + raw_id_fields: Sequence[str] = ... + fields: Sequence[Union[str, Sequence[str]]] = ... + exclude: Sequence[str] = ... + fieldsets: _FieldsetSpec = ... + form: Type[BaseForm] = ... + filter_vertical: Sequence[str] = ... + filter_horizontal: Sequence[str] = ... + radio_fields: Mapping[str, _Direction] = ... + prepopulated_fields: Mapping[str, Sequence[str]] = ... + formfield_overrides: Mapping[Type[Field], Mapping[str, Any]] = ... + readonly_fields: Sequence[Union[str, Callable[[Model], Any]]] = ... + ordering: Sequence[str] = ... + sortable_by: Sequence[str] = ... view_on_site: bool = ... show_full_result_count: bool = ... checks_class: Any = ... @@ -93,7 +113,7 @@ class BaseModelAdmin: def has_module_permission(self, request: HttpRequest) -> bool: ... class ModelAdmin(BaseModelAdmin): - list_display: Sequence[Union[str, Callable]] = ... + list_display: Sequence[Union[str, Callable[[Model], Any]]] = ... list_display_links: Optional[Sequence[Union[str, Callable]]] = ... list_filter: Sequence[Union[str, Type[ListFilter], Tuple[str, Type[ListFilter]]]] = ... list_select_related: Union[bool, Sequence[str]] = ... @@ -101,21 +121,21 @@ class ModelAdmin(BaseModelAdmin): list_max_show_all: int = ... list_editable: Sequence[str] = ... search_fields: Sequence[str] = ... - date_hierarchy: Optional[Any] = ... + date_hierarchy: Optional[str] = ... save_as: bool = ... save_as_continue: bool = ... save_on_top: bool = ... - paginator: Any = ... + paginator: Type = ... preserve_filters: bool = ... inlines: Sequence[Type[InlineModelAdmin]] = ... - add_form_template: Any = ... - change_form_template: Any = ... - change_list_template: Any = ... - delete_confirmation_template: Any = ... - delete_selected_confirmation_template: Any = ... - object_history_template: Any = ... - popup_response_template: Any = ... - actions: Any = ... + add_form_template: str = ... + change_form_template: str = ... + change_list_template: str = ... + delete_confirmation_template: str = ... + delete_selected_confirmation_template: str = ... + object_history_template: str = ... + popup_response_template: str = ... + actions: Sequence[Callable[[ModelAdmin, HttpRequest, QuerySet], None]] = ... action_form: Any = ... actions_on_top: bool = ... actions_on_bottom: bool = ... @@ -227,9 +247,9 @@ class ModelAdmin(BaseModelAdmin): def history_view(self, request: HttpRequest, object_id: str, extra_context: None = ...) -> HttpResponse: ... class InlineModelAdmin(BaseModelAdmin): - model: Any = ... - fk_name: Any = ... - formset: Any = ... + model: Type[Model] = ... + fk_name: str = ... + formset: BaseFormSet = ... extra: int = ... min_num: Optional[int] = ... max_num: Optional[int] = ... @@ -238,8 +258,8 @@ class InlineModelAdmin(BaseModelAdmin): verbose_name_plural: Optional[str] = ... can_delete: bool = ... show_change_link: bool = ... - classes: Any = ... - admin_site: Any = ... + classes: Optional[Sequence[str]] = ... + admin_site: AdminSite = ... parent_model: Any = ... opts: Any = ... has_registered_model: Any = ... diff --git a/test-data/typecheck/contrib/admin/test_options.yml b/test-data/typecheck/contrib/admin/test_options.yml new file mode 100644 index 0000000..ebcc3fe --- /dev/null +++ b/test-data/typecheck/contrib/admin/test_options.yml @@ -0,0 +1,126 @@ +# "Happy path" test for model admin, trying to cover as many valid +# configurations as possible. +- case: test_full_admin + main: | + from django.contrib import admin + from django.forms import Form, Textarea + from django.db import models + from django.core.paginator import Paginator + from django.contrib.admin.sites import AdminSite + from django.db.models.options import Options + from django.http.request import HttpRequest + from django.db.models.query import QuerySet + + def an_action(modeladmin: admin.ModelAdmin, request: HttpRequest, queryset: QuerySet) -> None: + pass + + class A(admin.ModelAdmin): + # BaseModelAdmin + autocomplete_fields = ("strs",) + raw_id_fields = ["strs"] + fields = ( + "a field", + ["a", "list of", "fields"], + ) + exclude = ("a", "b") + fieldsets = [ + (None, {"fields": ["a", "b"]}), + ("group", {"fields": ("c",), "classes": ("a",), "description": "foo"}), + ] + form = Form + filter_vertical = ("fields",) + filter_horizontal = ("plenty", "of", "fields") + radio_fields = { + "some_field": admin.VERTICAL, + "another_field": admin.HORIZONTAL, + } + prepopulated_fields = {"slug": ("title",)} + formfield_overrides = {models.TextField: {"widget": Textarea}} + readonly_fields = ("date_modified",) + ordering = ("-pk", "date_modified") + sortable_by = ["pk"] + view_on_site = True + show_full_result_count = False + + # ModelAdmin + list_display = ("pk",) + list_display_links = ("str",) + list_filter = ("str", admin.SimpleListFilter, ("str", admin.SimpleListFilter)) + list_select_related = True + list_per_page = 1 + list_max_show_all = 2 + list_editable = ("a", "b") + search_fields = ("c", "d") + date_hirearchy = "f" + save_as = False + save_as_continue = True + save_on_top = False + paginator = Paginator + presserve_filters = False + inlines = (admin.TabularInline, admin.StackedInline) + add_form_template = "template" + change_form_template = "template" + change_list_template = "template" + delete_confirmation_template = "template" + delete_selected_confirmation_template = "template" + object_history_template = "template" + popup_response_template = "template" + actions = (an_action,) + actions_on_top = True + actions_on_bottom = False + actions_selection_counter = True + admin_site = AdminSite() +# This test is here to make sure we're not running into a mypy issue which is +# worked around using a somewhat complicated _ListOrTuple union type. Once the +# issue is solved upstream this test should pass even with the workaround +# replaced by a simpler Sequence type. +# https://github.com/python/mypy/issues/8921 +- case: test_fieldset_workaround_regression + main: | + from django.contrib import admin + + class A(admin.ModelAdmin): + fieldsets = ( + (None, { + 'fields': ('name',), + }), + ) +- case: errors_on_omitting_fields_from_fieldset_opts + main: | + from django.contrib import admin + + class A(admin.ModelAdmin): + fieldsets = [ # type: ignore + (None, {}), # E: Key 'fields' missing for TypedDict "_FieldOpts" + ] +- case: errors_on_invalid_radio_fields + main: | + from django.contrib import admin + + class A(admin.ModelAdmin): + radio_fields = {"some_field": 0} # E: Dict entry 0 has incompatible type "str": "Literal[0]"; expected "str": "Union[Literal[1], Literal[2]]" + + class B(admin.ModelAdmin): + radio_fields = {1: admin.VERTICAL} # E: Dict entry 0 has incompatible type "int": "Literal[2]"; expected "str": "Union[Literal[1], Literal[2]]" +- case: errors_for_invalid_formfield_overrides + main: | + from django.contrib import admin + from django.forms import Textarea + + class A(admin.ModelAdmin): + formfield_overrides = { + "not a field": { # E: Dict entry 0 has incompatible type "str": "Dict[str, Any]"; expected "Type[Field[Any, Any]]": "Mapping[str, Any]" + "widget": Textarea + } + } +- case: errors_for_invalid_action_signature + main: | + from django.contrib import admin + from django.http.request import HttpRequest + from django.db.models.query import QuerySet + + def an_action(modeladmin: None) -> None: + pass + + class A(admin.ModelAdmin): + actions = [an_action] # E: List item 0 has incompatible type "Callable[[None], None]"; expected "Callable[[ModelAdmin, HttpRequest, QuerySet[Any]], None]"