From ec9007cc3e6757ba7c8a829c7cd6dbf19a0a5318 Mon Sep 17 00:00:00 2001 From: Petter Friberg Date: Thu, 20 Jan 2022 20:47:36 +0100 Subject: [PATCH 1/3] Refactor to more easily support additional config options --- mypy_django_plugin/config.py | 98 +++++++++++++++++++++++++++++ mypy_django_plugin/main.py | 89 ++------------------------ tests/test_error_handling.py | 117 ++++++++++++++++++++--------------- 3 files changed, 169 insertions(+), 135 deletions(-) create mode 100644 mypy_django_plugin/config.py diff --git a/mypy_django_plugin/config.py b/mypy_django_plugin/config.py new file mode 100644 index 000000000..b972f7cea --- /dev/null +++ b/mypy_django_plugin/config.py @@ -0,0 +1,98 @@ +import configparser +import textwrap +from functools import partial +from pathlib import Path +from typing import Any, Callable, Dict, NoReturn, Optional + +import tomli + +INI_USAGE = """ +(config) +... +[mypy.plugins.django_stubs] + django_settings_module: str (required) +... +""" +TOML_USAGE = """ +(config) +... +[tool.django-stubs] +django_settings_module = str (required) +... +""" +INVALID_FILE = "mypy config file is not specified or found" +COULD_NOT_LOAD_FILE = "could not load configuration file" +MISSING_SECTION = "no section [{section}] found".format +MISSING_DJANGO_SETTINGS = "missing required 'django_settings_module' config" +INVALID_SETTING = "invalid {key!r}: the setting must be a boolean".format + + +def exit_with_error(msg: str, is_toml: bool = False) -> NoReturn: + """Using mypy's argument parser, raise `SystemExit` to fail hard if validation fails. + + Considering that the plugin's startup duration is around double as long as mypy's, this aims to + import and construct objects only when that's required - which happens once and terminates the + run. Considering that most of the runs are successful, there's no need for this to linger in the + global scope. + """ + from mypy.main import CapturableArgumentParser + + handler = CapturableArgumentParser( + prog="(django-stubs) mypy", usage=textwrap.dedent(TOML_USAGE if is_toml else INI_USAGE) + ) + handler.error(msg) + + +class DjangoPluginConfig: + __slots__ = ("django_settings_module",) + django_settings_module: str + + def __init__(self, config_file: Optional[str]) -> None: + if not config_file: + exit_with_error(INVALID_FILE) + + filepath = Path(config_file) + if not filepath.is_file(): + exit_with_error(INVALID_FILE) + + if filepath.suffix.lower() == ".toml": + self.parse_toml_file(filepath) + else: + self.parse_ini_file(filepath) + + def parse_toml_file(self, filepath: Path) -> None: + toml_exit: Callable[[str], NoReturn] = partial(exit_with_error, is_toml=True) + try: + with filepath.open(mode="rb") as f: + data = tomli.load(f) + except (tomli.TOMLDecodeError, OSError): + toml_exit(COULD_NOT_LOAD_FILE) + + try: + config: Dict[str, Any] = data["tool"]["django-stubs"] + except KeyError: + toml_exit(MISSING_SECTION(section="tool.django-stubs")) + + if "django_settings_module" not in config: + toml_exit(MISSING_DJANGO_SETTINGS) + + self.django_settings_module = config["django_settings_module"] + if not isinstance(self.django_settings_module, str): + toml_exit("invalid 'django_settings_module': the setting must be a string") + + def parse_ini_file(self, filepath: Path) -> None: + parser = configparser.ConfigParser() + try: + with filepath.open(encoding="utf-8") as f: + parser.read_file(f, source=str(filepath)) + except OSError: + exit_with_error(COULD_NOT_LOAD_FILE) + + section = "mypy.plugins.django-stubs" + if not parser.has_section(section): + exit_with_error(MISSING_SECTION(section=section)) + + if not parser.has_option(section, "django_settings_module"): + exit_with_error(MISSING_DJANGO_SETTINGS) + + self.django_settings_module = parser.get(section, "django_settings_module").strip("'\"") diff --git a/mypy_django_plugin/main.py b/mypy_django_plugin/main.py index 1ad7e9b85..5a14877a3 100644 --- a/mypy_django_plugin/main.py +++ b/mypy_django_plugin/main.py @@ -1,10 +1,7 @@ -import configparser import sys -import textwrap from functools import partial -from typing import Callable, Dict, List, NoReturn, Optional, Tuple, cast +from typing import Callable, Dict, List, Optional, Tuple -import tomli from django.db.models.fields.related import RelatedField from mypy.modulefinder import mypy_path from mypy.nodes import MypyFile, TypeInfo @@ -21,6 +18,7 @@ from mypy.types import Type as MypyType import mypy_django_plugin.transformers.orm_lookups +from mypy_django_plugin.config import DjangoPluginConfig from mypy_django_plugin.django.context import DjangoContext from mypy_django_plugin.lib import fullnames, helpers from mypy_django_plugin.transformers import fields, forms, init_create, meta, querysets, request, settings @@ -60,94 +58,15 @@ def add_new_manager_base_hook(ctx: ClassDefContext) -> None: helpers.add_new_manager_base(ctx.api, ctx.cls.fullname) -def extract_django_settings_module(config_file_path: Optional[str]) -> str: - def exit(error_type: int) -> NoReturn: - """Using mypy's argument parser, raise `SystemExit` to fail hard if validation fails. - - Considering that the plugin's startup duration is around double as long as mypy's, this aims to - import and construct objects only when that's required - which happens once and terminates the - run. Considering that most of the runs are successful, there's no need for this to linger in the - global scope. - """ - from mypy.main import CapturableArgumentParser - - usage = """ - (config) - ... - [mypy.plugins.django_stubs] - django_settings_module: str (required) - ... - """ - handler = CapturableArgumentParser(prog="(django-stubs) mypy", usage=textwrap.dedent(usage)) - messages = { - 1: "mypy config file is not specified or found", - 2: "no section [mypy.plugins.django-stubs]", - 3: "the setting is not provided", - } - handler.error("'django_settings_module' is not set: " + messages[error_type]) - - def exit_toml(error_type: int) -> NoReturn: - from mypy.main import CapturableArgumentParser - - usage = """ - (config) - ... - [tool.django-stubs] - django_settings_module = str (required) - ... - """ - handler = CapturableArgumentParser(prog="(django-stubs) mypy", usage=textwrap.dedent(usage)) - messages = { - 1: "mypy config file is not specified or found", - 2: "no section [tool.django-stubs]", - 3: "the setting is not provided", - 4: "the setting must be a string", - } - handler.error("'django_settings_module' not found or invalid: " + messages[error_type]) - - if config_file_path and helpers.is_toml(config_file_path): - try: - with open(config_file_path, encoding="utf-8") as config_file_obj: - toml_data = tomli.loads(config_file_obj.read()) - except Exception: - exit_toml(1) - try: - config = toml_data["tool"]["django-stubs"] - except KeyError: - exit_toml(2) - - if "django_settings_module" not in config: - exit_toml(3) - - if not isinstance(config["django_settings_module"], str): - exit_toml(4) - - return config["django_settings_module"] - else: - parser = configparser.ConfigParser() - try: - with open(cast(str, config_file_path)) as handle: - parser.read_file(handle, source=config_file_path) - except (IsADirectoryError, OSError): - exit(1) - - section = "mypy.plugins.django-stubs" - if not parser.has_section(section): - exit(2) - settings = parser.get(section, "django_settings_module", fallback=None) or exit(3) - - return settings.strip("'\"") - - class NewSemanalDjangoPlugin(Plugin): def __init__(self, options: Options) -> None: super().__init__(options) - django_settings_module = extract_django_settings_module(options.config_file) + self.plugin_config = DjangoPluginConfig(options.config_file) # Add paths from MYPYPATH env var sys.path.extend(mypy_path()) # Add paths from mypy_path config option sys.path.extend(options.mypy_path) - self.django_context = DjangoContext(django_settings_module) + self.django_context = DjangoContext(self.plugin_config.django_settings_module) def _get_current_queryset_bases(self) -> Dict[str, int]: model_sym = self.lookup_fully_qualified(fullnames.QUERYSET_CLASS_FULLNAME) diff --git a/tests/test_error_handling.py b/tests/test_error_handling.py index bb200810c..f667978b0 100644 --- a/tests/test_error_handling.py +++ b/tests/test_error_handling.py @@ -1,9 +1,11 @@ import tempfile import typing +import uuid +from contextlib import contextmanager import pytest -from mypy_django_plugin.main import extract_django_settings_module +from mypy_django_plugin.config import DjangoPluginConfig TEMPLATE = """ (config) @@ -11,7 +13,7 @@ [mypy.plugins.django_stubs] django_settings_module: str (required) ... -(django-stubs) mypy: error: 'django_settings_module' is not set: {} +(django-stubs) mypy: error: {} """ TEMPLATE_TOML = """ @@ -20,31 +22,34 @@ [tool.django-stubs] django_settings_module = str (required) ... -(django-stubs) mypy: error: 'django_settings_module' not found or invalid: {} +(django-stubs) mypy: error: {} """ +@contextmanager +def write_to_file(file_contents: str, suffix: typing.Optional[str] = None) -> typing.Generator[str, None, None]: + with tempfile.NamedTemporaryFile(mode="w+", suffix=suffix) as config_file: + config_file.write(file_contents) + config_file.seek(0) + yield config_file.name + + @pytest.mark.parametrize( - "config_file_contents,message_part", + ("config_file_contents", "message_part"), [ - pytest.param( - None, - "mypy config file is not specified or found", - id="missing-file", - ), pytest.param( ["[not-really-django-stubs]"], - "no section [mypy.plugins.django-stubs]", + "no section [mypy.plugins.django-stubs] found", id="missing-section", ), pytest.param( ["[mypy.plugins.django-stubs]", "\tnot_django_not_settings_module = badbadmodule"], - "the setting is not provided", + "missing required 'django_settings_module' config", id="missing-settings-module", ), pytest.param( ["[mypy.plugins.django-stubs]"], - "the setting is not provided", + "missing required 'django_settings_module' config", id="no-settings-given", ), ], @@ -52,53 +57,69 @@ def test_misconfiguration_handling(capsys, config_file_contents, message_part): # type: (typing.Any, typing.List[str], str) -> None """Invalid configuration raises `SystemExit` with a precise error message.""" - with tempfile.NamedTemporaryFile(mode="w+") as config_file: - if not config_file_contents: - config_file.close() - else: - config_file.write("\n".join(config_file_contents).expandtabs(4)) - config_file.seek(0) - + contents = "\n".join(config_file_contents).expandtabs(4) + with write_to_file(contents) as filename: with pytest.raises(SystemExit, match="2"): - extract_django_settings_module(config_file.name) + DjangoPluginConfig(filename) error_message = "usage: " + TEMPLATE.format(message_part) assert error_message == capsys.readouterr().err @pytest.mark.parametrize( - "config_file_contents,message_part", + "filename", + [ + pytest.param(uuid.uuid4().hex, id="not matching an existing file"), + pytest.param("", id="as empty string"), + pytest.param(None, id="as none"), + ], +) +def test_handles_filename(capsys, filename: str): + with pytest.raises(SystemExit, match="2"): + DjangoPluginConfig(filename) + + error_message = "usage: " + TEMPLATE.format("mypy config file is not specified or found") + assert error_message == capsys.readouterr().err + + +@pytest.mark.parametrize( + ("config_file_contents", "message_part"), [ - ( + pytest.param( """ [tool.django-stubs] django_settings_module = 123 """, - "the setting must be a string", + "invalid 'django_settings_module': the setting must be a string", + id="django_settings_module not string", ), - ( + pytest.param( """ [tool.not-really-django-stubs] django_settings_module = "my.module" """, - "no section [tool.django-stubs]", + "no section [tool.django-stubs] found", + id="missing django-stubs section", ), - ( + pytest.param( """ [tool.django-stubs] not_django_not_settings_module = "badbadmodule" """, - "the setting is not provided", + "missing required 'django_settings_module' config", + id="missing django_settings_module", + ), + pytest.param( + "tool.django-stubs]", + "could not load configuration file", + id="invalid toml", ), ], ) def test_toml_misconfiguration_handling(capsys, config_file_contents, message_part): - with tempfile.NamedTemporaryFile(mode="w+", suffix=".toml") as config_file: - config_file.write(config_file_contents) - config_file.seek(0) - + with write_to_file(config_file_contents, suffix=".toml") as filename: with pytest.raises(SystemExit, match="2"): - extract_django_settings_module(config_file.name) + DjangoPluginConfig(filename) error_message = "usage: " + TEMPLATE_TOML.format(message_part) assert error_message == capsys.readouterr().err @@ -111,26 +132,22 @@ def test_correct_toml_configuration() -> None: django_settings_module = "my.module" """ - with tempfile.NamedTemporaryFile(mode="w+", suffix=".toml") as config_file: - config_file.write(config_file_contents) - config_file.seek(0) - - extracted = extract_django_settings_module(config_file.name) + with write_to_file(config_file_contents, suffix=".toml") as filename: + config = DjangoPluginConfig(filename) - assert extracted == "my.module" + assert config.django_settings_module == "my.module" def test_correct_configuration() -> None: """Django settings module gets extracted given valid configuration.""" - config_file_contents = [ - "[mypy.plugins.django-stubs]", - "\tsome_other_setting = setting", - "\tdjango_settings_module = my.module", - ] - with tempfile.NamedTemporaryFile(mode="w+") as config_file: - config_file.write("\n".join(config_file_contents).expandtabs(4)) - config_file.seek(0) - - extracted = extract_django_settings_module(config_file.name) - - assert extracted == "my.module" + config_file_contents = "\n".join( + [ + "[mypy.plugins.django-stubs]", + "\tsome_other_setting = setting", + "\tdjango_settings_module = my.module", + ] + ).expandtabs(4) + with write_to_file(config_file_contents) as filename: + config = DjangoPluginConfig(filename) + + assert config.django_settings_module == "my.module" From 428c34d083a7e5a6e82d25b11cbc1119c25e2c80 Mon Sep 17 00:00:00 2001 From: Petter Friberg Date: Thu, 20 Jan 2022 20:50:14 +0100 Subject: [PATCH 2/3] Notify when Manager.from_queryset happens inside model class body - A warning will be emitted whenever `Manager.from_queryset` happens inside of a model class body --- mypy_django_plugin/errorcodes.py | 3 ++ mypy_django_plugin/main.py | 6 +++ mypy_django_plugin/transformers/managers.py | 20 +++++++++- .../managers/querysets/test_from_queryset.yml | 37 +++++++++++++++++++ 4 files changed, 65 insertions(+), 1 deletion(-) create mode 100644 mypy_django_plugin/errorcodes.py diff --git a/mypy_django_plugin/errorcodes.py b/mypy_django_plugin/errorcodes.py new file mode 100644 index 000000000..475144a34 --- /dev/null +++ b/mypy_django_plugin/errorcodes.py @@ -0,0 +1,3 @@ +from mypy.errorcodes import ErrorCode + +MANAGER_UNTYPED = ErrorCode("django-manager", "Untyped manager disallowed", "Django") diff --git a/mypy_django_plugin/main.py b/mypy_django_plugin/main.py index 5a14877a3..3a5da38ba 100644 --- a/mypy_django_plugin/main.py +++ b/mypy_django_plugin/main.py @@ -24,6 +24,7 @@ from mypy_django_plugin.transformers import fields, forms, init_create, meta, querysets, request, settings from mypy_django_plugin.transformers.managers import ( create_new_manager_class_from_from_queryset_method, + fail_if_manager_type_created_in_model_body, resolve_manager_method, ) from mypy_django_plugin.transformers.models import ( @@ -225,6 +226,11 @@ def get_method_hook(self, fullname: str) -> Optional[Callable[[MethodContext], M django_context=self.django_context, ) + if method_name == "from_queryset": + info = self._get_typeinfo_or_none(class_fullname) + if info and info.has_base(fullnames.BASE_MANAGER_CLASS_FULLNAME): + return fail_if_manager_type_created_in_model_body + return None def get_base_class_hook(self, fullname: str) -> Optional[Callable[[ClassDefContext], None]]: diff --git a/mypy_django_plugin/transformers/managers.py b/mypy_django_plugin/transformers/managers.py index ef7ff4cc7..c86886ee1 100644 --- a/mypy_django_plugin/transformers/managers.py +++ b/mypy_django_plugin/transformers/managers.py @@ -15,11 +15,12 @@ TypeInfo, Var, ) -from mypy.plugin import AttributeContext, ClassDefContext, DynamicClassDefContext +from mypy.plugin import AttributeContext, ClassDefContext, DynamicClassDefContext, MethodContext from mypy.types import AnyType, CallableType, Instance, ProperType from mypy.types import Type as MypyType from mypy.types import TypeOfAny, TypeVarType, UnboundType, get_proper_type +from mypy_django_plugin import errorcodes from mypy_django_plugin.lib import fullnames, helpers @@ -278,3 +279,20 @@ def create_new_manager_class_from_from_queryset_method(ctx: DynamicClassDefConte # Insert the new manager (dynamic) class assert semanal_api.add_symbol_table_node(ctx.name, SymbolTableNode(GDEF, new_manager_info, plugin_generated=True)) + + +def fail_if_manager_type_created_in_model_body(ctx: MethodContext) -> MypyType: + """ + Method hook that checks if method `.from_queryset` is called inside a model class body. + + Doing so won't, for instance, trigger the dynamic class hook(`create_new_manager_class_from_from_queryset_method`) + for managers. + """ + api = helpers.get_typechecker_api(ctx) + outer_model_info = api.scope.active_class() + if not outer_model_info or not outer_model_info.has_base(fullnames.MODEL_CLASS_FULLNAME): + # Not inside a model class definition + return ctx.default_return_type + + api.fail("`.from_queryset` called from inside model class body", ctx.context, code=errorcodes.MANAGER_UNTYPED) + return ctx.default_return_type diff --git a/tests/typecheck/managers/querysets/test_from_queryset.yml b/tests/typecheck/managers/querysets/test_from_queryset.yml index 724d5db19..a9c9cecd5 100644 --- a/tests/typecheck/managers/querysets/test_from_queryset.yml +++ b/tests/typecheck/managers/querysets/test_from_queryset.yml @@ -309,3 +309,40 @@ NewManager = MyManager.from_queryset(ModelQuerySet) class MyModel(models.Model): objects = NewManager() + +- case: from_queryset_in_model_class_body_yields_message + main: | + from myapp.models import MyModel + reveal_type(MyModel.base_manager) # N: Revealed type is "myapp.models.BaseManagerFromMyQuerySet[myapp.models.MyModel]" + reveal_type(MyModel.manager) # N: Revealed type is "myapp.models.ManagerFromMyQuerySet[myapp.models.MyModel]" + reveal_type(MyModel.custom_manager) # N: Revealed type is "myapp.models.MyManagerFromMyQuerySet[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"]): + def queryset_method(self) -> int: + return 1 + + class MyManager(BaseManager): + ... + + BaseManagerFromMyQuerySet = BaseManager.from_queryset(MyQuerySet) + ManagerFromMyQuerySet = models.Manager.from_queryset(MyQuerySet) + MyManagerFromMyQuerySet = MyManager.from_queryset(MyQuerySet) + class MyModel(models.Model): + objects1 = BaseManager.from_queryset(MyQuerySet)() # E: `.from_queryset` called from inside model class body + objects2 = BaseManager.from_queryset(MyQuerySet) # E: `.from_queryset` called from inside model class body + objects3 = models.Manager.from_queryset(MyQuerySet)() # E: `.from_queryset` called from inside model class body + objects4 = models.Manager.from_queryset(MyQuerySet) # E: `.from_queryset` called from inside model class body + objects5 = MyManager.from_queryset(MyQuerySet) # E: `.from_queryset` called from inside model class body + objects6 = MyManager.from_queryset(MyQuerySet)() # E: `.from_queryset` called from inside model class body + # Initiating the manager type is fine + base_manager = BaseManagerFromMyQuerySet() + manager = ManagerFromMyQuerySet() + custom_manager = MyManagerFromMyQuerySet() From 2ee29955fac87dd515e08c3297285b1ac1263433 Mon Sep 17 00:00:00 2001 From: Petter Friberg Date: Fri, 21 Jan 2022 16:56:04 +0100 Subject: [PATCH 3/3] Resolve generated default manager types before final iteration A default manager on a model should always exist, eventually. Although, we extend to look through dynamically generated managers on each iteration instead of deferring until the final iteration. --- mypy_django_plugin/transformers/models.py | 32 +++++++++++------------ tests/typecheck/fields/test_related.yml | 16 +++++++++--- 2 files changed, 27 insertions(+), 21 deletions(-) diff --git a/mypy_django_plugin/transformers/models.py b/mypy_django_plugin/transformers/models.py index 3472658e2..b689f7727 100644 --- a/mypy_django_plugin/transformers/models.py +++ b/mypy_django_plugin/transformers/models.py @@ -280,17 +280,17 @@ def run_with_model_cls(self, model_cls: Type[Model]) -> None: try: default_manager_info = self.lookup_typeinfo_or_incomplete_defn_error(default_manager_fullname) except helpers.IncompleteDefnException as exc: - if not self.api.final_iteration: - raise exc - else: - # On final round, see if the default manager is a generated (dynamic class) manager - base_manager_fullname = helpers.get_class_fullname(default_manager_cls.__bases__[0]) - generated_manager_info = self.get_generated_manager_info( - default_manager_fullname, base_manager_fullname - ) - if generated_manager_info is None: - return - default_manager_info = generated_manager_info + # Check if default manager could be a generated manager + base_manager_fullname = helpers.get_class_fullname(default_manager_cls.__bases__[0]) + generated_manager_info = self.get_generated_manager_info(default_manager_fullname, base_manager_fullname) + if generated_manager_info is None: + # Manager doesn't appear to be generated. Unless we're on the final round, + # see if another round could help figuring out the default manager type + if not self.api.final_iteration: + raise exc + else: + return None + default_manager_info = generated_manager_info default_manager = Instance(default_manager_info, [Instance(self.model_classdef.info, [])]) self.add_new_node_to_model_class("_default_manager", default_manager) @@ -326,10 +326,8 @@ def run_with_model_cls(self, model_cls: Type[Model]) -> None: related_manager_info = self.lookup_typeinfo_or_incomplete_defn_error( fullnames.RELATED_MANAGER_CLASS ) # noqa: E501 - # TODO: Use default manager instead of 'objects' - # See: https://docs.djangoproject.com/en/dev/topics/db/queries/#using-a-custom-reverse-manager - objects = related_model_info.get("objects") - if not objects: + default_manager = related_model_info.get("_default_manager") + if not default_manager: raise helpers.IncompleteDefnException() except helpers.IncompleteDefnException as exc: if not self.api.final_iteration: @@ -339,7 +337,7 @@ def run_with_model_cls(self, model_cls: Type[Model]) -> None: # create new RelatedManager subclass parametrized_related_manager_type = Instance(related_manager_info, [Instance(related_model_info, [])]) - default_manager_type = objects.type + default_manager_type = default_manager.type if default_manager_type is None: default_manager_type = self.try_generate_related_manager(related_model_cls, related_model_info) if ( @@ -360,7 +358,7 @@ def run_with_model_cls(self, model_cls: Type[Model]) -> None: def try_generate_related_manager( self, related_model_cls: Type[Model], related_model_info: TypeInfo ) -> Optional[Instance]: - manager = related_model_cls._meta.managers_map["objects"] + manager = related_model_cls._meta.managers_map["_default_manager"] base_manager_fullname = helpers.get_class_fullname(manager.__class__.__bases__[0]) manager_fullname = helpers.get_class_fullname(manager.__class__) generated_managers = self.get_generated_manager_mappings(base_manager_fullname) diff --git a/tests/typecheck/fields/test_related.yml b/tests/typecheck/fields/test_related.yml index a28230b3d..1120678c3 100644 --- a/tests/typecheck/fields/test_related.yml +++ b/tests/typecheck/fields/test_related.yml @@ -608,8 +608,14 @@ reveal_type(Article().registered_by_user) # N: Revealed type is "myapp.models.MyUser*" user = MyUser() - reveal_type(user.book_set) # N: Revealed type is "django.db.models.manager.RelatedManager[myapp.models.Book]" - reveal_type(user.article_set) # N: Revealed type is "django.db.models.manager.RelatedManager[myapp.models.Article]" + reveal_type(user.book_set) # N: Revealed type is "myapp.models.MyUser_Book_RelatedManager1" + reveal_type(user.article_set) # N: Revealed type is "myapp.models.MyUser_Article_RelatedManager1" + reveal_type(user.book_set.add) # N: Revealed type is "def (*objs: Union[myapp.models.Book*, builtins.int], *, bulk: builtins.bool =)" + reveal_type(user.article_set.add) # N: Revealed type is "def (*objs: Union[myapp.models.Article*, builtins.int], *, bulk: builtins.bool =)" + reveal_type(user.book_set.filter) # N: Revealed type is "def (*args: Any, **kwargs: Any) -> myapp.models.LibraryEntityQuerySet[myapp.models.Book*]" + reveal_type(user.article_set.filter) # N: Revealed type is "def (*args: Any, **kwargs: Any) -> myapp.models.LibraryEntityQuerySet[myapp.models.Article*]" + reveal_type(user.book_set.queryset_method()) # N: Revealed type is "builtins.int" + reveal_type(user.article_set.queryset_method()) # N: Revealed type is "builtins.int" installed_apps: - myapp files: @@ -620,11 +626,13 @@ class MyUser(models.Model): pass class LibraryEntityQuerySet(models.QuerySet): - pass + def queryset_method(self) -> int: + return 1 + LibraryEntityManager = models.Manager.from_queryset(LibraryEntityQuerySet) class LibraryEntity(models.Model): class Meta: abstract = True - objects = models.Manager.from_queryset(LibraryEntityQuerySet)() + objects = LibraryEntityManager() registered_by_user = models.ForeignKey(MyUser, on_delete=models.CASCADE) class Book(LibraryEntity): pass