From f885cb8a2fbf1f6cf778328f18d3715eb56a97a0 Mon Sep 17 00:00:00 2001 From: Felix Scherz Date: Mon, 26 May 2025 12:59:45 +0200 Subject: [PATCH] [ty] use `__getattribute__` to lookup unknown members on a type (#18280) ## Summary `Type::member_lookup_with_policy` now falls back to calling `__getattribute__` when a member cannot be found as a second fallback after `__getattr__`. closes https://github.com/astral-sh/ty/issues/441 ## Test Plan Added markdown tests. --------- Co-authored-by: Alex Waygood Co-authored-by: David Peter --- .../resources/mdtest/attributes.md | 59 +++++++++++++++++++ crates/ty_python_semantic/src/types.rs | 29 ++++++++- 2 files changed, 86 insertions(+), 2 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/attributes.md b/crates/ty_python_semantic/resources/mdtest/attributes.md index 98ce6b5aa9..b1a1573ce4 100644 --- a/crates/ty_python_semantic/resources/mdtest/attributes.md +++ b/crates/ty_python_semantic/resources/mdtest/attributes.md @@ -1527,6 +1527,65 @@ def _(ns: argparse.Namespace): reveal_type(ns.whatever) # revealed: Any ``` +## Classes with custom `__getattribute__` methods + +If a type provides a custom `__getattribute__`, we use its return type as the type for unknown +attributes. Note that this behavior differs from runtime, where `__getattribute__` is called +unconditionally, even for known attributes. The rationale for doing this is that it allows users to +specify more precise types for specific attributes, such as `x: str` in the example below. This +behavior matches other type checkers such as mypy and pyright. + +```py +from typing import Any + +class Foo: + x: str + def __getattribute__(self, attr: str) -> Any: + return 42 + +reveal_type(Foo().x) # revealed: str +reveal_type(Foo().y) # revealed: Any +``` + +A standard library example for a class with a custom `__getattribute__` method is `SimpleNamespace`: + +```py +from types import SimpleNamespace + +sn = SimpleNamespace(a="a") + +reveal_type(sn.a) # revealed: Any +``` + +`__getattribute__` takes precedence over `__getattr__`: + +```py +class C: + def __getattribute__(self, name: str) -> int: + return 1 + + def __getattr__(self, name: str) -> str: + return "a" + +c = C() + +reveal_type(c.x) # revealed: int +``` + +Like all dunder methods, `__getattribute__` is not looked up on instances: + +```py +def external_getattribute(name) -> int: + return 1 + +class ThisFails: + def __init__(self): + self.__getattribute__ = external_getattribute + +# error: [unresolved-attribute] +ThisFails().x +``` + ## Classes with custom `__setattr__` methods ### Basic diff --git a/crates/ty_python_semantic/src/types.rs b/crates/ty_python_semantic/src/types.rs index 25c8be75fb..e1a8f9debb 100644 --- a/crates/ty_python_semantic/src/types.rs +++ b/crates/ty_python_semantic/src/types.rs @@ -3200,6 +3200,29 @@ impl<'db> Type<'db> { .into() }; + let custom_getattribute_result = || { + // Avoid cycles when looking up `__getattribute__` + if "__getattribute__" == name.as_str() { + return Symbol::Unbound.into(); + } + + // Typeshed has a `__getattribute__` method defined on `builtins.object` so we + // explicitly hide it here using `MemberLookupPolicy::MRO_NO_OBJECT_FALLBACK`. + self.try_call_dunder_with_policy( + db, + "__getattribute__", + &mut CallArgumentTypes::positional([Type::StringLiteral( + StringLiteralType::new(db, Box::from(name.as_str())), + )]), + MemberLookupPolicy::MRO_NO_OBJECT_FALLBACK + | MemberLookupPolicy::NO_INSTANCE_FALLBACK, + ) + .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), @@ -3208,11 +3231,13 @@ impl<'db> Type<'db> { member @ SymbolAndQualifiers { symbol: Symbol::Type(_, Boundness::PossiblyUnbound), qualifiers: _, - } => member.or_fall_back_to(db, custom_getattr_result), + } => member + .or_fall_back_to(db, custom_getattribute_result) + .or_fall_back_to(db, custom_getattr_result), SymbolAndQualifiers { symbol: Symbol::Unbound, qualifiers: _, - } => custom_getattr_result(), + } => custom_getattribute_result().or_fall_back_to(db, custom_getattr_result), } }