Skip to content

Commit 0b8b124

Browse files
Generic forms.ModelChoiceField (#1889)
* Add failing test * Add generic types to ModelMultipleChoiceField * Add generic monkey patch entry
1 parent 34522da commit 0b8b124

File tree

3 files changed

+53
-11
lines changed

3 files changed

+53
-11
lines changed

django-stubs/forms/models.pyi

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -251,20 +251,20 @@ class ModelChoiceIterator:
251251
def __bool__(self) -> bool: ...
252252
def choice(self, obj: Model) -> tuple[ModelChoiceIteratorValue, str]: ...
253253

254-
class ModelChoiceField(ChoiceField):
254+
class ModelChoiceField(ChoiceField, Generic[_M]):
255255
disabled: bool
256256
help_text: _StrOrPromise
257257
required: bool
258258
show_hidden_initial: bool
259259
validators: list[Any]
260260
iterator: type[ModelChoiceIterator]
261261
empty_label: _StrOrPromise | None
262-
queryset: QuerySet[models.Model] | None
262+
queryset: QuerySet[_M] | None
263263
limit_choices_to: _AllLimitChoicesTo | None
264264
to_field_name: str | None
265265
def __init__(
266266
self,
267-
queryset: None | Manager[models.Model] | QuerySet[models.Model],
267+
queryset: Manager[_M] | QuerySet[_M] | None,
268268
*,
269269
empty_label: _StrOrPromise | None = ...,
270270
required: bool = ...,
@@ -278,27 +278,27 @@ class ModelChoiceField(ChoiceField):
278278
**kwargs: Any,
279279
) -> None: ...
280280
def get_limit_choices_to(self) -> _LimitChoicesTo: ...
281-
def label_from_instance(self, obj: Model) -> str: ...
281+
def label_from_instance(self, obj: _M) -> str: ...
282282
choices: _PropertyDescriptor[
283283
_FieldChoices | _ChoicesCallable | CallableChoiceIterator,
284284
_FieldChoices | CallableChoiceIterator | ModelChoiceIterator,
285285
]
286286
def prepare_value(self, value: Any) -> Any: ...
287-
def to_python(self, value: Any | None) -> Model | None: ...
288-
def validate(self, value: Model | None) -> None: ...
287+
def to_python(self, value: Any | None) -> _M | None: ...
288+
def validate(self, value: _M | None) -> None: ...
289289
def has_changed(self, initial: Model | int | str | UUID | None, data: int | str | None) -> bool: ...
290290

291-
class ModelMultipleChoiceField(ModelChoiceField):
291+
class ModelMultipleChoiceField(ModelChoiceField[_M]):
292292
disabled: bool
293293
empty_label: _StrOrPromise | None
294294
help_text: _StrOrPromise
295295
required: bool
296296
show_hidden_initial: bool
297297
widget: _ClassLevelWidgetT
298298
hidden_widget: type[Widget]
299-
def __init__(self, queryset: None | Manager[Model] | QuerySet[Model], **kwargs: Any) -> None: ...
300-
def to_python(self, value: Any) -> list[Model]: ... # type: ignore[override]
301-
def clean(self, value: Any) -> QuerySet[Model]: ...
299+
def __init__(self, queryset: Manager[_M] | QuerySet[_M] | None, **kwargs: Any) -> None: ...
300+
def to_python(self, value: Any) -> list[_M]: ... # type: ignore[override]
301+
def clean(self, value: Any) -> QuerySet[_M]: ...
302302
def prepare_value(self, value: Any) -> Any: ...
303303
def has_changed(self, initial: Collection[Any] | None, data: Collection[Any] | None) -> bool: ... # type: ignore[override]
304304

ext/django_stubs_ext/patch.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
from django.db.models.manager import BaseManager
1717
from django.db.models.query import QuerySet
1818
from django.forms.formsets import BaseFormSet
19-
from django.forms.models import BaseModelForm, BaseModelFormSet
19+
from django.forms.models import BaseModelForm, BaseModelFormSet, ModelChoiceField
2020
from django.utils.connection import BaseConnectionHandler
2121
from django.views.generic.detail import SingleObjectMixin
2222
from django.views.generic.edit import DeletionMixin, FormMixin
@@ -63,6 +63,7 @@ def __repr__(self) -> str:
6363
MPGeneric(BaseFormSet),
6464
MPGeneric(BaseModelForm),
6565
MPGeneric(BaseModelFormSet),
66+
MPGeneric(ModelChoiceField),
6667
MPGeneric(Feed),
6768
MPGeneric(Sitemap),
6869
MPGeneric(SuccessMessageMixin),

tests/typecheck/test_forms.yml

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,3 +71,44 @@
7171
7272
class SuccessMessageFirstView(FormMixin, SuccessMessageMixin):
7373
pass
74+
75+
- case: generic_modelchoicefield_label_from_instance
76+
main: |
77+
from django import forms
78+
from myapp.models import Article, Category
79+
80+
class ArticleChoiceField(forms.ModelChoiceField[Article]):
81+
def label_from_instance(self, obj: Article) -> str:
82+
return obj.name
83+
84+
class BrokenArticleChoiceField(forms.ModelChoiceField[Article]):
85+
def label_from_instance(self, obj: Article) -> str:
86+
return obj.title # E: "Article" has no attribute "title" [attr-defined]
87+
88+
class ArticleMultipleChoiceField(forms.ModelMultipleChoiceField[Article]):
89+
def label_from_instance(self, obj: Article) -> str:
90+
return obj.name
91+
92+
class ChooseArticleForm(forms.Form):
93+
articles = ArticleMultipleChoiceField(
94+
queryset=Article.objects.none(),
95+
)
96+
best_article = ArticleChoiceField(
97+
queryset=Article.objects.none(),
98+
)
99+
best_category = ArticleChoiceField(
100+
queryset=Category.objects.none(), # E: Argument "queryset" to "ArticleChoiceField" has incompatible type "_QuerySet[Category, Category]"; expected "Union[Manager[Article], _QuerySet[Article, Article], None]" [arg-type]
101+
)
102+
installed_apps:
103+
- myapp
104+
files:
105+
- path: myapp/__init__.py
106+
- path: myapp/models.py
107+
content: |
108+
from django.db import models
109+
110+
class Category(models.Model):
111+
title = models.CharField(max_length=128)
112+
113+
class Article(models.Model):
114+
name = models.CharField(max_length=128)

0 commit comments

Comments
 (0)