diff --git a/crates/red_knot_python_semantic/resources/mdtest/attributes.md b/crates/red_knot_python_semantic/resources/mdtest/attributes.md index 00a88e1e10..d30e42cb8e 100644 --- a/crates/red_knot_python_semantic/resources/mdtest/attributes.md +++ b/crates/red_knot_python_semantic/resources/mdtest/attributes.md @@ -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 diff --git a/crates/red_knot_python_semantic/src/types.rs b/crates/red_knot_python_semantic/src/types.rs index 7d15f381d6..8eb90bab3b 100644 --- a/crates/red_knot_python_semantic/src/types.rs +++ b/crates/red_knot_python_semantic/src/types.rs @@ -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(..) => {