From bff57decf235c4a441a36aff29a16738b6c43497 Mon Sep 17 00:00:00 2001 From: Asuka Minato Date: Sun, 23 Aug 2026 02:58:25 +0900 Subject: [PATCH 1/2] test ow preserves explicitly assigned Django manager types, so django-model-utils APIs such as InheritanceManager.select_subclasses() remain available and correctly specialize to the model type. --- pyrefly/lib/test/django/fields.rs | 13 ++- pyrefly/lib/test/django/model.rs | 173 ++++++++++++++++++++++++++++++ 2 files changed, 181 insertions(+), 5 deletions(-) diff --git a/pyrefly/lib/test/django/fields.rs b/pyrefly/lib/test/django/fields.rs index 4cf83308ab..557e6f1695 100644 --- a/pyrefly/lib/test/django/fields.rs +++ b/pyrefly/lib/test/django/fields.rs @@ -497,7 +497,7 @@ assert_type(Ordinary.records.get(), Base) ); django_testcase!( - test_queryset_manager_inferred_custom_interface, + test_queryset_manager_inferred_manager_replacements, r#" from typing import assert_type from django.db import models @@ -516,11 +516,14 @@ class Base(ModelBase): standard = models.Manager[ModelBase]() base_manager = BaseManager[ModelBase]() -class InvalidManager(Base): - records = models.Manager() # E: overrides parent class `Base` in an inconsistent manner +class PlainManager(Base): + records = models.Manager() -class InvalidQuerySet(Base): - records = Q.as_manager() # E: overrides parent class `Base` in an inconsistent manner +class GeneratedManager(Base): + records = Q.as_manager() + +assert_type(PlainManager.records.get(), PlainManager) +assert_type(GeneratedManager.records.get(), GeneratedManager) class Valid(Base): records = SpecificManager() diff --git a/pyrefly/lib/test/django/model.rs b/pyrefly/lib/test/django/model.rs index 45c2d6020f..958be174ab 100644 --- a/pyrefly/lib/test/django/model.rs +++ b/pyrefly/lib/test/django/model.rs @@ -6,6 +6,179 @@ */ use crate::django_testcase; +use crate::test::django::util::django_env; +use crate::test::util::TestEnv; +use crate::testcase; + +fn django_model_utils_env() -> TestEnv { + let mut env = django_env(); + env.add( + "model_utils.managers", + r#" +from typing import TypeVar + +from django.db import models + +ModelT = TypeVar("ModelT", bound=models.Model, covariant=True) + +class InheritanceQuerySet(models.QuerySet[ModelT]): ... + +class InheritanceManager(models.Manager[ModelT]): + def select_subclasses(self) -> InheritanceQuerySet[ModelT]: ... +"#, + ); + env +} + +testcase!( + test_django_model_utils_custom_manager, + django_model_utils_env(), + r#" +from typing import assert_type + +from django.db import models +from model_utils.managers import InheritanceManager, InheritanceQuerySet + +class Place(models.Model): + objects = InheritanceManager() + +assert_type(Place.objects, InheritanceManager[Place]) +assert_type(Place.objects.select_subclasses(), InheritanceQuerySet[Place]) +assert_type(Place.objects.get(), Place) +assert_type(Place.objects.create(), Place) +assert_type(Place.objects.filter(), models.QuerySet[Place]) + +class Child(Place): + objects = models.Manager() + +assert_type(Child.objects, models.Manager[Child]) +assert_type(Child.objects.get(), Child) + +class SiblingManager(models.Manager): + def custom(self) -> int: + return 1 + +class OtherChild(Place): + objects = SiblingManager() + +assert_type(OtherChild.objects, SiblingManager) +assert_type(OtherChild.objects.get(), OtherChild) +assert_type(OtherChild.objects.custom(), int) +"#, +); + +django_testcase!( + test_bare_custom_manager_model_binding, + r#" +from typing import assert_type +from django.db import models + +class PersonManager(models.Manager): + def custom(self) -> int: + return 1 + +class Person(models.Model): + objects = PersonManager() + +class Child(Person): ... + +assert_type(Person.objects, PersonManager) +assert_type(Person.objects.get(), Person) +assert_type(Person.objects.create(), Person) +assert_type(Person.objects.filter(), models.QuerySet[Person]) +assert_type(Person.objects.model, type[Person]) +assert_type(Person.objects.custom(), int) +assert_type(Person.objects.db_manager().get(), Person) +assert_type(Child.objects.get(), Child) +assert_type(Child.objects.filter(), models.QuerySet[Child]) + +def accept_manager(manager: models.Manager[Person]) -> None: ... +accept_manager(Person.objects) +"#, +); + +django_testcase!( + test_custom_manager_inherited_qualifiers, + r#" +from typing import Any, ClassVar, Final, Protocol, assert_type +from django.db import models + +class CustomManager(models.Manager): ... + +class Base(models.Model): + records: ClassVar[models.Manager["Base"]] = models.Manager() + locked: Final[models.Manager["Base"]] = models.Manager() + +class Person(Base): + objects = CustomManager() + records = CustomManager() + locked = CustomManager() # E: `locked` is declared as final in parent class `Base` + +class HasManager(Protocol): + objects: models.Manager[Any] + +def accept_model(model: HasManager) -> None: ... + +class HasCustomManager(Protocol): + objects: CustomManager + +def accept_custom_model(model: HasCustomManager) -> None: ... +accept_custom_model(Person) + +person = Person() +person.objects = Person.objects # E: Cannot set field `objects` +person.records = Person.records # E: Cannot set field `records` +Person.locked = Person.locked # E: Cannot set field `locked` + +class Child(Person): + objects = models.Manager() + records = models.Manager() + +assert_type(Child.objects.get(), Child) +assert_type(Child.records.get(), Child) +accept_model(Child) +child = Child() +child.objects = Child.objects # E: Cannot set field `objects` +child.records = Child.records # E: Cannot set field `records` + +class Plain(models.Model): + objects = models.Manager() + +accept_model(Plain) +plain = Plain() +plain.objects = Plain.objects # E: Cannot set field `objects` +"#, +); + +django_testcase!( + test_custom_manager_constructor_arguments, + r#" +from typing import Generic, TypeVar, assert_type +from django.db import models + +T = TypeVar("T") +M = TypeVar("M", bound=models.Model) + +class CustomManager(models.Manager[M], Generic[T, M]): + def __init__(self, value: T) -> None: + self.value = value + + def custom(self) -> T: + return self.value + +class Person(models.Model): + objects = CustomManager("label") + +assert_type(Person.objects, CustomManager[str, Person]) +assert_type(Person.objects.custom(), str) +assert_type(Person.objects.get(), Person) + +class Ordinary: + objects = models.Manager[Person]() + +assert_type(Ordinary.objects.get(), Person) +"#, +); django_testcase!( test_model, From 6037e70271e8c692a78205d9bc69715bbe91762c Mon Sep 17 00:00:00 2001 From: Asuka Minato Date: Tue, 3 Nov 2026 04:58:13 +0900 Subject: [PATCH 2/2] fix --- crates/pyrefly_types/src/class.rs | 8 ++-- pyrefly/lib/alt/class/class_field.rs | 13 ++++-- pyrefly/lib/alt/class/classdef.rs | 2 +- pyrefly/lib/alt/class/django.rs | 65 ++++++++++++++++++++++++++-- 4 files changed, 75 insertions(+), 13 deletions(-) diff --git a/crates/pyrefly_types/src/class.rs b/crates/pyrefly_types/src/class.rs index b7248a83aa..aca6e3a0b5 100644 --- a/crates/pyrefly_types/src/class.rs +++ b/crates/pyrefly_types/src/class.rs @@ -467,7 +467,7 @@ pub struct ClassType(Class, TArgs, Option>); /// Construction-time Django specializations on nominal class instances. /// -/// The manager resolves generated members against its queryset. A specialized queryset uses +/// The manager resolves generated members against its queryset. A specialized class uses /// its effective base for inherited members and nominal upcasts. Type traversal and substitution /// include this data, and instance binding preserves it when replacing `Self`. Explicit method /// return types stand alone: neither their model nor their row is rebound to the manager's model. @@ -475,14 +475,14 @@ pub struct ClassType(Class, TArgs, Option>); #[derive(TypeEq)] pub enum DjangoClassMetadata { Manager { queryset: ClassType }, - QuerySet { base: ClassType }, + SpecializedBase { base: ClassType }, } impl Visit for Arc { fn recurse<'a>(&'a self, f: &mut dyn FnMut(&'a Type)) { match self.as_ref() { DjangoClassMetadata::Manager { queryset } => queryset.visit(f), - DjangoClassMetadata::QuerySet { base } => base.visit(f), + DjangoClassMetadata::SpecializedBase { base } => base.visit(f), } } } @@ -491,7 +491,7 @@ impl VisitMut for Arc { fn recurse_mut(&mut self, f: &mut dyn FnMut(&mut Type)) { match Arc::make_mut(self) { DjangoClassMetadata::Manager { queryset } => queryset.visit_mut(f), - DjangoClassMetadata::QuerySet { base } => base.visit_mut(f), + DjangoClassMetadata::SpecializedBase { base } => base.visit_mut(f), } } } diff --git a/pyrefly/lib/alt/class/class_field.rs b/pyrefly/lib/alt/class/class_field.rs index 4965fdf9fd..29bbb5bb63 100644 --- a/pyrefly/lib/alt/class/class_field.rs +++ b/pyrefly/lib/alt/class/class_field.rs @@ -3340,7 +3340,7 @@ impl<'ctx, 'answer, Ans: LookupAnswer> AnswersSolver<'ctx, 'answer, Ans> { || cls.has_qname("django.db.models.manager", "BaseManager")) }) && let ExprOrBinding::Expr(e) = value - && let Some(manager) = self.get_django_manager_from_queryset_type(class, e) + && let Some(manager) = self.get_django_manager_assignment_type(class, e) { // Standard manager interfaces allow model rebinding. Explicit custom interfaces // retain their declared type and use ordinary assignment checking below. @@ -4514,13 +4514,18 @@ impl<'ctx, 'answer, Ans: LookupAnswer> AnswersSolver<'ctx, 'answer, Ans> { } } if metadata.is_django_model() - && !class_field.has_explicit_annotation() + && !cls_fields.is_field_annotated(field_name) && self.is_django_manager_type(&class_field.ty()) - && matches!(want_class_field.ty(), Type::ClassType(cls) if + && self.is_django_manager_type(&want_class_field.ty()) + && (matches!(want_class_field.ty(), Type::ClassType(cls) if cls.has_qname("django.db.models.manager", "Manager") || cls.has_qname("django.db.models.manager", "BaseManager")) + || self + .get_class_fields(&want_field.defining_class) + .is_some_and(|fields| !fields.is_field_annotated(field_name))) { - // Standard manager interfaces allow rebinding to the receiving model. + // Django permits replacing inferred managers, including sibling manager classes. + // Explicit custom interfaces still require ordinary override checks. continue; } let want_is_static = want_class_field.is_staticmethod(); diff --git a/pyrefly/lib/alt/class/classdef.rs b/pyrefly/lib/alt/class/classdef.rs index 2460ef1504..a38724b6ec 100644 --- a/pyrefly/lib/alt/class/classdef.rs +++ b/pyrefly/lib/alt/class/classdef.rs @@ -259,7 +259,7 @@ impl<'ctx, 'answer, Ans: LookupAnswer> AnswersSolver<'ctx, 'answer, Ans> { pub fn as_superclass(&self, class: &ClassType, want: &Class) -> Option { if class.class_object() == want { Some(class.clone()) - } else if let Some(DjangoClassMetadata::QuerySet { base }) = class.django_metadata() + } else if let Some(DjangoClassMetadata::SpecializedBase { base }) = class.django_metadata() && self.has_superclass(base.class_object(), want) { self.as_superclass(base, want) diff --git a/pyrefly/lib/alt/class/django.rs b/pyrefly/lib/alt/class/django.rs index 9f719c795c..f5a6a96a3d 100644 --- a/pyrefly/lib/alt/class/django.rs +++ b/pyrefly/lib/alt/class/django.rs @@ -5,6 +5,8 @@ * LICENSE file in the root directory of this source tree. */ +use std::iter; + use pyrefly_python::module_name::ModuleName; use pyrefly_python::module_name::is_python_identifier; use pyrefly_types::callable::Callable; @@ -199,7 +201,7 @@ impl<'ctx, 'answer, Ans: LookupAnswer> AnswersSolver<'ctx, 'answer, Ans> { if queryset.has_qname("django.db.models.query", "QuerySet") { queryset = base; } else { - queryset = queryset.with_django_metadata(DjangoClassMetadata::QuerySet { base }); + queryset = queryset.with_django_metadata(DjangoClassMetadata::SpecializedBase { base }); } self.heap.mk_class_type(queryset) } @@ -428,7 +430,7 @@ impl<'ctx, 'answer, Ans: LookupAnswer> AnswersSolver<'ctx, 'answer, Ans> { let queryset = if queryset.has_qname("django.db.models.query", "QuerySet") { base } else { - queryset.with_django_metadata(DjangoClassMetadata::QuerySet { base }) + queryset.with_django_metadata(DjangoClassMetadata::SpecializedBase { base }) }; let Type::ClassDef(manager) = self.try_get_from_export( ModuleName::from_str("django.db.models.manager"), @@ -450,9 +452,64 @@ impl<'ctx, 'answer, Ans: LookupAnswer> AnswersSolver<'ctx, 'answer, Ans> { ) } + /// Bind a constructed manager to its model without changing its nominal class. + pub(crate) fn get_django_manager_assignment_type( + &self, + model: &Class, + initial_value_expr: &Expr, + ) -> Option { + if let Some(manager) = self.get_django_manager_from_queryset_type(model, initial_value_expr) + { + return Some(manager); + } + let call = initial_value_expr.as_call_expr()?; + let callee = self.expr_infer(&call.func, &self.error_swallower()); + let (_, constructor) = self.unwrap_class_object_silently(&callee)?; + let Type::ClassType(constructor) = constructor else { + return None; + }; + let mut base = iter::once(&constructor) + .chain( + self.get_mro_for_class(constructor.class_object()) + .ancestors(self.stdlib), + ) + .find(|base| { + base.has_qname("django.db.models.manager", "Manager") + || base.has_qname("django.db.models.manager", "BaseManager") + })? + .clone(); + let Type::ClassType(mut manager) = + self.expr_infer(initial_value_expr, &self.error_swallower()) + else { + return None; + }; + if manager.class_object() != constructor.class_object() { + return None; + } + let [original_model] = base.targs().as_slice() else { + return None; + }; + let model = self.heap.mk_self_type(self.as_class_type_unchecked(model)); + // The model parameter can occur at any position in a custom manager's parameters. + // Preserve other arguments inferred from the constructor. + let parameters = manager.tparams().clone(); + for (parameter, argument) in parameters.iter().zip(manager.targs_mut().as_mut()) { + if matches!(original_model, Type::Quantified(q) if q.as_ref() == parameter) { + *argument = model.clone(); + } + } + base.targs_mut().as_mut()[0] = model; + let manager = if manager.class_object() == base.class_object() { + base + } else { + manager.with_django_metadata(DjangoClassMetadata::SpecializedBase { base }) + }; + Some(self.heap.mk_class_type(manager)) + } + /// Return the effective QuerySet base, including a construction-time specialization. fn django_queryset_base(&self, cls: &ClassType) -> Option { - if let Some(DjangoClassMetadata::QuerySet { base }) = cls.django_metadata() { + if let Some(DjangoClassMetadata::SpecializedBase { base }) = cls.django_metadata() { Some(base.clone()) } else if cls.has_qname("django.db.models.query", "QuerySet") { Some(cls.clone()) @@ -537,7 +594,7 @@ impl<'ctx, 'answer, Ans: LookupAnswer> AnswersSolver<'ctx, 'answer, Ans> { } self.get_instance_attribute(queryset, name) } - DjangoClassMetadata::QuerySet { base } => { + DjangoClassMetadata::SpecializedBase { base } => { let member = self.get_class_member_with_defining_class(cls.class_object(), name)?; if !self.has_superclass(base.class_object(), &member.defining_class) { return None;