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

Resolve all queryset methods on managers as attributes #1028

Merged

Conversation

ljodal
Copy link
Contributor

@ljodal ljodal commented Jun 28, 2022

This changes the logic for resolving queryset methods on managers from copying the methods over to adding them as attributes and then resolving the type of those attributes in the get_attribute_hook callback method, similar to how it is done for custom manager methods.

This might have introduced some new errors, but I'm not sure. The test suite passes, but I'm seeing some new errors in our project. Those might be from changes made since 1.11 that we're currently using though.

Related issues

This changes to logic for resolving methods from the base QuerySet class
on managers from copying the methods to use the attribute approach
that's already used for methods from custom querysets. This resolves the
phantom type errors that stem from the copying.
ljodal and others added 2 commits June 28, 2022 20:55
Make sure the test will fail regardless of which mypy.ini file is being using.

Co-authored-by: Petter Friberg <petter@5monkeys.se>
Comment on lines 258 to 263
# Find all methods that are defined on BaseManager
manager_method_names = []
for manager_mro_info in new_manager_info.mro:
if manager_mro_info.fullname == fullnames.BASE_MANAGER_CLASS_FULLNAME:
for name, sym in manager_mro_info.names.items():
for name in manager_mro_info.names:
manager_method_names.append(name)
Copy link
Member

Choose a reason for hiding this comment

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

Perhaps a micro-optimisation and out of scope. But manager_method_names could be a set, right?

manager_method_names = set()
for manager_mro_info in new_manager_info.mro:
    if manager_mro_info.fullname == fullnames.BASE_MANAGER_CLASS_FULLNAME:
        for name in manager_mro_info.names:
             manager_method_names.add(name)
}

One could also make a comprehension out of it. Though I'm not sure if that's more, or less, readable..

manager_method_names = {
    *manager_mro_info.names
    for manager_mro_info in new_manager_info.mro
    if manager_mro_info.fullname == fullnames.BASE_MANAGER_CLASS_FULLNAME
}

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Haha, I just did basically the same, but decided that it was less readable, so I changed it back.

I'm wondering if it would be better to have a simple predefined list of methods here though. It's fairly easy to define that list and it might be a better approach than trying to detect which methods return querysets. What do you think of that? Then we can use that same list to update the return types in resolve_manager_method and avoid potentially affecting custom queryset methods

Copy link
Member

@flaeppe flaeppe Jun 28, 2022

Choose a reason for hiding this comment

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

What do you think of that?

I'd agree with that (I've had similar thoughts too). It's more difficult to understand what that "_QS" is there for, in addition to this list..

Copy link
Member

@flaeppe flaeppe Jun 28, 2022

Choose a reason for hiding this comment

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

I mean, as long as there's a/some test cases testing that whole list. That should suffice for coverage and future changes.

I added a case close to that some time ago

- case: from_queryset_includes_methods_returning_queryset
main: |
from myapp.models import MyModel
reveal_type(MyModel.objects.none) # N: Revealed type is "def () -> myapp.models.MyQuerySet[myapp.models.MyModel]"
reveal_type(MyModel.objects.all) # N: Revealed type is "def () -> myapp.models.MyQuerySet[myapp.models.MyModel]"
reveal_type(MyModel.objects.filter) # N: Revealed type is "def (*args: Any, **kwargs: Any) -> myapp.models.MyQuerySet[myapp.models.MyModel]"
reveal_type(MyModel.objects.exclude) # N: Revealed type is "def (*args: Any, **kwargs: Any) -> myapp.models.MyQuerySet[myapp.models.MyModel]"
reveal_type(MyModel.objects.complex_filter) # N: Revealed type is "def (filter_obj: Any) -> myapp.models.MyQuerySet[myapp.models.MyModel]"
reveal_type(MyModel.objects.union) # N: Revealed type is "def (*other_qs: Any, *, all: builtins.bool =) -> myapp.models.MyQuerySet[myapp.models.MyModel]"
reveal_type(MyModel.objects.intersection) # N: Revealed type is "def (*other_qs: Any) -> myapp.models.MyQuerySet[myapp.models.MyModel]"
reveal_type(MyModel.objects.difference) # N: Revealed type is "def (*other_qs: Any) -> myapp.models.MyQuerySet[myapp.models.MyModel]"
reveal_type(MyModel.objects.select_for_update) # N: Revealed type is "def (nowait: builtins.bool =, skip_locked: builtins.bool =, of: typing.Sequence[builtins.str] =, no_key: builtins.bool =) -> myapp.models.MyQuerySet[myapp.models.MyModel]"
reveal_type(MyModel.objects.select_related) # N: Revealed type is "def (*fields: Any) -> myapp.models.MyQuerySet[myapp.models.MyModel]"
reveal_type(MyModel.objects.prefetch_related) # N: Revealed type is "def (*lookups: Any) -> myapp.models.MyQuerySet[myapp.models.MyModel]"
reveal_type(MyModel.objects.annotate) # N: Revealed type is "def (*args: Any, **kwargs: Any) -> myapp.models.MyQuerySet[myapp.models.MyModel]"
reveal_type(MyModel.objects.alias) # N: Revealed type is "def (*args: Any, **kwargs: Any) -> myapp.models.MyQuerySet[myapp.models.MyModel]"
reveal_type(MyModel.objects.order_by) # N: Revealed type is "def (*field_names: Any) -> myapp.models.MyQuerySet[myapp.models.MyModel]"
reveal_type(MyModel.objects.distinct) # N: Revealed type is "def (*field_names: Any) -> myapp.models.MyQuerySet[myapp.models.MyModel]"
reveal_type(MyModel.objects.reverse) # N: Revealed type is "def () -> myapp.models.MyQuerySet[myapp.models.MyModel]"
reveal_type(MyModel.objects.defer) # N: Revealed type is "def (*fields: Any) -> myapp.models.MyQuerySet[myapp.models.MyModel]"
reveal_type(MyModel.objects.only) # N: Revealed type is "def (*fields: Any) -> myapp.models.MyQuerySet[myapp.models.MyModel]"
reveal_type(MyModel.objects.using) # N: Revealed type is "def (alias: Union[builtins.str, None]) -> myapp.models.MyQuerySet[myapp.models.MyModel]"
installed_apps:
- myapp
files:
- path: myapp/__init__.py
- path: myapp/models.py
content: |
from django.db import models
from django.db.models.manager import BaseManager
class MyQuerySet(models.QuerySet["MyModel"]):
...
MyManager = BaseManager.from_queryset(MyQuerySet)
class MyModel(models.Model):
objects = MyManager()

Asserting on all django manager builtins.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I decided to implement this and think the code is much more readable and understandable now. It's just 20 methods that we need to modify. I updated (and sorted) the from_queryset_includes_methods_returning_queryset test as well, it was missing .extra() :)

Copy link
Member

@flaeppe flaeppe Jun 28, 2022

Choose a reason for hiding this comment

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

Agreed, it looks way way better. Django's queryset API, thus also stubs, won't change that often that there's a need to figure them out programmatically..

mypy_django_plugin/transformers/managers.py Outdated Show resolved Hide resolved
@flaeppe
Copy link
Member

flaeppe commented Jun 28, 2022

The test suite passes, but I'm seeing some new errors in our project.

For some additional reference, I just tried running this on one of our projects without issues.

@flaeppe flaeppe mentioned this pull request Jun 28, 2022
The list of manager methods that returns a queryset, and thus need to
have it's return type changed, is small and well defined. Using a
predefined list of methods rather than trying to detect these at runtime
makes the code much more readable and probably faster as well.

Also add `extra()` to the methods tested in
from_queryset_includes_methods_returning_queryset, and sort the methods
alphabetically.
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! Looks promising

@@ -46,7 +47,7 @@ jobs:
pip install -r ./requirements.txt

- name: Run tests
run: pytest --mypy-ini-file=mypy.ini
run: pytest --mypy-ini-file="${{ matrix.mypy_ini_file }}"
Copy link
Member

Choose a reason for hiding this comment

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

Hm, good idea. Let's try this. You can create a new PR with just this change. It will be helpful!

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah, I removed it as it's no longer needed to reproduce the problem after adding disable_cache: true to the test case. If we're going to add this I think it makes sense to rename the files as well, but I stole this from #1017 so I think I'll leave it up to the author there to decide if he wants to create a PR for that.

mypy_django_plugin/transformers/managers.py Outdated Show resolved Hide resolved
mypy_django_plugin/transformers/managers.py Show resolved Hide resolved
With cache_disable: true on the test case this is no longer needed to
reproduce the bug.
 - Remove unused imports left behind
 - Change MANAGER_METHODS_RETURNING_QUERYSET to Final[FrozenSet[str]]
Was added in 3.8, we still support 3.7


- case: from_queryset_custom_auth_user_model
disable_cache: true
Copy link
Member

Choose a reason for hiding this comment

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

Let's add a comment why do we need this.
And a link to all the discussions somewhere above this test case.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done ✅

mypy_django_plugin/transformers/managers.py Outdated Show resolved Hide resolved
mypy_django_plugin/transformers/managers.py Outdated Show resolved Hide resolved
ljodal and others added 3 commits June 28, 2022 22:35
Copy link
Member

@flaeppe flaeppe left a comment

Choose a reason for hiding this comment

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

LGTM 👍

@ljodal ljodal requested a review from sobolevn June 29, 2022 10:27
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, great work!

@sobolevn sobolevn merged commit 84eff75 into typeddjango:master Jun 30, 2022
@christianbundy
Copy link
Contributor

Thanks a lot! I'm excited to try this when a new version is released.

colons added a commit to very-scary-scenario/nkd.su that referenced this pull request Jul 17, 2022
A temporary workaround for typeddjango/django-stubs#1022, while waiting
for a version with typeddjango/django-stubs#1028 to be released.
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.

error: Name "Sequence" is not defined Alias argument to QuerySet.using() typed as _SpecialForm
4 participants