mirror of
https://github.com/astral-sh/ruff.git
synced 2025-08-04 10:48:32 +00:00
[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:
parent
a176c1ac80
commit
083df0cf84
2 changed files with 132 additions and 2 deletions
|
@ -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
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue