Skip to content

Commit deb3d3d

Browse files
authored
[ty] Fall back to object for attribute access on synthesized protocols (#20286)
1 parent 982a0a2 commit deb3d3d

4 files changed

Lines changed: 39 additions & 2 deletions

File tree

crates/ty_python_semantic/resources/mdtest/annotations/callable.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -392,8 +392,14 @@ from inspect import getattr_static
392392

393393
def f_okay(c: Callable[[], None]):
394394
if hasattr(c, "__qualname__"):
395-
c.__qualname__ # okay
395+
reveal_type(c.__qualname__) # revealed: object
396+
397+
# TODO: should be `property`
398+
# (or complain that we don't know that `type(c)` has the attribute at all!)
399+
reveal_type(type(c).__qualname__) # revealed: @Todo(Intersection meta-type)
400+
396401
# `hasattr` only guarantees that an attribute is readable.
402+
#
397403
# error: [invalid-assignment] "Object of type `Literal["my_callable"]` is not assignable to attribute `__qualname__` on type `(() -> None) & <Protocol with members '__qualname__'>`"
398404
c.__qualname__ = "my_callable"
399405

crates/ty_python_semantic/resources/mdtest/narrow/hasattr.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,3 +84,17 @@ def _(obj: MaybeWithSpam):
8484
# error: [possibly-unbound-attribute]
8585
reveal_type(obj.spam) # revealed: int
8686
```
87+
88+
All attribute available on `object` are still available on these synthesized protocols, but
89+
attributes that are not present on `object` are not available:
90+
91+
```py
92+
def f(x: object):
93+
if hasattr(x, "__qualname__"):
94+
reveal_type(x.__repr__) # revealed: bound method object.__repr__() -> str
95+
reveal_type(x.__str__) # revealed: bound method object.__str__() -> str
96+
reveal_type(x.__dict__) # revealed: dict[str, Any]
97+
98+
# error: [unresolved-attribute] "Type `<Protocol with members '__qualname__'>` has no attribute `foo`"
99+
reveal_type(x.foo) # revealed: Unknown
100+
```

crates/ty_python_semantic/src/types.rs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3409,6 +3409,23 @@ impl<'db> Type<'db> {
34093409

34103410
Type::ModuleLiteral(module) => module.static_member(db, name_str),
34113411

3412+
// If a protocol does not include a member and the policy disables falling back to
3413+
// `object`, we return `Place::Unbound` here. This short-circuits attribute lookup
3414+
// before we find the "fallback to attribute access on `object`" logic later on
3415+
// (otherwise we would infer that all synthesized protocols have `__getattribute__`
3416+
// methods, and therefore that all synthesized protocols have all possible attributes.)
3417+
//
3418+
// Note that we could do this for *all* protocols, but it's only *necessary* for synthesized
3419+
// ones, and the standard logic is *probably* more performant for class-based protocols?
3420+
Type::ProtocolInstance(ProtocolInstanceType {
3421+
inner: Protocol::Synthesized(protocol),
3422+
..
3423+
}) if policy.mro_no_object_fallback()
3424+
&& !protocol.interface().includes_member(db, name_str) =>
3425+
{
3426+
Place::Unbound.into()
3427+
}
3428+
34123429
_ if policy.no_instance_fallback() => self.invoke_descriptor_protocol(
34133430
db,
34143431
name_str,

crates/ty_python_semantic/src/types/protocol_class.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -227,7 +227,7 @@ impl<'db> ProtocolInterface<'db> {
227227
place: Place::bound(member.ty()),
228228
qualifiers: member.qualifiers(),
229229
})
230-
.unwrap_or_else(|| Type::object(db).instance_member(db, name))
230+
.unwrap_or_else(|| Type::object(db).member(db, name))
231231
}
232232

233233
/// Return `true` if if all members on `self` are also members of `other`.

0 commit comments

Comments
 (0)