-
-
Notifications
You must be signed in to change notification settings - Fork 454
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
QuerySet.annotate improvements #398
Conversation
e7f0544
to
41de338
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Great work!
The only question I have is: how one can represent annotated User
statically? For example, we have this code:
def allowed(param: User):
return param.foo # E: "User" has no attribute "foo"
But, when one will pass there an instance with .annotate(foo=...)
it will work in runtime, but fail in typechecking. Do you have any solution in mind?
Thanks. At the moment, I don't have a solution for representing annotated User as a type annotation in the actual code. I wanted to address the issue with calling .annotate turning the Manager[SomeModel] into QuerySet[Any], which is annoying and loses information. One idea is to make all _Annotations = TypeVar('_Annotations')
class Model(Generic[_Annotations]):
pass
class MyModel(Model): ...
class MyAnnotations(TypedDict):
foo: str
bar: str
def func(param: MyModel[MyAnnotations]): pass And handle this in the plugin. But this might be too intrusive? We also have to see if we can default the generic param in a way so that by default the model is not annotated. Another idea would be to make use of from typing import Type
from typing_extensions import Annotated
from django.db.models.base import Model
class Annotations:
def __init__(self, **kwargs: Type): ...
# User code:
# If no arguments, assume any
def func(user: Annotated[Model, Annotations()]) -> str:
return user.foo + user.bar
# If we later want to support specifying which fields are annotated, it could look like this:
def func2(user: Annotated[Model, Annotations(foo=str, bar=str)]) -> str:
return user.foo + user.bar At first, I thought it's only available in Python 3.9 (unreleased), but it looks like it's in Another downside of using Annotated is that the type-checker won't check for type compatibility between different annotated models, e.g. assuming that one is annotated with a TypedDict that is a subset of another TypedDict. |
Looks like it will be some work to fix django typechecking |
@syastrov I really like your Thanks a lot for the great work! 👍 |
The tests are passing now, but I have some reservations about this, and would like to do some additional testing. So please, hold off on merging. |
By the way, looks like PyCharm is working on supporting On second thought, I don't have any reservations as long as this PR is just fixing the problem within functions and not ACROSS functions (due to not being able to add type annotations using an "annotated" model or QuerySet). I still need some more tests of .values() / .values_list() with no params and I think it should be good. |
@sobolevn I finally found some time to finish this :) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's merge our own refactored code first. Then we can rebase these changed and apply them as well. |
- QuerySets that have an annotated model do not report errors during .filter() when called with invalid fields. - QuerySets that have an annotated model return ordinary dict rather than TypedDict for .values() - QuerySets that have an annotated model return Any rather than typed Tuple for .values_list()
…ypechecking Django testsuite.
…ming QuerySet.first() won't return None) Fix mypy self-check.
…s_list. Cleanup tests.
935590f
to
ebf9500
Compare
Hmm, this is getting tricky. I believe the type ignores in _ValuesQuerySet are causing certain things to not work. I had an issue with the test code:
The actual value is To try to fix the inheritance by removing that type ignore, I tried changing the class definition, but this causes a failure in values_list_flat_true_with_ids. The definition I am trying:
I believe the problem is the type ignore on So there is not really any way to resolve this, I think, except re-instating _BaseQuerySet, I believe. |
… PEP 583 Annotated type.
I managed to make it possible to annotate functions as taking "annotated models" or QuerySets using PEP 593 The "repr" also looks good due to some hacks :) I quite like this way of doing it, as it's more self-explainable when you see the error messages, and it makes sense that users can also annotate their own functions using these types. So, finally, if the issues above regarding inheritance of _ValuesQuerySet are fixed, then this PR should be ready (hopefully) :) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Great work. Like really great work! 👍
username = models.CharField(max_length=100) | ||
|
||
|
||
def func(m: WithAnnotations[MyModel]) -> str: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Will it be possible for us to later add something like WithAnnotations[Model, TypedDict]
and not break things for users? Double checking 🙂
|
||
|
||
def get_or_create_annotated_type( | ||
api: Union[SemanticAnalyzer, CheckerPluginInterface], model_type: Instance |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
api: SemanticAnalyzer
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Or even TypeAnalyzer
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's unfortunately used both in MethodContext
(CheckerPluginInterface
) and AnalyzeTypeContext
(where I can grab SemanticAnalyzer
from the api.api
attribute).
Looks like we still have a broken test:
Is it the one you have mnetioned in #398 (comment) |
This currently has the drawback that error messages display the internal type _QuerySet, with both type arguments. See also discussion on typeddjango#661 and typeddjango#608. Fixes typeddjango#635: QuerySet methods on Managers (like .all()) now return QuerySets rather than Managers. Address code review by @sobolevn.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is pure gold 🏅 Thank you!
Merge when you fill confident!
Thanks! I tried to explore this route with annotating using I updated the README as well, so you can see the current limitations. Let's see if I didn't break a lot of things :) |
2106a39
to
51f0448
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
💯
I would like to test it against an internal code-base before merging. However, there is still one problem which I don't think we can solve at the moment, at least with the current implementation approach, but which I think would be useful to solve. I would like to define a function which can add annotations to an existing class FooDict(TypedDict):
foo: int
_Model = TypeVar("_Model", bound=MyModel)
def add_annotation_with_typevar(qs: QuerySet[_Model]) -> QuerySet[WithAnnotations[_Model, FooDict]]:
return qs.annotate(foo=F('id')) I'd like to be able to have multiple functions like these and chain them together so I can build up a This won't work correctly in the mypy plugin since Anyway, I don't think this problem should prevent this PR from being merged. |
By the way, I found this issue python/mypy#6501 regarding getting a better repr for types. |
…or example). Fix some edge case with from_queryset after QuerySet changed to be an alias to _QuerySet. Can't make a minimal test case as this only occurred on a large internal codebase.
Alright, I got this to work against the internal codebase after a small fix. I also noticed another bug, which I was able to workaround, but I was not able to make a minimal test case of. It involves having a custom manager which has a method with an argument annotated with Perhaps there is some If you agree, let's merge this! |
Hmm, I did notice that when using |
@syastrov let's open two separate issues for things you described and merge this! 👍 |
…ent with a type annotation on the QuerySet. The mypy docstring on anal_type says not to call defer() after it.
I fixed the first issue ( |
7fd6691
to
d3ea945
Compare
@sobolevn You are welcome to merge if the tests pass :) Then I'll open an issue on the second thing |
@syastrov thanks a lot for your amazing work! 🎉 |
Wow, this has been a long time coming! When can we expect a release with these changes? |
You are welcome! :) Glad it's merged. I opened an issue for the other thing |
Calling QuerySet.annotate introduces an "annotated" model type which is a subtype of the original model dynamically (named "MyModel (annotated)"), but which allows arbitrary getting/setting of attributes.
This is originally to fix this issue:
error: Incompatible types in assignment (expression has type “QuerySet[Any]“, variable has type “Manager[Any]“)
The problem is because of not using self-type