[red-knot] add fixpoint iteration for Type::member_lookup_with_policy (#17464)

## Summary

Member lookup can be cyclic, with type inference of implicit members. A
sample case is shown in the added mdtest.

There's no clear way to handle such cases other than to fixpoint-iterate
the cycle.

Fixes #17457.

## Test Plan

Added test.
This commit is contained in:
Carl Meyer 2025-04-18 10:20:03 -07:00 committed by GitHub
parent 08221454f6
commit 27a315b740
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 97 additions and 5 deletions

View file

@ -1820,6 +1820,78 @@ def f(never: Never):
never.another_attribute = never
```
### Cyclic implicit attributes
Inferring types for undeclared implicit attributes can be cyclic:
```py
class C:
def __init__(self):
self.x = 1
def copy(self, other: "C"):
self.x = other.x
reveal_type(C().x) # revealed: Unknown | Literal[1]
```
If the only assignment to a name is cyclic, we just infer `Unknown` for that attribute:
```py
class D:
def copy(self, other: "D"):
self.x = other.x
reveal_type(D().x) # revealed: Unknown
```
If there is an annotation for a name, we don't try to infer any type from the RHS of assignments to
that name, so these cases don't trigger any cycle:
```py
class E:
def __init__(self):
self.x: int = 1
def copy(self, other: "E"):
self.x = other.x
reveal_type(E().x) # revealed: int
class F:
def __init__(self):
self.x = 1
def copy(self, other: "F"):
self.x: int = other.x
reveal_type(F().x) # revealed: int
class G:
def copy(self, other: "G"):
self.x: int = other.x
reveal_type(G().x) # revealed: int
```
We can even handle cycles involving multiple classes:
```py
class A:
def __init__(self):
self.x = 1
def copy(self, other: "B"):
self.x = other.x
class B:
def copy(self, other: "A"):
self.x = other.x
reveal_type(B().x) # revealed: Unknown | Literal[1]
reveal_type(A().x) # revealed: Unknown | Literal[1]
```
### Builtin types attributes
This test can probably be removed eventually, but we currently include it because we do not yet

View file

@ -147,6 +147,12 @@ enum AttributeKind {
NormalOrNonDataDescriptor,
}
impl AttributeKind {
const fn is_data(self) -> bool {
matches!(self, Self::DataDescriptor)
}
}
/// This enum is used to control the behavior of the descriptor protocol implementation.
/// When invoked on a class object, the fallback type (a class attribute) can shadow a
/// non-data descriptor of the meta-type (the class's metaclass). However, this is not
@ -217,10 +223,24 @@ impl Default for MemberLookupPolicy {
}
}
impl AttributeKind {
const fn is_data(self) -> bool {
matches!(self, Self::DataDescriptor)
}
fn member_lookup_cycle_recover<'db>(
_db: &'db dyn Db,
_value: &SymbolAndQualifiers<'db>,
_count: u32,
_self: Type<'db>,
_name: Name,
_policy: MemberLookupPolicy,
) -> salsa::CycleRecoveryAction<SymbolAndQualifiers<'db>> {
salsa::CycleRecoveryAction::Iterate
}
fn member_lookup_cycle_initial<'db>(
_db: &'db dyn Db,
_self: Type<'db>,
_name: Name,
_policy: MemberLookupPolicy,
) -> SymbolAndQualifiers<'db> {
Symbol::bound(Type::Never).into()
}
/// Meta data for `Type::Todo`, which represents a known limitation in red-knot.
@ -2631,7 +2651,7 @@ impl<'db> Type<'db> {
/// Similar to [`Type::member`], but allows the caller to specify what policy should be used
/// when looking up attributes. See [`MemberLookupPolicy`] for more information.
#[salsa::tracked]
#[salsa::tracked(cycle_fn=member_lookup_cycle_recover, cycle_initial=member_lookup_cycle_initial)]
fn member_lookup_with_policy(
self,
db: &'db dyn Db,