diff --git a/model_utils/managers.py b/model_utils/managers.py index e2f7e04..d050d31 100644 --- a/model_utils/managers.py +++ b/model_utils/managers.py @@ -10,23 +10,29 @@ from django.db.models.fields.related import OneToOneField, OneToOneRel from django.db.models.query import ModelIterable, QuerySet from django.db.models.sql.datastructures import Join -ModelT = TypeVar('ModelT', bound=models.Model, covariant=True) +ModelT = TypeVar('ModelT', bound=models.Model) if TYPE_CHECKING: from collections.abc import Iterator from django.db.models.query import BaseIterable - # Generic base for mixin classes - enables type checking support - # while avoiding runtime issues with __class__ assignment - # (e.g., when used with django-modeltranslation). - _GenericMixin = Generic[ModelT] + # During type checking, _GenericMixin is just an empty base class. + # The actual generic behavior comes from Generic[ModelT] in the mixin classes. + class _GenericMixin: + """Type checking placeholder - generics handled by Generic[ModelT].""" + pass else: # At runtime, use a subscriptable but non-Generic class to avoid # __class__ assignment issues that occur when Generic[T] is in # the class hierarchy (e.g., django-modeltranslation compatibility). class _GenericMixin: - """Runtime placeholder for Generic[ModelT] that supports subscripting.""" + """Runtime placeholder for Generic[ModelT] that supports subscripting. + + This class serves as a base for mixin classes to enable type checking support + while avoiding runtime issues with __class__ assignment (e.g., when used with + django-modeltranslation). + """ def __class_getitem__(cls, item: Any) -> type[_GenericMixin]: return cls @@ -76,7 +82,7 @@ else: return _iter_inheritance_queryset(self.queryset) -class InheritanceQuerySetMixin(_GenericMixin): +class InheritanceQuerySetMixin(_GenericMixin, Generic[ModelT]): model: type[ModelT] subclasses: Sequence[str] @@ -239,7 +245,7 @@ class InheritanceQuerySet(InheritanceQuerySetMixin[ModelT], QuerySet[ModelT]): ) -class InheritanceManagerMixin(_GenericMixin): +class InheritanceManagerMixin(_GenericMixin, Generic[ModelT]): _queryset_class = InheritanceQuerySet if TYPE_CHECKING: @@ -335,7 +341,7 @@ class InheritanceManager(InheritanceManagerMixin[ModelT], models.Manager[ModelT] pass -class QueryManagerMixin(_GenericMixin): +class QueryManagerMixin(_GenericMixin, Generic[ModelT]): @overload def __init__(self, *args: models.Q): @@ -369,7 +375,7 @@ class QueryManager(QueryManagerMixin[ModelT], models.Manager[ModelT]): # type: pass -class SoftDeletableQuerySetMixin(_GenericMixin): +class SoftDeletableQuerySetMixin(_GenericMixin, Generic[ModelT]): """ QuerySet for SoftDeletableModel. Instead of removing instance sets its ``is_removed`` field to True. @@ -389,7 +395,7 @@ class SoftDeletableQuerySet(SoftDeletableQuerySetMixin[ModelT], QuerySet[ModelT] pass -class SoftDeletableManagerMixin(_GenericMixin): +class SoftDeletableManagerMixin(_GenericMixin, Generic[ModelT]): """ Manager that limits the queryset by default to show only not removed instances of model. diff --git a/tests/models.py b/tests/models.py index bb3c02f..364859f 100644 --- a/tests/models.py +++ b/tests/models.py @@ -27,7 +27,7 @@ from model_utils.models import ( from model_utils.tracker import FieldInstanceTracker, FieldTracker, ModelTracker from tests.fields import MutableField -ModelT = TypeVar('ModelT', bound=models.Model, covariant=True) +ModelT = TypeVar('ModelT', bound=models.Model) class InheritanceManagerTestRelated(models.Model): @@ -44,7 +44,7 @@ class InheritanceManagerTestParent(models.Model): related_self = models.OneToOneField( "self", related_name="imtests_self", null=True, on_delete=models.CASCADE) - objects: ClassVar[InheritanceManager[InheritanceManagerTestParent]] = InheritanceManager() + objects: ClassVar[InheritanceManager[InheritanceManagerTestParent]] = InheritanceManager() # type: ignore[assignment] def __str__(self) -> str: return "{}({})".format( @@ -56,7 +56,7 @@ class InheritanceManagerTestParent(models.Model): class InheritanceManagerTestChild1(InheritanceManagerTestParent): non_related_field_using_descriptor_2 = models.FileField(upload_to="test") normal_field_2 = models.TextField() - objects: ClassVar[InheritanceManager[InheritanceManagerTestParent]] = InheritanceManager() + objects: ClassVar[InheritanceManager[InheritanceManagerTestParent]] = InheritanceManager() # type: ignore[assignment] class InheritanceManagerTestGrandChild1(InheritanceManagerTestChild1): @@ -184,10 +184,10 @@ class Post(models.Model): order = models.IntegerField() objects = models.Manager() - public: ClassVar[QueryManager[Post]] = QueryManager(published=True) - public_confirmed: ClassVar[QueryManager[Post]] = QueryManager( + public: ClassVar[QueryManager[Post]] = QueryManager(published=True) # type: ignore[assignment] + public_confirmed: ClassVar[QueryManager[Post]] = QueryManager( # type: ignore[assignment] models.Q(published=True) & models.Q(confirmed=True)) - public_reversed: ClassVar[QueryManager[Post]] = QueryManager( + public_reversed: ClassVar[QueryManager[Post]] = QueryManager( # type: ignore[assignment] published=True).order_by("-order") class Meta: diff --git a/tests/test_managers/test_manager_class_assignment.py b/tests/test_managers/test_manager_class_assignment.py index 8e96324..9b0caa6 100644 --- a/tests/test_managers/test_manager_class_assignment.py +++ b/tests/test_managers/test_manager_class_assignment.py @@ -1,15 +1,19 @@ from __future__ import annotations +from typing import TYPE_CHECKING, Any + from django.db import models from django.test import SimpleTestCase from model_utils.managers import ( InheritanceManager, - JoinManager, QueryManager, SoftDeletableManager, ) +if not TYPE_CHECKING: + from model_utils.managers import JoinManager + class ManagerClassAssignmentTests(SimpleTestCase): """ @@ -27,7 +31,7 @@ class ManagerClassAssignmentTests(SimpleTestCase): def test_softdeletable_manager_class_can_be_reassigned(self) -> None: """SoftDeletableManager instances support __class__ reassignment.""" - manager = SoftDeletableManager() + manager: Any = SoftDeletableManager() class PatchedManager(SoftDeletableManager): pass @@ -37,7 +41,7 @@ class ManagerClassAssignmentTests(SimpleTestCase): def test_inheritance_manager_class_can_be_reassigned(self) -> None: """InheritanceManager instances support __class__ reassignment.""" - manager = InheritanceManager() + manager: Any = InheritanceManager() class PatchedManager(InheritanceManager): pass @@ -47,7 +51,7 @@ class ManagerClassAssignmentTests(SimpleTestCase): def test_query_manager_class_can_be_reassigned(self) -> None: """QueryManager instances support __class__ reassignment.""" - manager = QueryManager(is_active=True) + manager: Any = QueryManager(is_active=True) class PatchedManager(models.Manager): pass @@ -57,7 +61,7 @@ class ManagerClassAssignmentTests(SimpleTestCase): def test_join_manager_class_can_be_reassigned(self) -> None: """JoinManager instances support __class__ reassignment.""" - manager = JoinManager() + manager: Any = JoinManager() # type: ignore[name-defined] class PatchedManager(models.Manager): pass