From deb3d3d150c8552e9d9d035afb97d0907c6f3b9c Mon Sep 17 00:00:00 2001 From: Alex Waygood Date: Mon, 8 Sep 2025 13:04:37 +0100 Subject: [PATCH] [ty] Fall back to `object` for attribute access on synthesized protocols (#20286) --- .../resources/mdtest/annotations/callable.md | 8 +++++++- .../resources/mdtest/narrow/hasattr.md | 14 ++++++++++++++ crates/ty_python_semantic/src/types.rs | 17 +++++++++++++++++ .../src/types/protocol_class.rs | 2 +- 4 files changed, 39 insertions(+), 2 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/annotations/callable.md b/crates/ty_python_semantic/resources/mdtest/annotations/callable.md index 37f5923496..7ffb49bb72 100644 --- a/crates/ty_python_semantic/resources/mdtest/annotations/callable.md +++ b/crates/ty_python_semantic/resources/mdtest/annotations/callable.md @@ -392,8 +392,14 @@ from inspect import getattr_static def f_okay(c: Callable[[], None]): if hasattr(c, "__qualname__"): - c.__qualname__ # okay + reveal_type(c.__qualname__) # revealed: object + + # TODO: should be `property` + # (or complain that we don't know that `type(c)` has the attribute at all!) + reveal_type(type(c).__qualname__) # revealed: @Todo(Intersection meta-type) + # `hasattr` only guarantees that an attribute is readable. + # # error: [invalid-assignment] "Object of type `Literal["my_callable"]` is not assignable to attribute `__qualname__` on type `(() -> None) & `" c.__qualname__ = "my_callable" diff --git a/crates/ty_python_semantic/resources/mdtest/narrow/hasattr.md b/crates/ty_python_semantic/resources/mdtest/narrow/hasattr.md index f6839562de..02e07d80c2 100644 --- a/crates/ty_python_semantic/resources/mdtest/narrow/hasattr.md +++ b/crates/ty_python_semantic/resources/mdtest/narrow/hasattr.md @@ -84,3 +84,17 @@ def _(obj: MaybeWithSpam): # error: [possibly-unbound-attribute] reveal_type(obj.spam) # revealed: int ``` + +All attribute available on `object` are still available on these synthesized protocols, but +attributes that are not present on `object` are not available: + +```py +def f(x: object): + if hasattr(x, "__qualname__"): + reveal_type(x.__repr__) # revealed: bound method object.__repr__() -> str + reveal_type(x.__str__) # revealed: bound method object.__str__() -> str + reveal_type(x.__dict__) # revealed: dict[str, Any] + + # error: [unresolved-attribute] "Type `` has no attribute `foo`" + reveal_type(x.foo) # revealed: Unknown +``` diff --git a/crates/ty_python_semantic/src/types.rs b/crates/ty_python_semantic/src/types.rs index 1b064ac8d6..f8e8177606 100644 --- a/crates/ty_python_semantic/src/types.rs +++ b/crates/ty_python_semantic/src/types.rs @@ -3409,6 +3409,23 @@ impl<'db> Type<'db> { Type::ModuleLiteral(module) => module.static_member(db, name_str), + // If a protocol does not include a member and the policy disables falling back to + // `object`, we return `Place::Unbound` here. This short-circuits attribute lookup + // before we find the "fallback to attribute access on `object`" logic later on + // (otherwise we would infer that all synthesized protocols have `__getattribute__` + // methods, and therefore that all synthesized protocols have all possible attributes.) + // + // Note that we could do this for *all* protocols, but it's only *necessary* for synthesized + // ones, and the standard logic is *probably* more performant for class-based protocols? + Type::ProtocolInstance(ProtocolInstanceType { + inner: Protocol::Synthesized(protocol), + .. + }) if policy.mro_no_object_fallback() + && !protocol.interface().includes_member(db, name_str) => + { + Place::Unbound.into() + } + _ if policy.no_instance_fallback() => self.invoke_descriptor_protocol( db, name_str, diff --git a/crates/ty_python_semantic/src/types/protocol_class.rs b/crates/ty_python_semantic/src/types/protocol_class.rs index 147187db90..6b27905a80 100644 --- a/crates/ty_python_semantic/src/types/protocol_class.rs +++ b/crates/ty_python_semantic/src/types/protocol_class.rs @@ -227,7 +227,7 @@ impl<'db> ProtocolInterface<'db> { place: Place::bound(member.ty()), qualifiers: member.qualifiers(), }) - .unwrap_or_else(|| Type::object(db).instance_member(db, name)) + .unwrap_or_else(|| Type::object(db).member(db, name)) } /// Return `true` if if all members on `self` are also members of `other`.