diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7130eca8d..51f6caa8b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -49,6 +49,35 @@ jobs: - name: Run mypy on the test cases run: uv run mypy --strict tests + test: + timeout-minutes: 15 + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ['3.10', '3.11', '3.12', '3.13'] + shard: [0, 1, 2, 3] + fail-fast: false + steps: + - uses: actions/checkout@v5 + - name: Setup system dependencies + run: | + sudo apt-get update + sudo apt-get install binutils libproj-dev gdal-bin + - name: Install uv + uses: astral-sh/setup-uv@v6 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v6 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: uv sync --no-dev --group tests + + # Must match `shard` definition in the test matrix: + - name: Run pytest tests + run: uv run pytest --num-shards=4 --shard-id=${{ matrix.shard }} -n auto tests + + stubtest: timeout-minutes: 10 runs-on: ubuntu-latest diff --git a/README.md b/README.md index 5805d9173..101a2c8dd 100644 --- a/README.md +++ b/README.md @@ -146,6 +146,14 @@ The supported settings are: Set to `false` if using dynamic settings, as [described below](https://github.com/typeddjango/django-stubs#how-to-use-a-custom-library-to-handle-django-settings). +- `strict_model_abstract_attrs`, a boolean, default `true`. + + Set to `false` if you want to keep `.objects`, `.DoesNotExist`, + and `.MultipleObjectsReturned` attributes on `models.Model` type. + [See here why](https://github.com/typeddjango/django-stubs?tab=readme-ov-file#how-to-use-typemodel-annotation-with-objects-attribute) + this is dangerous to do by default. + + ## FAQ ### Is this an official Django project? @@ -389,6 +397,11 @@ def assert_zero_count(model_type: type[models.Model]) -> None: assert model_type._default_manager.count() == 0 ``` +Configurable with `strict_model_abstract_attrs = false` +to skip removing `.objects`, `.DoesNotExist`, and `.MultipleObjectsReturned` +attributes from `model.Model` if you are using our mypy plugin. + +Use this setting on your own risk, because it can hide valid errors. ### How to type a custom `models.Field`? diff --git a/mypy_django_plugin/config.py b/mypy_django_plugin/config.py index a8f53bb99..be65d1971 100644 --- a/mypy_django_plugin/config.py +++ b/mypy_django_plugin/config.py @@ -18,6 +18,7 @@ [mypy.plugins.django-stubs] django_settings_module = str (default: `os.getenv("DJANGO_SETTINGS_MODULE")`) strict_settings = bool (default: true) +strict_model_abstract_attrs = bool (default: true) ... """ TOML_USAGE = """ @@ -26,14 +27,17 @@ [tool.django-stubs] django_settings_module = str (default: `os.getenv("DJANGO_SETTINGS_MODULE")`) strict_settings = bool (default: true) +strict_model_abstract_attrs = bool (default: true) ... """ 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" DJANGO_SETTINGS_ENV_VAR = "DJANGO_SETTINGS_MODULE" -MISSING_DJANGO_SETTINGS = f"missing required 'django_settings_module' config.\ - Either specify this config or set your `{DJANGO_SETTINGS_ENV_VAR}` env var" +MISSING_DJANGO_SETTINGS = ( + "missing required 'django_settings_module' config.\n" + f"Either specify this config or set your `{DJANGO_SETTINGS_ENV_VAR}` env var" +) INVALID_BOOL_SETTING = "invalid {key!r}: the setting must be a boolean" @@ -54,7 +58,8 @@ def exit_with_error(msg: str, is_toml: bool = False) -> NoReturn: class DjangoPluginConfig: - __slots__ = ("django_settings_module", "strict_settings") + __slots__ = ("django_settings_module", "strict_settings", "strict_model_abstract_attrs") + django_settings_module: str strict_settings: bool @@ -96,6 +101,9 @@ def parse_toml_file(self, filepath: Path) -> None: self.strict_settings = config.get("strict_settings", True) if not isinstance(self.strict_settings, bool): toml_exit(INVALID_BOOL_SETTING.format(key="strict_settings")) + self.strict_model_abstract_attrs = config.get("strict_model_abstract_attrs", True) + if not isinstance(self.strict_model_abstract_attrs, bool): + toml_exit(INVALID_BOOL_SETTING.format(key="strict_model_abstract_attrs")) def parse_ini_file(self, filepath: Path) -> None: parser = configparser.ConfigParser() @@ -124,10 +132,16 @@ def parse_ini_file(self, filepath: Path) -> None: except ValueError: exit_with_error(INVALID_BOOL_SETTING.format(key="strict_settings")) + try: + self.strict_model_abstract_attrs = parser.getboolean(section, "strict_model_abstract_attrs", fallback=True) + except ValueError: + exit_with_error(INVALID_BOOL_SETTING.format(key="strict_model_abstract_attrs")) + def to_json(self, extra_data: dict[str, Any]) -> dict[str, Any]: """We use this method to reset mypy cache via `report_config_data` hook.""" return { "django_settings_module": self.django_settings_module, "strict_settings": self.strict_settings, + "strict_model_abstract_attrs": self.strict_model_abstract_attrs, **dict(sorted(extra_data.items())), } diff --git a/mypy_django_plugin/main.py b/mypy_django_plugin/main.py index a49a91543..3d9ca48f1 100644 --- a/mypy_django_plugin/main.py +++ b/mypy_django_plugin/main.py @@ -45,6 +45,7 @@ resolve_manager_method, ) from mypy_django_plugin.transformers.models import ( + MetaclassAdjustments, handle_annotated_type, process_model_class, set_auth_user_model_boolean_fields, @@ -211,7 +212,7 @@ def get_customize_class_mro_hook(self, fullname: str) -> Callable[[ClassDefConte def get_metaclass_hook(self, fullname: str) -> Callable[[ClassDefContext], None] | None: if fullname == fullnames.MODEL_METACLASS_FULLNAME: - return None + return partial(MetaclassAdjustments.adjust_model_class, plugin_config=self.plugin_config) return None def get_base_class_hook(self, fullname: str) -> Callable[[ClassDefContext], None] | None: @@ -291,11 +292,9 @@ def get_dynamic_class_hook(self, fullname: str) -> Callable[[DynamicClassDefCont def report_config_data(self, ctx: ReportConfigContext) -> dict[str, Any]: # Cache would be cleared if any settings do change. - extra_data = {} - # In all places we use '_User' alias as a type we want to clear cache if - # AUTH_USER_MODEL setting changes - if ctx.id.startswith("django.contrib.auth") or ctx.id in {"django.http.request", "django.test.client"}: - extra_data["AUTH_USER_MODEL"] = self.django_context.settings.AUTH_USER_MODEL + extra_data = { + "AUTH_USER_MODEL": self.django_context.settings.AUTH_USER_MODEL, + } return self.plugin_config.to_json(extra_data) diff --git a/mypy_django_plugin/transformers/models.py b/mypy_django_plugin/transformers/models.py index 31964c4a6..17b68e271 100644 --- a/mypy_django_plugin/transformers/models.py +++ b/mypy_django_plugin/transformers/models.py @@ -40,6 +40,7 @@ from mypy.types import Type as MypyType from mypy.typevars import fill_typevars, fill_typevars_with_any +from mypy_django_plugin.config import DjangoPluginConfig from mypy_django_plugin.django.context import DjangoContext from mypy_django_plugin.errorcodes import MANAGER_MISSING from mypy_django_plugin.exceptions import UnregisteredModelError @@ -991,13 +992,35 @@ def create_many_related_manager(self, model: Instance) -> None: class MetaclassAdjustments(ModelClassInitializer): @classmethod - def adjust_model_class(cls, ctx: ClassDefContext) -> None: + def adjust_model_class(cls, ctx: ClassDefContext, plugin_config: DjangoPluginConfig) -> None: """ For the sake of type checkers other than mypy, some attributes that are dynamically added by Django's model metaclass has been annotated on `django.db.models.base.Model`. We remove those attributes and will handle them through the plugin. + + Configurable with `strict_model_abstract_attrs = false` to skip removing any objects from models. + + Basically, this code:: + + from django.db import models + + def get_model_count(model_type: type[models.Model]) -> int: + return model_type.objects.count() + + - Will raise an error + `"type[models.Model]" has no attribute "objects" [attr-defined]` + when `strict_model_abstract_attrs = true` (default) + - Return without any type errors + when `strict_model_abstract_attrs = false` + + But, beware that the code above can fail in runtime, as mypy correctly shows. + Example: `get_model_count(models.Model)` + + Turn this setting off at your own risk. """ + if not plugin_config.strict_model_abstract_attrs: + return if ctx.cls.fullname != fullnames.MODEL_CLASS_FULLNAME: return @@ -1006,8 +1029,6 @@ def adjust_model_class(cls, ctx: ClassDefContext) -> None: if attr is not None and isinstance(attr.node, Var) and not attr.plugin_generated: del ctx.cls.info.names[attr_name] - return - def get_exception_bases(self, name: str) -> list[Instance]: bases = [] for model_base in self.model_classdef.info.direct_base_classes(): diff --git a/tests/test_error_handling.py b/tests/test_error_handling.py index 32d29bb95..d7f8fb32f 100644 --- a/tests/test_error_handling.py +++ b/tests/test_error_handling.py @@ -16,6 +16,7 @@ [mypy.plugins.django-stubs] django_settings_module = str (default: `os.getenv("DJANGO_SETTINGS_MODULE")`) strict_settings = bool (default: true) +strict_model_abstract_attrs = bool (default: true) ... (django-stubs) mypy: error: {} """ @@ -26,6 +27,7 @@ [tool.django-stubs] django_settings_module = str (default: `os.getenv("DJANGO_SETTINGS_MODULE")`) strict_settings = bool (default: true) +strict_model_abstract_attrs = bool (default: true) ... (django-stubs) mypy: error: {} """ @@ -49,20 +51,33 @@ def write_to_file(file_contents: str, suffix: str | None = None) -> Generator[st ), pytest.param( ["[mypy.plugins.django-stubs]", "\tnot_django_not_settings_module = badbadmodule"], - "missing required 'django_settings_module' config.\ - Either specify this config or set your `DJANGO_SETTINGS_MODULE` env var", + ( + "missing required 'django_settings_module' config.\n" + "Either specify this config or set your `DJANGO_SETTINGS_MODULE` env var" + ), id="missing-settings-module", ), pytest.param( ["[mypy.plugins.django-stubs]"], - "missing required 'django_settings_module' config.\ - Either specify this config or set your `DJANGO_SETTINGS_MODULE` env var", + ( + "missing required 'django_settings_module' config.\n" + "Either specify this config or set your `DJANGO_SETTINGS_MODULE` env var" + ), id="no-settings-given", ), pytest.param( ["[mypy.plugins.django-stubs]", "django_settings_module = some.module", "strict_settings = bad"], "invalid 'strict_settings': the setting must be a boolean", - id="missing-settings-module", + id="invalid-strict_settings", + ), + pytest.param( + [ + "[mypy.plugins.django-stubs]", + "django_settings_module = some.module", + "strict_model_abstract_attrs = bad", + ], + "invalid 'strict_model_abstract_attrs': the setting must be a boolean", + id="invalid-strict_model_abstract_attrs", ), ], ) @@ -117,8 +132,10 @@ def test_handles_filename(capsys: Any, filename: str) -> None: [tool.django-stubs] not_django_not_settings_module = "badbadmodule" """, - "missing required 'django_settings_module' config.\ - Either specify this config or set your `DJANGO_SETTINGS_MODULE` env var", + ( + "missing required 'django_settings_module' config.\n" + "Either specify this config or set your `DJANGO_SETTINGS_MODULE` env var" + ), id="missing django_settings_module", ), pytest.param( @@ -135,6 +152,15 @@ def test_handles_filename(capsys: Any, filename: str) -> None: "invalid 'strict_settings': the setting must be a boolean", id="invalid strict_settings type", ), + pytest.param( + """ + [tool.django-stubs] + django_settings_module = "some.module" + strict_model_abstract_attrs = "a" + """, + "invalid 'strict_model_abstract_attrs': the setting must be a boolean", + id="invalid strict_model_abstract_attrs type", + ), ], ) def test_toml_misconfiguration_handling(capsys: Any, config_file_contents: str, message_part: str) -> None: diff --git a/tests/typecheck/contrib/auth/test_decorators.yml b/tests/typecheck/contrib/auth/test_decorators.yml index 6a5addbce..adf3eb238 100644 --- a/tests/typecheck/contrib/auth/test_decorators.yml +++ b/tests/typecheck/contrib/auth/test_decorators.yml @@ -1,89 +1,122 @@ -- case: login_required_bare +- case: login_required main: | from typing import Any from typing_extensions import reveal_type + from django.core.handlers.asgi import ASGIRequest + from django.core.handlers.wsgi import WSGIRequest from django.contrib.auth.decorators import login_required from django.http import HttpRequest, HttpResponse + + # Bare: + @login_required - def view_func(request: HttpRequest) -> HttpResponse: ... - reveal_type(view_func) # N: Revealed type is "def (request: django.http.request.HttpRequest) -> django.http.response.HttpResponse" -- case: login_required_bare_async - main: | - from typing import Any - from typing_extensions import reveal_type - from django.contrib.auth.decorators import login_required - from django.http import HttpRequest, HttpResponse + def view_func1(request: HttpRequest) -> HttpResponse: ... + reveal_type(view_func1) # N: Revealed type is "def (request: django.http.request.HttpRequest) -> django.http.response.HttpResponse" + @login_required - async def view_func(request: HttpRequest) -> HttpResponse: ... - reveal_type(view_func) # N: Revealed type is "def (request: django.http.request.HttpRequest) -> typing.Coroutine[Any, Any, django.http.response.HttpResponse]" -- case: login_required_fancy - main: | - from typing_extensions import reveal_type - from django.contrib.auth.decorators import login_required - from django.core.handlers.wsgi import WSGIRequest - from django.http import HttpResponse + async def view_func2(request: HttpRequest) -> HttpResponse: ... + reveal_type(view_func2) # N: Revealed type is "def (request: django.http.request.HttpRequest) -> typing.Coroutine[Any, Any, django.http.response.HttpResponse]" + + # Fancy: + @login_required(redirect_field_name='a', login_url='b') - def view_func(request: WSGIRequest, arg: str) -> HttpResponse: ... - reveal_type(view_func) # N: Revealed type is "def (request: django.core.handlers.wsgi.WSGIRequest, arg: builtins.str) -> django.http.response.HttpResponse" -- case: login_required_fancy_async - main: | - from typing_extensions import reveal_type - from django.contrib.auth.decorators import login_required - from django.core.handlers.asgi import ASGIRequest - from django.http import HttpResponse + def view_func3(request: WSGIRequest, arg: str) -> HttpResponse: ... + reveal_type(view_func3) # N: Revealed type is "def (request: django.core.handlers.wsgi.WSGIRequest, arg: builtins.str) -> django.http.response.HttpResponse" + @login_required(redirect_field_name='a', login_url='b') - async def view_func(request: ASGIRequest, arg: str) -> HttpResponse: ... - reveal_type(view_func) # N: Revealed type is "def (request: django.core.handlers.asgi.ASGIRequest, arg: builtins.str) -> typing.Coroutine[Any, Any, django.http.response.HttpResponse]" -- case: login_required_weird - main: | - from typing_extensions import reveal_type - from django.contrib.auth.decorators import login_required - from django.http import HttpRequest, HttpResponse + async def view_func4(request: ASGIRequest, arg: str) -> HttpResponse: ... + reveal_type(view_func4) # N: Revealed type is "def (request: django.core.handlers.asgi.ASGIRequest, arg: builtins.str) -> typing.Coroutine[Any, Any, django.http.response.HttpResponse]" + # This is non-conventional usage, but covered in Django tests, so we allow it. - def view_func(request: HttpRequest) -> HttpResponse: ... - wrapped_view = login_required(view_func, redirect_field_name='a', login_url='b') + + def view_func5(request: HttpRequest) -> HttpResponse: ... + wrapped_view = login_required(view_func5, redirect_field_name='a', login_url='b') reveal_type(wrapped_view) # N: Revealed type is "def (request: django.http.request.HttpRequest) -> django.http.response.HttpResponse" -- case: login_required_incorrect_return + + # Wrong: + + @login_required() # E: Value of type variable "_VIEW" of function cannot be "Callable[[Any], str]" [type-var] + def view_func6(request: Any) -> str: ... + + custom_settings: | + INSTALLED_APPS = ("django.contrib.contenttypes", "django.contrib.auth", "myapp") + AUTH_USER_MODEL = "myapp.MyUser" + files: + - path: myapp/__init__.py + - path: myapp/models.py + content: | + from django.db import models + + class MyUser(models.Model): + ... + + +- case: login_required_default_settings main: | from typing import Any + from typing_extensions import reveal_type + from django.core.handlers.asgi import ASGIRequest + from django.core.handlers.wsgi import WSGIRequest from django.contrib.auth.decorators import login_required + from django.http import HttpRequest, HttpResponse + + @login_required + def view_func1(request: HttpRequest) -> HttpResponse: ... + reveal_type(view_func1) # N: Revealed type is "def (request: django.http.request.HttpRequest) -> django.http.response.HttpResponse" + + @login_required + async def view_func2(request: HttpRequest) -> HttpResponse: ... + reveal_type(view_func2) # N: Revealed type is "def (request: django.http.request.HttpRequest) -> typing.Coroutine[Any, Any, django.http.response.HttpResponse]" + @login_required() # E: Value of type variable "_VIEW" of function cannot be "Callable[[Any], str]" [type-var] - def view_func2(request: Any) -> str: ... + def view_func3(request: Any) -> str: ... + + - case: user_passes_test main: | from typing_extensions import reveal_type from django.contrib.auth.decorators import user_passes_test from django.http import HttpRequest, HttpResponse + + @user_passes_test(lambda u: u.is_authenticated and u.is_active) + def view_func1(request: HttpRequest) -> HttpResponse: ... + reveal_type(view_func1) # N: Revealed type is "def (request: django.http.request.HttpRequest) -> django.http.response.HttpResponse" + @user_passes_test(lambda u: u.get_username().startswith('super')) - def view_func(request: HttpRequest) -> HttpResponse: ... - reveal_type(view_func) # N: Revealed type is "def (request: django.http.request.HttpRequest) -> django.http.response.HttpResponse" -- case: user_passes_test_async - main: | - from typing_extensions import reveal_type - from django.contrib.auth.decorators import user_passes_test - from django.http import HttpRequest, HttpResponse - @user_passes_test(lambda u: u.get_username().startswith('super')) - async def view_func(request: HttpRequest) -> HttpResponse: ... - reveal_type(view_func) # N: Revealed type is "def (request: django.http.request.HttpRequest) -> typing.Coroutine[Any, Any, django.http.response.HttpResponse]" -- case: user_passes_test_bare_is_error - main: | - from django.http import HttpRequest, HttpResponse - from django.contrib.auth.decorators import user_passes_test - @user_passes_test # E: Argument 1 to "user_passes_test" has incompatible type "Callable[[HttpRequest], HttpResponse]"; expected "Callable[[AbstractBaseUser | AnonymousUser], bool]" [arg-type] - def view_func(request: HttpRequest) -> HttpResponse: ... + async def view_func2(request: HttpRequest) -> HttpResponse: ... + reveal_type(view_func2) # N: Revealed type is "def (request: django.http.request.HttpRequest) -> typing.Coroutine[Any, Any, django.http.response.HttpResponse]" + + # Wrong: + + @user_passes_test # E: Argument 1 to "user_passes_test" has incompatible type "Callable[[HttpRequest], HttpResponse]"; expected "Callable[[User | AnonymousUser], bool]" [arg-type] + def view_func3(request: HttpRequest) -> HttpResponse: ... + + @user_passes_test(lambda u: u.get_username().startswith('super')) # E: Value of type variable "_VIEW" of function cannot be "Callable[[str], str]" [type-var] + def view_func4(request: str) -> str: ... + custom_settings: | + INSTALLED_APPS = ("django.contrib.contenttypes", "django.contrib.auth") + - case: permission_required main: | + from typing import Any from typing_extensions import reveal_type from django.contrib.auth.decorators import permission_required from django.http import HttpRequest, HttpResponse + @permission_required('polls.can_vote') - def view_func(request: HttpRequest) -> HttpResponse: ... - reveal_type(view_func) # N: Revealed type is "def (request: django.http.request.HttpRequest) -> django.http.response.HttpResponse" -- case: permission_required_async - main: | - from typing_extensions import reveal_type - from django.contrib.auth.decorators import permission_required - from django.http import HttpRequest, HttpResponse - @permission_required('polls.can_vote') - async def view_func(request: HttpRequest) -> HttpResponse: ... - reveal_type(view_func) # N: Revealed type is "def (request: django.http.request.HttpRequest) -> typing.Coroutine[Any, Any, django.http.response.HttpResponse]" + def view_func1(request: HttpRequest) -> HttpResponse: ... + reveal_type(view_func1) # N: Revealed type is "def (request: django.http.request.HttpRequest) -> django.http.response.HttpResponse" + + @permission_required(['polls.can_vote', 'polls.can_close'], login_url='/abc') + async def view_func2(request: HttpRequest) -> HttpResponse: ... + reveal_type(view_func2) # N: Revealed type is "def (request: django.http.request.HttpRequest) -> typing.Coroutine[Any, Any, django.http.response.HttpResponse]" + + # Wrong: + + @permission_required('polls.can_vote') # E: Value of type variable "_VIEW" of function cannot be "Callable[[Any], str]" [type-var] + def view_func3(request: Any) -> str: ... + + @permission_required # E: Argument 1 to "permission_required" has incompatible type "Callable[[HttpRequest], HttpResponse]"; expected "Iterable[str] | str" [arg-type] + def view_func4(request: HttpRequest) -> HttpResponse: ... + custom_settings: | + INSTALLED_APPS = ("django.contrib.contenttypes", "django.contrib.auth") diff --git a/tests/typecheck/models/test_metaclass.yml b/tests/typecheck/models/test_metaclass.yml index fc3031c0b..81f292c6e 100644 --- a/tests/typecheck/models/test_metaclass.yml +++ b/tests/typecheck/models/test_metaclass.yml @@ -74,6 +74,87 @@ class Concrete3(Concrete2): ... +- case: test_contributions_from_modelbase_metaclass_no_processing + mypy_config: | + [mypy.plugins.django-stubs] + django_settings_module = mysettings + strict_model_abstract_attrs = false + main: | + from typing_extensions import reveal_type + from myapp.models import Abstract1, Abstract2, Concrete1, Concrete2, Concrete3 + from django.db import models + + reveal_type(models.Model.DoesNotExist) # N: Revealed type is "type[django.core.exceptions.ObjectDoesNotExist]" + reveal_type(models.Model.MultipleObjectsReturned) # N: Revealed type is "type[django.core.exceptions.MultipleObjectsReturned]" + reveal_type(models.Model.objects) # N: Revealed type is "django.db.models.manager.Manager[django.db.models.base.Model]" + + reveal_type(Abstract1.DoesNotExist) # N: Revealed type is "type[django.core.exceptions.ObjectDoesNotExist]" + reveal_type(Abstract2.DoesNotExist) # N: Revealed type is "type[django.core.exceptions.ObjectDoesNotExist]" + reveal_type(Concrete1.DoesNotExist) # N: Revealed type is "def (*args: builtins.object) -> myapp.models.Concrete1.DoesNotExist" + reveal_type(Concrete2.DoesNotExist) # N: Revealed type is "def (*args: builtins.object) -> myapp.models.Concrete2.DoesNotExist" + + reveal_type(Abstract1.MultipleObjectsReturned) # N: Revealed type is "type[django.core.exceptions.MultipleObjectsReturned]" + reveal_type(Abstract2.MultipleObjectsReturned) # N: Revealed type is "type[django.core.exceptions.MultipleObjectsReturned]" + reveal_type(Concrete1.MultipleObjectsReturned) # N: Revealed type is "def (*args: builtins.object) -> myapp.models.Concrete1.MultipleObjectsReturned" + reveal_type(Concrete2.MultipleObjectsReturned) # N: Revealed type is "def (*args: builtins.object) -> myapp.models.Concrete2.MultipleObjectsReturned" + + reveal_type(super(Concrete1, Concrete1()).DoesNotExist()) # N: Revealed type is "django.core.exceptions.ObjectDoesNotExist" + reveal_type(super(Concrete2, Concrete2()).DoesNotExist()) # N: Revealed type is "myapp.models.Concrete1.DoesNotExist" + + a: type[Concrete1.DoesNotExist] + a = Concrete1.DoesNotExist + a = Concrete2.DoesNotExist + a = Concrete3.DoesNotExist + + b: type[Concrete2.DoesNotExist] + b = Concrete1.DoesNotExist # E: Incompatible types in assignment (expression has type "type[myapp.models.Concrete1.DoesNotExist]", variable has type "type[myapp.models.Concrete2.DoesNotExist]") [assignment] + b = Concrete2.DoesNotExist + b = Concrete3.DoesNotExist + + c: type[Concrete3.DoesNotExist] + c = Concrete1.DoesNotExist # E: Incompatible types in assignment (expression has type "type[myapp.models.Concrete1.DoesNotExist]", variable has type "type[myapp.models.Concrete3.DoesNotExist]") [assignment] + c = Concrete2.DoesNotExist # E: Incompatible types in assignment (expression has type "type[myapp.models.Concrete2.DoesNotExist]", variable has type "type[myapp.models.Concrete3.DoesNotExist]") [assignment] + c = Concrete3.DoesNotExist + + d: type[Concrete1.MultipleObjectsReturned] + d = Concrete1.MultipleObjectsReturned + d = Concrete2.MultipleObjectsReturned + d = Concrete3.MultipleObjectsReturned + + e: type[Concrete2.MultipleObjectsReturned] + e = Concrete1.MultipleObjectsReturned # E: Incompatible types in assignment (expression has type "type[myapp.models.Concrete1.MultipleObjectsReturned]", variable has type "type[myapp.models.Concrete2.MultipleObjectsReturned]") [assignment] + e = Concrete2.MultipleObjectsReturned + e = Concrete3.MultipleObjectsReturned + + f: type[Concrete3.MultipleObjectsReturned] + f = Concrete1.MultipleObjectsReturned # E: Incompatible types in assignment (expression has type "type[myapp.models.Concrete1.MultipleObjectsReturned]", variable has type "type[myapp.models.Concrete3.MultipleObjectsReturned]") [assignment] + f = Concrete2.MultipleObjectsReturned # E: Incompatible types in assignment (expression has type "type[myapp.models.Concrete2.MultipleObjectsReturned]", variable has type "type[myapp.models.Concrete3.MultipleObjectsReturned]") [assignment] + f = Concrete3.MultipleObjectsReturned + installed_apps: + - myapp + files: + - path: myapp/__init__.py + - path: myapp/models.py + content: | + from django.db import models + + class Abstract1(models.Model): + class Meta: + abstract = True + + class Abstract2(Abstract1): + class Meta: + abstract = True + + class Concrete1(Abstract2): + ... + + class Concrete2(Concrete1): + ... + + class Concrete3(Concrete2): + ... + - case: test_custom_model_base_metaclass main: | from typing_extensions import reveal_type