[red-knot] Add custom __setattr__ support (#16748)

## Summary

Add support for classes with a custom `__setattr__` method.

## Test Plan

New Markdown tests, ecosystem checks.
This commit is contained in:
David Peter 2025-04-09 08:04:11 +02:00 committed by GitHub
parent fab7d820bd
commit 2cee86d807
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 119 additions and 14 deletions

View file

@ -1395,6 +1395,59 @@ def _(ns: argparse.Namespace):
reveal_type(ns.whatever) # revealed: Any
```
## Classes with custom `__setattr__` methods
### Basic
If a type provides a custom `__setattr__` method, we use the parameter type of that method as the
type to validate attribute assignments. Consider the following `CustomSetAttr` class:
```py
class CustomSetAttr:
def __setattr__(self, name: str, value: int) -> None:
pass
```
We can set arbitrary attributes on instances of this class:
```py
c = CustomSetAttr()
c.whatever = 42
```
### Type of the `name` parameter
If the `name` parameter of the `__setattr__` method is annotated with a (union of) literal type(s),
we only consider the attribute assignment to be valid if the assigned attribute is one of them:
```py
from typing import Literal
class Date:
def __setattr__(self, name: Literal["day", "month", "year"], value: int) -> None:
pass
date = Date()
date.day = 8
date.month = 4
date.year = 2025
# error: [unresolved-attribute] "Can not assign object of `Literal["UTC"]` to attribute `tz` on type `Date` with custom `__setattr__` method."
date.tz = "UTC"
```
### `argparse.Namespace`
A standard library example of a class with a custom `__setattr__` method is `argparse.Namespace`:
```py
import argparse
def _(ns: argparse.Namespace):
ns.whatever = 42
```
## 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

@ -3422,13 +3422,31 @@ impl<'db> Type<'db> {
/// Returns an `Err` if the dunder method can't be called,
/// or the given arguments are not valid.
fn try_call_dunder(
self,
db: &'db dyn Db,
name: &str,
argument_types: CallArgumentTypes<'_, 'db>,
) -> Result<Bindings<'db>, CallDunderError<'db>> {
self.try_call_dunder_with_policy(db, name, argument_types, MemberLookupPolicy::empty())
}
/// Same as `try_call_dunder`, but allows specifying a policy for the member lookup. In
/// particular, this allows to specify `MemberLookupPolicy::MRO_NO_OBJECT_FALLBACK` to avoid
/// looking up dunder methods on `object`, which is needed for functions like `__init__`,
/// `__new__`, or `__setattr__`.
fn try_call_dunder_with_policy(
self,
db: &'db dyn Db,
name: &str,
mut argument_types: CallArgumentTypes<'_, 'db>,
policy: MemberLookupPolicy,
) -> Result<Bindings<'db>, CallDunderError<'db>> {
match self
.member_lookup_with_policy(db, name.into(), MemberLookupPolicy::NO_INSTANCE_FALLBACK)
.member_lookup_with_policy(
db,
name.into(),
MemberLookupPolicy::NO_INSTANCE_FALLBACK | policy,
)
.symbol
{
Symbol::Type(dunder_callable, boundness) => {

View file

@ -82,7 +82,9 @@ use crate::types::{
Truthiness, TupleType, Type, TypeAliasType, TypeAndQualifiers, TypeArrayDisplay,
TypeQualifiers, TypeVarBoundOrConstraints, TypeVarInstance, UnionBuilder, UnionType,
};
use crate::types::{CallableType, FunctionDecorators, Signature};
use crate::types::{
CallableType, FunctionDecorators, MemberLookupPolicy, Signature, StringLiteralType,
};
use crate::unpack::{Unpack, UnpackPosition};
use crate::util::subscript::{PyIndex, PySlice};
use crate::Db;
@ -2480,19 +2482,51 @@ impl<'db> TypeInferenceBuilder<'db> {
ensure_assignable_to(instance_attr_ty)
} else {
if emit_diagnostics {
self.context.report_lint(
&UNRESOLVED_ATTRIBUTE,
target,
format_args!(
"Unresolved attribute `{}` on type `{}`.",
attribute,
object_ty.display(db)
),
);
}
let result = object_ty.try_call_dunder_with_policy(
db,
"__setattr__",
CallArgumentTypes::positional([
Type::StringLiteral(StringLiteralType::new(
db,
Box::from(attribute),
)),
value_ty,
]),
MemberLookupPolicy::MRO_NO_OBJECT_FALLBACK,
);
false
match result {
Ok(_) | Err(CallDunderError::PossiblyUnbound(_)) => true,
Err(CallDunderError::CallError(..)) => {
if emit_diagnostics {
self.context.report_lint(
&UNRESOLVED_ATTRIBUTE,
target,
format_args!(
"Can not assign object of `{}` to attribute `{attribute}` on type `{}` with custom `__setattr__` method.",
value_ty.display(db),
object_ty.display(db)
),
);
}
false
}
Err(CallDunderError::MethodNotAvailable) => {
if emit_diagnostics {
self.context.report_lint(
&UNRESOLVED_ATTRIBUTE,
target,
format_args!(
"Unresolved attribute `{}` on type `{}`.",
attribute,
object_ty.display(db)
),
);
}
false
}
}
}
}
}