Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Refactor support for annotate to utilise mypy.types.Instance.extra_attrs #2319

Merged
merged 2 commits into from
Aug 5, 2024

Conversation

flaeppe
Copy link
Member

@flaeppe flaeppe commented Aug 4, 2024

I figured out that the implementation in mypy for functools.partial, in python/mypy#16939, added some pieces that we can utilise to get better support for .annotate.

Namely that python/mypy#16939 added support for writing Instance.extra_attrs to cache.

There are 2 main pieces at play in this implementation:

  1. Annotated attributes are written to mypy.types.Instance.extra_attrs. This allows .annotate calls to bring in temporary attributes on same model instances.
  2. The approach in 1. does not help out when comparing instance A´1 with A´2 for annotated attributes. Essentially nothing in .extra_attrs is considered when comparing instances, there could be completely different attributes in there and mypy still considers the instances equal/compatible. To be able to resolve that, the plugin creates a subclass for each model that also has django_stubs_ext.Annotations as base with a generic parameter. This subclass will be replace any encounter of WithAnnotations[<Model>, <TypedDict>], where <TypedDict> populates the generic parameter of the model subclass. This allows for falling back on mypy for type compatibility depending on annotated attributes.

The description in 2. is something like this in code

class MyModel(models.Model):
    username = models.CharField(max_length=100)

class MyModel_AnnotatedWith(models.Model, django_stubs_ext.Annotations[django_stubs_ext.annotations._Annotations]):
    ...

Then with a use case example

class AnnotatedAttrs(TypedDict):
    foo: int

def func(instance: django_stubs_ext.WithAnnotations[MyModel, AnnotatedAttrs]) -> int:
    return instance.foo

func(MyModel.objects.annotate(foo=Value("")).get())  # OK
func(MyModel.objects.annotate(bar=Value("")).get())  # Error

reveal_type(func)  # Revealed type is "def (instance: MyModel@AnnotatedWith[TypedDict('AnnotatedAttrs', {'foo': builtins.int})]) -> builtins.int"

The main downside here is the subclass and that it needs a name, which is set to {Model name}@AnnotatedWith


Most of this is backwards compatible with our old WithAnnotations except for the fact that;

  • WithAnnotations[MyModel] is no longer supported, it didn't really make sense to ask for an annotation of nothing. Though this enabled support for accepting any attribute in the previous version, but IMO # type: ignore will make do for that.

It might be more compatibility issues but our test suite is kind of lacking cases for using .annotate.

Let me know what you think, any feedback is greatly appreciated here.

Related issues

There are a whole bunch of issues reported in regards to annotate: https://github.com/typeddjango/django-stubs/issues?q=is%3Aissue+is%3Aopen+annotate

I'm not going to hunt down and include which repro cases are resolved with this PR, but was thinking to do that subsequently to close off resolved issues. Though I will include closing one (major) cache issue we've had forever, since that should no longer be any issue with this implementation.

Comment on lines +250 to +276
# NOTE: It's important that we use 'special_form' for 'Any' as otherwise we can
# get stuck with mypy interpreting an overload ambiguity towards the
# overloaded 'Field.__get__' method when its 'model' argument gets matched. This
# is because the model argument gets matched with a model subclass that is
# parametrized with a type that contains the 'Any' below and then mypy runs in
# to a (false?) ambiguity, due to 'Any', and can't decide what overload/return
# type to select
#
# Example:
# class MyModel(models.Model):
# field = models.CharField()
#
# # Our plugin auto generates the following subclass
# class MyModel_WithAnnotations(MyModel, django_stubs_ext.Annotations[_Annotations]):
# ...
# # Assume
# x = MyModel.objects.annotate(foo=F("id")).get()
# reveal_type(x) # MyModel_WithAnnotations[TypedDict({"foo": Any})]
# # Then, on an attribute access of 'field' like
# reveal_type(x.field)
#
# Now 'CharField.__get__', which is overloaded, is passed 'x' as the 'model'
# argument and mypy consider it ambiguous to decide which overload method to
# select due to the 'Any' in 'TypedDict({"foo": Any})'. But if we specify the
# 'Any' as 'TypeOfAny.special_form' mypy doesn't consider the model instance to
# contain 'Any' and the ambiguity goes away.
field_types = {name: AnyType(TypeOfAny.special_form) for name, _ in kwargs.items()}
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't know if what I try to describe here is a mypy problem, I tried to reproduce this issue in mypy playground but wasn't able to.

This is as far as I got, but without getting any error: https://mypy-play.net/?mypy=latest&python=3.12&gist=e4745bfc8e2f42b02911adec2214e6e5

from typing import Any, Generic, Mapping, TypeVar, overload, TypedDict

_Annotations = TypeVar("_Annotations", covariant=True, bound=Mapping[str, Any])

class Annotations(Generic[_Annotations]): ...

T = TypeVar("T")

class Field(Generic[T]):
    @overload
    def __get__(self, instance: None, owner: Any) -> None: ...
    @overload
    def __get__(self, instance: Model, owner: Any) -> T: ...
    def __get__(self, instance: Model | None, owner: Any) -> T | None: ...

class Model:
    field = Field[str]()

class Foo(TypedDict):
    foo: Any

class A(Model, Annotations[_Annotations]):
    ...
    
a = A[Foo]()
reveal_type(a)
reveal_type(a.field)

Copy link
Member

@sobolevn sobolevn left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you! Very good idea!

@@ -235,21 +235,11 @@ class MyModel(models.Model):
username = models.CharField(max_length=100)


def func(m: WithAnnotations[MyModel]) -> str:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think that we should remove this feature. Because I see that a lot of users discuss this and use this. Maybe we can treat WithAnnotations[MyModel] as WithAnnotations[MyModel, Any] implicitly? Via type var defaults or something?

This way we can continue to support this code (maybe with a deprecation warning, why not). But breaking it - does not seem right to me :(

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But, I agree that we should not have done this in the first place.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I suppose, gonna have to see if I can get it working. The support for any/arbitrary attributes is a bit of work I think

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We only need to support Any, no other arguments / types.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, the Any type argument is fine. But that doesn't add support for accessing an arbitrary attribute, like the previous implementation supported.

Essentially you'll still get something like:

def func(m: WithAnnotations[MyModel]) -> str:
    return m.asdf  # E: MyModel@AnnotatedWith[Any] has no attribute "asdf"

I don't have any solution that removes the "has no attribute" error.

Copy link
Member Author

@flaeppe flaeppe Aug 5, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's 1 problem that I see with that, unless I'm missing something, which is that Model@AnnotatedWith[Any] is an Instance and not a TypeInfo. But we need to add the __getattr__ method to the TypeInfo though if we do, all instances of Model@AnnotatedWith gets the __getattr__ method

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Won't .extra_attrs do the job for single instance __getattr__? 🤔
I am not sure, just asking.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

__getattr__ will work for the arbitrary attributes. But we still want/need to be able to match the model type on e.g. a function argument.

This whole thing is kind of twofold.

  1. Subclassing the model and the generic TypedDict argument for proper matching for WithAnnotations as a function argument
  2. extra_attrs for accessing declared annotated attributes e.g. inside a function body

__getattr__ would help out with attributes accessed inside the body. But I can't find a way to add it and keep 1. working and not affect all other instances. I've also tried adding the __getattr__ method to extra_attrs with no luck: ExtraAttrs(attrs={"__getattr__": CallableType(...)}, ...)

There's also the TypeInfo.fallback_to_any which is the same as subclassing Any or our AnyAttrAllowed. But same issue here, it affects the type and not instances of said type.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks a lot for your research! Not really what I hoped for :(

In this case, let's do the cleanest solution and break WithAnnotations[M] usage in a way that we restrict known attributes.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was also hoping to find a way that kept backwards compatibility. At least I'm hoping breaking this will make it a bit easier going forward.

Do you mean restrict known attributes like emitting "has no attribute" errors?

Then, calling wise, if we replace

WithAnnotations[MyModel] -> MyModel@AnnotatedWith[Any]

We can require that .annotate has been called, but we don't care with what. Just like before. I'm thinking this is the implementation we want.

Else if we replace like (this is what we do now)

WithAnnotations[MyModel] -> MyModel

This wouldn't distinguish between having called .annotate or not. Meaning that there's no requirement of having called .annotate but having done it is also accepted.

mypy_django_plugin/transformers/models.py Outdated Show resolved Hide resolved
tests/typecheck/test_annotated.yml Show resolved Hide resolved
Copy link
Member

@sobolevn sobolevn left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's go for it!

@flaeppe
Copy link
Member Author

flaeppe commented Aug 5, 2024

Agreed, let's do it. 🚀

@flaeppe flaeppe merged commit 45364bb into typeddjango:master Aug 5, 2024
36 checks passed
@flaeppe flaeppe deleted the fix/annotate branch August 5, 2024 20:50
@flaeppe flaeppe added the release notes reminder User impact should be explicitly documented in release notes. label Aug 5, 2024
@flaeppe flaeppe removed the release notes reminder User impact should be explicitly documented in release notes. label Sep 23, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Development

Successfully merging this pull request may close these issues.

WithAnnotations Crash in 1.9.0
2 participants