Skip to content

Commit 45364bb

Browse files
authored
Refactor support for annotate to utilise mypy.types.Instance.extra_attrs (#2319)
1 parent 7141d32 commit 45364bb

File tree

12 files changed

+259
-148
lines changed

12 files changed

+259
-148
lines changed

README.md

Lines changed: 5 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -211,11 +211,11 @@ class MyManager(models.Manager["MyModel"]):
211211

212212
### How do I annotate cases where I called QuerySet.annotate?
213213

214-
Django-stubs provides a special type, `django_stubs_ext.WithAnnotations[Model]`, which indicates that the `Model` has
215-
been annotated, meaning it allows getting/setting extra attributes on the model instance.
214+
Django-stubs provides a special type, `django_stubs_ext.WithAnnotations[Model, <Annotations>]`, which indicates that
215+
the `Model` has been annotated, meaning it requires extra attributes on the model instance.
216216

217-
Optionally, you can provide a `TypedDict` of these attributes,
218-
e.g. `WithAnnotations[MyModel, MyTypedDict]`, to specify which annotated attributes are present.
217+
You should provide a `TypedDict` of these attributes, e.g. `WithAnnotations[MyModel, MyTypedDict]`, to specify which
218+
annotated attributes are present.
219219

220220
Currently, the mypy plugin can recognize that specific names were passed to `QuerySet.annotate` and
221221
include them in the type, but does not record the types of these attributes.
@@ -235,21 +235,11 @@ class MyModel(models.Model):
235235
username = models.CharField(max_length=100)
236236

237237

238-
def func(m: WithAnnotations[MyModel]) -> str:
239-
return m.asdf # OK, since the model is annotated as allowing any attribute
240-
241-
242-
func(MyModel.objects.annotate(foo=Value("")).get(id=1)) # OK
243-
func(
244-
MyModel.objects.get(id=1)
245-
) # Error, since this model will not allow access to any attribute
246-
247-
248238
class MyTypedDict(TypedDict):
249239
foo: str
250240

251241

252-
def func2(m: WithAnnotations[MyModel, MyTypedDict]) -> str:
242+
def func(m: WithAnnotations[MyModel, MyTypedDict]) -> str:
253243
print(m.bar) # Error, since field "bar" is not in MyModel or MyTypedDict.
254244
return m.foo # OK, since we said field "foo" was allowed
255245

ext/django_stubs_ext/annotations.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ class Annotations(Generic[_Annotations]):
1717

1818
WithAnnotations = Annotated[_T, Annotations[_Annotations]]
1919
"""Alias to make it easy to annotate the model `_T` as having annotations
20-
`_Annotations` (a `TypedDict` or `Any` if not provided).
20+
`_Annotations` (a `TypedDict`).
2121
22-
Use as `WithAnnotations[MyModel]` or `WithAnnotations[MyModel, MyTypedDict]`.
22+
Use as `WithAnnotations[MyModel, MyTypedDict]`.
2323
"""

mypy_django_plugin/django/context.py

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,6 @@
3636

3737
from mypy_django_plugin.exceptions import UnregisteredModelError
3838
from mypy_django_plugin.lib import fullnames, helpers
39-
from mypy_django_plugin.lib.fullnames import WITH_ANNOTATIONS_FULLNAME
4039

4140
# This import fails when `psycopg2` is not installed, avoid crashing the plugin.
4241
try:
@@ -142,15 +141,6 @@ def model_modules(self) -> Dict[str, Dict[str, Type[Model]]]:
142141

143142
def get_model_class_by_fullname(self, fullname: str) -> Optional[Type[Model]]:
144143
"""Returns None if Model is abstract"""
145-
annotated_prefix = WITH_ANNOTATIONS_FULLNAME + "["
146-
if fullname.startswith(annotated_prefix):
147-
# For our "annotated models", extract the original model fullname
148-
fullname = fullname[len(annotated_prefix) :].rstrip("]")
149-
if "," in fullname:
150-
# Remove second type arg, which might be present
151-
fullname = fullname[: fullname.index(",")]
152-
fullname = fullname.replace("__", ".")
153-
154144
module, _, model_cls_name = fullname.rpartition(".")
155145
return self.model_modules.get(module, {}).get(model_cls_name)
156146

mypy_django_plugin/lib/fullnames.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
MANAGER_CLASS_FULLNAME = "django.db.models.manager.Manager"
2020
RELATED_MANAGER_CLASS = "django.db.models.fields.related_descriptors.RelatedManager"
2121

22-
WITH_ANNOTATIONS_FULLNAME = "django_stubs_ext.WithAnnotations"
22+
WITH_ANNOTATIONS_FULLNAME = "django_stubs_ext.annotations.WithAnnotations"
2323
ANNOTATIONS_FULLNAME = "django_stubs_ext.annotations.Annotations"
2424

2525
BASEFORM_CLASS_FULLNAME = "django.forms.forms.BaseForm"

mypy_django_plugin/lib/helpers.py

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -41,14 +41,14 @@
4141
from typing_extensions import TypedDict
4242

4343
from mypy_django_plugin.lib import fullnames
44-
from mypy_django_plugin.lib.fullnames import WITH_ANNOTATIONS_FULLNAME
4544

4645
if TYPE_CHECKING:
4746
from mypy_django_plugin.django.context import DjangoContext
4847

4948

5049
class DjangoTypeMetadata(TypedDict, total=False):
5150
is_abstract_model: bool
51+
is_annotated_model: bool
5252
from_queryset_manager: str
5353
reverse_managers: Dict[str, str]
5454
baseform_bases: Dict[str, int]
@@ -104,6 +104,14 @@ def get_manager_to_model(manager: TypeInfo) -> Optional[str]:
104104
return get_django_metadata(manager).get("manager_to_model")
105105

106106

107+
def mark_as_annotated_model(model: TypeInfo) -> None:
108+
get_django_metadata(model)["is_annotated_model"] = True
109+
110+
111+
def is_annotated_model(model: TypeInfo) -> bool:
112+
return get_django_metadata(model).get("is_annotated_model", False)
113+
114+
107115
class IncompleteDefnException(Exception):
108116
pass
109117

@@ -285,10 +293,6 @@ def get_nested_meta_node_for_current_class(info: TypeInfo) -> Optional[TypeInfo]
285293
return None
286294

287295

288-
def is_annotated_model_fullname(model_cls_fullname: str) -> bool:
289-
return model_cls_fullname.startswith(WITH_ANNOTATIONS_FULLNAME + "[")
290-
291-
292296
def create_type_info(name: str, module: str, bases: List[Instance]) -> TypeInfo:
293297
# make new class expression
294298
classdef = ClassDef(name, Block([]))
@@ -382,9 +386,12 @@ def convert_any_to_type(typ: MypyType, referred_to_type: MypyType) -> MypyType:
382386

383387

384388
def make_typeddict(
385-
api: CheckerPluginInterface, fields: "OrderedDict[str, MypyType]", required_keys: Set[str]
389+
api: Union[SemanticAnalyzer, CheckerPluginInterface], fields: Dict[str, MypyType], required_keys: Set[str]
386390
) -> TypedDictType:
387-
fallback_type = api.named_generic_type("typing._TypedDict", [])
391+
if isinstance(api, CheckerPluginInterface):
392+
fallback_type = api.named_generic_type("typing._TypedDict", [])
393+
else:
394+
fallback_type = api.named_type("typing._TypedDict", [])
388395
typed_dict_type = TypedDictType(fields, required_keys=required_keys, fallback=fallback_type)
389396
return typed_dict_type
390397

mypy_django_plugin/main.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -296,7 +296,7 @@ def get_type_analyze_hook(self, fullname: str) -> Optional[Callable[[AnalyzeType
296296
"typing_extensions.Annotated",
297297
"django_stubs_ext.annotations.WithAnnotations",
298298
):
299-
return partial(handle_annotated_type, django_context=self.django_context)
299+
return partial(handle_annotated_type, fullname=fullname)
300300
else:
301301
return None
302302

mypy_django_plugin/transformers/models.py

Lines changed: 103 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -22,19 +22,29 @@
2222
TypeInfo,
2323
Var,
2424
)
25-
from mypy.plugin import AnalyzeTypeContext, AttributeContext, CheckerPluginInterface, ClassDefContext
25+
from mypy.plugin import AnalyzeTypeContext, AttributeContext, ClassDefContext
2626
from mypy.plugins import common
2727
from mypy.semanal import SemanticAnalyzer
2828
from mypy.typeanal import TypeAnalyser
29-
from mypy.types import AnyType, Instance, ProperType, TypedDictType, TypeOfAny, TypeType, TypeVarType, get_proper_type
29+
from mypy.types import (
30+
AnyType,
31+
ExtraAttrs,
32+
Instance,
33+
ProperType,
34+
TypedDictType,
35+
TypeOfAny,
36+
TypeType,
37+
TypeVarType,
38+
get_proper_type,
39+
)
3040
from mypy.types import Type as MypyType
31-
from mypy.typevars import fill_typevars
41+
from mypy.typevars import fill_typevars, fill_typevars_with_any
3242

3343
from mypy_django_plugin.django.context import DjangoContext
3444
from mypy_django_plugin.errorcodes import MANAGER_MISSING
3545
from mypy_django_plugin.exceptions import UnregisteredModelError
3646
from mypy_django_plugin.lib import fullnames, helpers
37-
from mypy_django_plugin.lib.fullnames import ANNOTATIONS_FULLNAME, ANY_ATTR_ALLOWED_CLASS_FULLNAME, MODEL_CLASS_FULLNAME
47+
from mypy_django_plugin.lib.fullnames import ANNOTATIONS_FULLNAME, MODEL_CLASS_FULLNAME
3848
from mypy_django_plugin.transformers.fields import FieldDescriptorTypes, get_field_descriptor_types
3949
from mypy_django_plugin.transformers.managers import (
4050
MANAGER_METHODS_RETURNING_QUERYSET,
@@ -200,6 +210,50 @@ def run_with_model_cls(self, model_cls: Type[Model]) -> None:
200210
raise NotImplementedError(f"Implement this in subclass {self.__class__.__name__}")
201211

202212

213+
class AddAnnotateUtilities(ModelClassInitializer):
214+
"""
215+
Creates a model subclass that will be used when the model's manager/queryset calls
216+
'annotate' to hold on to attributes that Django adds to a model instance.
217+
218+
Example:
219+
220+
class MyModel(models.Model):
221+
...
222+
223+
class MyModel@AnnotatedWith(MyModel, django_stubs_ext.Annotations[_Annotations]):
224+
...
225+
"""
226+
227+
def run(self) -> None:
228+
annotations = self.lookup_typeinfo_or_incomplete_defn_error("django_stubs_ext.Annotations")
229+
object_does_not_exist = self.lookup_typeinfo_or_incomplete_defn_error(fullnames.OBJECT_DOES_NOT_EXIST)
230+
multiple_objects_returned = self.lookup_typeinfo_or_incomplete_defn_error(fullnames.MULTIPLE_OBJECTS_RETURNED)
231+
annotated_model_name = self.model_classdef.info.name + "@AnnotatedWith"
232+
annotated_model = self.lookup_typeinfo(self.model_classdef.info.module_name + "." + annotated_model_name)
233+
if annotated_model is None:
234+
model_type = fill_typevars_with_any(self.model_classdef.info)
235+
assert isinstance(model_type, Instance)
236+
annotations_type = fill_typevars(annotations)
237+
assert isinstance(annotations_type, Instance)
238+
annotated_model = self.add_new_class_for_current_module(
239+
annotated_model_name, bases=[model_type, annotations_type]
240+
)
241+
annotated_model.defn.type_vars = annotations.defn.type_vars
242+
annotated_model.add_type_vars()
243+
helpers.mark_as_annotated_model(annotated_model)
244+
if self.is_model_abstract:
245+
# Below are abstract attributes, and in a stub file mypy requires
246+
# explicit ABCMeta if not all abstract attributes are implemented i.e.
247+
# class is kept abstract. So we add the attributes to get mypy off our
248+
# back
249+
helpers.add_new_sym_for_info(
250+
annotated_model, "DoesNotExist", TypeType(Instance(object_does_not_exist, []))
251+
)
252+
helpers.add_new_sym_for_info(
253+
annotated_model, "MultipleObjectsReturned", TypeType(Instance(multiple_objects_returned, []))
254+
)
255+
256+
203257
class InjectAnyAsBaseForNestedMeta(ModelClassInitializer):
204258
"""
205259
Replaces
@@ -1034,6 +1088,7 @@ def run(self) -> None:
10341088

10351089
def process_model_class(ctx: ClassDefContext, django_context: DjangoContext) -> None:
10361090
initializers = [
1091+
AddAnnotateUtilities,
10371092
InjectAnyAsBaseForNestedMeta,
10381093
AddDefaultPrimaryKey,
10391094
AddPrimaryKeyAlias,
@@ -1059,77 +1114,72 @@ def set_auth_user_model_boolean_fields(ctx: AttributeContext, django_context: Dj
10591114
return Instance(boolinfo, [])
10601115

10611116

1062-
def handle_annotated_type(ctx: AnalyzeTypeContext, django_context: DjangoContext) -> MypyType:
1117+
def handle_annotated_type(ctx: AnalyzeTypeContext, fullname: str) -> MypyType:
1118+
"""
1119+
Replaces the 'WithAnnotations' type with a type that can represent an annotated
1120+
model.
1121+
"""
1122+
is_with_annotations = fullname == fullnames.WITH_ANNOTATIONS_FULLNAME
10631123
args = ctx.type.args
1124+
if not args:
1125+
return AnyType(TypeOfAny.from_omitted_generics) if is_with_annotations else ctx.type
10641126
type_arg = ctx.api.analyze_type(args[0])
10651127
if not isinstance(type_arg, Instance) or not type_arg.type.has_base(MODEL_CLASS_FULLNAME):
10661128
return type_arg
10671129

10681130
fields_dict = None
10691131
if len(args) > 1:
10701132
second_arg_type = get_proper_type(ctx.api.analyze_type(args[1]))
1071-
if isinstance(second_arg_type, TypedDictType):
1133+
if isinstance(second_arg_type, TypedDictType) and is_with_annotations:
10721134
fields_dict = second_arg_type
10731135
elif isinstance(second_arg_type, Instance) and second_arg_type.type.fullname == ANNOTATIONS_FULLNAME:
10741136
annotations_type_arg = get_proper_type(second_arg_type.args[0])
10751137
if isinstance(annotations_type_arg, TypedDictType):
10761138
fields_dict = annotations_type_arg
10771139
elif not isinstance(annotations_type_arg, AnyType):
10781140
ctx.api.fail("Only TypedDicts are supported as type arguments to Annotations", ctx.context)
1141+
elif annotations_type_arg.type_of_any == TypeOfAny.from_omitted_generics:
1142+
ctx.api.fail("Missing required TypedDict parameter for generic type Annotations", ctx.context)
1143+
1144+
if fields_dict is None:
1145+
return type_arg
10791146

10801147
assert isinstance(ctx.api, TypeAnalyser)
10811148
assert isinstance(ctx.api.api, SemanticAnalyzer)
1082-
return get_or_create_annotated_type(ctx.api.api, type_arg, fields_dict=fields_dict)
1149+
return get_annotated_type(ctx.api.api, type_arg, fields_dict=fields_dict)
10831150

10841151

1085-
def get_or_create_annotated_type(
1086-
api: Union[SemanticAnalyzer, CheckerPluginInterface], model_type: Instance, fields_dict: Optional[TypedDictType]
1152+
def get_annotated_type(
1153+
api: Union[SemanticAnalyzer, TypeChecker], model_type: Instance, fields_dict: TypedDictType
10871154
) -> ProperType:
10881155
"""
1089-
1090-
Get or create the type for a model for which you getting/setting any attr is allowed.
1091-
1092-
The generated type is an subclass of the model and django._AnyAttrAllowed.
1093-
The generated type is placed in the django_stubs_ext module, with the name WithAnnotations[ModelName].
1094-
If the user wanted to annotate their code using this type, then this is the annotation they would use.
1095-
This is a bit of a hack to make a pretty type for error messages and which would make sense for users.
1156+
Get a model type that can be used to represent an annotated model
10961157
"""
1097-
model_module_name = "django_stubs_ext"
1098-
1099-
if helpers.is_annotated_model_fullname(model_type.type.fullname):
1100-
# If it's already a generated class, we want to use the original model as a base
1101-
model_type = model_type.type.bases[0]
1102-
1103-
if fields_dict is not None:
1104-
type_name = f"WithAnnotations[{model_type.type.fullname.replace('.', '__')}, {fields_dict}]"
1158+
if model_type.extra_attrs:
1159+
extra_attrs = ExtraAttrs(
1160+
attrs={**model_type.extra_attrs.attrs, **(fields_dict.items if fields_dict is not None else {})},
1161+
immutable=model_type.extra_attrs.immutable.copy(),
1162+
mod_name=None,
1163+
)
11051164
else:
1106-
type_name = f"WithAnnotations[{model_type.type.fullname.replace('.', '__')}]"
1107-
1108-
annotated_typeinfo = helpers.lookup_fully_qualified_typeinfo(
1109-
cast(TypeChecker, api), model_module_name + "." + type_name
1110-
)
1111-
if annotated_typeinfo is None:
1112-
model_module_file = api.modules.get(model_module_name) # type: ignore[union-attr]
1113-
if model_module_file is None:
1114-
return AnyType(TypeOfAny.from_error)
1115-
1116-
if isinstance(api, SemanticAnalyzer):
1117-
annotated_model_type = api.named_type_or_none(ANY_ATTR_ALLOWED_CLASS_FULLNAME, [])
1118-
assert annotated_model_type is not None
1119-
else:
1120-
annotated_model_type = api.named_generic_type(ANY_ATTR_ALLOWED_CLASS_FULLNAME, [])
1121-
1122-
annotated_typeinfo = helpers.add_new_class_for_module(
1123-
model_module_file,
1124-
type_name,
1125-
bases=[model_type] if fields_dict is not None else [model_type, annotated_model_type],
1126-
fields=fields_dict.items if fields_dict is not None else None,
1127-
no_serialize=True,
1165+
extra_attrs = ExtraAttrs(
1166+
attrs=fields_dict.items if fields_dict is not None else {},
1167+
immutable=None,
1168+
mod_name=None,
11281169
)
1129-
if fields_dict is not None:
1130-
# To allow structural subtyping, make it a Protocol
1131-
annotated_typeinfo.is_protocol = True
1132-
# Save for later to easily find which field types were annotated
1133-
annotated_typeinfo.metadata["annotated_field_types"] = fields_dict.items
1134-
annotated_type = Instance(annotated_typeinfo, [])
1135-
return annotated_type
1170+
1171+
annotated_model: Optional[TypeInfo]
1172+
if helpers.is_annotated_model(model_type.type):
1173+
annotated_model = model_type.type
1174+
if model_type.args and isinstance(model_type.args[0], TypedDictType):
1175+
fields_dict = helpers.make_typeddict(
1176+
api,
1177+
fields={**model_type.args[0].items, **fields_dict.items},
1178+
required_keys={*model_type.args[0].required_keys, *fields_dict.required_keys},
1179+
)
1180+
else:
1181+
annotated_model = helpers.lookup_fully_qualified_typeinfo(api, model_type.type.fullname + "@AnnotatedWith")
1182+
1183+
if annotated_model is None:
1184+
return model_type
1185+
return Instance(annotated_model, [fields_dict], extra_attrs=extra_attrs)

mypy_django_plugin/transformers/orm_lookups.py

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
from mypy_django_plugin.django.context import DjangoContext
66
from mypy_django_plugin.exceptions import UnregisteredModelError
77
from mypy_django_plugin.lib import fullnames, helpers
8-
from mypy_django_plugin.lib.helpers import is_annotated_model_fullname
98

109

1110
def typecheck_queryset_filter(ctx: MethodContext, django_context: DjangoContext) -> MypyType:
@@ -33,13 +32,10 @@ def typecheck_queryset_filter(ctx: MethodContext, django_context: DjangoContext)
3332
provided_type = resolve_combinable_type(provided_type, django_context)
3433

3534
lookup_type: MypyType
36-
if is_annotated_model_fullname(model_cls_fullname):
37-
lookup_type = AnyType(TypeOfAny.implementation_artifact)
38-
else:
39-
try:
40-
lookup_type = django_context.resolve_lookup_expected_type(ctx, model_cls, lookup_kwarg)
41-
except UnregisteredModelError:
42-
lookup_type = AnyType(TypeOfAny.from_error)
35+
try:
36+
lookup_type = django_context.resolve_lookup_expected_type(ctx, model_cls, lookup_kwarg)
37+
except UnregisteredModelError:
38+
lookup_type = AnyType(TypeOfAny.from_error)
4339
# Managers as provided_type is not supported yet
4440
if isinstance(provided_type, Instance) and helpers.has_any_of_bases(
4541
provided_type.type, (fullnames.MANAGER_CLASS_FULLNAME, fullnames.QUERYSET_CLASS_FULLNAME)

0 commit comments

Comments
 (0)