Infer type for Form.fields to a TypedDict - #3534
randspace0 wants to merge 3 commits into
Conversation
sobolevn
left a comment
There was a problem hiding this comment.
Thanks! Looks interesting :)
| name = forms.EmailField() # override parent field with a different type | ||
|
|
||
| form = MyForm() | ||
| reveal_type(form.fields["age"]) # N: Revealed type is "django.forms.fields.IntegerField" |
There was a problem hiding this comment.
please, assert the type of form.fields instead :)
| # dynamic reassignment of the whole dict is still allowed | ||
| form.fields = {} | ||
|
|
||
| # a plain, undeclared Form still falls back to the default 'dict[str, Field]' typing |
There was a problem hiding this comment.
Can you please add a comment to the implementation: why is that? Some reasoning would be nice to have for the history
| from typing_extensions import reveal_type | ||
|
|
||
| class MyForm(forms.Form): | ||
| brands = forms.ModelChoiceField(required=True, queryset=None) # type: ignore[var-annotated] |
There was a problem hiding this comment.
Why do we have a type ignore here?
UnknownPlatypus
left a comment
There was a problem hiding this comment.
Thanks for starting work on this. This will be a very cool and much needed feature.
I left some comments for things that could be improved.
I would also like that we check in more detail how we do similar stuff for django Model._meta.get_fields() because it feels a bit similar
|
|
||
|
|
||
| BASEFORM_CLASS_FULLNAME: Final = "django.forms.forms.BaseForm" | ||
| FORM_FIELD_FULLNAME: Final = "django.forms.fields.Field" |
There was a problem hiding this comment.
| FORM_FIELD_FULLNAME: Final = "django.forms.fields.Field" | |
| FORM_FIELD_CLASS_FULLNAME: Final = "django.forms.fields.Field" |
To match the other names pattern
|
|
||
|
|
||
| def transform_form_fields_attr_type(ctx: AttributeContext) -> MypyType: | ||
| """Create type `form.fields` as a TypedDict of the form's declared fields, instead of `dict[str, Field]`.""" |
There was a problem hiding this comment.
Is a typedict a good idea ? I see a few potential issues with that (might need some test case to validate)
for f in self.fields.values(): f.widget.attrs[...] = ...will raise "object" has no attribute "widget" because TypedDict is Mapping[str, object]del self.fields["age"]orself.fields.pop("name", None)will raiseTypedDict key "age" cannot be deletedself.fields[k] = ...->TypedDict key must be a string literal [literal-required]
I don't know if it's possible to have a loose typedict, maybe with overloads ?
There was a problem hiding this comment.
Right. I'm thinking to just extend the dict type for flexibility. So we can just type the statically analyzed field and keep the dynamic field untyped.
Something like
class MyForm(BaseForm):
age = forms.IntegerField()
name = forms.EmailField()
class FieldsDict(dict[str, Field]): # Created on the fly
@overload
def __getitem__(self, key: Literal["age"]) -> IntegerField: ...
@overload
def __getitem__(self, key: Literal["name"]) -> EmailField: ...
@overload
def __getitem__(self, key: str) -> Field: ...
fields: FieldsDict # Created on the flyThere was a problem hiding this comment.
yes maybe, probably need to start with a few test from my previous comment to see if it works
| if not isinstance(object_type, Instance): | ||
| return ctx.default_attr_type | ||
|
|
||
| field_types: dict[str, MypyType] = {} |
There was a problem hiding this comment.
I think we will miss the fields that are implicitly declared for exemple when using a ModelForm ?
class ArticleForm(forms.ModelForm):
slug = forms.SlugField(required=False)
class Meta:
model = Article
fields = ["name", "slug"]
f.fields["name"] # E: "name" is not a valid TypedDict key; expected one of ("slug")There was a problem hiding this comment.
For ModelForm we could use django's fields_for_model(model, fields, exclude) using django_context but that might be better to redo it manually here to avoid more coupling with the django runtime ?
We would like to be less coupled at some point. See #3376
| seen: set[str] = set() | ||
| for base_info in info.mro: | ||
| for stmt in base_info.defn.defs.body: | ||
| if isinstance(stmt, AssignmentStmt) and len(stmt.lvalues) == 1 and isinstance(stmt.lvalues[0], NameExpr): |
There was a problem hiding this comment.
I think this will register annotation-only attrs as fields, for ex other: forms.IntegerField but they won't be actual fields.
| def _iter_declared_field_names(info: TypeInfo) -> Iterator[str]: | ||
| """Yield each class-body-assigned attribute name once, starts from the most-derived class.""" | ||
| seen: set[str] = set() | ||
| for base_info in info.mro: |
There was a problem hiding this comment.
We need to skip class in the mro if they don't have a form base. See https://github.com/django/django/blob/8362cdc7efc9c881fdba48ce19e89b9194de7c46/django/forms/forms.py#L38
| if info.has_base(fullnames.BASEFORM_CLASS_FULLNAME) and attr_name == "fields": | ||
| return forms.transform_form_fields_attr_type |
There was a problem hiding this comment.
This might become a bit expensive since we recompute on every fields access. Maybe it's fine, people don't do that too often ?
Otherwise an option would be to do like model._meta.get_fields() by registering the result at the module scope so we can do simpel lookup to get it later. see get_or_create_annotated_type
This should solve the related issue. I have implemented what I have describe in the issue description.
Related issues
Fixes #1514
AI Policy