diff --git a/README.md b/README.md index 294b495..7352d4a 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,7 @@ We rely on different `django` and `mypy` versions: | django-stubs | mypy version | django version | python version | ------------ | ---- | ---- | ---- | +| 1.7.0 | 0.790 | 2.2.x \|\| 3.x | ^3.6 | 1.6.0 | 0.780 | 2.2.x \|\| 3.x | ^3.6 | 1.5.0 | 0.770 | 2.2.x \|\| 3.x | ^3.6 | 1.4.0 | 0.760 | 2.2.x \|\| 3.x | ^3.6 @@ -77,11 +78,13 @@ option to get extra information about the error. ### I cannot use QuerySet or Manager with type annotations You can get a `TypeError: 'type' object is not subscriptable` -when you will try to use `QuerySet[MyModel]` or `Manager[MyModel]`. +when you will try to use `QuerySet[MyModel]`, `Manager[MyModel]` or some other Django-based Generic types. -This happens because Django classes do not support [`__class_getitem__`](https://www.python.org/dev/peps/pep-0560/#class-getitem) magic method. +This happens because these Django classes do not support [`__class_getitem__`](https://www.python.org/dev/peps/pep-0560/#class-getitem) magic method in runtime. -You can use strings instead: `'QuerySet[MyModel]'` and `'Manager[MyModel]'`, this way it will work as a type for `mypy` and as a regular `str` in runtime. +1. You can go with our + +2. You can use strings instead: `'QuerySet[MyModel]'` and `'Manager[MyModel]'`, this way it will work as a type for `mypy` and as a regular `str` in runtime. Currently we [are working](https://github.com/django/django/pull/12405) on providing `__class_getitem__` to the classes where we need them. diff --git a/django-stubs/core/management/commands/compilemessages.pyi b/django-stubs/core/management/commands/compilemessages.pyi index f841ee4..64fd802 100644 --- a/django-stubs/core/management/commands/compilemessages.pyi +++ b/django-stubs/core/management/commands/compilemessages.pyi @@ -1,5 +1,9 @@ import os -from django.core.management.base import BaseCommand as BaseCommand, CommandError as CommandError, CommandParser as CommandParser +from django.core.management.base import ( + BaseCommand as BaseCommand, + CommandError as CommandError, + CommandParser as CommandParser, +) from django.core.management.utils import find_command as find_command, popen_wrapper as popen_wrapper from typing import List, Tuple, Union diff --git a/django-stubs/core/management/commands/createcachetable.pyi b/django-stubs/core/management/commands/createcachetable.pyi index 19a5e0f..e5ca53d 100644 --- a/django-stubs/core/management/commands/createcachetable.pyi +++ b/django-stubs/core/management/commands/createcachetable.pyi @@ -2,7 +2,14 @@ from django.conf import settings as settings from django.core.cache import caches as caches from django.core.cache.backends.db import BaseDatabaseCache as BaseDatabaseCache from django.core.management.base import BaseCommand as BaseCommand, CommandError as CommandError -from django.db import DEFAULT_DB_ALIAS as DEFAULT_DB_ALIAS, DatabaseError as DatabaseError, connections as connections, models as models, router as router, transaction as transaction +from django.db import ( + DEFAULT_DB_ALIAS as DEFAULT_DB_ALIAS, + DatabaseError as DatabaseError, + connections as connections, + models as models, + router as router, + transaction as transaction, +) from typing import Any, List class Command(BaseCommand): diff --git a/django-stubs/core/management/commands/dbshell.pyi b/django-stubs/core/management/commands/dbshell.pyi index b392dc8..7a1277e 100644 --- a/django-stubs/core/management/commands/dbshell.pyi +++ b/django-stubs/core/management/commands/dbshell.pyi @@ -1,4 +1,8 @@ -from django.core.management.base import BaseCommand as BaseCommand, CommandError as CommandError, CommandParser as CommandParser +from django.core.management.base import ( + BaseCommand as BaseCommand, + CommandError as CommandError, + CommandParser as CommandParser, +) from django.db import DEFAULT_DB_ALIAS as DEFAULT_DB_ALIAS, connections as connections class Command(BaseCommand): ... diff --git a/django-stubs/core/management/commands/diffsettings.pyi b/django-stubs/core/management/commands/diffsettings.pyi index d4c5168..b5b2229 100644 --- a/django-stubs/core/management/commands/diffsettings.pyi +++ b/django-stubs/core/management/commands/diffsettings.pyi @@ -4,5 +4,9 @@ from typing import Any, Callable, Dict, List def module_to_dict(module: Any, omittable: Callable[[str], bool] = ...) -> Dict[str, str]: ... class Command(BaseCommand): - def output_hash(self, user_settings: Dict[str, str], default_settings: Dict[str, str], **options: Any) -> List[str]: ... - def output_unified(self, user_settings: Dict[str, str], default_settings: Dict[str, str], **options: Any) -> List[str]: ... + def output_hash( + self, user_settings: Dict[str, str], default_settings: Dict[str, str], **options: Any + ) -> List[str]: ... + def output_unified( + self, user_settings: Dict[str, str], default_settings: Dict[str, str], **options: Any + ) -> List[str]: ... diff --git a/django-stubs/core/management/commands/inspectdb.pyi b/django-stubs/core/management/commands/inspectdb.pyi index 2d8367f..de0e24d 100644 --- a/django-stubs/core/management/commands/inspectdb.pyi +++ b/django-stubs/core/management/commands/inspectdb.pyi @@ -7,6 +7,10 @@ class Command(BaseCommand): stealth_options: Tuple[str] = ... db_module: str = ... def handle_inspection(self, options: Dict[str, Any]) -> Iterable[str]: ... - def normalize_col_name(self, col_name: str, used_column_names: List[str], is_relation: bool) -> Tuple[str, Dict[str, str], List[str]]: ... + def normalize_col_name( + self, col_name: str, used_column_names: List[str], is_relation: bool + ) -> Tuple[str, Dict[str, str], List[str]]: ... def get_field_type(self, connection: Any, table_name: Any, row: Any) -> Tuple[str, Dict[str, str], List[str]]: ... - def get_meta(self, table_name: str, constraints: Any, column_to_field_name: Any, is_view: Any, is_partition: Any) -> List[str]: ... + def get_meta( + self, table_name: str, constraints: Any, column_to_field_name: Any, is_view: Any, is_partition: Any + ) -> List[str]: ... diff --git a/django-stubs/core/management/commands/makemigrations.pyi b/django-stubs/core/management/commands/makemigrations.pyi index b2125a5..d8390c5 100644 --- a/django-stubs/core/management/commands/makemigrations.pyi +++ b/django-stubs/core/management/commands/makemigrations.pyi @@ -1,11 +1,24 @@ from django.apps import apps as apps from django.conf import settings as settings -from django.core.management.base import BaseCommand as BaseCommand, CommandError as CommandError, no_translations as no_translations -from django.db import DEFAULT_DB_ALIAS as DEFAULT_DB_ALIAS, OperationalError as OperationalError, connections as connections, router as router +from django.core.management.base import ( + BaseCommand as BaseCommand, + CommandError as CommandError, + no_translations as no_translations, +) +from django.db import ( + DEFAULT_DB_ALIAS as DEFAULT_DB_ALIAS, + OperationalError as OperationalError, + connections as connections, + router as router, +) from django.db.migrations import Migration as Migration from django.db.migrations.autodetector import MigrationAutodetector as MigrationAutodetector from django.db.migrations.loader import MigrationLoader as MigrationLoader -from django.db.migrations.questioner import InteractiveMigrationQuestioner as InteractiveMigrationQuestioner, MigrationQuestioner as MigrationQuestioner, NonInteractiveMigrationQuestioner as NonInteractiveMigrationQuestioner +from django.db.migrations.questioner import ( + InteractiveMigrationQuestioner as InteractiveMigrationQuestioner, + MigrationQuestioner as MigrationQuestioner, + NonInteractiveMigrationQuestioner as NonInteractiveMigrationQuestioner, +) from django.db.migrations.state import ProjectState as ProjectState from django.db.migrations.utils import get_migration_name_timestamp as get_migration_name_timestamp from django.db.migrations.writer import MigrationWriter as MigrationWriter diff --git a/django-stubs/core/management/commands/migrate.pyi b/django-stubs/core/management/commands/migrate.pyi index d5e0b8b..17c432e 100644 --- a/django-stubs/core/management/commands/migrate.pyi +++ b/django-stubs/core/management/commands/migrate.pyi @@ -1,6 +1,13 @@ from django.apps import apps as apps -from django.core.management.base import BaseCommand as BaseCommand, CommandError as CommandError, no_translations as no_translations -from django.core.management.sql import emit_post_migrate_signal as emit_post_migrate_signal, emit_pre_migrate_signal as emit_pre_migrate_signal +from django.core.management.base import ( + BaseCommand as BaseCommand, + CommandError as CommandError, + no_translations as no_translations, +) +from django.core.management.sql import ( + emit_post_migrate_signal as emit_post_migrate_signal, + emit_pre_migrate_signal as emit_pre_migrate_signal, +) from django.db import DEFAULT_DB_ALIAS as DEFAULT_DB_ALIAS, connections as connections, router as router from django.db.migrations.autodetector import MigrationAutodetector as MigrationAutodetector from django.db.migrations.executor import MigrationExecutor as MigrationExecutor diff --git a/django-stubs/db/migrations/loader.pyi b/django-stubs/db/migrations/loader.pyi index ec0b505..0713719 100644 --- a/django-stubs/db/migrations/loader.pyi +++ b/django-stubs/db/migrations/loader.pyi @@ -3,7 +3,12 @@ from typing import Any, Dict, Optional, Sequence, Set, Tuple, Union from django.db.migrations.migration import Migration from django.db.migrations.state import ProjectState -from .exceptions import AmbiguityError as AmbiguityError, BadMigrationError as BadMigrationError, InconsistentMigrationHistory as InconsistentMigrationHistory, NodeNotFoundError as NodeNotFoundError +from .exceptions import ( + AmbiguityError as AmbiguityError, + BadMigrationError as BadMigrationError, + InconsistentMigrationHistory as InconsistentMigrationHistory, + NodeNotFoundError as NodeNotFoundError, +) MIGRATIONS_MODULE_NAME: str diff --git a/django_stubs_ext/django_stubs_ext/monkeypatch.py b/django_stubs_ext/django_stubs_ext/monkeypatch.py index eac324d..d6ec638 100644 --- a/django_stubs_ext/django_stubs_ext/monkeypatch.py +++ b/django_stubs_ext/django_stubs_ext/monkeypatch.py @@ -1,11 +1,14 @@ -from typing import Any, Generic, List, Optional, Type, TypeVar +from typing import Any, Generic, List, Optional, Tuple, Type, TypeVar import django from django.contrib.admin import ModelAdmin from django.contrib.admin.options import BaseModelAdmin +from django.db.models.manager import BaseManager +from django.db.models.query import QuerySet from django.views.generic.edit import FormMixin _T = TypeVar("_T") +_VersionSpec = Tuple[int, int] class MPGeneric(Generic[_T]): @@ -21,11 +24,15 @@ class MPGeneric(Generic[_T]): version: Optional[int] cls: Type[_T] - def __init__(self, cls: Type[_T], version: Optional[int] = None): + def __init__(self, cls: Type[_T], version: Optional[_VersionSpec] = None): """Set the data fields, basic constructor.""" self.version = version self.cls = cls + def __repr__(self) -> str: + """Better representation in tests and debug.""" + return "".format(self.cls, self.version or "all") + # certain django classes need to be generic, but lack the __class_getitem__ dunder needed to # annotate them: https://github.com/typeddjango/django-stubs/issues/507 @@ -34,13 +41,20 @@ _need_generic: List[MPGeneric[Any]] = [ MPGeneric(ModelAdmin), MPGeneric(FormMixin), MPGeneric(BaseModelAdmin), + # These types do have native `__class_getitem__` method since django 3.1: + MPGeneric(QuerySet, (3, 1)), + MPGeneric(BaseManager, (3, 1)), ] # currently just adds the __class_getitem__ dunder. if more monkeypatching is needed, add it here def monkeypatch() -> None: """Monkey patch django as necessary to work properly with mypy.""" - for el in filter(lambda x: django.VERSION[0] == x.version or x.version is None, _need_generic): + suited_for_this_version = filter( + spec.version is None or django.VERSION[:2] <= spec.version, + _need_generic, + ) + for el in suited_for_this_version: el.cls.__class_getitem__ = classmethod(lambda cls, *args, **kwargs: cls) diff --git a/django_stubs_ext/tests/test_monkeypatching.py b/django_stubs_ext/tests/test_monkeypatching.py index 741de9b..9320b7d 100644 --- a/django_stubs_ext/tests/test_monkeypatching.py +++ b/django_stubs_ext/tests/test_monkeypatching.py @@ -1,11 +1,49 @@ +import pytest + import django_stubs_ext -from django_stubs_ext.monkeypatch import _need_generic - -django_stubs_ext.monkeypatch() +from django_stubs_ext.monkeypatch import _need_generic, _VersionSpec, django -def test_patched_generics() -> None: +@pytest.fixture(scope="function") +def make_generic_classes(request, monkeypatch): + def fin(): + for el in _need_generic: + delattr(el.cls, "__class_getitem__") + + def factory(django_version=None): + if django_version is not None: + monkeypatch.setattr(django, "VERSION", django_version) + django_stubs_ext.monkeypatch() + + request.addfinalizer(fin) + return factory + + +def test_patched_generics(make_generic_classes) -> None: """Test that the generics actually get patched.""" + make_generic_classes() + for el in _need_generic: - # This only throws an exception if the monkeypatch failed - assert el.cls[type] == el.cls # `type` is arbitrary + if el.version is None: + assert el.cls[type] is el.cls # `type` is arbitrary + + +@pytest.mark.parametrize( + "django_version", + [ + (2, 2), + (3, 0), + (3, 1), + (3, 2), + ], +) +def test_patched_version_specific( + django_version: _VersionSpec, + make_generic_classes, +) -> None: + """Test version speicific types.""" + make_generic_classes(django_version) + + for el in _need_generic: + if el.version is not None and el.version[:2] <= django_version: + assert el.cls[int] is el.cls