[red-knot] Support custom __getattr__ methods (#16668)

## Summary

Add support for calling custom `__getattr__` methods in case an
attribute is not otherwise found. This allows us to get rid of many
ecosystem false positives where we previously emitted errors when
accessing attributes on `argparse.Namespace`.

closes #16614

## Test Plan

* New Markdown tests
* Observed expected ecosystem changes (the changes for `arrow` also look
fine, since the `Arrow` class has a custom [`__getattr__`
here](1d70d00919/arrow/arrow.py (L802-L815)))
This commit is contained in:
David Peter 2025-03-12 13:44:11 +01:00 committed by GitHub
parent a176c1ac80
commit 083df0cf84
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 132 additions and 2 deletions

View file

@ -1199,6 +1199,98 @@ reveal_type(C.__mro__) # revealed: tuple[Literal[C], Literal[B], Any, Literal[A
reveal_type(C.x) # revealed: Literal[1] & Any
```
## Classes with custom `__getattr__` methods
### Basic
If a type provides a custom `__getattr__` method, we use the return type of that method as the type
for unknown attributes. Consider the following `CustomGetAttr` class:
```py
from typing import Literal
def flag() -> bool:
return True
class GetAttrReturnType: ...
class CustomGetAttr:
class_attr: int = 1
if flag():
possibly_unbound: bytes = b"a"
def __init__(self) -> None:
self.instance_attr: str = "a"
def __getattr__(self, name: str) -> GetAttrReturnType:
return GetAttrReturnType()
```
We can access arbitrary attributes on instances of this class, and the type of the attribute will be
`GetAttrReturnType`:
```py
c = CustomGetAttr()
reveal_type(c.whatever) # revealed: GetAttrReturnType
```
If an attribute is defined on the class, it takes precedence over the `__getattr__` method:
```py
reveal_type(c.class_attr) # revealed: int
```
If the class attribute is possibly unbound, we union the type of the attribute with the fallback
type of the `__getattr__` method:
```py
reveal_type(c.possibly_unbound) # revealed: bytes | GetAttrReturnType
```
Instance attributes also take precedence over the `__getattr__` method:
```py
# Note: we could attempt to union with the fallback type of `__getattr__` here, as we currently do not
# attempt to determine if instance attributes are always bound or not. Neither mypy nor pyright do this,
# so it's not a priority.
reveal_type(c.instance_attr) # revealed: str
```
### Type of the `name` parameter
If the `name` parameter of the `__getattr__` method is annotated with a (union of) literal type(s),
we only consider the attribute access to be valid if the accessed attribute is one of them:
```py
from typing import Literal
class Date:
def __getattr__(self, name: Literal["day", "month", "year"]) -> int:
return 0
date = Date()
reveal_type(date.day) # revealed: int
reveal_type(date.month) # revealed: int
reveal_type(date.year) # revealed: int
# error: [unresolved-attribute] "Type `Date` has no attribute `century`"
reveal_type(date.century) # revealed: Unknown
```
### `argparse.Namespace`
A standard library example of a class with a custom `__getattr__` method is `argparse.Namespace`:
```py
import argparse
def _(ns: argparse.Namespace):
reveal_type(ns.whatever) # revealed: Any
```
## Objects of all types have a `__class__` method
The type of `x.__class__` is the same as `x`'s meta-type. `x.__class__` is always the same value as

View file

@ -2008,12 +2008,50 @@ impl<'db> Type<'db> {
| Type::FunctionLiteral(..) => {
let fallback = self.instance_member(db, name_str);
self.invoke_descriptor_protocol(
let result = self.invoke_descriptor_protocol(
db,
name_str,
fallback,
InstanceFallbackShadowsNonDataDescriptor::No,
)
);
let custom_getattr_result =
|| {
// Typeshed has a fake `__getattr__` on `types.ModuleType` to help out with dynamic imports.
// We explicitly hide it here to prevent arbitrary attributes from being available on modules.
if self.into_instance().is_some_and(|instance| {
instance.class.is_known(db, KnownClass::ModuleType)
}) {
return Symbol::Unbound.into();
}
self.try_call_dunder(
db,
"__getattr__",
&CallArguments::positional([Type::StringLiteral(
StringLiteralType::new(db, Box::from(name.as_str())),
)]),
)
.map(|outcome| Symbol::bound(outcome.return_type(db)))
// TODO: Handle call errors here.
.unwrap_or(Symbol::Unbound)
.into()
};
match result {
member @ SymbolAndQualifiers {
symbol: Symbol::Type(_, Boundness::Bound),
qualifiers: _,
} => member,
member @ SymbolAndQualifiers {
symbol: Symbol::Type(_, Boundness::PossiblyUnbound),
qualifiers: _,
} => member.or_fall_back_to(db, custom_getattr_result),
SymbolAndQualifiers {
symbol: Symbol::Unbound,
qualifiers: _,
} => custom_getattr_result(),
}
}
Type::ClassLiteral(..) | Type::SubclassOf(..) => {