Skip to content

Commit fd2cc37

Browse files
authored
[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 astral-sh/ty#205 and astral-sh/ty#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: https://github.com/med1844/ruff/blob/b279508bdc63c1ed6fc4ccf9d43d3719fe7a202b/crates/ty_python_semantic/resources/mdtest/attributes.md?plain=1#L754-L755 I tried to add `instance_member().is_unbound()` check in this [else branch](https://github.com/med1844/ruff/blob/b279508bdc63c1ed6fc4ccf9d43d3719fe7a202b/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.
1 parent ca79338 commit fd2cc37

4 files changed

Lines changed: 190 additions & 33 deletions

File tree

crates/ty_python_semantic/resources/mdtest/attributes.md

Lines changed: 26 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -522,8 +522,8 @@ class C:
522522
# error: [unresolved-attribute]
523523
reveal_type(C.x) # revealed: Unknown
524524

525-
# TODO: this should raise `unresolved-attribute` as well, and the type should be `Unknown`
526-
reveal_type(C().x) # revealed: Unknown | Literal[1]
525+
# error: [unresolved-attribute]
526+
reveal_type(C().x) # revealed: Unknown
527527

528528
# This also works if `staticmethod` is aliased:
529529

@@ -537,8 +537,8 @@ class D:
537537
# error: [unresolved-attribute]
538538
reveal_type(D.x) # revealed: Unknown
539539

540-
# TODO: this should raise `unresolved-attribute` as well, and the type should be `Unknown`
541-
reveal_type(D().x) # revealed: Unknown | Literal[1]
540+
# error: [unresolved-attribute]
541+
reveal_type(D().x) # revealed: Unknown
542542
```
543543

544544
If `staticmethod` is something else, that should not influence the behavior:
@@ -571,8 +571,8 @@ class C:
571571
# error: [unresolved-attribute]
572572
reveal_type(C.x) # revealed: Unknown
573573

574-
# TODO: this should raise `unresolved-attribute` as well, and the type should be `Unknown`
575-
reveal_type(C().x) # revealed: Unknown | Literal[1]
574+
# error: [unresolved-attribute]
575+
reveal_type(C().x) # revealed: Unknown
576576
```
577577

578578
#### Attributes defined in statically-known-to-be-false branches
@@ -742,17 +742,9 @@ class C:
742742
# for a more realistic example, let's actually call the method
743743
C.class_method()
744744

745-
# TODO: We currently plan to support this and show no error here.
746-
# mypy shows an error here, pyright does not.
747-
# error: [unresolved-attribute]
748-
reveal_type(C.pure_class_variable) # revealed: Unknown
745+
reveal_type(C.pure_class_variable) # revealed: Unknown | Literal["value set in class method"]
749746

750-
# TODO: should be no error when descriptor protocol is supported
751-
# and the assignment is properly attributed to the class method.
752-
# error: [invalid-attribute-access] "Cannot assign to instance attribute `pure_class_variable` from the class object `<class 'C'>`"
753747
C.pure_class_variable = "overwritten on class"
754-
# TODO: should be no error
755-
# error: [unresolved-attribute] "Attribute `pure_class_variable` can only be accessed on instances, not on the class object `<class 'C'>` itself."
756748
reveal_type(C.pure_class_variable) # revealed: Literal["overwritten on class"]
757749

758750
c_instance = C()
@@ -2150,6 +2142,25 @@ class C:
21502142
reveal_type(C().x) # revealed: int
21512143
```
21522144

2145+
### Attributes defined in methods with unknown decorators
2146+
2147+
When an attribute is defined in a method that is decorated with an unknown decorator, we consider it
2148+
to be accessible on both the class itself and instances of that class. This is consistent with the
2149+
gradual guarantee, because the unknown decorator *could* be an alias for `builtins.classmethod`.
2150+
2151+
```py
2152+
# error: [unresolved-import]
2153+
from unknown_library import unknown_decorator
2154+
2155+
class C:
2156+
@unknown_decorator
2157+
def f(self):
2158+
self.x: int = 1
2159+
2160+
reveal_type(C.x) # revealed: int
2161+
reveal_type(C().x) # revealed: int
2162+
```
2163+
21532164
## Enum classes
21542165

21552166
Enums are not supported yet; attribute access on an enum class is inferred as `Todo`.

crates/ty_python_semantic/src/types.rs

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2503,6 +2503,18 @@ impl<'db> Type<'db> {
25032503
}
25042504
}
25052505

2506+
#[salsa::tracked]
2507+
#[allow(unused_variables)]
2508+
// If we choose name `_unit`, the macro will generate code that uses `_unit`, causing clippy to fail.
2509+
fn lookup_dunder_new(self, db: &'db dyn Db, unit: ()) -> Option<PlaceAndQualifiers<'db>> {
2510+
self.find_name_in_mro_with_policy(
2511+
db,
2512+
"__new__",
2513+
MemberLookupPolicy::MRO_NO_OBJECT_FALLBACK
2514+
| MemberLookupPolicy::META_CLASS_NO_TYPE_FALLBACK,
2515+
)
2516+
}
2517+
25062518
/// Look up an attribute in the MRO of the meta-type of `self`. This returns class-level attributes
25072519
/// when called on an instance-like type, and metaclass attributes when called on a class-like type.
25082520
///
@@ -4662,12 +4674,7 @@ impl<'db> Type<'db> {
46624674
// An alternative might be to not skip `object.__new__` but instead mark it such that it's
46634675
// easy to check if that's the one we found?
46644676
// Note that `__new__` is a static method, so we must inject the `cls` argument.
4665-
let new_method = self_type.find_name_in_mro_with_policy(
4666-
db,
4667-
"__new__",
4668-
MemberLookupPolicy::MRO_NO_OBJECT_FALLBACK
4669-
| MemberLookupPolicy::META_CLASS_NO_TYPE_FALLBACK,
4670-
);
4677+
let new_method = self_type.lookup_dunder_new(db, ());
46714678
let new_call_outcome = new_method.and_then(|new_method| {
46724679
match new_method.place.try_call_dunder_get(db, self_type) {
46734680
Place::Type(new_method, boundness) => {

crates/ty_python_semantic/src/types/class.rs

Lines changed: 57 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@ use std::sync::{LazyLock, Mutex};
44
use super::TypeVarVariance;
55
use super::{
66
IntersectionBuilder, MemberLookupPolicy, Mro, MroError, MroIterator, SpecialFormType,
7-
SubclassOfType, Truthiness, Type, TypeQualifiers, class_base::ClassBase, infer_expression_type,
8-
infer_unpack_types,
7+
SubclassOfType, Truthiness, Type, TypeQualifiers,
8+
class_base::ClassBase,
9+
function::{FunctionDecorators, FunctionType},
10+
infer_expression_type, infer_unpack_types,
911
};
1012
use crate::semantic_index::DeclarationWithConstraint;
1113
use crate::semantic_index::definition::{Definition, DefinitionState};
@@ -639,6 +641,29 @@ impl<'db> From<ClassType<'db>> for Type<'db> {
639641
}
640642
}
641643

644+
/// A filter that describes which methods are considered when looking for implicit attribute assignments
645+
/// in [`ClassLiteral::implicit_attribute`].
646+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
647+
pub(super) enum MethodDecorator {
648+
None,
649+
ClassMethod,
650+
StaticMethod,
651+
}
652+
653+
impl MethodDecorator {
654+
fn try_from_fn_type(db: &dyn Db, fn_type: FunctionType) -> Result<Self, ()> {
655+
match (
656+
fn_type.has_known_decorator(db, FunctionDecorators::CLASSMETHOD),
657+
fn_type.has_known_decorator(db, FunctionDecorators::STATICMETHOD),
658+
) {
659+
(true, true) => Err(()), // A method can't be static and class method at the same time.
660+
(true, false) => Ok(Self::ClassMethod),
661+
(false, true) => Ok(Self::StaticMethod),
662+
(false, false) => Ok(Self::None),
663+
}
664+
}
665+
}
666+
642667
/// Representation of a class definition statement in the AST: either a non-generic class, or a
643668
/// generic class that has not been specialized.
644669
///
@@ -1284,8 +1309,10 @@ impl<'db> ClassLiteral<'db> {
12841309
{
12851310
return Place::bound(synthesized_member).into();
12861311
}
1312+
// The symbol was not found in the class scope. It might still be implicitly defined in `@classmethod`s.
1313+
return Self::implicit_attribute(db, body_scope, name, MethodDecorator::ClassMethod)
1314+
.into();
12871315
}
1288-
12891316
symbol
12901317
}
12911318

@@ -1601,12 +1628,15 @@ impl<'db> ClassLiteral<'db> {
16011628
}
16021629
}
16031630

1604-
/// Tries to find declarations/bindings of an instance attribute named `name` that are only
1605-
/// "implicitly" defined in a method of the class that corresponds to `class_body_scope`.
1606-
fn implicit_instance_attribute(
1631+
/// Tries to find declarations/bindings of an attribute named `name` that are only
1632+
/// "implicitly" defined (`self.x = …`, `cls.x = …`) in a method of the class that
1633+
/// corresponds to `class_body_scope`. The `target_method_decorator` parameter is
1634+
/// used to skip methods that do not have the expected decorator.
1635+
fn implicit_attribute(
16071636
db: &'db dyn Db,
16081637
class_body_scope: ScopeId<'db>,
16091638
name: &str,
1639+
target_method_decorator: MethodDecorator,
16101640
) -> Place<'db> {
16111641
// If we do not see any declarations of an attribute, neither in the class body nor in
16121642
// any method, we build a union of `Unknown` with the inferred types of all bindings of
@@ -1626,6 +1656,17 @@ impl<'db> ClassLiteral<'db> {
16261656
attribute_assignments(db, class_body_scope, name)
16271657
{
16281658
let method_scope = method_scope_id.to_scope_id(db, file);
1659+
if let Some(method_def) = method_scope.node(db).as_function(&module) {
1660+
let method_name = method_def.name.as_str();
1661+
if let Place::Type(Type::FunctionLiteral(method_type), _) =
1662+
class_symbol(db, class_body_scope, method_name).place
1663+
{
1664+
let method_decorator = MethodDecorator::try_from_fn_type(db, method_type);
1665+
if method_decorator != Ok(target_method_decorator) {
1666+
continue;
1667+
}
1668+
}
1669+
}
16291670
let method_map = use_def_map(db, method_scope);
16301671

16311672
// The attribute assignment inherits the reachability of the method which contains it
@@ -1901,7 +1942,7 @@ impl<'db> ClassLiteral<'db> {
19011942
// The attribute is declared and bound in the class body.
19021943

19031944
if let Some(implicit_ty) =
1904-
Self::implicit_instance_attribute(db, body_scope, name)
1945+
Self::implicit_attribute(db, body_scope, name, MethodDecorator::None)
19051946
.ignore_possibly_unbound()
19061947
{
19071948
if declaredness == Boundness::Bound {
@@ -1935,9 +1976,13 @@ impl<'db> ClassLiteral<'db> {
19351976
if declaredness == Boundness::Bound {
19361977
declared.with_qualifiers(qualifiers)
19371978
} else {
1938-
if let Some(implicit_ty) =
1939-
Self::implicit_instance_attribute(db, body_scope, name)
1940-
.ignore_possibly_unbound()
1979+
if let Some(implicit_ty) = Self::implicit_attribute(
1980+
db,
1981+
body_scope,
1982+
name,
1983+
MethodDecorator::None,
1984+
)
1985+
.ignore_possibly_unbound()
19411986
{
19421987
Place::Type(
19431988
UnionType::from_elements(db, [declared_ty, implicit_ty]),
@@ -1958,7 +2003,7 @@ impl<'db> ClassLiteral<'db> {
19582003
// The attribute is not *declared* in the class body. It could still be declared/bound
19592004
// in a method.
19602005

1961-
Self::implicit_instance_attribute(db, body_scope, name).into()
2006+
Self::implicit_attribute(db, body_scope, name, MethodDecorator::None).into()
19622007
}
19632008
Err((declared, _conflicting_declarations)) => {
19642009
// There are conflicting declarations for this attribute in the class body.
@@ -1969,7 +2014,7 @@ impl<'db> ClassLiteral<'db> {
19692014
// This attribute is neither declared nor bound in the class body.
19702015
// It could still be implicitly defined in a method.
19712016

1972-
Self::implicit_instance_attribute(db, body_scope, name).into()
2017+
Self::implicit_attribute(db, body_scope, name, MethodDecorator::None).into()
19732018
}
19742019
}
19752020

crates/ty_python_semantic/src/types/infer.rs

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10918,4 +10918,98 @@ mod tests {
1091810918

1091910919
Ok(())
1092010920
}
10921+
10922+
#[test]
10923+
fn dependency_implicit_class_member() -> anyhow::Result<()> {
10924+
fn x_rhs_expression(db: &TestDb) -> Expression<'_> {
10925+
let file_main = system_path_to_file(db, "/src/main.py").unwrap();
10926+
let ast = parsed_module(db, file_main).load(db);
10927+
// Get the third statement in `main.py` (x = …) and extract the expression
10928+
// node on the right-hand side:
10929+
let x_rhs_node = &ast.syntax().body[2].as_assign_stmt().unwrap().value;
10930+
10931+
let index = semantic_index(db, file_main);
10932+
index.expression(x_rhs_node.as_ref())
10933+
}
10934+
10935+
let mut db = setup_db();
10936+
10937+
db.write_dedented(
10938+
"/src/mod.py",
10939+
r#"
10940+
class C:
10941+
def __init__(self):
10942+
self.instance_attr: str = "24"
10943+
10944+
@classmethod
10945+
def method(cls):
10946+
cls.class_attr: int = 42
10947+
"#,
10948+
)?;
10949+
db.write_dedented(
10950+
"/src/main.py",
10951+
r#"
10952+
from mod import C
10953+
C.method()
10954+
x = C().class_attr
10955+
"#,
10956+
)?;
10957+
10958+
let file_main = system_path_to_file(&db, "/src/main.py").unwrap();
10959+
let attr_ty = global_symbol(&db, file_main, "x").place.expect_type();
10960+
assert_eq!(attr_ty.display(&db).to_string(), "Unknown | int");
10961+
10962+
// Change the type of `class_attr` to `str`; this should trigger the type of `x` to be re-inferred
10963+
db.write_dedented(
10964+
"/src/mod.py",
10965+
r#"
10966+
class C:
10967+
def __init__(self):
10968+
self.instance_attr: str = "24"
10969+
10970+
@classmethod
10971+
def method(cls):
10972+
cls.class_attr: str = "42"
10973+
"#,
10974+
)?;
10975+
10976+
let events = {
10977+
db.clear_salsa_events();
10978+
let attr_ty = global_symbol(&db, file_main, "x").place.expect_type();
10979+
assert_eq!(attr_ty.display(&db).to_string(), "Unknown | str");
10980+
db.take_salsa_events()
10981+
};
10982+
assert_function_query_was_run(&db, infer_expression_types, x_rhs_expression(&db), &events);
10983+
10984+
// Add a comment; this should not trigger the type of `x` to be re-inferred
10985+
db.write_dedented(
10986+
"/src/mod.py",
10987+
r#"
10988+
class C:
10989+
def __init__(self):
10990+
self.instance_attr: str = "24"
10991+
10992+
@classmethod
10993+
def method(cls):
10994+
# comment
10995+
cls.class_attr: str = "42"
10996+
"#,
10997+
)?;
10998+
10999+
let events = {
11000+
db.clear_salsa_events();
11001+
let attr_ty = global_symbol(&db, file_main, "x").place.expect_type();
11002+
assert_eq!(attr_ty.display(&db).to_string(), "Unknown | str");
11003+
db.take_salsa_events()
11004+
};
11005+
11006+
assert_function_query_was_not_run(
11007+
&db,
11008+
infer_expression_types,
11009+
x_rhs_expression(&db),
11010+
&events,
11011+
);
11012+
11013+
Ok(())
11014+
}
1092111015
}

0 commit comments

Comments
 (0)