mirror of
https://github.com/astral-sh/ruff.git
synced 2025-11-01 20:31:57 +00:00
## Summary
We currently infer a `@Todo` type whenever we access an attribute on an
intersection type with negative components. This can happen very
naturally. Consequently, this `@Todo` type is rather pervasive and hides
a lot of true positives that ty could otherwise detect:
```py
class Foo:
attr: int = 1
def _(f: Foo | None):
if f:
reveal_type(f) # Foo & ~AlwaysFalsy
reveal_type(f.attr) # now: int, previously: @Todo
```
The changeset here proposes to handle member access on these
intersection types by simply ignoring all negative contributions. This
is not always ideal: a negative contribution like `~<Protocol with
members 'attr'>` could be a hint that `.attr` should not be accessible
on the full intersection type. The behavior can certainly be improved in
the future, but this seems like a reasonable initial step to get rid of
this unnecessary `@Todo` type.
## Ecosystem analysis
There are quite a few changes here. I spot-checked them and found one
bug where attribute access on pure negation types (`~P == object & ~P`)
would not allow attributes on `object` to be accessed. After that was
fixed, I only see true positives and known problems. The fact that a lot
of `unused-ignore-comment` diagnostics go away are also evidence for the
fact that this touches a sensitive area, where static analysis clashes
with dynamically adding attributes to objects:
```py
… # type: ignore # Runtime attribute access
```
## Test Plan
Updated tests.
2.6 KiB
2.6 KiB
Narrowing using hasattr()
The builtin function hasattr() can be used to narrow nominal and structural types. This is
accomplished using an intersection with a synthesized protocol:
from typing import final
from typing_extensions import LiteralString
class NonFinalClass: ...
def _(obj: NonFinalClass):
if hasattr(obj, "spam"):
reveal_type(obj) # revealed: NonFinalClass & <Protocol with members 'spam'>
reveal_type(obj.spam) # revealed: object
else:
reveal_type(obj) # revealed: NonFinalClass & ~<Protocol with members 'spam'>
# error: [unresolved-attribute]
reveal_type(obj.spam) # revealed: Unknown
if hasattr(obj, "not-an-identifier"):
reveal_type(obj) # revealed: NonFinalClass
else:
reveal_type(obj) # revealed: NonFinalClass
For a final class, we recognize that there is no way that an object of FinalClass could ever have
a spam attribute, so the type is narrowed to Never:
@final
class FinalClass: ...
def _(obj: FinalClass):
if hasattr(obj, "spam"):
reveal_type(obj) # revealed: Never
reveal_type(obj.spam) # revealed: Never
else:
reveal_type(obj) # revealed: FinalClass
# error: [unresolved-attribute]
reveal_type(obj.spam) # revealed: Unknown
When the corresponding attribute is already defined on the class, hasattr narrowing does not
change the type. <Protocol with members 'spam'> is a supertype of WithSpam, and so
WithSpam & <Protocol …> simplifies to WithSpam:
class WithSpam:
spam: int = 42
def _(obj: WithSpam):
if hasattr(obj, "spam"):
reveal_type(obj) # revealed: WithSpam
reveal_type(obj.spam) # revealed: int
else:
reveal_type(obj) # revealed: Never
When a class may or may not have a spam attribute, hasattr narrowing can provide evidence that
the attribute exists. Here, no possibly-unbound-attribute error is emitted in the if branch:
def returns_bool() -> bool:
return False
class MaybeWithSpam:
if returns_bool():
spam: int = 42
def _(obj: MaybeWithSpam):
# error: [possibly-unbound-attribute]
reveal_type(obj.spam) # revealed: int
if hasattr(obj, "spam"):
reveal_type(obj) # revealed: MaybeWithSpam & <Protocol with members 'spam'>
reveal_type(obj.spam) # revealed: int
else:
reveal_type(obj) # revealed: MaybeWithSpam & ~<Protocol with members 'spam'>
# TODO: Ideally, we would emit `[unresolved-attribute]` and reveal `Unknown` here:
# error: [possibly-unbound-attribute]
reveal_type(obj.spam) # revealed: int