diff --git a/crates/ty_python_semantic/resources/mdtest/attributes.md b/crates/ty_python_semantic/resources/mdtest/attributes.md index 9697310d6f..19a05d27a5 100644 --- a/crates/ty_python_semantic/resources/mdtest/attributes.md +++ b/crates/ty_python_semantic/resources/mdtest/attributes.md @@ -37,9 +37,7 @@ reveal_type(c_instance.inferred_from_other_attribute) # revealed: Unknown # See https://github.com/astral-sh/ruff/issues/15960 for a related discussion. reveal_type(c_instance.inferred_from_param) # revealed: Unknown | int | None -# TODO: Should be `bytes` with no error, like mypy and pyright? -# error: [unresolved-attribute] -reveal_type(c_instance.declared_only) # revealed: Unknown +reveal_type(c_instance.declared_only) # revealed: bytes reveal_type(c_instance.declared_and_bound) # revealed: bool @@ -58,6 +56,9 @@ c_instance.declared_and_bound = "incompatible" # error: [unresolved-attribute] "Attribute `inferred_from_value` can only be accessed on instances, not on the class object `` itself." reveal_type(C.inferred_from_value) # revealed: Unknown +# error: [unresolved-attribute] +reveal_type(C.declared_and_bound) # revealed: Unknown + # mypy shows no error here, but pyright raises "reportAttributeAccessIssue" # error: [invalid-attribute-access] "Cannot assign to instance attribute `inferred_from_value` from the class object ``" C.inferred_from_value = "overwritten on class" @@ -143,16 +144,14 @@ class C: c_instance = C(True) reveal_type(c_instance.only_declared_in_body) # revealed: str | None -# TODO: should be `str | None` without error -# error: [unresolved-attribute] -reveal_type(c_instance.only_declared_in_init) # revealed: Unknown +reveal_type(c_instance.only_declared_in_init) # revealed: str | None reveal_type(c_instance.declared_in_body_and_init) # revealed: str | None reveal_type(c_instance.declared_in_body_defined_in_init) # revealed: str | None # TODO: This should be `str | None`. Fixing this requires an overhaul of the `Symbol` API, # which is planned in https://github.com/astral-sh/ruff/issues/14297 -reveal_type(c_instance.bound_in_body_declared_in_init) # revealed: Unknown | Literal["a"] +reveal_type(c_instance.bound_in_body_declared_in_init) # revealed: Unknown | str | None reveal_type(c_instance.bound_in_body_and_init) # revealed: Unknown | None | Literal["a"] ``` @@ -183,9 +182,7 @@ reveal_type(c_instance.inferred_from_other_attribute) # revealed: Unknown reveal_type(c_instance.inferred_from_param) # revealed: Unknown | int | None -# TODO: should be `bytes` with no error, like mypy and pyright? -# error: [unresolved-attribute] -reveal_type(c_instance.declared_only) # revealed: Unknown +reveal_type(c_instance.declared_only) # revealed: bytes reveal_type(c_instance.declared_and_bound) # revealed: bool diff --git a/crates/ty_python_semantic/src/semantic_index.rs b/crates/ty_python_semantic/src/semantic_index.rs index e111f9aac5..b6e6755f0f 100644 --- a/crates/ty_python_semantic/src/semantic_index.rs +++ b/crates/ty_python_semantic/src/semantic_index.rs @@ -124,6 +124,30 @@ pub(crate) fn attribute_assignments<'db, 's>( }) } +/// Returns all attribute declarations (and their method scope IDs) with a symbol name matching +/// the one given for a specific class body scope. +/// +/// Only call this when doing type inference on the same file as `class_body_scope`, otherwise it +/// introduces a direct dependency on that file's AST. +pub(crate) fn attribute_declarations<'db, 's>( + db: &'db dyn Db, + class_body_scope: ScopeId<'db>, + name: &'s str, +) -> impl Iterator, FileScopeId)> + use<'s, 'db> { + let file = class_body_scope.file(db); + let index = semantic_index(db, file); + + attribute_scopes(db, class_body_scope).filter_map(|function_scope_id| { + let place_table = index.place_table(function_scope_id); + let place = place_table.place_id_by_instance_attribute_name(name)?; + let use_def = &index.use_def_maps[function_scope_id]; + Some(( + use_def.inner.all_reachable_declarations(place), + function_scope_id, + )) + }) +} + /// Returns all attribute assignments as scope IDs for a specific class body scope. /// /// Only call this when doing type inference on the same file as `class_body_scope`, otherwise it diff --git a/crates/ty_python_semantic/src/semantic_index/use_def.rs b/crates/ty_python_semantic/src/semantic_index/use_def.rs index 3abdd3e192..671e68db31 100644 --- a/crates/ty_python_semantic/src/semantic_index/use_def.rs +++ b/crates/ty_python_semantic/src/semantic_index/use_def.rs @@ -509,6 +509,18 @@ impl<'db> UseDefMap<'db> { .is_always_false() } + pub(crate) fn is_declaration_reachable( + &self, + db: &dyn crate::Db, + declaration: &DeclarationWithConstraint<'db>, + ) -> Truthiness { + self.reachability_constraints.evaluate( + db, + &self.predicates, + declaration.reachability_constraint, + ) + } + pub(crate) fn is_binding_reachable( &self, db: &dyn crate::Db, diff --git a/crates/ty_python_semantic/src/types/class.rs b/crates/ty_python_semantic/src/types/class.rs index b013a39b90..a1104beeed 100644 --- a/crates/ty_python_semantic/src/types/class.rs +++ b/crates/ty_python_semantic/src/types/class.rs @@ -11,7 +11,7 @@ use super::{ }; use crate::semantic_index::definition::{Definition, DefinitionState}; use crate::semantic_index::place::NodeWithScopeKind; -use crate::semantic_index::{DeclarationWithConstraint, SemanticIndex}; +use crate::semantic_index::{DeclarationWithConstraint, SemanticIndex, attribute_declarations}; use crate::types::context::InferContext; use crate::types::diagnostic::{INVALID_LEGACY_TYPE_VARIABLE, INVALID_TYPE_ALIAS_TYPE}; use crate::types::function::{DataclassTransformerParams, KnownFunction}; @@ -1763,10 +1763,7 @@ impl<'db> ClassLiteral<'db> { let class_map = use_def_map(db, class_body_scope); let class_table = place_table(db, class_body_scope); - for (attribute_assignments, method_scope_id) in - attribute_assignments(db, class_body_scope, name) - { - let method_scope = method_scope_id.to_scope_id(db, file); + let is_valid_scope = |method_scope: ScopeId<'db>| { if let Some(method_def) = method_scope.node(db).as_function(&module) { let method_name = method_def.name.as_str(); if let Place::Type(Type::FunctionLiteral(method_type), _) = @@ -1774,10 +1771,53 @@ impl<'db> ClassLiteral<'db> { { let method_decorator = MethodDecorator::try_from_fn_type(db, method_type); if method_decorator != Ok(target_method_decorator) { - continue; + return false; } } } + true + }; + + // First check declarations + for (attribute_declarations, method_scope_id) in + attribute_declarations(db, class_body_scope, name) + { + let method_scope = method_scope_id.to_scope_id(db, file); + if !is_valid_scope(method_scope) { + continue; + } + + for attribute_declaration in attribute_declarations { + let DefinitionState::Defined(decl) = attribute_declaration.declaration else { + continue; + }; + + let DefinitionKind::AnnotatedAssignment(annotated) = decl.kind(db) else { + continue; + }; + + if use_def_map(db, method_scope) + .is_declaration_reachable(db, &attribute_declaration) + .is_always_false() + { + continue; + } + + let annotation_ty = + infer_expression_type(db, index.expression(annotated.annotation(&module))); + + return Place::bound(annotation_ty); + } + } + + for (attribute_assignments, method_scope_id) in + attribute_assignments(db, class_body_scope, name) + { + let method_scope = method_scope_id.to_scope_id(db, file); + if !is_valid_scope(method_scope) { + continue; + } + let method_map = use_def_map(db, method_scope); // The attribute assignment inherits the reachability of the method which contains it @@ -2015,6 +2055,7 @@ impl<'db> ClassLiteral<'db> { let declarations = use_def.end_of_scope_declarations(place_id); let declared_and_qualifiers = place_from_declarations(db, declarations); + match declared_and_qualifiers { Ok(PlaceAndQualifiers { place: mut declared @ Place::Type(declared_ty, declaredness),