Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions crates/pyrefly_types/src/class.rs
Original file line number Diff line number Diff line change
Expand Up @@ -467,22 +467,22 @@ pub struct ClassType(Class, TArgs, Option<Arc<DjangoClassMetadata>>);

/// 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.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[derive(TypeEq)]
pub enum DjangoClassMetadata {
Manager { queryset: ClassType },
QuerySet { base: ClassType },
SpecializedBase { base: ClassType },
}

impl Visit<Type> for Arc<DjangoClassMetadata> {
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),
}
}
}
Expand All @@ -491,7 +491,7 @@ impl VisitMut<Type> for Arc<DjangoClassMetadata> {
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),
}
}
}
Expand Down
13 changes: 9 additions & 4 deletions pyrefly/lib/alt/class/class_field.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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();
Expand Down
2 changes: 1 addition & 1 deletion pyrefly/lib/alt/class/classdef.rs
Original file line number Diff line number Diff line change
Expand Up @@ -259,7 +259,7 @@ impl<'ctx, 'answer, Ans: LookupAnswer> AnswersSolver<'ctx, 'answer, Ans> {
pub fn as_superclass(&self, class: &ClassType, want: &Class) -> Option<ClassType> {
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)
Expand Down
65 changes: 61 additions & 4 deletions pyrefly/lib/alt/class/django.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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"),
Expand All @@ -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<Type> {
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<ClassType> {
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())
Expand Down Expand Up @@ -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;
Expand Down
13 changes: 8 additions & 5 deletions pyrefly/lib/test/django/fields.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
Expand Down
173 changes: 173 additions & 0 deletions pyrefly/lib/test/django/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading