[ty] Add decorator check for implicit attribute assignments (#18587)

## Summary

Previously, the checks for implicit attribute assignments didn't
properly account for method decorators. This PR fixes that by:

- Adding a decorator check in `implicit_instance_attribute`. This allows
it to filter out methods with mismatching decorators when analyzing
attribute assignments.
- Adding attribute search for implicit class attributes: if an attribute
can't be found directly in the class body, the
`ClassLiteral::own_class_member` function will now search in
classmethods.
- Adding `staticmethod`: it has been added into `KnownClass` and
together with the new decorator check, it will no longer expose
attributes when the assignment target name is the same as the first
method name.

If accepted, it should fix https://github.com/astral-sh/ty/issues/205
and https://github.com/astral-sh/ty/issues/207.

## Test Plan

This is tested with existing mdtest suites and is able to get most of
the TODO marks for implicit assignments in classmethods and
staticmethods removed.

However, there's one specific test case I failed to figure out how to
correctly resolve:


b279508bdc/crates/ty_python_semantic/resources/mdtest/attributes.md (L754-L755)

I tried to add `instance_member().is_unbound()` check in this [else
branch](b279508bdc/crates/ty_python_semantic/src/types/infer.rs (L3299-L3301))
but it causes tests with class attributes defined in class body to fail.
While it's possible to implicitly add `ClassVar` to qualifiers to make
this assignment fail and keep everything else passing, it doesn't feel
like the right solution.
This commit is contained in:
med1844 2025-06-24 02:42:10 -07:00 committed by GitHub
parent ca7933804e
commit fd2cc37f90
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 190 additions and 33 deletions

View file

@ -522,8 +522,8 @@ class C:
# error: [unresolved-attribute]
reveal_type(C.x) # revealed: Unknown
# TODO: this should raise `unresolved-attribute` as well, and the type should be `Unknown`
reveal_type(C().x) # revealed: Unknown | Literal[1]
# error: [unresolved-attribute]
reveal_type(C().x) # revealed: Unknown
# This also works if `staticmethod` is aliased:
@ -537,8 +537,8 @@ class D:
# error: [unresolved-attribute]
reveal_type(D.x) # revealed: Unknown
# TODO: this should raise `unresolved-attribute` as well, and the type should be `Unknown`
reveal_type(D().x) # revealed: Unknown | Literal[1]
# error: [unresolved-attribute]
reveal_type(D().x) # revealed: Unknown
```
If `staticmethod` is something else, that should not influence the behavior:
@ -571,8 +571,8 @@ class C:
# error: [unresolved-attribute]
reveal_type(C.x) # revealed: Unknown
# TODO: this should raise `unresolved-attribute` as well, and the type should be `Unknown`
reveal_type(C().x) # revealed: Unknown | Literal[1]
# error: [unresolved-attribute]
reveal_type(C().x) # revealed: Unknown
```
#### Attributes defined in statically-known-to-be-false branches
@ -742,17 +742,9 @@ class C:
# for a more realistic example, let's actually call the method
C.class_method()
# TODO: We currently plan to support this and show no error here.
# mypy shows an error here, pyright does not.
# error: [unresolved-attribute]
reveal_type(C.pure_class_variable) # revealed: Unknown
reveal_type(C.pure_class_variable) # revealed: Unknown | Literal["value set in class method"]
# TODO: should be no error when descriptor protocol is supported
# and the assignment is properly attributed to the class method.
# error: [invalid-attribute-access] "Cannot assign to instance attribute `pure_class_variable` from the class object `<class 'C'>`"
C.pure_class_variable = "overwritten on class"
# TODO: should be no error
# error: [unresolved-attribute] "Attribute `pure_class_variable` can only be accessed on instances, not on the class object `<class 'C'>` itself."
reveal_type(C.pure_class_variable) # revealed: Literal["overwritten on class"]
c_instance = C()
@ -2150,6 +2142,25 @@ class C:
reveal_type(C().x) # revealed: int
```
### Attributes defined in methods with unknown decorators
When an attribute is defined in a method that is decorated with an unknown decorator, we consider it
to be accessible on both the class itself and instances of that class. This is consistent with the
gradual guarantee, because the unknown decorator *could* be an alias for `builtins.classmethod`.
```py
# error: [unresolved-import]
from unknown_library import unknown_decorator
class C:
@unknown_decorator
def f(self):
self.x: int = 1
reveal_type(C.x) # revealed: int
reveal_type(C().x) # revealed: int
```
## Enum classes
Enums are not supported yet; attribute access on an enum class is inferred as `Todo`.