mirror of
https://github.com/astral-sh/ruff.git
synced 2025-08-10 05:38:15 +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